diff --git a/.github/workflows/component-tests.yaml b/.github/workflows/component-tests.yaml index ab410177ab..d328c69385 100644 --- a/.github/workflows/component-tests.yaml +++ b/.github/workflows/component-tests.yaml @@ -56,7 +56,7 @@ jobs: # Test_05_MemoryLeak_10K_Alerts, Test_06_KillProcessInTheMiddle, Test_07_RuleBindingApplyTest, - Test_08_ApplicationProfilePatching, + Test_08_ContainerProfilePatching, Test_10_MalwareDetectionTest, Test_11_EndpointTest, Test_12_MergingProfilesTest, diff --git a/mocks/readfiles.go b/mocks/readfiles.go index ba75a5c6f8..ee85a21bf0 100644 --- a/mocks/readfiles.go +++ b/mocks/readfiles.go @@ -5,7 +5,6 @@ import ( "path" "runtime" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" corev1 "k8s.io/api/core/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" @@ -20,8 +19,6 @@ const ( TestKindPod TestKinds = "Pod" TestKindRS TestKinds = "ReplicaSet" TestKindDeploy TestKinds = "Deployment" - TestKindAP TestKinds = "ApplicationProfile" - TestKindNN TestKinds = "NetworkNeighborhood" ) const ( @@ -30,20 +27,14 @@ const ( ) const ( - nginxPodBytes = "testdata/nginx_pod.json" - nginxRSBytes = "testdata/nginx_rs.json" - nginxDeploymentBytes = "testdata/nginx_deploy.json" - nginxApplicationProfileBytes = "testdata/nginx_applicationprofiles.json" - nginxApplicationActivityBytes = "testdata/nginx_applicationactivities.json" - nginxNetworkNeighborhoodBytes = "testdata/nginx_networkneighborhood.json" + nginxPodBytes = "testdata/nginx_pod.json" + nginxRSBytes = "testdata/nginx_rs.json" + nginxDeploymentBytes = "testdata/nginx_deploy.json" ) const ( - collectionPodBytes = "testdata/collection_pod.json" - collectionRSBytes = "testdata/collection_rs.json" - collectionDeploymentBytes = "testdata/collection_deploy.json" - collectionApplicationProfileBytes = "testdata/collection_applicationprofiles.json" - collectionApplicationActivityBytes = "testdata/collection_applicationactivities.json" - collectionNetworkNeighborhoodBytes = "testdata/collection_networkneighborhood.json" + collectionPodBytes = "testdata/collection_pod.json" + collectionRSBytes = "testdata/collection_rs.json" + collectionDeploymentBytes = "testdata/collection_deploy.json" ) var NAMESPACE = "" @@ -86,16 +77,6 @@ func UnstructuredToRuntime(u *unstructured.Unstructured) k8sruntime.Object { if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured(u.Object, deploy); err == nil { return deploy } - case TestKindAP: - ap := &v1beta1.ApplicationProfile{} - if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured(u.Object, ap); err == nil { - return ap - } - case TestKindNN: - nn := &v1beta1.NetworkNeighborhood{} - if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured(u.Object, nn); err == nil { - return nn - } } return nil } @@ -143,20 +124,6 @@ func GetBytes(kind TestKinds, name TestName) []byte { case TestCollection: return readFile(collectionDeploymentBytes) } - case TestKindAP: - switch name { - case TestNginx: - return readFile(nginxApplicationProfileBytes) - case TestCollection: - return readFile(collectionApplicationProfileBytes) - } - case TestKindNN: - switch name { - case TestNginx: - return readFile(nginxNetworkNeighborhoodBytes) - case TestCollection: - return readFile(collectionNetworkNeighborhoodBytes) - } } return []byte{} } diff --git a/mocks/readfiles_test.go b/mocks/readfiles_test.go index e838eb290d..2da88d75b2 100644 --- a/mocks/readfiles_test.go +++ b/mocks/readfiles_test.go @@ -25,14 +25,6 @@ func TestUnstructuredToPod(t *testing.T) { name: TestNginx, kind: TestKindDeploy, }, - { - name: TestNginx, - kind: TestKindAP, - }, - { - name: TestNginx, - kind: TestKindNN, - }, { name: TestCollection, kind: TestKindPod, @@ -45,14 +37,6 @@ func TestUnstructuredToPod(t *testing.T) { name: TestCollection, kind: TestKindDeploy, }, - { - name: TestCollection, - kind: TestKindAP, - }, - { - name: TestCollection, - kind: TestKindNN, - }, } for _, tt := range tests { t.Run(fmt.Sprintf("%s/%s", tt.name, tt.kind), func(t *testing.T) { diff --git a/mocks/testdata/collection_applicationprofiles.json b/mocks/testdata/collection_applicationprofiles.json deleted file mode 100644 index 38d925e0b8..0000000000 --- a/mocks/testdata/collection_applicationprofiles.json +++ /dev/null @@ -1,253 +0,0 @@ -{ - "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", - "kind": "ApplicationProfile", - "metadata": { - "annotations": { - "kubescape.io/resource-size": "24", - "kubescape.io/completion": "complete", - "kubescape.io/status": "completed" - }, - "labels": { - "kubescape.io/instance-template-hash": "94c495554", - "kubescape.io/workload-api-group": "apps", - "kubescape.io/workload-api-version": "v1", - "kubescape.io/workload-init-container-name": "busybox", - "kubescape.io/workload-kind": "Deployment", - "kubescape.io/workload-name": "collection" - }, - "name": "replicaset-collection-94c495554" - }, - "spec": { - "containers": [ - { - "execs": [ - { - "args": [ - "-c", - "/bin/sh", - "nc -lnvp 8080" - ], - "path": "/bin/sh" - }, - { - "args": [ - "-lnvp", - "/usr/bin/nc", - "8080" - ], - "path": "/usr/bin/nc" - } - ], - "name": "alpine-container" - }, - { - "execs": [ - { - "args": [ - "-c", - "/bin/sh", - "wget https://kubernetes.io/ --background; sleep 1; wget https://cloud.armosec.io/ --background; wget https://console.cloud.goog" - ], - "path": "/bin/sh" - }, - { - "args": [ - "--background", - "/usr/bin/wget", - "https://cloud.armosec.io/", - "https://console.cloud.google.com/", - "https://kubernetes.io/" - ], - "path": "/usr/bin/wget" - }, - { - "args": [ - "/bin/sleep", - "1" - ], - "path": "/bin/sleep" - }, - { - "args": [ - "/usr/local/bin/redis-server" - ], - "path": "/usr/local/bin/redis-server" - } - ], - "name": "redis" - }, - { - "execs": [ - { - "args": [ - "-d ", - "-f1", - "/usr/bin/cut" - ], - "path": "/usr/bin/cut" - }, - { - "args": [ - "%u:%g", - "-c", - ".", - "/usr/bin/stat" - ], - "path": "/usr/bin/stat" - }, - { - "args": [ - "-", - "--create", - "--directory", - "--extract", - "--file", - "--group", - "--owner", - ".", - "/bin/tar", - "/usr/src/wordpress", - "www-data" - ], - "path": "/bin/tar" - }, - { - "args": [ - "/usr/bin/sha1sum" - ], - "path": "/usr/bin/sha1sum" - }, - { - "args": [ - "-f", - "/bin/rm", - "/var/run/apache2/apache2.pid" - ], - "path": "/bin/rm" - }, - { - "args": [ - "\n\t\t\t\t\t/put your unique phrase here/ {\n\t\t\t\t\t\tcmd = \"head -c1m /dev/urandom | sha1sum | cut -d\\\\ -f1\"\n\t\t\t\t\t\tcmd | getline str\n\t\t", - "/usr/bin/awk", - "wp-config-docker.php" - ], - "path": "/usr/bin/awk" - }, - { - "args": [ - "/bin/bash", - "/usr/local/bin/docker-entrypoint.sh", - "apache2-foreground" - ], - "path": "/bin/bash" - }, - { - "args": [ - "/usr/local/bin/docker-entrypoint.sh", - "apache2-foreground" - ], - "path": "/usr/local/bin/docker-entrypoint.sh" - }, - { - "args": [ - "-g", - "-u", - "/usr/bin/id" - ], - "path": "/usr/bin/id" - }, - { - "args": [ - "-c1m", - "/dev/urandom", - "/usr/bin/head" - ], - "path": "/usr/bin/head" - }, - { - "args": [ - "-p", - "/bin/mkdir", - "/var/lock/apache2", - "/var/log/apache2", - "/var/run/apache2" - ], - "path": "/bin/mkdir" - }, - { - "args": [ - "/usr/bin/dirname", - "/var/lock/apache2", - "/var/log/apache2", - "/var/run/apache2" - ], - "path": "/usr/bin/dirname" - }, - { - "args": [ - "-maxdepth", - "-mindepth", - "-name", - "-not", - "/usr/bin/find", - "1", - "wp-content" - ], - "path": "/usr/bin/find" - }, - { - "args": [ - "/bin/chown", - "wp-config.php", - "www-data:www-data" - ], - "path": "/bin/chown" - }, - { - "args": [ - "-DFOREGROUND", - "/usr/sbin/apache2" - ], - "path": "/usr/sbin/apache2" - }, - { - "args": [ - "/usr/local/bin/apache2-foreground" - ], - "path": "/usr/local/bin/apache2-foreground" - } - ], - "name": "wordpress" - } - ], - "initContainers": [ - { - "execs": [ - { - "args": [ - "-c", - "/bin/sh", - "echo \"Initialization complete.\"" - ], - "path": "/bin/sh" - } - ], - "name": "busybox" - }, - { - "execs": [ - { - "args": [ - "-c", - "/bin/sh", - "echo \"Performing initialization tasks...\"\\napk add --no-cache curl\\n# Add more initialization tasks as needed\\n" - ], - "path": "/bin/sh" - } - ], - "name": "alpine" - } - ] - }, - "status": {} -} \ No newline at end of file diff --git a/mocks/testdata/collection_networkneighborhood.json b/mocks/testdata/collection_networkneighborhood.json deleted file mode 100644 index 9ad260491c..0000000000 --- a/mocks/testdata/collection_networkneighborhood.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", - "kind": "NetworkNeighborhood", - "metadata": { - "annotations": { - "kubescape.io/resource-size": "24", - "kubescape.io/completion": "complete", - "kubescape.io/status": "completed" - }, - "labels": { - "kubescape.io/instance-template-hash": "94c495554", - "kubescape.io/workload-api-group": "apps", - "kubescape.io/workload-api-version": "v1", - "kubescape.io/workload-init-container-name": "busybox", - "kubescape.io/workload-kind": "Deployment", - "kubescape.io/workload-name": "collection" - }, - "name": "replicaset-collection-94c495554" - }, - "spec": { - "matchLabels": { - "app": "collection" - }, - "containers": [ - { - "egress": null, - "ingress": null, - "name": "alpine-container" - }, - { - "egress": null, - "ingress": null, - "name": "redis" - }, - { - "egress": null, - "ingress": null, - "name": "wordpress" - } - ], - "initContainers": [ - { - "egress": null, - "ingress": null, - "name": "busybox" - }, - { - "egress": null, - "ingress": null, - "name": "alpine" - } - ] - }, - "status": {} -} \ No newline at end of file diff --git a/mocks/testdata/nginx_applicationprofiles.json b/mocks/testdata/nginx_applicationprofiles.json deleted file mode 100644 index 1f5e2419bd..0000000000 --- a/mocks/testdata/nginx_applicationprofiles.json +++ /dev/null @@ -1,265 +0,0 @@ -{ - "kind": "ApplicationProfile", - "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", - "metadata": { - "name": "replicaset-nginx-77b4fdf86c", - "creationTimestamp": "2024-03-19T09:27:05Z", - "labels": { - "kubescape.io/instance-template-hash": "77b4fdf86c", - "kubescape.io/workload-api-group": "apps", - "kubescape.io/workload-api-version": "v1", - "kubescape.io/workload-kind": "Deployment", - "kubescape.io/workload-name": "nginx" - }, - "annotations": { - "kubescape.io/completion": "complete", - "kubescape.io/resource-size": "85", - "kubescape.io/status": "completed" - } - }, - "spec": { - "architectures": [ - "amd64", - "arm64" - ], - "containers": [ - { - "capabilities": [ - "NET_BIND_SERVICE", - "DAC_OVERRIDE", - "CHOWN", - "SETGID", - "SETUID" - ], - "execs": [ - { - "path": "/usr/bin/dpkg-query", - "args": [ - "--show", - "--showformat=${Conffiles}\\n", - "/usr/bin/dpkg-query", - "nginx" - ] - }, - { - "path": "/docker-entrypoint.sh", - "args": [ - "-g", - "/docker-entrypoint.sh", - "daemon off;", - "nginx" - ] - }, - { - "path": "/usr/bin/cut", - "args": [ - "-d ", - "-f", - "/usr/bin/cut", - "3" - ] - }, - { - "path": "/docker-entrypoint.d/30-tune-worker-processes.sh", - "args": [ - "/docker-entrypoint.d/30-tune-worker-processes.sh" - ] - }, - { - "path": "/docker-entrypoint.d/10-listen-on-ipv6-by-default.sh", - "args": [ - "/docker-entrypoint.d/10-listen-on-ipv6-by-default.sh" - ] - }, - { - "path": "/usr/bin/touch", - "args": [ - "/etc/nginx/conf.d/default.conf", - "/usr/bin/touch" - ] - }, - { - "path": "/usr/sbin/nginx", - "args": [ - "-g", - "/usr/sbin/nginx", - "daemon off;" - ] - }, - { - "path": "/usr/bin/md5sum", - "args": [ - "-", - "-c", - "/usr/bin/md5sum" - ] - }, - { - "path": "/usr/bin/sed", - "args": [ - "-E", - "-i", - "/etc/nginx/conf.d/default.conf", - "/usr/bin/sed", - "s,listen 80;,listen 80;\\n listen [::]:80;," - ] - }, - { - "path": "/usr/bin/grep", - "args": [ - "-q", - "/etc/nginx/conf.d/default.conf", - "/usr/bin/grep", - "etc/nginx/conf.d/default.conf", - "listen \\[::]\\:80;" - ] - }, - { - "path": "/etc/nginx/conf.d/sedzJNyeW" - } - ], - "opens": null, - "syscalls": null, - "seccompProfile": { - "path": "default/replicaset-nginx-bf5d5cf98-nginx.json", - "spec": { - "defaultAction": "SCMP_ACT_ERRNO", - "architectures": [ - "SCMP_ARCH_X86_64", - "SCMP_ARCH_X86", - "SCMP_ARCH_X32" - ], - "syscalls": [ - { - "names": [ - "accept4", - "epoll_wait", - "pselect6", - "futex", - "madvise", - "epoll_ctl", - "getsockname", - "setsockopt", - "vfork", - "mmap", - "arch_prctl", - "sysinfo", - "symlinkat", - "connect", - "dup3", - "getcwd", - "getpid", - "brk", - "fchdir", - "pread64", - "wait4", - "clone3", - "setuid", - "write", - "prctl", - "munmap", - "rt_sigprocmask", - "rt_sigreturn", - "fsetxattr", - "getrandom", - "ioctl", - "mount", - "getpeername", - "gettid", - "fcntl", - "mkdirat", - "prlimit64", - "setgid", - "fgetxattr", - "pwrite64", - "sched_yield", - "uname", - "openat2", - "vfork", - "openat", - "umask", - "nanosleep", - "eventfd2", - "getgid", - "listen", - "mprotect", - "epoll_ctl", - "fchmodat", - "getegid", - "epoll_pwait", - "keyctl", - "mkdir", - "set_robust_list", - "tgkill", - "read", - "rseq", - "statfs", - "unlinkat", - "capset", - "epoll_create", - "fstatfs", - "sched_getaffinity", - "fchownat", - "newfstatat", - "sendmsg", - "chown", - "clone", - "execve", - "faccessat2", - "rename", - "umount2", - "access", - "futex", - "getsockopt", - "readlinkat", - "dup2", - "geteuid", - "bind", - "pipe2", - "rt_sigsuspend", - "fstat", - "socketpair", - "mmap", - "set_tid_address", - "setgroups", - "setsid", - "exit", - "getuid", - "io_setup", - "mknodat", - "unshare", - "getppid", - "madvise", - "setsockopt", - "getdents64", - "lseek", - "mount_setattr", - "rt_sigaction", - "sigaltstack", - "capget", - "chdir", - "epoll_wait", - "linkat", - "exit_group", - "getrlimit", - "recvmsg", - "socket", - "fadvise64", - "pivot_root", - "sethostname", - "utimensat", - "close", - "epoll_create1", - "fchown", - "getsockname" - ], - "action": "SCMP_ACT_ALLOW" - } - ] - } - } - } - ] - }, - "status": {} -} diff --git a/mocks/testdata/nginx_networkneighborhood.json b/mocks/testdata/nginx_networkneighborhood.json deleted file mode 100644 index 1b22012c8b..0000000000 --- a/mocks/testdata/nginx_networkneighborhood.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "apiVersion": "spdx.softwarecomposition.kubescape.io/v1beta1", - "kind": "NetworkNeighborhood", - "metadata": { - "annotations": { - "kubescape.io/resource-size": "85", - "kubescape.io/completion": "complete", - "kubescape.io/status": "completed" - }, - "creationTimestamp": "2024-03-19T09:27:05Z", - "labels": { - "kubescape.io/instance-template-hash": "77b4fdf86c", - "kubescape.io/workload-api-group": "apps", - "kubescape.io/workload-api-version": "v1", - "kubescape.io/workload-kind": "Deployment", - "kubescape.io/workload-name": "nginx" - }, - "name": "replicaset-nginx-77b4fdf86c" - }, - "spec": { - "matchLabels": { - "app": "nginx" - }, - "containers": [ - { - "egress": null, - "ingress": null, - "name": "nginx" - } - ] - }, - "status": {} -} \ No newline at end of file diff --git a/pkg/metricsmanager/metrics_manager_interface.go b/pkg/metricsmanager/metrics_manager_interface.go index c40dc3d315..44df5e5b18 100644 --- a/pkg/metricsmanager/metrics_manager_interface.go +++ b/pkg/metricsmanager/metrics_manager_interface.go @@ -46,6 +46,7 @@ type MetricsManager interface { ObserveProjectionApplyDuration(d time.Duration) IncProjectionReconcileTriggered(trigger string) IncHelperCall(helper string) + IncUserDefinedProfileUnresolved(namespace string) // user-defined-profile label set but no ContainerProfile resolved (silent-upgrade visibility) SetProjectionUndeclaredRulesDetail(ruleIDs []string) // Memory-savings metrics — detailed (gated by profileProjection.detailedMetricsEnabled). diff --git a/pkg/metricsmanager/metrics_manager_mock.go b/pkg/metricsmanager/metrics_manager_mock.go index d33e06428b..67396b08e4 100644 --- a/pkg/metricsmanager/metrics_manager_mock.go +++ b/pkg/metricsmanager/metrics_manager_mock.go @@ -75,25 +75,26 @@ func (m *MetricsMock) ReportContainerProfileReconcilerDuration(_ string, _ time. func (m *MetricsMock) ReportContainerProfileReconcilerEviction(_ string) {} func (m *MetricsMock) ReportContainerProfileSplit() {} func (m *MetricsMock) ReportContainerProfileChunkDropped(_ 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) {} -func (m *MetricsMock) ReportSBOMScan(_ string) {} -func (m *MetricsMock) ObserveSBOMScanDuration(_ string, _ time.Duration) {} -func (m *MetricsMock) ReportSBOMScannerRestart() {} -func (m *MetricsMock) SetSBOMScannerReady(_ bool) {} -func (m *MetricsMock) ReportAlertSuppressed(_, _ 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) IncUserDefinedProfileUnresolved(_ 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) {} +func (m *MetricsMock) ReportSBOMScan(_ string) {} +func (m *MetricsMock) ObserveSBOMScanDuration(_ string, _ time.Duration) {} +func (m *MetricsMock) ReportSBOMScannerRestart() {} +func (m *MetricsMock) SetSBOMScannerReady(_ bool) {} +func (m *MetricsMock) ReportAlertSuppressed(_, _ string) {} diff --git a/pkg/metricsmanager/metrics_manager_noop.go b/pkg/metricsmanager/metrics_manager_noop.go index a8533de845..294f2797c8 100644 --- a/pkg/metricsmanager/metrics_manager_noop.go +++ b/pkg/metricsmanager/metrics_manager_noop.go @@ -19,8 +19,7 @@ func (m *MetricsNoop) ReportFailedEvent() {} func (m *MetricsNoop) ReportRuleProcessed(_ string) {} func (m *MetricsNoop) ReportRulePrefiltered(_ string) {} func (m *MetricsNoop) ReportRuleAlert(_ string) {} -func (m *MetricsNoop) ReportRuleEvaluationTime(_ context.Context, _ string, _ utils.EventType, _ time.Duration) { -} +func (m *MetricsNoop) ReportRuleEvaluationTime(_ context.Context, _ string, _ utils.EventType, _ time.Duration) {} func (m *MetricsNoop) ReportContainerStart() {} func (m *MetricsNoop) ReportContainerStop() {} func (m *MetricsNoop) ReportDedupEvent(_ utils.EventType, _ bool) {} @@ -42,6 +41,7 @@ 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) IncUserDefinedProfileUnresolved(_ string) {} func (m *MetricsNoop) SetProjectionUndeclaredRulesDetail(_ []string) {} func (m *MetricsNoop) ObserveProfileRawSize(_ float64) {} func (m *MetricsNoop) ObserveProfileProjectedSize(_ float64) {} diff --git a/pkg/metricsmanager/otel/otel_metrics_manager.go b/pkg/metricsmanager/otel/otel_metrics_manager.go index 784c72eb20..4e0e8836c4 100644 --- a/pkg/metricsmanager/otel/otel_metrics_manager.go +++ b/pkg/metricsmanager/otel/otel_metrics_manager.go @@ -50,14 +50,15 @@ type OTELMetricsManager struct { projUndeclaredRules metric.Float64Gauge // Rule projection — detailed (gated by caller) - projSpecCompileTotal metric.Int64Counter - projSpecHashChangeTotal metric.Int64Counter - projSpecPatterns metric.Float64Gauge - projSpecAllField metric.Float64Gauge - projApplyDuration metric.Float64Histogram - projReconcileTriggeredTotal metric.Int64Counter - projHelperCallTotal metric.Int64Counter - projUndeclaredRulesDetail metric.Float64Gauge + projSpecCompileTotal metric.Int64Counter + projSpecHashChangeTotal metric.Int64Counter + projSpecPatterns metric.Float64Gauge + projSpecAllField metric.Float64Gauge + projApplyDuration metric.Float64Histogram + projReconcileTriggeredTotal metric.Int64Counter + projHelperCallTotal metric.Int64Counter + userDefinedProfileUnresolvedTotal metric.Int64Counter + projUndeclaredRulesDetail metric.Float64Gauge // Memory-savings metrics (dev-only, kept for interface compat; candidates for removal) profileRawSize metric.Float64Histogram @@ -199,6 +200,8 @@ func NewOTELMetricsManager(ownContainerID string) *OTELMetricsManager { "Projection reconcile triggers by type") m.projHelperCallTotal = mustCounter("node_agent.rule.projection.helper_call.total", "Profile-helper CEL function calls by helper name") + m.userDefinedProfileUnresolvedTotal = mustCounter("node_agent.container_profile.user_defined_unresolved.total", + "Times a pod's user-defined-profile label was set but no ContainerProfile resolved") // program runtime gauges intentionally omitted — dead code since initial implementation m.projUndeclaredRulesDetail = mustGauge("node_agent.rule.projection.undeclared_rules_detail", "Per-rule gauge for undeclared rules (high-cardinality; candidate for removal in Phase 3)") @@ -437,6 +440,12 @@ func (m *OTELMetricsManager) IncHelperCall(helper string) { )) } +func (m *OTELMetricsManager) IncUserDefinedProfileUnresolved(namespace string) { + m.userDefinedProfileUnresolvedTotal.Add(context.Background(), 1, metric.WithAttributes( + attribute.String("namespace", namespace), + )) +} + // SetProjectionUndeclaredRulesDetail records 1 for each rule currently undeclared // and 0 for rules that were in the previous call but are no longer undeclared. // OTEL synchronous gauges have no Reset(); zeroing removed entries is the equivalent. diff --git a/pkg/metricsmanager/prometheus/prometheus.go b/pkg/metricsmanager/prometheus/prometheus.go index 36b4e11986..e4cf754609 100644 --- a/pkg/metricsmanager/prometheus/prometheus.go +++ b/pkg/metricsmanager/prometheus/prometheus.go @@ -25,23 +25,23 @@ const ( var _ metricsmanager.MetricsManager = (*PrometheusMetric)(nil) type PrometheusMetric struct { - ebpfExecCounter prometheus.Counter - ebpfOpenCounter prometheus.Counter - ebpfNetworkCounter prometheus.Counter - ebpfDNSCounter prometheus.Counter - ebpfSyscallCounter prometheus.Counter - ebpfCapabilityCounter prometheus.Counter - ebpfRandomXCounter prometheus.Counter - ebpfFailedCounter prometheus.Counter - ebpfSymlinkCounter prometheus.Counter - ebpfHardlinkCounter prometheus.Counter - ebpfSSHCounter prometheus.Counter - ebpfHTTPCounter prometheus.Counter - ebpfPtraceCounter prometheus.Counter - ebpfIoUringCounter prometheus.Counter - ebpfKmodCounter prometheus.Counter - ebpfUnshareCounter prometheus.Counter - ebpfBpfCounter prometheus.Counter + ebpfExecCounter prometheus.Counter + ebpfOpenCounter prometheus.Counter + ebpfNetworkCounter prometheus.Counter + ebpfDNSCounter prometheus.Counter + ebpfSyscallCounter prometheus.Counter + ebpfCapabilityCounter prometheus.Counter + ebpfRandomXCounter prometheus.Counter + ebpfFailedCounter prometheus.Counter + ebpfSymlinkCounter prometheus.Counter + ebpfHardlinkCounter prometheus.Counter + ebpfSSHCounter prometheus.Counter + ebpfHTTPCounter prometheus.Counter + ebpfPtraceCounter prometheus.Counter + ebpfIoUringCounter prometheus.Counter + ebpfKmodCounter prometheus.Counter + ebpfUnshareCounter prometheus.Counter + ebpfBpfCounter prometheus.Counter ruleCounter *prometheus.CounterVec rulePrefilteredCounter *prometheus.CounterVec alertCounter *prometheus.CounterVec @@ -74,7 +74,7 @@ type PrometheusMetric struct { cpChunkDroppedCounter *prometheus.CounterVec // Profile projection metrics — always-on - cpProjectionMissingDeclCounter *prometheus.CounterVec + cpProjectionMissingDeclCounter *prometheus.CounterVec cpProjectionUndeclaredLiteralCounter *prometheus.CounterVec cpProjectionStaleEntriesGauge prometheus.Gauge cpProjectionUndeclaredRulesGauge prometheus.Gauge @@ -86,6 +86,7 @@ type PrometheusMetric struct { cpProjectionSpecAllFieldsGauge *prometheus.GaugeVec cpProjectionApplyDurationHistogram prometheus.Histogram cpProjectionReconcileTriggeredCounter *prometheus.CounterVec + cpUserDefinedProfileUnresolvedCounter *prometheus.CounterVec cpHelperCallCounter *prometheus.CounterVec cpProjectionUndeclaredRulesListGauge *prometheus.GaugeVec @@ -336,6 +337,10 @@ func NewPrometheusMetric() *PrometheusMetric { Name: "rule_helper_call_total", Help: "Total number of profile-helper CEL function calls.", }, []string{"helper"}), + cpUserDefinedProfileUnresolvedCounter: promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "container_profile_user_defined_unresolved_total", + Help: "Total times a pod's user-defined-profile label was set but no ContainerProfile resolved (legacy AP/NN are no longer read).", + }, []string{"namespace"}), 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.", @@ -449,6 +454,7 @@ func (p *PrometheusMetric) Destroy() { prometheus.Unregister(p.cpProjectionSpecAllFieldsGauge) prometheus.Unregister(p.cpProjectionApplyDurationHistogram) prometheus.Unregister(p.cpProjectionReconcileTriggeredCounter) + prometheus.Unregister(p.cpUserDefinedProfileUnresolvedCounter) prometheus.Unregister(p.cpHelperCallCounter) prometheus.Unregister(p.cpProjectionUndeclaredRulesListGauge) prometheus.Unregister(p.cpProfileRawSizeHistogram) @@ -701,6 +707,10 @@ func (p *PrometheusMetric) SetProjectionSpecAllField(field string, isAll bool) { func (p *PrometheusMetric) ObserveProjectionApplyDuration(d time.Duration) { p.cpProjectionApplyDurationHistogram.Observe(d.Seconds()) } +func (p *PrometheusMetric) IncUserDefinedProfileUnresolved(namespace string) { + p.cpUserDefinedProfileUnresolvedCounter.WithLabelValues(namespace).Inc() +} + func (p *PrometheusMetric) IncProjectionReconcileTriggered(trigger string) { p.cpProjectionReconcileTriggeredCounter.WithLabelValues(trigger).Inc() } diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 3c2535ab8c..b79b859c26 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -61,28 +61,25 @@ type CachedContainerProfile struct { PodUID string WorkloadID string - // UserAPRef / UserNNRef are set when the entry was built with a legacy - // user-authored AP/NN overlay. Used by the reconciler to re-fetch on - // refresh and to key deprecation warnings. - UserAPRef *namespacedName - UserNNRef *namespacedName + // UserCPRef is set when the user-defined-profile label names a single + // user-authored ContainerProfile (the migrated "new way"), which is used + // as the authoritative base for the container. It is the only user-defined + // source — the legacy AP/NN overlay is no longer supported. Used by the + // reconciler to re-fetch on refresh. + UserCPRef *namespacedName // CPName is the storage name of the ContainerProfile. Populated at // addContainer time so the reconciler can re-fetch without re-querying // shared data (which may have been evicted from K8sObjectCache by then). CPName string - // WorkloadName is the per-workload slug used to fetch the workload-level - // ApplicationProfile / NetworkNeighborhood (primary data source while the - // storage-side consolidated CP isn't publicly queryable) and, with the - // "ug-" prefix, the user-managed AP/NN. Populated at addContainer time. + // WorkloadName is the per-workload slug used to synthesize a CP name when the + // consolidated ContainerProfile is not yet queryable in storage. Populated at + // addContainer time. WorkloadName string - 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 - UserAPRV string // user-AP (label-referenced) resourceVersion at last projection, "" if no overlay - UserNNRV string // user-NN (label-referenced) resourceVersion at last projection, "" if no overlay + RV string // ContainerProfile resourceVersion at last load + UserCPRV string // user-defined ContainerProfile (label-referenced) RV at last load, "" if not used } // pendingContainer captures the minimum state needed to retry the initial @@ -108,14 +105,10 @@ type ContainerProfileCacheImpl struct { k8sObjectCache objectcache.K8sObjectCache metricsManager metricsmanager.MetricsManager - reconcileEvery time.Duration - rpcBudget time.Duration + reconcileEvery time.Duration + rpcBudget time.Duration refreshInProgress atomic.Bool - // 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 @@ -159,10 +152,6 @@ func NewContainerProfileCache(cfg config.Config, storageClient storage.ProfileCl return c } -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 { @@ -328,45 +317,79 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( cp = nil } - // Fetch user-managed AP / NN published at "ug-". Legacy - // caches auto-detected these via the `kubescape.io/managed-by: User` - // annotation and merged them on top of the base profile; we read them - // directly by their well-known name instead, avoiding a List and an - // annotation filter. Both are optional: nil on 404. - var userManagedAP *v1beta1.ApplicationProfile - var userManagedNN *v1beta1.NetworkNeighborhood - if workloadName != "" { - ugName := helpersv1.UserApplicationProfilePrefix + workloadName - var ugAPErr error + // Fetch the user-authored ContainerProfile when the pod carries the + // UserDefinedProfileMetadataKey label. Migration (#862/#864) is a HARD + // cutover: the label now names a single user-authored ContainerProfile — + // the unified replacement for the legacy AP+NN overlay pair, which is no + // longer supported. The CP is authoritative and needs no overlay merge. On + // a fetch error (transient, or the CP hasn't landed yet) it is left nil and + // the container stays pending; UserCPRef — recorded unconditionally below — + // drives the reconciler to retry the CP on every tick until it materialises. + var userDefinedCP *v1beta1.ContainerProfile + overlayName, hasOverlay := container.K8s.PodLabels[helpersv1.UserDefinedProfileMetadataKey] + // resolvedOverlayName is the ContainerProfile name the label ultimately + // resolves to; it is recorded in entry.UserCPRef so refreshOneEntry re-fetches + // the SAME object every tick. It defaults to the bare label value — the + // single-container convention and the safest retry target when the + // per-container fetch does not cleanly succeed. + resolvedOverlayName := overlayName + if hasOverlay && overlayName != "" { + // Per-container binding (review finding on node-agent#864): a + // multi-container pod shares one label value but each container is + // profiled independently, so its authored ContainerProfile is published + // at "-". Try that per-container name first; on a + // genuine NotFound fall back to the bare "" (single-container + // pods). A transient error is NOT a fallback trigger — the CP is left nil + // for this tick and the bare name stays the retry target. + perContainerName := overlayName + "-" + container.Runtime.ContainerName + var userCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedAP, ugAPErr = c.storageClient.GetApplicationProfile(rctx, ns, ugName) - return ugAPErr + userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, ns, perContainerName) + return userCPErr }) - if ugAPErr != nil { - if shouldLogOptionalUserManagedFetchError(ugAPErr) { - logger.L().Debug("failed to fetch user-managed ApplicationProfile", + switch { + case userCPErr == nil && userDefinedCP != nil: + resolvedOverlayName = perContainerName + case apierrors.IsNotFound(userCPErr): + // Fall back to the bare overlay name (single-container convention). + userDefinedCP = nil + var bareErr error + _ = c.refreshRPC(ctx, func(rctx context.Context) error { + userDefinedCP, bareErr = c.storageClient.GetContainerProfile(rctx, ns, overlayName) + return bareErr + }) + if bareErr != nil { + logger.L().Debug("user-defined ContainerProfile not available", helpers.String("containerID", containerID), helpers.String("namespace", ns), - helpers.String("name", ugName), - helpers.Error(ugAPErr)) + helpers.String("name", overlayName), + helpers.Error(bareErr)) + userDefinedCP = nil } - userManagedAP = nil + default: + // Transient error on the per-container fetch: keep probing the bare + // name on later ticks (the common single-container recovery target). + logger.L().Debug("user-defined ContainerProfile not available", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", perContainerName), + helpers.Error(userCPErr)) + userDefinedCP = nil } - ugNNName := helpersv1.UserNetworkNeighborhoodPrefix + workloadName - var ugNNErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedNN, ugNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, ugNNName) - return ugNNErr - }) - if ugNNErr != nil { - 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 + } + + // A label-referenced ContainerProfile must be USER-AUTHORED, not a learned + // one. A learned CP carries lifecycle annotations (status/completion); an + // authored one carries none. If the label resolves to a learned CP, ignore + // it — otherwise its real state is overwritten with Completed/Full below and + // a still-learning profile would be enforced as complete (false positives). + if userDefinedCP != nil { + if _, learned := userDefinedCP.Annotations[helpersv1.StatusMetadataKey]; learned { + logger.L().Warning("user-defined-profile label resolves to a learned ContainerProfile; ignoring it", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", overlayName)) + userDefinedCP = nil } } @@ -374,8 +397,11 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // Learning/ready profiles are still being written; caching them would let // rules fire against incomplete data. TooLarge is terminal: the manager // stopped collecting but the truncated data is still valid for detection. - // Return false so the synthetic-CP fallback below does not bypass the gate. - if cp != nil && !isTerminalCPStatus(cp.Annotations[helpersv1.StatusMetadataKey]) { + // This gate runs AFTER the authored-CP fetch (review finding on + // node-agent#864): a user-authored CP replaces the learned one outright, so + // if one was adopted the learned CP's non-terminal status must not block it. + // Only gate when no authored CP will be used. + if userDefinedCP == nil && cp != nil && !isTerminalCPStatus(cp.Annotations[helpersv1.StatusMetadataKey]) { logger.L().Debug("tryPopulateEntry: CP status not terminal; keeping pending", helpers.String("containerID", containerID), helpers.String("namespace", ns), @@ -383,48 +409,41 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( return false } - // Fetch user-authored legacy CRDs when the pod carries the - // UserDefinedProfileMetadataKey label. Fix (reviewer #2): fetch - // independently of the base-CP result, so a container that only has a - // user-defined profile still gets a cache entry. Recording the refs is - // gated on successful fetch here (otherwise the projection has no data - // to merge); the reconciler's refresh path re-fetches on each tick so - // transient failures are recovered. - var userAP *v1beta1.ApplicationProfile - var userNN *v1beta1.NetworkNeighborhood - overlayName, hasOverlay := container.K8s.PodLabels[helpersv1.UserDefinedProfileMetadataKey] - if hasOverlay && overlayName != "" { - var userAPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, ns, overlayName) - return userAPErr - }) - if userAPErr != nil { - logger.L().Debug("user-defined ApplicationProfile not available", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", overlayName), - helpers.Error(userAPErr)) - userAP = nil - } - var userNNErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, overlayName) - return userNNErr - }) - if userNNErr != nil { - logger.L().Debug("user-defined NetworkNeighborhood not available", + // Need SOMETHING to cache. If we have nothing, stay pending and retry. + if cp == nil && userDefinedCP == nil { + // Visibility for the upgrade path: a workload whose user-defined-profile + // label is set but resolves to nothing (e.g. still-legacy AP/NN that are + // no longer read) would otherwise pend forever with only a Debug trace. + // Warn once — before the container enters `pending` — so the periodic + // retry doesn't spam. + if hasOverlay && overlayName != "" && !c.pending.Has(containerID) { + logger.L().Warning("user-defined-profile label set but no ContainerProfile resolved; container has no profile (legacy ApplicationProfile/NetworkNeighborhood are no longer read)", helpers.String("containerID", containerID), helpers.String("namespace", ns), - helpers.String("name", overlayName), - helpers.Error(userNNErr)) - userNN = nil + helpers.String("name", overlayName)) + c.metricsManager.IncUserDefinedProfileUnresolved(ns) } + return false } - // Need SOMETHING to cache. If we have nothing, stay pending and retry. - if cp == nil && userManagedAP == nil && userManagedNN == nil && userAP == nil && userNN == nil { - return false + // Capture the LEARNED CP's ResourceVersion before cp is repointed at the + // authored profile. entry.RV must track the object entry.CPName points at + // (the learned slug). If it held the authored RV instead, refreshOneEntry + // would compare it against a GET on the learned slug — which 404s for a + // user-defined container (learning is suppressed) — and read that 404 as a + // transient error, freezing the entry so authored-CP edits are never picked + // up (review finding on node-agent#864). + learnedRV := "" + if cp != nil { + learnedRV = cp.ResourceVersion + } + + // A user-defined ContainerProfile is authoritative for this container: it is + // the migrated replacement for the AP+NN overlay pair, so it becomes the + // base. Learning is suppressed for user-defined containers, so no + // consolidated CP competes with it. + if userDefinedCP != nil { + cp = userDefinedCP } // When no consolidated CP is available, synthesize an empty CP named @@ -455,21 +474,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( helpers.String("podName", container.K8s.PodName)) } - // User-managed projection pass (published at the - // "ug-" well-known name). Legacy caches auto-merged these - // in handleUserManagedProfile after detecting the managed-by annotation; - // here we always union in whatever's published at the convention name. - // This is what Test_12_MergingProfilesTest / Test_13_MergingNetworkNeighborhoodTest - // exercise: rules must alert on events absent from the merged base+user-managed - // profile. - userManagedApplied := userManagedAP != nil || userManagedNN != nil - if userManagedApplied { - projected, warnings := projectUserProfiles(cp, userManagedAP, userManagedNN, pod, container.Runtime.ContainerName) - cp = projected - c.emitOverlayMetrics(userManagedAP, userManagedNN, warnings) - } - - entry := c.buildEntry(cp, userAP, userNN, pod, container, sharedData) + entry := c.buildEntry(cp, 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 @@ -477,26 +482,37 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // refresh queries the synthetic name, always 404s, and the fast-skip // keeps the synthetic entry forever (stored RV is "" == absent-match). entry.CPName = cpName - // Fill in user-managed bookkeeping so refreshOneEntry can re-fetch these - // sources on every tick. WorkloadName is the "ug-" lookup prefix. + // buildEntry derives RV from whatever it projected — the authored CP when one + // was adopted. refreshOneEntry compares entry.RV against a GET on entry.CPName + // (the learned slug), so leaving the authored RV here makes the permanent 404 + // on that slug look transient and freezes the entry. Track the learned RV. + entry.RV = learnedRV + // WorkloadName is the synthesize-name source refreshOneEntry uses when it + // rebuilds an entry whose consolidated CP is not yet in storage. entry.WorkloadName = workloadName - if userManagedAP != nil { - entry.UserManagedAPRV = userManagedAP.ResourceVersion - } - if userManagedNN != nil { - entry.UserManagedNNRV = userManagedNN.ResourceVersion - } - // Fix (reviewer #2): when the overlay label is set, record UserAPRef / - // UserNNRef even if the initial fetch failed. The refresh loop uses - // these refs to re-fetch on every tick; without them, a transient 404 - // at add time would permanently lose the overlay. + // When the overlay label is set, ALWAYS record UserCPRef so the reconciler + // keeps probing for the user-authored ContainerProfile on every tick — even + // when this first fetch failed (transient error, or the CP simply hasn't + // landed yet). refreshOneEntry only re-fetches the user-defined CP + // `if e.UserCPRef != nil`; without this unconditional assignment a transient + // error at add time would leave the container without an authored profile + // until it restarts. There is no legacy AP/NN fallback anymore — the CP is + // the only user-defined source. if hasOverlay && overlayName != "" { - if entry.UserAPRef == nil { - entry.UserAPRef = &namespacedName{Namespace: ns, Name: overlayName} - } - if entry.UserNNRef == nil { - entry.UserNNRef = &namespacedName{Namespace: ns, Name: overlayName} + entry.UserCPRef = &namespacedName{Namespace: ns, Name: resolvedOverlayName} + if userDefinedCP != nil { + entry.UserCPRV = userDefinedCP.ResourceVersion + // A user-authored profile is authoritative and complete by + // definition — it carries no learning-lifecycle status/completion + // annotations (those are meaningless on an authored profile). Force + // the terminal state so the rule engine enforces it (rule_manager + // gates on Completed+Full). + entry.State = &objectcache.ProfileState{ + Status: helpersv1.Completed, + Completion: helpersv1.Full, + Name: userDefinedCP.Name, + } } } @@ -519,8 +535,6 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // stored. func (c *ContainerProfileCacheImpl) buildEntry( cp *v1beta1.ContainerProfile, - userAP *v1beta1.ApplicationProfile, - userNN *v1beta1.NetworkNeighborhood, pod *corev1.Pod, container *containercollection.Container, sharedData *objectcache.WatchedContainerData, @@ -537,21 +551,9 @@ func (c *ContainerProfileCacheImpl) buildEntry( entry.PodUID = string(pod.UID) } - // Apply label-referenced user overlay (if any). + // The base is authoritative as-is: a user-defined profile is a whole + // ContainerProfile adopted directly as `cp` (no AP/NN merge). 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 - } - if userNN != nil { - entry.UserNNRef = &namespacedName{Namespace: userNN.Namespace, Name: userNN.Name} - entry.UserNNRV = userNN.ResourceVersion - } - c.emitOverlayMetrics(userAP, userNN, warnings) - } // Build call-stack search tree. tree := callstackcache.NewCallStackSearchTree() diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go index 66dbbcaf43..d2eef13649 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go @@ -26,68 +26,60 @@ import ( // always returns the same CP pointer (so the fast-path can be asserted via // pointer equality). type fakeProfileClient struct { - cp *v1beta1.ContainerProfile - ap *v1beta1.ApplicationProfile // returned for Get by ap.Name match (or any if overlayOnly is empty) - nn *v1beta1.NetworkNeighborhood - cpErr error - apErr error - nnErr error - - // userManagedAP / userManagedNN, when non-nil, are returned for any - // GetApplicationProfile / GetNetworkNeighborhood whose name starts with - // the "ug-" prefix (the convention used by legacy user-managed profiles). - // This lets tests exercise the user-managed merge path added for - // Test_12_MergingProfilesTest / Test_13_MergingNetworkNeighborhoodTest - // without fighting the overlayOnly restriction. - userManagedAP *v1beta1.ApplicationProfile - userManagedNN *v1beta1.NetworkNeighborhood - - // overlayOnly, if non-empty, restricts ap/nn returns to only the given - // name; other names return (nil, nil). Tests that mix workload-AP/NN - // with overlay-AP/NN use this to keep the fixture scoped. + cp *v1beta1.ContainerProfile + // userCP, when non-nil, is returned by GetContainerProfile for a name + // matching userCP.Name (the migrated user-defined ContainerProfile). Other + // names fall through to cp. Lets tests exercise the new-way overlay path. + userCP *v1beta1.ContainerProfile + cpErr error + + // userCPsByName, when non-empty, is consulted before the cp/userCP + // fallbacks: a name present in the map returns its CP (nil error), a name + // absent returns cp/cpErr. Lets tests publish DISTINCT authored + // ContainerProfiles per container name ("-") so the + // per-container binding path can be exercised end-to-end. + userCPsByName map[string]*v1beta1.ContainerProfile + + // overlayOnly, if non-empty, scopes the overlay name whose GetContainerProfile + // returns a genuine NotFound (or overlayCPErr). Tests use this to keep the + // user-defined-CP fixture scoped. overlayOnly string + // overlayCPErr, when non-nil, is returned by GetContainerProfile for a + // name matching overlayOnly, instead of the default NotFound. Lets tests + // simulate a *transient* RPC failure on the user-defined-CP fetch (as + // opposed to a genuine "doesn't exist yet"), to prove the overlay is not + // permanently lost. + overlayCPErr error + getCPCalls int } 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 - } - if f.overlayOnly != "" && name != f.overlayOnly { - return nil, nil +func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { + f.getCPCalls++ + // Name-keyed authored CPs take precedence: this is how a multi-container pod + // serves a different CP per "-" name. + if f.userCPsByName != nil { + if cp, ok := f.userCPsByName[name]; ok { + return cp, nil + } } - return f.ap, f.apErr -} -func (f *fakeProfileClient) GetNetworkNeighborhood(_ context.Context, _, name string) (*v1beta1.NetworkNeighborhood, error) { - if len(name) >= 3 && name[:3] == helpersv1.UserNetworkNeighborhoodPrefix { - return f.userManagedNN, nil + if f.userCP != nil && name == f.userCP.Name { + return f.userCP, nil } - if f.overlayOnly != "" && name != f.overlayOnly { - return nil, nil + // The overlay label points at overlayOnly; with no user CP published at that + // name it is absent (or a transient error). The base CP fetch uses the + // derived slug, a different name, and still gets f.cp. + if f.overlayOnly != "" && name == f.overlayOnly { + if f.overlayCPErr != nil { + return nil, f.overlayCPErr + } + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, name) } - return f.nn, f.nnErr -} -func (f *fakeProfileClient) GetContainerProfile(_ context.Context, _, _ string) (*v1beta1.ContainerProfile, error) { - f.getCPCalls++ return f.cp, f.cpErr } -func (f *fakeProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (f *fakeProfileClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // newTestCache returns a cache wired with an in-memory K8sObjectCacheMock. func newTestCache(t *testing.T, client storage.ProfileClient) (*ContainerProfileCacheImpl, *objectcache.K8sObjectCacheMock) { @@ -168,29 +160,79 @@ func TestSharedFastPath_NoOverlay(t *testing.T) { assert.NotNil(t, entryB.Projected, "entry B must have a projected profile") } -// 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{ +// TestOverlayPath_UserDefinedCP_NewWay verifies the migrated path: when the +// user-defined-profile label names a user-authored ContainerProfile +// (managed-by: User), it becomes the authoritative base — UserCPRef is set and +// the projection reflects the CP. +func TestOverlayPath_UserDefinedCP_NewWay(t *testing.T) { + // A genuine authored CP carries NO learning-lifecycle annotations (no + // status/completion) — only managed-by: User. A CP that carried a status + // annotation would be treated as learned and ignored (see + // TestUserDefinedCP_LearnedProfileIgnored). + userCP := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ - Name: "cp-1", Namespace: "default", ResourceVersion: "1", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, + Name: "override", Namespace: "default", ResourceVersion: "uc1", + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + }, }, - Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"NET_BIND_SERVICE"}}, } - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, + // cp: nil (learning suppressed for user-defined); userCP served at "override". + client := &fakeProfileClient{cp: nil, cpErr: apierrors.NewNotFound(schema.GroupResource{}, "x"), userCP: userCP} + c, k8s := newTestCache(t, client) + + id := "container-udcp" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "override"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + assert.NotNil(t, entry.Projected, "user-defined CP path must produce a projected profile") + require.NotNil(t, entry.UserCPRef, "UserCPRef must be recorded for refresh") + assert.Equal(t, "override", entry.UserCPRef.Name) + assert.Equal(t, "uc1", entry.UserCPRV) +} + +// TestOverlayPath_CPFetchTransientError_RecordsUserCPRef pins the cutover +// semantics: when the overlay label is present but the GetContainerProfile +// fetch at the overlay name fails *transiently* (an RPC error, not a genuine +// absence), an entry that is still built from a present base CP must record +// UserCPRef so the reconciler keeps probing for the user-defined CP on later +// ticks. There is no legacy AP/NN fallback anymore — the CP is the only +// user-defined source — so without this the authored profile would be silently +// lost until the container restarts. +// +// This test fails on code that does not record UserCPRef on a transient +// overlay-CP fetch failure and passes once it does. +func TestOverlayPath_CPFetchTransientError_RecordsUserCPRef(t *testing.T) { + // A completed base CP is present (fetched by the derived slug name), but the + // CP fetch at the overlay name errors transiently, so userDefinedCP is nil + // for this add and the entry is built from the base CP. + baseCP := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cp-base", Namespace: "default", ResourceVersion: "1", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, + }, }, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, + } + // The per-container fetch ("override-nginx") errors transiently. A transient + // error is not a fallback trigger, so the bare "override" stays the recorded + // retry target — proving UserCPRef is set even when this fetch fails. + client := &fakeProfileClient{ + cp: baseCP, + overlayOnly: "override-nginx", + overlayCPErr: errors.New("etcdserver: request timed out"), // transient } - client := &fakeProfileClient{cp: cp, ap: userAP, overlayOnly: "override"} c, k8s := newTestCache(t, client) - id := "container-overlay" + id := "container-cp-transient" primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") ev := eventContainer(id) @@ -199,10 +241,12 @@ func TestOverlayPath_DeepCopies(t *testing.T) { entry, ok := c.entries.Load(id) require.True(t, ok) - 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) + // The contract: UserCPRef is recorded even though the overlay-CP fetch failed + // transiently, so refreshOneEntry (which only re-fetches the CP + // `if e.UserCPRef != nil`) will retry it once the transient error clears. + require.NotNil(t, entry.UserCPRef, "UserCPRef must be recorded so the reconciler retries the CP after a transient failure") + assert.Equal(t, "override", entry.UserCPRef.Name) + assert.Equal(t, "default", entry.UserCPRef.Namespace) } // TestDeleteContainer_LockAndCleanup verifies that deleteContainer removes @@ -321,6 +365,316 @@ func TestCallStackIndexBuiltFromProfile(t *testing.T) { assert.True(t, hasCallID, "call-stack tree must contain CallID 'r1' from CP") } +// authoredCP builds a genuine user-authored ContainerProfile: managed-by: User +// and, crucially, NO learning-lifecycle annotations (no status/completion), so +// the authored-validation gate does not treat it as a learned profile. Its spec +// carries a single distinctive Exec so per-container adoption is observable in +// the projection. +func authoredCP(name, execPath, rv string) *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, Namespace: "default", ResourceVersion: rv, + Annotations: map[string]string{ + helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, + }, + }, + Spec: v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: execPath}}}, + } +} + +// execsAllSpec is a projection spec that retains every Exec path in Values so +// tests can assert per-container adoption via the projected allow-list. +func execsAllSpec(hash string) objectcache.RuleProjectionSpec { + return objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: hash, + } +} + +// TestUserDefinedCP_PerContainerBinding proves blocker #2: in a multi-container +// pod that shares ONE user-defined-profile label value, each container must +// adopt its OWN authored ContainerProfile, resolved by the +// "-" naming convention — not the same CP for every +// container. +func TestUserDefinedCP_PerContainerBinding(t *testing.T) { + frontend := authoredCP("nw-20-multi-container-frontend", "/bin/frontend", "1") + sidecar := authoredCP("nw-20-multi-container-sidecar", "/bin/sidecar", "1") + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{ + "nw-20-multi-container-frontend": frontend, + "nw-20-multi-container-sidecar": sidecar, + }, + } + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(execsAllSpec("per-container")) + + cases := []struct { + id, cname, ownExec, otherExec, cpName string + }{ + {"cid-frontend", "frontend", "/bin/frontend", "/bin/sidecar", "nw-20-multi-container-frontend"}, + {"cid-sidecar", "sidecar", "/bin/sidecar", "/bin/frontend", "nw-20-multi-container-sidecar"}, + } + for _, tc := range cases { + primeSharedData(t, k8s, tc.id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(tc.id) + ev.Runtime.ContainerName = tc.cname + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "nw-20-multi-container"} + require.NoError(t, c.addContainer(ev, context.Background())) + } + + for _, tc := range cases { + entry, ok := c.entries.Load(tc.id) + require.True(t, ok, "entry present for %s", tc.cname) + require.NotNil(t, entry.UserCPRef) + assert.Equal(t, tc.cpName, entry.UserCPRef.Name, + "%s must resolve to its per-container CP so the reconciler re-fetches the same object", tc.cname) + proj := c.GetProjectedContainerProfile(tc.id) + require.NotNil(t, proj) + _, hasOwn := proj.Execs.Values[tc.ownExec] + _, hasOther := proj.Execs.Values[tc.otherExec] + assert.True(t, hasOwn, "%s must adopt its OWN CP (%s present)", tc.cname, tc.ownExec) + assert.False(t, hasOther, "%s must NOT adopt the sibling container's CP (%s absent)", tc.cname, tc.otherExec) + } +} + +// TestUserDefinedCP_SingleContainerBareFallback proves the single-container +// fallback in blocker #2: when no "-" CP exists, the +// resolver falls back to the bare "" name. +func TestUserDefinedCP_SingleContainerBareFallback(t *testing.T) { + bare := authoredCP("override", "/bin/only", "1") + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{"override": bare}, + } + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(execsAllSpec("bare-fallback")) + + id := "cid-single" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) // ContainerName "nginx" → per-container "override-nginx" is absent + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "override"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.NotNil(t, entry.UserCPRef) + assert.Equal(t, "override", entry.UserCPRef.Name, "single-container pod falls back to the bare overlay name") + proj := c.GetProjectedContainerProfile(id) + require.NotNil(t, proj) + _, hasOnly := proj.Execs.Values["/bin/only"] + assert.True(t, hasOnly, "bare-name CP must be adopted") +} + +// TestUserDefinedCP_LearnedProfileIgnored proves blocker #3 on the add path: a +// CP published at the label name that carries a lifecycle status ("ready") is a +// LEARNED profile, not authored. It must be ignored — never adopted and never +// force-enforced as Completed/Full. With no other profile source, the container +// stays pending. +func TestUserDefinedCP_LearnedProfileIgnored(t *testing.T) { + learnedAtLabel := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ready-cp", Namespace: "default", ResourceVersion: "1", + Annotations: map[string]string{ + helpersv1.StatusMetadataKey: helpersv1.Learning, // status: ready → still learning + helpersv1.CompletionMetadataKey: helpersv1.Partial, + }, + }, + Spec: v1beta1.ContainerProfileSpec{Execs: []v1beta1.ExecCalls{{Path: "/bin/leaked"}}}, + } + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{"ready-cp": learnedAtLabel}, + } + c, k8s := newTestCache(t, client) + + id := "cid-learned-label" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "ready-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + _, ok := c.entries.Load(id) + assert.False(t, ok, "a learned CP at the label name must NOT be adopted/force-enforced") + assert.Equal(t, 1, c.pending.Len(), "container stays pending when the label resolves only to a learned CP") +} + +// TestRefreshReflectsAuthoredCPEdit_RVFreezeProof is the key proof for fix #1 +// (RV freeze). A user-defined-only container (NO learned CP — learning is +// suppressed) is added; the entry's learned RV must be empty. When the authored +// CP is later edited (RV bumped + spec changed), a single refresh MUST reflect +// the edit. This only holds because entry.RV tracks the LEARNED slug (empty), +// so the permanent 404 on that slug during refresh is not mistaken for a +// transient error that would freeze the entry. +func TestRefreshReflectsAuthoredCPEdit_RVFreezeProof(t *testing.T) { + authored := authoredCP("authored-cp-nginx", "/bin/init", "a1") + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{"authored-cp-nginx": authored}, + } + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(execsAllSpec("rv-freeze")) + + id := "cid-rvfreeze" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "authored-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.NotNil(t, entry.UserCPRef) + assert.Equal(t, "authored-cp-nginx", entry.UserCPRef.Name) + assert.Equal(t, "", entry.RV, "learned RV must be empty (no learned CP) — the freeze-proof invariant") + assert.Equal(t, "a1", entry.UserCPRV) + + before := c.GetProjectedContainerProfile(id) + require.NotNil(t, before) + _, hasInit := before.Execs.Values["/bin/init"] + assert.True(t, hasInit) + _, hasEditedYet := before.Execs.Values["/bin/edited"] + require.False(t, hasEditedYet, "edit not applied before it happens") + + // Edit the authored CP: bump RV and append an Exec. + authored.ResourceVersion = "a2" + authored.Spec.Execs = append(authored.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/edited"}) + + c.refreshAllEntries(context.Background()) + + after := c.GetProjectedContainerProfile(id) + require.NotNil(t, after) + _, hasEditedNow := after.Execs.Values["/bin/edited"] + assert.True(t, hasEditedNow, "authored-CP edit MUST be reflected after one refresh (entry not frozen)") + updated, _ := c.entries.Load(id) + assert.Equal(t, "a2", updated.UserCPRV, "UserCPRV must track the edited authored CP") + assert.Equal(t, "", updated.RV, "learned RV stays empty across refresh") +} + +// TestRefreshUserCP_NoLearnedCP covers the user-defined-only refresh path: an +// authored CP present with NO learned CP is force-enforced Completed/Full at add +// time, and an unchanged refresh fast-skips (same entry pointer) while keeping +// the terminal state. +func TestRefreshUserCP_NoLearnedCP(t *testing.T) { + authored := authoredCP("authored-cp-nginx", "/bin/authored", "a1") + client := &fakeProfileClient{ + cp: nil, + cpErr: apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, "learned"), + userCPsByName: map[string]*v1beta1.ContainerProfile{"authored-cp-nginx": authored}, + } + c, k8s := newTestCache(t, client) + + id := "cid-nolearned" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "authored-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + assert.Equal(t, "", entry.RV, "no learned CP → learned RV empty") + assert.Equal(t, "a1", entry.UserCPRV) + require.NotNil(t, entry.State) + assert.Equal(t, helpersv1.Completed, entry.State.Status, "authored CP is force-enforced Completed") + assert.Equal(t, helpersv1.Full, entry.State.Completion, "authored CP is force-enforced Full") + + c.refreshAllEntries(context.Background()) + + stored, ok := c.entries.Load(id) + require.True(t, ok) + assert.Same(t, entry, stored, "no source changed → fast-skip keeps the same entry pointer") + assert.Equal(t, helpersv1.Completed, stored.State.Status) +} + +// TestRefreshUserCP_FastSkipWhenRVsMatch: with BOTH a learned base CP and an +// authored CP, an unchanged refresh (learned RV + authored RV both match) +// fast-skips and preserves the entry pointer. +func TestRefreshUserCP_FastSkipWhenRVsMatch(t *testing.T) { + learned := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "learned-base", Namespace: "default", ResourceVersion: "L1", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, + }, + }, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"NET_ADMIN"}}, + } + authored := authoredCP("authored-cp-nginx", "/bin/authored", "a1") + client := &fakeProfileClient{ + cp: learned, + userCPsByName: map[string]*v1beta1.ContainerProfile{"authored-cp-nginx": authored}, + } + c, k8s := newTestCache(t, client) + + id := "cid-fastskip" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "authored-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.NotEmpty(t, entry.RV, "learned RV recorded") + require.Equal(t, "a1", entry.UserCPRV, "authored RV recorded") + + c.refreshAllEntries(context.Background()) + + stored, ok := c.entries.Load(id) + require.True(t, ok) + assert.Same(t, entry, stored, "matching learned RV + authored RV → fast-skip, same pointer") +} + +// TestRefreshUserCP_RebuildWhenUserCPRVChanges: with the learned RV unchanged +// but the authored CP's RV bumped, refresh rebuilds the entry and the edit is +// reflected. +func TestRefreshUserCP_RebuildWhenUserCPRVChanges(t *testing.T) { + learned := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Name: "learned-base", Namespace: "default", ResourceVersion: "L1", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, + }, + }, + } + authored := authoredCP("authored-cp-nginx", "/bin/v1", "a1") + client := &fakeProfileClient{ + cp: learned, + userCPsByName: map[string]*v1beta1.ContainerProfile{"authored-cp-nginx": authored}, + } + c, k8s := newTestCache(t, client) + c.SetProjectionSpec(execsAllSpec("usercp-rebuild")) + + id := "cid-usercp-rebuild" + primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") + ev := eventContainer(id) + ev.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "authored-cp"} + require.NoError(t, c.addContainer(ev, context.Background())) + + entry, ok := c.entries.Load(id) + require.True(t, ok) + require.Equal(t, "a1", entry.UserCPRV) + + // Bump ONLY the authored CP (learned RV stays L1). + authored.ResourceVersion = "a2" + authored.Spec.Execs = append(authored.Spec.Execs, v1beta1.ExecCalls{Path: "/bin/v2"}) + + c.refreshAllEntries(context.Background()) + + stored, ok := c.entries.Load(id) + require.True(t, ok) + assert.NotSame(t, entry, stored, "authored RV change → rebuild, new pointer") + assert.Equal(t, "a2", stored.UserCPRV, "UserCPRV updated to the edited authored CP") + proj := c.GetProjectedContainerProfile(id) + require.NotNil(t, proj) + _, hasV2 := proj.Execs.Values["/bin/v2"] + assert.True(t, hasV2, "the authored-CP edit is reflected after rebuild") +} + // TestGetContainerProfile_Miss sanity-checks the nil path returns nil and a // synthetic error ProfileState (no panic). func TestGetContainerProfile_Miss(t *testing.T) { diff --git a/pkg/objectcache/containerprofilecache/export_test.go b/pkg/objectcache/containerprofilecache/export_test.go index c5277665c0..fe3ae6ee0f 100644 --- a/pkg/objectcache/containerprofilecache/export_test.go +++ b/pkg/objectcache/containerprofilecache/export_test.go @@ -36,15 +36,3 @@ func (c *ContainerProfileCacheImpl) WarmPendingForTest(ids []string) { c.pending.Delete(id) } } - -// SeedEntryWithOverlayForTest seeds an entry with user AP and NN overlay refs. -// Pass empty strings to leave a ref nil. -func (c *ContainerProfileCacheImpl) SeedEntryWithOverlayForTest(containerID string, entry *CachedContainerProfile, apNS, apName, nnNS, nnName string) { - if apName != "" { - entry.UserAPRef = &namespacedName{Namespace: apNS, Name: apName} - } - if nnName != "" { - entry.UserNNRef = &namespacedName{Namespace: nnNS, Name: nnName} - } - c.entries.Set(containerID, entry) -} diff --git a/pkg/objectcache/containerprofilecache/integration_helpers_test.go b/pkg/objectcache/containerprofilecache/integration_helpers_test.go index 4965f0c732..c72d5725f7 100644 --- a/pkg/objectcache/containerprofilecache/integration_helpers_test.go +++ b/pkg/objectcache/containerprofilecache/integration_helpers_test.go @@ -55,8 +55,6 @@ func makeTestPod(name, namespace, uid string, containerStatuses []corev1.Contain type stubStorage struct { mu sync.RWMutex cp *v1beta1.ContainerProfile - ap *v1beta1.ApplicationProfile - nn *v1beta1.NetworkNeighborhood } var _ storage.ProfileClient = (*stubStorage)(nil) @@ -71,26 +69,6 @@ func (s *stubStorage) GetContainerProfile(_ context.Context, _, _ string) (*v1be return s.cp, nil } -func (s *stubStorage) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.ap, nil -} - -func (s *stubStorage) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.nn, nil -} - -func (s *stubStorage) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} - -func (s *stubStorage) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} - // stubK8sCache is a controllable K8sObjectCache stub. type stubK8sCache struct { mu sync.RWMutex diff --git a/pkg/objectcache/containerprofilecache/metrics.go b/pkg/objectcache/containerprofilecache/metrics.go deleted file mode 100644 index 3a3a48cee7..0000000000 --- a/pkg/objectcache/containerprofilecache/metrics.go +++ /dev/null @@ -1,66 +0,0 @@ -package containerprofilecache - -import ( - "fmt" - - "github.com/kubescape/go-logger" - "github.com/kubescape/go-logger/helpers" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" -) - -// Kind labels for ReportContainerProfileLegacyLoad and related metrics. -const ( - kindApplication = "application" - kindNetwork = "network" - - completenessFull = "full" - completenessPartial = "partial" -) - -// reportDeprecationWarn emits a one-shot WARN log for a user-authored legacy -// CRD (ApplicationProfile or NetworkNeighborhood) that was merged into the -// ContainerProfile. Dedup key is (kind, namespace, name, resourceVersion) so a -// single RV only logs once per process lifetime, even across many containers. -func (c *ContainerProfileCacheImpl) reportDeprecationWarn(kind, namespace, name, rv string, reason string) { - key := fmt.Sprintf("%s|%s/%s@%s", kind, namespace, name, rv) - if _, already := c.deprecationDedup.LoadOrStore(key, struct{}{}); already { - return - } - logger.L().Warning("ContainerProfileCache - user-authored legacy profile merged (deprecated)", - helpers.String("kind", kind), - helpers.String("namespace", namespace), - helpers.String("name", name), - helpers.String("resourceVersion", rv), - helpers.String("reason", reason)) -} - -// emitOverlayMetrics fires the per-kind completeness metric + deprecation WARN -// once per (kind, namespace, name, rv). Shared by addContainer's buildEntry -// and the reconciler's rebuildEntry so the two stay in lockstep. -func (c *ContainerProfileCacheImpl) emitOverlayMetrics( - userAP *v1beta1.ApplicationProfile, - userNN *v1beta1.NetworkNeighborhood, - warnings []partialProfileWarning, -) { - partialByKind := map[string]struct{}{} - for _, w := range warnings { - partialByKind[w.Kind] = struct{}{} - c.metricsManager.ReportContainerProfileLegacyLoad(w.Kind, completenessPartial) - c.reportDeprecationWarn(w.Kind, w.Namespace, w.Name, w.ResourceVersion, - fmt.Sprintf("pod has containers missing from user CRD: %v", w.MissingContainers)) - } - if userAP != nil { - if _, partial := partialByKind[kindApplication]; !partial { - c.metricsManager.ReportContainerProfileLegacyLoad(kindApplication, completenessFull) - } - c.reportDeprecationWarn(kindApplication, userAP.Namespace, userAP.Name, userAP.ResourceVersion, - "user-authored ApplicationProfile merged into ContainerProfile") - } - if userNN != nil { - if _, partial := partialByKind[kindNetwork]; !partial { - c.metricsManager.ReportContainerProfileLegacyLoad(kindNetwork, completenessFull) - } - c.reportDeprecationWarn(kindNetwork, userNN.Namespace, userNN.Name, userNN.ResourceVersion, - "user-authored NetworkNeighborhood merged into ContainerProfile") - } -} diff --git a/pkg/objectcache/containerprofilecache/projection.go b/pkg/objectcache/containerprofilecache/projection.go deleted file mode 100644 index da1e45fb2f..0000000000 --- a/pkg/objectcache/containerprofilecache/projection.go +++ /dev/null @@ -1,352 +0,0 @@ -package containerprofilecache - -import ( - "github.com/kubescape/node-agent/pkg/utils" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// partialProfileWarning describes a user-authored legacy CRD that couldn't be -// fully merged into the ContainerProfile (e.g. the user CRD is missing entries -// for containers that exist in the pod spec). Emitted by the cache at merge -// time for deprecation observability. -type partialProfileWarning struct { - Kind string // "application" | "network" - Namespace string - Name string - ResourceVersion string - MissingContainers []string -} - -// projectUserProfiles overlays a user-authored ApplicationProfile and/or -// NetworkNeighborhood onto a base ContainerProfile for a single container. -// Returns a DeepCopy of the base with user fields merged in and a list of -// partial-merge warnings when the user CRD doesn't cover every container in -// the pod spec. -// -// cp MUST be non-nil. Either (or both) of userAP / userNN may be nil; nil -// user inputs contribute no merge but also no warning. pod may be nil, in -// which case the missing-container check is skipped (but the name-based -// per-container merge still runs). -func projectUserProfiles( - cp *v1beta1.ContainerProfile, - userAP *v1beta1.ApplicationProfile, - userNN *v1beta1.NetworkNeighborhood, - pod *corev1.Pod, - containerName string, -) (projected *v1beta1.ContainerProfile, warnings []partialProfileWarning) { - projected = cp.DeepCopy() - - if userAP != nil { - if missing := mergeApplicationProfile(projected, userAP, pod, containerName); len(missing) > 0 { - warnings = append(warnings, partialProfileWarning{ - Kind: kindApplication, - Namespace: userAP.Namespace, - Name: userAP.Name, - ResourceVersion: userAP.ResourceVersion, - MissingContainers: missing, - }) - } - } - - if userNN != nil { - if missing := mergeNetworkNeighborhood(projected, userNN, pod, containerName); len(missing) > 0 { - warnings = append(warnings, partialProfileWarning{ - Kind: kindNetwork, - Namespace: userNN.Namespace, - Name: userNN.Name, - ResourceVersion: userNN.ResourceVersion, - MissingContainers: missing, - }) - } - } - - return projected, warnings -} - -// mergeApplicationProfile finds the container entry in userAP matching -// containerName (across Spec.Containers / InitContainers / EphemeralContainers) -// and merges its fields into projected.Spec. Returns the list of pod-spec -// container names that are not present anywhere in userAP.Spec. -// -// ported from pkg/objectcache/applicationprofilecache/applicationprofilecache.go:660-673 -// (mergeContainer), applied here to a single-container ContainerProfile -// instead of a full ApplicationProfile. -func mergeApplicationProfile(projected *v1beta1.ContainerProfile, userAP *v1beta1.ApplicationProfile, pod *corev1.Pod, containerName string) []string { - // Defensive copy: slices inside matched (e.g. Execs[i].Args, Opens[i].Flags, - // Endpoints[i].Methods) would otherwise alias the caller's CRD object and - // could change if the CRD is refreshed concurrently. - userAP = userAP.DeepCopy() - if matched := findUserAPContainer(userAP, containerName); matched != nil { - projected.Spec.Capabilities = append(projected.Spec.Capabilities, matched.Capabilities...) - projected.Spec.Execs = append(projected.Spec.Execs, matched.Execs...) - projected.Spec.Opens = append(projected.Spec.Opens, matched.Opens...) - projected.Spec.Syscalls = append(projected.Spec.Syscalls, matched.Syscalls...) - projected.Spec.Endpoints = append(projected.Spec.Endpoints, matched.Endpoints...) - if projected.Spec.PolicyByRuleId == nil && len(matched.PolicyByRuleId) > 0 { - projected.Spec.PolicyByRuleId = make(map[string]v1beta1.RulePolicy, len(matched.PolicyByRuleId)) - } - for k, v := range matched.PolicyByRuleId { - if existing, ok := projected.Spec.PolicyByRuleId[k]; ok { - projected.Spec.PolicyByRuleId[k] = utils.MergePolicies(existing, v) - } else { - projected.Spec.PolicyByRuleId[k] = v - } - } - } - - return missingPodContainers(pod, userAPNames(userAP)) -} - -// mergeNetworkNeighborhood finds the container entry in userNN matching -// containerName and merges its Ingress/Egress into projected.Spec, then -// overlays the user CRD's pod LabelSelector onto projected's embedded -// LabelSelector. Returns missing-from-userNN pod container names. -// -// ported from pkg/objectcache/networkneighborhoodcache/networkneighborhoodcache.go:560-636 -// (performMerge, mergeContainer, mergeNetworkNeighbors) applied to a single -// container's rules on a ContainerProfile. -func mergeNetworkNeighborhood(projected *v1beta1.ContainerProfile, userNN *v1beta1.NetworkNeighborhood, pod *corev1.Pod, containerName string) []string { - // Defensive copy: neighbor slices (DNSNames, Ports, MatchExpressions) and - // LabelSelector.MatchExpressions would otherwise alias the caller's CRD. - userNN = userNN.DeepCopy() - if matched := findUserNNContainer(userNN, containerName); matched != nil { - projected.Spec.Ingress = mergeNetworkNeighbors(projected.Spec.Ingress, matched.Ingress) - projected.Spec.Egress = mergeNetworkNeighbors(projected.Spec.Egress, matched.Egress) - } - - // Merge LabelSelector (ContainerProfileSpec embeds metav1.LabelSelector). - if userNN.Spec.LabelSelector.MatchLabels != nil { - if projected.Spec.LabelSelector.MatchLabels == nil { - projected.Spec.LabelSelector.MatchLabels = make(map[string]string) - } - for k, v := range userNN.Spec.LabelSelector.MatchLabels { - projected.Spec.LabelSelector.MatchLabels[k] = v - } - } - projected.Spec.LabelSelector.MatchExpressions = append( - projected.Spec.LabelSelector.MatchExpressions, - userNN.Spec.LabelSelector.MatchExpressions..., - ) - - return missingPodContainers(pod, userNNNames(userNN)) -} - -func findUserAPContainer(userAP *v1beta1.ApplicationProfile, containerName string) *v1beta1.ApplicationProfileContainer { - if userAP == nil { - return nil - } - for i := range userAP.Spec.Containers { - if userAP.Spec.Containers[i].Name == containerName { - return &userAP.Spec.Containers[i] - } - } - for i := range userAP.Spec.InitContainers { - if userAP.Spec.InitContainers[i].Name == containerName { - return &userAP.Spec.InitContainers[i] - } - } - for i := range userAP.Spec.EphemeralContainers { - if userAP.Spec.EphemeralContainers[i].Name == containerName { - return &userAP.Spec.EphemeralContainers[i] - } - } - return nil -} - -func findUserNNContainer(userNN *v1beta1.NetworkNeighborhood, containerName string) *v1beta1.NetworkNeighborhoodContainer { - if userNN == nil { - return nil - } - for i := range userNN.Spec.Containers { - if userNN.Spec.Containers[i].Name == containerName { - return &userNN.Spec.Containers[i] - } - } - for i := range userNN.Spec.InitContainers { - if userNN.Spec.InitContainers[i].Name == containerName { - return &userNN.Spec.InitContainers[i] - } - } - for i := range userNN.Spec.EphemeralContainers { - if userNN.Spec.EphemeralContainers[i].Name == containerName { - return &userNN.Spec.EphemeralContainers[i] - } - } - return nil -} - -func userAPNames(userAP *v1beta1.ApplicationProfile) map[string]struct{} { - names := map[string]struct{}{} - if userAP == nil { - return names - } - for _, c := range userAP.Spec.Containers { - names[c.Name] = struct{}{} - } - for _, c := range userAP.Spec.InitContainers { - names[c.Name] = struct{}{} - } - for _, c := range userAP.Spec.EphemeralContainers { - names[c.Name] = struct{}{} - } - return names -} - -func userNNNames(userNN *v1beta1.NetworkNeighborhood) map[string]struct{} { - names := map[string]struct{}{} - if userNN == nil { - return names - } - for _, c := range userNN.Spec.Containers { - names[c.Name] = struct{}{} - } - for _, c := range userNN.Spec.InitContainers { - names[c.Name] = struct{}{} - } - for _, c := range userNN.Spec.EphemeralContainers { - names[c.Name] = struct{}{} - } - return names -} - -// missingPodContainers returns the set of pod-spec container names that are -// not present in the given set. If pod is nil, returns nil (check skipped). -func missingPodContainers(pod *corev1.Pod, have map[string]struct{}) []string { - if pod == nil { - return nil - } - var missing []string - for _, c := range pod.Spec.Containers { - if _, ok := have[c.Name]; !ok { - missing = append(missing, c.Name) - } - } - for _, c := range pod.Spec.InitContainers { - if _, ok := have[c.Name]; !ok { - missing = append(missing, c.Name) - } - } - for _, c := range pod.Spec.EphemeralContainers { - if _, ok := have[c.Name]; !ok { - missing = append(missing, c.Name) - } - } - return missing -} - -// mergeNetworkNeighbors merges user neighbors into a normal-neighbor list, -// keyed by Identifier. ported from -// pkg/objectcache/networkneighborhoodcache/networkneighborhoodcache.go:617-636. -func mergeNetworkNeighbors(normalNeighbors, userNeighbors []v1beta1.NetworkNeighbor) []v1beta1.NetworkNeighbor { - neighborMap := make(map[string]int, len(normalNeighbors)) - for i, neighbor := range normalNeighbors { - neighborMap[neighbor.Identifier] = i - } - for _, userNeighbor := range userNeighbors { - if idx, exists := neighborMap[userNeighbor.Identifier]; exists { - normalNeighbors[idx] = mergeNetworkNeighbor(normalNeighbors[idx], userNeighbor) - } else { - normalNeighbors = append(normalNeighbors, userNeighbor) - } - } - return normalNeighbors -} - -// mergeNetworkNeighbor merges a user-managed neighbor into an existing one. -// ported from -// pkg/objectcache/networkneighborhoodcache/networkneighborhoodcache.go:638-706. -func mergeNetworkNeighbor(normal, user v1beta1.NetworkNeighbor) v1beta1.NetworkNeighbor { - merged := normal.DeepCopy() - - dnsNamesSet := make(map[string]struct{}) - for _, dns := range normal.DNSNames { - dnsNamesSet[dns] = struct{}{} - } - for _, dns := range user.DNSNames { - dnsNamesSet[dns] = struct{}{} - } - merged.DNSNames = make([]string, 0, len(dnsNamesSet)) - for dns := range dnsNamesSet { - merged.DNSNames = append(merged.DNSNames, dns) - } - - merged.Ports = mergeNetworkPorts(merged.Ports, user.Ports) - - if user.PodSelector != nil { - if merged.PodSelector == nil { - merged.PodSelector = &metav1.LabelSelector{} - } - if user.PodSelector.MatchLabels != nil { - if merged.PodSelector.MatchLabels == nil { - merged.PodSelector.MatchLabels = make(map[string]string) - } - for k, v := range user.PodSelector.MatchLabels { - merged.PodSelector.MatchLabels[k] = v - } - } - merged.PodSelector.MatchExpressions = append( - merged.PodSelector.MatchExpressions, - user.PodSelector.MatchExpressions..., - ) - } - - if user.NamespaceSelector != nil { - if merged.NamespaceSelector == nil { - merged.NamespaceSelector = &metav1.LabelSelector{} - } - if user.NamespaceSelector.MatchLabels != nil { - if merged.NamespaceSelector.MatchLabels == nil { - merged.NamespaceSelector.MatchLabels = make(map[string]string) - } - for k, v := range user.NamespaceSelector.MatchLabels { - merged.NamespaceSelector.MatchLabels[k] = v - } - } - merged.NamespaceSelector.MatchExpressions = append( - merged.NamespaceSelector.MatchExpressions, - user.NamespaceSelector.MatchExpressions..., - ) - } - - if user.IPAddress != "" { - merged.IPAddress = user.IPAddress - } - if len(user.IPAddresses) > 0 { - ipSet := make(map[string]struct{}) - for _, ip := range merged.IPAddresses { - ipSet[ip] = struct{}{} - } - for _, ip := range user.IPAddresses { - ipSet[ip] = struct{}{} - } - merged.IPAddresses = make([]string, 0, len(ipSet)) - for ip := range ipSet { - merged.IPAddresses = append(merged.IPAddresses, ip) - } - } - if user.Type != "" { - merged.Type = user.Type - } - - return *merged -} - -// mergeNetworkPorts merges user ports into a normal-ports list, keyed by Name. -// ported from -// pkg/objectcache/networkneighborhoodcache/networkneighborhoodcache.go:708-727. -func mergeNetworkPorts(normalPorts, userPorts []v1beta1.NetworkPort) []v1beta1.NetworkPort { - portMap := make(map[string]int, len(normalPorts)) - for i, port := range normalPorts { - portMap[port.Name] = i - } - for _, userPort := range userPorts { - if idx, exists := portMap[userPort.Name]; exists { - normalPorts[idx] = userPort - } else { - normalPorts = append(normalPorts, userPort) - } - } - return normalPorts -} diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index 711ac7311f..7f34bf5351 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -261,4 +261,3 @@ func extractIngressAddresses(cp *v1beta1.ContainerProfile) []string { } return addrs } - diff --git a/pkg/objectcache/containerprofilecache/projection_apply_test.go b/pkg/objectcache/containerprofilecache/projection_apply_test.go index 13b0d28182..f342e32732 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply_test.go +++ b/pkg/objectcache/containerprofilecache/projection_apply_test.go @@ -417,6 +417,7 @@ func TestApply_ExactFilter_NoMatchYieldsNilValues(t *testing.T) { // - Path with a populated Args slice — projected as a CLONED slice // - Path with nil Args — projected as an empty (non-nil) slice // - Two ExecCalls with the same Path — last write wins +// // The cloned-slice invariant is checked by mutating the projected slice // and asserting the source is unchanged. func TestApply_ExecsByPath_PopulatesFromSpec(t *testing.T) { diff --git a/pkg/objectcache/containerprofilecache/projection_golden_test.go b/pkg/objectcache/containerprofilecache/projection_golden_test.go new file mode 100644 index 0000000000..750d120bcb --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_golden_test.go @@ -0,0 +1,403 @@ +package containerprofilecache + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "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" +) + +// This file adds a golden-corpus CHARACTERIZATION oracle for Apply. +// +// HONEST SCOPE — this is NOT a differential oracle. A true differential +// oracle would run the same inputs through the legacy ApplicationProfile / +// NetworkNeighborhood projection and assert byte-identical results. That is +// impossible here: those legacy types and their projection code were deleted +// in the ContainerProfile migration, so there is no second implementation to +// diff against. What this file provides instead is a regression / freeze +// oracle: it pins the EXACT projected output of the current Apply across a +// representative corpus of ContainerProfiles + compiled specs. Any later +// change to the projection that alters the output for these fixtures fails the +// oracle, forcing a deliberate golden regeneration and review. It complements +// (does not replace) the assertion-based TestApply_* property tests in +// projection_apply_test.go, which pin individual behaviours; this file pins +// the whole-profile shape. +// +// Regenerate goldens after an INTENTIONAL projection change: +// +// UPDATE_GOLDEN=1 go test ./pkg/objectcache/containerprofilecache/ -run TestApply_Golden -count=1 +// +// then review the diff under testdata/golden/ before committing. + +// projectionGolden is the serializable view of a ProjectedContainerProfile. +// +// We do NOT marshal *objectcache.ProjectedContainerProfile directly: its +// CallStackTree field is a *callstackcache.CallStackSearchTree whose +// BidirectionalNode carries a Parent back-pointer (a reference cycle) and an +// internal dghubble/trie — neither of which json.Marshal can encode (a cycle +// errors; the trie has no stable exported shape). Instead we copy every +// marshalable projected surface verbatim and reduce the call-stack tree to a +// deterministic summary (sorted CallIDs with per-path frame depths). The +// surfaces that Apply actually computes are frozen byte-for-byte; the tree — +// which Apply only stores by pointer, it does not transform it — is pinned by +// its structural summary. Go's json encoder emits map keys in sorted order, so +// Values / PrefixHits / SuffixHits / ExecsByPath / PolicyByRuleId all +// serialize canonically without extra work. +type projectionGolden struct { + SpecHash string `json:"specHash"` + SyncChecksum string `json:"syncChecksum"` + Opens objectcache.ProjectedField `json:"opens"` + Execs objectcache.ProjectedField `json:"execs"` + Endpoints objectcache.ProjectedField `json:"endpoints"` + Capabilities objectcache.ProjectedField `json:"capabilities"` + Syscalls objectcache.ProjectedField `json:"syscalls"` + EgressDomains objectcache.ProjectedField `json:"egressDomains"` + EgressAddresses objectcache.ProjectedField `json:"egressAddresses"` + IngressDomains objectcache.ProjectedField `json:"ingressDomains"` + IngressAddresses objectcache.ProjectedField `json:"ingressAddresses"` + ExecsByPath map[string][][]string `json:"execsByPath"` + PolicyByRuleId map[string]v1beta1.RulePolicy `json:"policyByRuleId"` + CallStacks []callStackSummary `json:"callStacks"` +} + +// callStackSummary is a deterministic structural digest of one identified +// call stack held in the search tree. +type callStackSummary struct { + CallID string `json:"callID"` + PathCount int `json:"pathCount"` + PathDepths []int `json:"pathDepths"` +} + +// toGolden converts a projected profile plus its (pre-built) call-stack tree +// into the serializable golden view. +func toGolden(pcp *objectcache.ProjectedContainerProfile, tree *callstackcache.CallStackSearchTree) projectionGolden { + g := projectionGolden{ + SpecHash: pcp.SpecHash, + SyncChecksum: pcp.SyncChecksum, + Opens: pcp.Opens, + Execs: pcp.Execs, + Endpoints: pcp.Endpoints, + Capabilities: pcp.Capabilities, + Syscalls: pcp.Syscalls, + EgressDomains: pcp.EgressDomains, + EgressAddresses: pcp.EgressAddresses, + IngressDomains: pcp.IngressDomains, + IngressAddresses: pcp.IngressAddresses, + ExecsByPath: pcp.ExecsByPath, + PolicyByRuleId: pcp.PolicyByRuleId, + } + if tree != nil { + for id, paths := range tree.PathsByCallID { + depths := make([]int, len(paths)) + for i, p := range paths { + depths[i] = len(p) + } + sort.Ints(depths) + g.CallStacks = append(g.CallStacks, callStackSummary{ + CallID: string(id), + PathCount: len(paths), + PathDepths: depths, + }) + } + sort.Slice(g.CallStacks, func(i, j int) bool { + return g.CallStacks[i].CallID < g.CallStacks[j].CallID + }) + } + return g +} + +// --- fixture construction helpers --- + +// declaredAll returns a FieldRequirement declaring the whole surface. +func declaredAll() typesv1.FieldRequirement { + return typesv1.FieldRequirement{Declared: true, All: true} +} + +// declaredPatterns returns a FieldRequirement declaring a set of pattern +// selectors (exact / prefix / suffix / contains). +func declaredPatterns(pats ...typesv1.PatternObject) typesv1.FieldRequirement { + return typesv1.FieldRequirement{Declared: true, Patterns: pats} +} + +// linearCallStack builds a single-path identified call stack from an ordered +// list of {fileID, lineno} frames. Root carries an empty frame (the +// tree-builder skips it), so the frames become one leaf path. +func linearCallStack(id string, frames [][2]string) v1beta1.IdentifiedCallStack { + var children []v1beta1.CallStackNode + for i := len(frames) - 1; i >= 0; i-- { + children = []v1beta1.CallStackNode{{ + Frame: v1beta1.StackFrame{FileID: frames[i][0], Lineno: frames[i][1]}, + Children: children, + }} + } + return v1beta1.IdentifiedCallStack{ + CallID: v1beta1.CallID(id), + CallStack: v1beta1.CallStack{ + Root: v1beta1.CallStackNode{Children: children}, + }, + } +} + +// buildTree assembles the call-stack search tree Apply expects the caller to +// have built from cp.Spec.IdentifiedCallStacks. +func buildTree(cp *v1beta1.ContainerProfile) *callstackcache.CallStackSearchTree { + if len(cp.Spec.IdentifiedCallStacks) == 0 { + return nil + } + tree := callstackcache.NewCallStackSearchTree() + for _, cs := range cp.Spec.IdentifiedCallStacks { + tree.AddCallStack(cs) + } + return tree +} + +// dyn is the single-segment dynamic identifier ("⋯"); wild is the +// zero-or-more wildcard ("*"). Always sourced from the storage constants so +// the fixtures stay pinned to whatever glyphs the detector uses. +var ( + dyn = dynamicpathdetector.DynamicIdentifier + wild = dynamicpathdetector.WildcardIdentifier +) + +// richProfile is a ContainerProfile exercising every surface Apply projects: +// execs (Args, ArgsRequired, a literal "*" arg, an "⋯" ellipsis arg, and a +// nil-Args entry), opens (a plain path, a trailing-"*" path, an "⋯" +// dynamic-segment path, and a suffix-matchable path), syscalls, capabilities, +// HTTP endpoints, network ingress/egress (IPAddresses incl. a literal, a CIDR +// and the "*" sentinel, plus DNS + DNSNames), identified call stacks, and +// PolicyByRuleId. It also carries the SyncChecksum annotation. +func richProfile() *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "kubescape.io/sync-checksum": "sync-abc123", + }, + }, + Spec: v1beta1.ContainerProfileSpec{ + Capabilities: []string{"NET_ADMIN", "SYS_PTRACE"}, + Syscalls: []string{"read", "write", "openat"}, + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/ls", Args: []string{"-la", "/tmp"}}, + {Path: "/bin/curl", Args: []string{wild, "https://example.com"}, ArgsRequired: true}, + {Path: "/usr/bin/app", Args: []string{"run", dyn}, ArgsRequired: true}, + {Path: "/bin/echo", Args: nil}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/passwd", Flags: []string{"O_RDONLY"}}, + {Path: "/var/log/" + wild, Flags: []string{"O_RDONLY"}}, + {Path: "/data/" + dyn + "/config", Flags: []string{"O_RDONLY"}}, + {Path: "/etc/app.conf", Flags: []string{"O_RDONLY"}}, + }, + Endpoints: []v1beta1.HTTPEndpoint{ + {Endpoint: "/api/v1/health", Methods: []string{"GET"}}, + {Endpoint: "/metrics", Methods: []string{"GET"}}, + }, + Ingress: []v1beta1.NetworkNeighbor{ + { + Identifier: "ingress-1", + DNSNames: []string{"client.internal"}, + IPAddresses: []string{"10.0.0.5", "10.1.0.0/16", wild}, + }, + }, + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "egress-1", + DNSNames: []string{"cdn.example.com"}, + IPAddresses: []string{"8.8.8.8", "0.0.0.0/0", wild}, + }, + }, + IdentifiedCallStacks: []v1beta1.IdentifiedCallStack{ + linearCallStack("cs-open-1", [][2]string{{"10", "100"}, {"20", "200"}, {"30", "300"}}), + linearCallStack("cs-exec-1", [][2]string{{"11", "111"}, {"22", "222"}}), + }, + PolicyByRuleId: map[string]v1beta1.RulePolicy{ + "R0001": {AllowedProcesses: []string{"cat", "ls"}}, + "R0002": {AllowedContainer: true}, + }, + }, + } +} + +// networkProfile isolates the network surfaces so the golden pins address / +// domain projection independently of the file surfaces. +func networkProfile() *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Ingress: []v1beta1.NetworkNeighbor{ + {Identifier: "in-a", DNS: "old.internal", DNSNames: []string{"a.internal", "b.internal"}, IPAddresses: []string{"192.168.1.10", "192.168.0.0/16"}}, + {Identifier: "in-b", IPAddresses: []string{wild}}, + }, + Egress: []v1beta1.NetworkNeighbor{ + {Identifier: "eg-a", DNSNames: []string{"c.example.com"}, IPAddress: "203.0.113.7", IPAddresses: []string{"203.0.113.0/24", wild}}, + }, + }, + } +} + +// --- rule sets that compile into representative specs --- + +// mixedFilterRules declares multiple surfaces with a mix of selector kinds so +// CompileSpec produces a spec that actually filters: +// - opens: exact + prefix + suffix + contains +// - execs: all +// - capabilities: exact +// - syscalls: all +// - endpoints: prefix +// - egress/ingress domains + addresses: all +func mixedFilterRules() []typesv1.Rule { + return []typesv1.Rule{ + { + ID: "RULE-A", + Name: "mixed-file-and-net", + ProfileDataRequired: &typesv1.ProfileDataRequired{ + Opens: declaredPatterns( + typesv1.PatternObject{Exact: "/etc/passwd"}, + typesv1.PatternObject{Prefix: "/var/"}, + typesv1.PatternObject{Suffix: ".conf"}, + typesv1.PatternObject{Contains: "config"}, + ), + Execs: declaredAll(), + Capabilities: declaredPatterns(typesv1.PatternObject{Exact: "NET_ADMIN"}), + Syscalls: declaredAll(), + Endpoints: declaredPatterns(typesv1.PatternObject{Prefix: "/api/"}), + EgressDomains: declaredAll(), + EgressAddresses: declaredAll(), + IngressDomains: declaredAll(), + IngressAddresses: declaredAll(), + }, + }, + { + // A second rule narrows capabilities further and adds an opens + // prefix, proving the union merge across rules. + ID: "RULE-B", + Name: "extra-caps", + ProfileDataRequired: &typesv1.ProfileDataRequired{ + Capabilities: declaredPatterns(typesv1.PatternObject{Exact: "SYS_PTRACE"}), + Opens: declaredPatterns(typesv1.PatternObject{Prefix: "/data/"}), + }, + }, + } +} + +// netAllRules declares the network surfaces as all-field so the network +// fixture projects every neighbour. +func netAllRules() []typesv1.Rule { + return []typesv1.Rule{ + { + ID: "RULE-NET", + Name: "net-all", + ProfileDataRequired: &typesv1.ProfileDataRequired{ + EgressDomains: declaredAll(), + EgressAddresses: declaredAll(), + IngressDomains: declaredAll(), + IngressAddresses: declaredAll(), + }, + }, + } +} + +// --- the oracle --- + +// goldenCase pairs a named fixture with the rules whose compiled spec drives +// its projection. +type goldenCase struct { + name string + cp *v1beta1.ContainerProfile + rules []typesv1.Rule +} + +func goldenCorpus() []goldenCase { + return []goldenCase{ + // Rich profile under a filtering spec: every surface exercised, most + // of them narrowed by selectors. + {name: "rich_filtered", cp: richProfile(), rules: mixedFilterRules()}, + // Same rich profile under NO ProfileDataRequired: CompileSpec yields a + // zero spec, so every surface is InUse=false → pass-through (All=true, + // all raw data retained). Pins the back-compat path. + {name: "rich_passthrough", cp: richProfile(), rules: nil}, + // Network-only profile under all-field network rules. + {name: "network_all", cp: networkProfile(), rules: netAllRules()}, + } +} + +// TestApply_Golden freezes the projected output of Apply across the corpus. +func TestApply_Golden(t *testing.T) { + update := os.Getenv("UPDATE_GOLDEN") != "" + + for _, tc := range goldenCorpus() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + spec := CompileSpec(tc.rules) + tree := buildTree(tc.cp) + + pcp := Apply(&spec, tc.cp, tree) + require.NotNil(t, pcp) + + got, err := json.MarshalIndent(toGolden(pcp, tree), "", " ") + require.NoError(t, err, "projected profile must serialize") + got = append(got, '\n') + + path := filepath.Join("testdata", "golden", tc.name+".json") + + if update { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, got, 0o644)) + t.Logf("wrote golden %s", path) + return + } + + want, err := os.ReadFile(path) + require.NoError(t, err, "missing golden %s — regenerate with UPDATE_GOLDEN=1", path) + assert.Equal(t, string(want), string(got), + "projection drift for %q — if intentional, regenerate with UPDATE_GOLDEN=1 and review", tc.name) + }) + } +} + +// TestApply_Golden_Idempotent pins that Apply is a pure transform: applying +// the same spec + profile twice yields byte-identical projected output. +func TestApply_Golden_Idempotent(t *testing.T) { + for _, tc := range goldenCorpus() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + spec := CompileSpec(tc.rules) + tree := buildTree(tc.cp) + + first, err := json.MarshalIndent(toGolden(Apply(&spec, tc.cp, tree), tree), "", " ") + require.NoError(t, err) + second, err := json.MarshalIndent(toGolden(Apply(&spec, tc.cp, tree), tree), "", " ") + require.NoError(t, err) + + assert.Equal(t, string(first), string(second), + "Apply must be idempotent for %q", tc.name) + }) + } +} + +// TestApply_Golden_SpecHashStable pins that compiling the same rule set twice +// produces the same SpecHash, and that Apply copies it into the projection. +func TestApply_Golden_SpecHashStable(t *testing.T) { + for _, tc := range goldenCorpus() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + specA := CompileSpec(tc.rules) + specB := CompileSpec(tc.rules) + assert.Equal(t, specA.Hash, specB.Hash, + "CompileSpec must be deterministic for %q", tc.name) + + pcp := Apply(&specA, tc.cp, buildTree(tc.cp)) + assert.Equal(t, specA.Hash, pcp.SpecHash, + "Apply must copy the spec hash into the projection for %q", tc.name) + }) + } +} diff --git a/pkg/objectcache/containerprofilecache/projection_test.go b/pkg/objectcache/containerprofilecache/projection_test.go deleted file mode 100644 index 85b106ee01..0000000000 --- a/pkg/objectcache/containerprofilecache/projection_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package containerprofilecache - -import ( - "testing" - - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func baseCP() *v1beta1.ContainerProfile { - return &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "cp", Namespace: "default", ResourceVersion: "1"}, - Spec: v1beta1.ContainerProfileSpec{ - Capabilities: []string{"SYS_PTRACE"}, - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/ls", Args: []string{"-la"}}, - }, - PolicyByRuleId: map[string]v1beta1.RulePolicy{ - "R0901": {AllowedProcesses: []string{"ls"}}, - }, - Ingress: []v1beta1.NetworkNeighbor{ - {Identifier: "ing-1", DNSNames: []string{"a.svc.local"}}, - }, - }, - } -} - -func podWith(containers ...string) *corev1.Pod { - var cs []corev1.Container - for _, n := range containers { - cs = append(cs, corev1.Container{Name: n}) - } - return &corev1.Pod{Spec: corev1.PodSpec{Containers: cs}} -} - -// TestProjection_UserAPOnly_Match verifies the happy-path merge of a matching -// user AP container: capabilities / execs / policies merged, no warnings. -func TestProjection_UserAPOnly_Match(t *testing.T) { - cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - Execs: []v1beta1.ExecCalls{{Path: "/bin/cat"}}, - PolicyByRuleId: map[string]v1beta1.RulePolicy{ - "R0901": {AllowedProcesses: []string{"cat"}}, - "R0902": {AllowedProcesses: []string{"echo"}}, - }, - }}, - }, - } - pod := podWith("nginx") - - projected, warnings := projectUserProfiles(cp, userAP, nil, pod, "nginx") - require.NotNil(t, projected) - assert.Empty(t, warnings) - assert.NotSame(t, cp, projected, "projected must be a distinct DeepCopy") - assert.ElementsMatch(t, []string{"SYS_PTRACE", "NET_BIND_SERVICE"}, projected.Spec.Capabilities) - assert.Len(t, projected.Spec.Execs, 2) - // R0901 merged, R0902 added - assert.Contains(t, projected.Spec.PolicyByRuleId, "R0901") - assert.Contains(t, projected.Spec.PolicyByRuleId, "R0902") -} - -// TestProjection_UserNNOnly_Match verifies merge of matching NN container: -// ingress merged by Identifier, LabelSelector MatchLabels overlaid. -func TestProjection_UserNNOnly_Match(t *testing.T) { - cp := baseCP() - cp.Spec.LabelSelector = metav1.LabelSelector{MatchLabels: map[string]string{"app": "nginx"}} - userNN := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{Name: "un", Namespace: "default", ResourceVersion: "n1"}, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{"env": "prod"}, - }, - Containers: []v1beta1.NetworkNeighborhoodContainer{{ - Name: "nginx", - Ingress: []v1beta1.NetworkNeighbor{ - {Identifier: "ing-1", DNSNames: []string{"b.svc.local"}}, - {Identifier: "ing-2", DNSNames: []string{"c.svc.local"}}, - }, - }}, - }, - } - pod := podWith("nginx") - - projected, warnings := projectUserProfiles(cp, nil, userNN, pod, "nginx") - require.NotNil(t, projected) - assert.Empty(t, warnings) - require.Len(t, projected.Spec.Ingress, 2) - // ing-1 merged (DNSNames union) - var merged v1beta1.NetworkNeighbor - for _, ing := range projected.Spec.Ingress { - if ing.Identifier == "ing-1" { - merged = ing - break - } - } - assert.ElementsMatch(t, []string{"a.svc.local", "b.svc.local"}, merged.DNSNames) - // LabelSelector overlaid - assert.Equal(t, "nginx", projected.Spec.LabelSelector.MatchLabels["app"]) - assert.Equal(t, "prod", projected.Spec.LabelSelector.MatchLabels["env"]) -} - -// TestProjection_Both verifies both AP and NN can overlay in a single call. -func TestProjection_Both(t *testing.T) { - cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_ADMIN"}, - }}, - }, - } - userNN := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{Name: "un", Namespace: "default", ResourceVersion: "n1"}, - Spec: v1beta1.NetworkNeighborhoodSpec{ - Containers: []v1beta1.NetworkNeighborhoodContainer{{ - Name: "nginx", - Ingress: []v1beta1.NetworkNeighbor{{Identifier: "ing-new"}}, - }}, - }, - } - pod := podWith("nginx") - - projected, warnings := projectUserProfiles(cp, userAP, userNN, pod, "nginx") - require.NotNil(t, projected) - assert.Empty(t, warnings) - assert.Contains(t, projected.Spec.Capabilities, "NET_ADMIN") - // Original ing-1 plus appended ing-new - assert.Len(t, projected.Spec.Ingress, 2) -} - -// TestProjection_UserAP_NonMatchingContainer verifies that when the user CRD -// doesn't include the target container name, no merge happens — but missing -// pod containers still produce a warning. -func TestProjection_UserAP_NonMatchingContainer(t *testing.T) { - cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "other", // not "nginx" - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, - }, - } - pod := podWith("nginx", "sidecar") - - projected, warnings := projectUserProfiles(cp, userAP, nil, pod, "nginx") - require.NotNil(t, projected) - // No merge because no container matched "nginx" - assert.ElementsMatch(t, []string{"SYS_PTRACE"}, projected.Spec.Capabilities) - require.Len(t, warnings, 1) - assert.Equal(t, kindApplication, warnings[0].Kind) - assert.ElementsMatch(t, []string{"nginx", "sidecar"}, warnings[0].MissingContainers) -} - -// TestProjection_UserAP_PartialContainers verifies that when the user AP has -// one container but the pod has two, we emit a partial warning naming the -// missing pod container. -func TestProjection_UserAP_PartialContainers(t *testing.T) { - cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, - }, - } - pod := podWith("nginx", "sidecar") - - projected, warnings := projectUserProfiles(cp, userAP, nil, pod, "nginx") - require.NotNil(t, projected) - // Target container merged. - assert.Contains(t, projected.Spec.Capabilities, "NET_BIND_SERVICE") - require.Len(t, warnings, 1) - assert.Equal(t, kindApplication, warnings[0].Kind) - assert.Equal(t, []string{"sidecar"}, warnings[0].MissingContainers) -} - -// TestProjection_NoUserCRDs verifies projection with neither user CRD returns -// a DeepCopy (distinct pointer) and no warnings. -func TestProjection_NoUserCRDs(t *testing.T) { - cp := baseCP() - pod := podWith("nginx") - - projected, warnings := projectUserProfiles(cp, nil, nil, pod, "nginx") - require.NotNil(t, projected) - assert.Empty(t, warnings) - assert.NotSame(t, cp, projected) - assert.Equal(t, cp.Spec.Capabilities, projected.Spec.Capabilities) -} - -// TestProjection_NilPod verifies the merge still runs when pod is nil; the -// missing-container check is skipped (no warning emitted for partial). -func TestProjection_NilPod(t *testing.T) { - cp := baseCP() - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "ua", Namespace: "default", ResourceVersion: "u1"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, - }, - } - - projected, warnings := projectUserProfiles(cp, userAP, nil, nil, "nginx") - require.NotNil(t, projected) - assert.Empty(t, warnings) - assert.Contains(t, projected.Spec.Capabilities, "NET_BIND_SERVICE") -} diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 0af5c8ee49..fd5961a1ef 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -5,16 +5,15 @@ // 1. reconcileOnce: evicts cache entries whose pod is gone or whose // container is no longer Running. // 2. refreshAllEntries (single-flight via atomic flag): re-fetches the -// consolidated CP, the workload-level AP+NN, the user-managed -// "ug-" AP+NN, and any label-referenced user AP/NN overlay, -// then rebuilds the projection iff any resourceVersion changed. Fast-skip -// when every RV matches what's already cached. +// consolidated ContainerProfile and any label-referenced user-defined +// ContainerProfile, then rebuilds the projection iff any resourceVersion +// changed. Fast-skip when every RV matches what's already cached. // -// RPC cost @ 300 containers / 30s cadence steady-state: up to 7 gets per -// entry per tick (CP + 3×AP + 3×NN). At 300 entries that's 70 RPC/s in the -// worst case, dropping close to 0 once fast-skip catches on. Most entries -// carry only workload-level AP+NN, so the common case is 3 RPC/tick per -// entry = 30 RPC/s. +// RPC cost @ 300 containers / 30s cadence steady-state: up to 2 gets per entry +// per tick (consolidated CP + label-referenced user-defined CP). At 300 entries +// that's ~20 RPC/s worst case, dropping close to 0 once fast-skip catches on. +// Most entries carry only the consolidated CP, so the common case is 1 RPC/tick +// per entry. package containerprofilecache import ( @@ -267,19 +266,17 @@ func (c *ContainerProfileCacheImpl) refreshAllEntries(ctx context.Context) { } // refreshOneEntry refreshes a single cache entry under the per-container lock. -// Re-fetches ALL sources the entry was originally built from (consolidated CP, -// workload-level AP/NN, user-managed AP/NN at "ug-", and any -// label-referenced user AP/NN overlay) and rebuilds the projection if ANY -// ResourceVersion changed. Keeping the existing entry on fetch errors is fine: -// the next tick will retry. +// Re-fetches ALL sources the entry was originally built from (the consolidated +// ContainerProfile and any label-referenced user-defined ContainerProfile) and +// rebuilds the projection if ANY ResourceVersion changed. Keeping the existing +// entry on fetch errors is fine: the next tick will retry. // -// Rebuild on refresh applies the same projection ladder as tryPopulateEntry: +// Rebuild on refresh mirrors tryPopulateEntry: a label-referenced user-defined +// CP, when present, REPLACES the learned CP as the authoritative base. // -// base CP → workload AP+NN → user-managed (ug-) AP+NN → user overlay AP+NN. -// -// The completed-only gate is re-applied here: if the CP regresses to a -// non-Completed status we keep the existing cached entry rather than -// projecting stale/incomplete data. +// The completed-only gate is re-applied here (only when no authored CP is +// adopted): if the learned CP regresses to a non-Completed status we keep the +// existing cached entry rather than projecting stale/incomplete data. func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id string, e *CachedContainerProfile) { // Resurrection guard (reviewer #1): refreshAllEntries snapshots entries // without holding containerLocks, so a concurrent deleteContainer / @@ -323,86 +320,56 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri helpers.Error(cpErr)) cp = nil } - if cp != nil && !isTerminalCPStatus(cp.Annotations[helpersv1.StatusMetadataKey]) { - logger.L().Debug("refreshOneEntry: CP status not terminal; keeping cached entry", - helpers.String("containerID", id), - helpers.String("cpName", e.CPName), - helpers.String("status", cp.Annotations[helpersv1.StatusMetadataKey])) - return - } - var userManagedAP *v1beta1.ApplicationProfile - var userManagedNN *v1beta1.NetworkNeighborhood - if e.WorkloadName != "" { - ugAPName := helpersv1.UserApplicationProfilePrefix + e.WorkloadName - var userManagedAPErr error + // Re-fetch the user-defined ContainerProfile (migrated "new way") FIRST, when + // the entry was built from one. It is the authoritative base and the only + // user-defined source (the legacy AP/NN overlay is no longer supported); a + // transient fetch error keeps the entry as-is. + // + // Ordering matters (review finding on node-agent#864): when an authored CP is + // present it REPLACES the learned CP as the base, so the learned-status gate + // below must not be allowed to early-return before the authored CP is + // fetched. Otherwise a learned CP stuck in a non-terminal status ("ready") + // would freeze authored-CP edits out of the cache forever. + var userDefinedCP *v1beta1.ContainerProfile + if e.UserCPRef != nil { + var userCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedAP, userManagedAPErr = c.storageClient.GetApplicationProfile(rctx, ns, ugAPName) - return userManagedAPErr + userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, e.UserCPRef.Namespace, e.UserCPRef.Name) + return userCPErr }) - if userManagedAPErr != nil && e.UserManagedAPRV != "" { - logger.L().Debug("refreshOneEntry: user-managed AP fetch failed; keeping cached entry", + if userCPErr != nil && e.UserCPRV != "" { + logger.L().Debug("refreshOneEntry: user-defined CP fetch failed; keeping cached entry", helpers.String("containerID", id), - helpers.String("name", ugAPName), - helpers.Error(userManagedAPErr)) + helpers.String("name", e.UserCPRef.Name), + helpers.Error(userCPErr)) return } - if userManagedAPErr != nil { - userManagedAP = nil // k8s client returns non-nil zero-value on 404; treat as absent - } - ugNNName := helpersv1.UserNetworkNeighborhoodPrefix + e.WorkloadName - var userManagedNNErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userManagedNN, userManagedNNErr = c.storageClient.GetNetworkNeighborhood(rctx, ns, ugNNName) - return userManagedNNErr - }) - if userManagedNNErr != nil && e.UserManagedNNRV != "" { - logger.L().Debug("refreshOneEntry: user-managed NN fetch failed; keeping cached entry", - helpers.String("containerID", id), - helpers.String("name", ugNNName), - helpers.Error(userManagedNNErr)) - return - } - if userManagedNNErr != nil { - userManagedNN = nil + if userCPErr != nil { + userDefinedCP = nil } } - var userAP *v1beta1.ApplicationProfile - var userNN *v1beta1.NetworkNeighborhood - if e.UserAPRef != nil { - var userAPErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userAP, userAPErr = c.storageClient.GetApplicationProfile(rctx, e.UserAPRef.Namespace, e.UserAPRef.Name) - return userAPErr - }) - if userAPErr != nil && e.UserAPRV != "" { - logger.L().Debug("refreshOneEntry: user-defined AP fetch failed; keeping cached entry", + // Authored-validation (mirror of the add path): a label-referenced CP that + // carries lifecycle annotations is a LEARNED profile, not an authored one. + // Ignore it so its real state is not overwritten with Completed/Full and a + // still-learning profile is not enforced as complete. + if userDefinedCP != nil { + if _, learned := userDefinedCP.Annotations[helpersv1.StatusMetadataKey]; learned { + logger.L().Debug("refreshOneEntry: user-defined-profile label resolves to a learned CP; ignoring it", helpers.String("containerID", id), - helpers.String("name", e.UserAPRef.Name), - helpers.Error(userAPErr)) - return - } - if userAPErr != nil { - userAP = nil + helpers.String("name", e.UserCPRef.Name)) + userDefinedCP = nil } } - if e.UserNNRef != nil { - var userNNErr error - _ = c.refreshRPC(ctx, func(rctx context.Context) error { - userNN, userNNErr = c.storageClient.GetNetworkNeighborhood(rctx, e.UserNNRef.Namespace, e.UserNNRef.Name) - return userNNErr - }) - if userNNErr != nil && e.UserNNRV != "" { - logger.L().Debug("refreshOneEntry: user-defined NN fetch failed; keeping cached entry", - helpers.String("containerID", id), - helpers.String("name", e.UserNNRef.Name), - helpers.Error(userNNErr)) - return - } - if userNNErr != nil { - userNN = nil - } + // Learned-status gate: only blocks when there is NO authored CP to adopt. + // With an authored CP present, the learned CP's status is irrelevant — the + // authored profile is the base and is enforced regardless. + if userDefinedCP == nil && cp != nil && !isTerminalCPStatus(cp.Annotations[helpersv1.StatusMetadataKey]) { + logger.L().Debug("refreshOneEntry: CP status not terminal; keeping cached entry", + helpers.String("containerID", id), + helpers.String("cpName", e.CPName), + helpers.String("status", cp.Annotations[helpersv1.StatusMetadataKey])) + return } - // 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. Also skip when the @@ -413,54 +380,45 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri currentSpecHash = spec.Hash } if rvsMatchCP(cp, e.RV) && - rvsMatchAP(userManagedAP, e.UserManagedAPRV) && - rvsMatchNN(userManagedNN, e.UserManagedNNRV) && - rvsMatchAP(userAP, e.UserAPRV) && - rvsMatchNN(userNN, e.UserNNRV) && + rvsMatchCP(userDefinedCP, e.UserCPRV) && e.SpecHash == currentSpecHash { return } - c.rebuildEntryFromSources(id, e, cp, userManagedAP, userManagedNN, userAP, userNN) + c.rebuildEntryFromSources(id, e, cp, userDefinedCP) } -// rvsMatchCP, rvsMatchAP, rvsMatchNN return true when either (a) the object is -// absent and the stored RV is empty, or (b) the object is present and its RV -// matches the stored RV. This lets fast-skip treat "still missing" as a match. +// rvsMatchCP returns true when either (a) the object is absent and the stored RV +// is empty, or (b) the object is present and its RV matches the stored RV. This +// lets fast-skip treat "still missing" as a match. func rvsMatchCP(obj *v1beta1.ContainerProfile, rv string) bool { if obj == nil { return rv == "" } return obj.ResourceVersion == rv } -func rvsMatchAP(obj *v1beta1.ApplicationProfile, rv string) bool { - if obj == nil { - return rv == "" - } - return obj.ResourceVersion == rv -} -func rvsMatchNN(obj *v1beta1.NetworkNeighborhood, rv string) bool { - if obj == nil { - return rv == "" - } - return obj.ResourceVersion == rv -} // rebuildEntryFromSources constructs a fresh CachedContainerProfile from the -// given sources and stores it under `id`. Applies the projection ladder from -// tryPopulateEntry: base CP (or synthesized) → user-managed (ug-) AP+NN → -// label-referenced user overlay AP+NN. +// given sources and stores it under `id`. Mirrors tryPopulateEntry: a +// label-referenced user-defined CP, when present, REPLACES the learned CP (or +// the synthesized base) as the authoritative base. // // Called by the reconciler when any input ResourceVersion has changed. func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( id string, prev *CachedContainerProfile, cp *v1beta1.ContainerProfile, - userManagedAP *v1beta1.ApplicationProfile, - userManagedNN *v1beta1.NetworkNeighborhood, - userAP *v1beta1.ApplicationProfile, - userNN *v1beta1.NetworkNeighborhood, + userDefinedCP *v1beta1.ContainerProfile, ) { + // Authored-validation (mirror of the add path): a label-referenced CP that + // carries lifecycle annotations is a LEARNED profile, not an authored one. + // Ignore it here too so it is never force-set Completed/Full below. + if userDefinedCP != nil { + if _, learned := userDefinedCP.Annotations[helpersv1.StatusMetadataKey]; learned { + userDefinedCP = nil + } + } + pod := c.k8sObjectCache.GetPod(prev.Namespace, prev.PodName) // Backfill PodUID when the entry was originally added before the pod @@ -474,10 +432,16 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( podUID = string(pod.UID) } - // When the consolidated CP is absent but we still have user-managed / - // user-defined overlays to project, synthesize an empty base so - // downstream state display is sensible. + // A user-defined ContainerProfile ("new way") is the authoritative base, + // replacing the learned CP for this container. cp (the learned CP) stays + // separate so RV bookkeeping tracks each source independently. effectiveCP := cp + if userDefinedCP != nil { + effectiveCP = userDefinedCP + } + + // When neither a learned nor a user-defined CP is available, synthesize an + // empty base so downstream state display is sensible. if effectiveCP == nil { syntheticName := prev.WorkloadName if syntheticName == "" { @@ -496,20 +460,6 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( } projected := effectiveCP - // Ladder pass #1: user-managed "ug-" AP + NN. - if userManagedAP != nil || userManagedNN != nil { - p, warnings := projectUserProfiles(projected, userManagedAP, userManagedNN, pod, prev.ContainerName) - projected = p - c.emitOverlayMetrics(userManagedAP, userManagedNN, warnings) - } - // Ladder pass #2: label-referenced user overlay AP + NN. - var userWarnings []partialProfileWarning - if userAP != nil || userNN != nil { - p, w := projectUserProfiles(projected, userAP, userNN, pod, prev.ContainerName) - projected = p - userWarnings = w - } - c.emitOverlayMetrics(userAP, userNN, userWarnings) // Rebuild the call-stack search tree from the projected profile. tree := callstackcache.NewCallStackSearchTree() @@ -527,60 +477,48 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( } newEntry := &CachedContainerProfile{ - 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, - Namespace: prev.Namespace, - PodUID: podUID, - WorkloadID: prev.WorkloadID, - CPName: prev.CPName, - WorkloadName: prev.WorkloadName, - RV: rvOfCP(cp), - UserManagedAPRV: rvOfAP(userManagedAP), - UserManagedNNRV: rvOfNN(userManagedNN), - UserAPRV: rvOfAP(userAP), - UserNNRV: rvOfNN(userNN), - } - if userAP != nil { - newEntry.UserAPRef = &namespacedName{Namespace: userAP.Namespace, Name: userAP.Name} - } else if prev.UserAPRef != nil { - // Preserve the ref so subsequent ticks still know to re-fetch the - // overlay (e.g. transient fetch error during this tick). - newEntry.UserAPRef = prev.UserAPRef - } - if userNN != nil { - newEntry.UserNNRef = &namespacedName{Namespace: userNN.Namespace, Name: userNN.Name} - } else if prev.UserNNRef != nil { - newEntry.UserNNRef = prev.UserNNRef + 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, + Namespace: prev.Namespace, + PodUID: podUID, + WorkloadID: prev.WorkloadID, + CPName: prev.CPName, + WorkloadName: prev.WorkloadName, + RV: rvOfCP(cp), + UserCPRV: rvOfCP(userDefinedCP), + } + if userDefinedCP != nil { + // The user-authored CP is authoritative and complete by definition (no + // learning-lifecycle annotations); force the terminal state so the rule + // engine enforces it. + newEntry.UserCPRef = &namespacedName{Namespace: userDefinedCP.Namespace, Name: userDefinedCP.Name} + newEntry.State = &objectcache.ProfileState{ + Status: helpersv1.Completed, + Completion: helpersv1.Full, + Name: userDefinedCP.Name, + } + } else if prev.UserCPRef != nil { + // No CP this tick (transient error or not-yet-landed): keep the ref so + // the reconciler retries the CP on the next tick. + newEntry.UserCPRef = prev.UserCPRef } c.entries.Set(id, newEntry) } -// rvOfCP / rvOfAP / rvOfNN return the object's ResourceVersion or "" when nil. -// Separate typed versions avoid the Go nil-interface trap where a typed-nil -// pointer wrapped in an interface is not == nil. +// rvOfCP returns the object's ResourceVersion or "" when nil. Using a typed +// helper avoids the Go nil-interface trap where a typed-nil pointer wrapped in +// an interface is not == nil. func rvOfCP(o *v1beta1.ContainerProfile) string { if o == nil { return "" } return o.ResourceVersion } -func rvOfAP(o *v1beta1.ApplicationProfile) string { - if o == nil { - return "" - } - return o.ResourceVersion -} -func rvOfNN(o *v1beta1.NetworkNeighborhood) string { - if o == nil { - return "" - } - return o.ResourceVersion -} // observeMemoryMetrics records per-field entry counts, retention ratios, and // total byte sizes for the raw vs projected profile. Called only when diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index e76c384d6a..5e89e9ac7e 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -16,7 +16,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" "k8s.io/apimachinery/pkg/types" ) @@ -62,60 +64,45 @@ func (k *controllableK8sCache) GetSharedContainerData(_ string) *objectcache.Wat func (k *controllableK8sCache) DeleteSharedContainerData(_ string) {} // countingProfileClient tracks per-method RPC counts so tests can assert -// fast-skip behavior. +// fast-skip behavior. It is name-aware: the base/learned CP is served for its +// own name, an optional authored CP for its own name, and every other name +// returns NotFound. This lets refresh tests distinguish the learned slug from +// the authored/overlay CP instead of returning the same object for any name. type countingProfileClient struct { - cp *v1beta1.ContainerProfile - ap *v1beta1.ApplicationProfile - nn *v1beta1.NetworkNeighborhood + cp *v1beta1.ContainerProfile // learned/base CP, keyed by cp.Name + userCP *v1beta1.ContainerProfile // authored CP, keyed by userCP.Name cpCalls atomic.Int64 - apCalls atomic.Int64 - nnCalls atomic.Int64 } var _ storage.ProfileClient = (*countingProfileClient)(nil) -func (f *countingProfileClient) GetContainerProfile(_ context.Context, _, _ string) (*v1beta1.ContainerProfile, error) { +func (f *countingProfileClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { f.cpCalls.Add(1) - return f.cp, nil -} -func (f *countingProfileClient) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - f.apCalls.Add(1) - return f.ap, nil -} -func (f *countingProfileClient) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - f.nnCalls.Add(1) - return f.nn, nil -} -func (f *countingProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (f *countingProfileClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil + if f.userCP != nil && name == f.userCP.Name { + return f.userCP, nil + } + if f.cp != nil && name == f.cp.Name { + return f.cp, nil + } + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "containerprofiles"}, name) } -// countingMetrics tallies ReportContainerProfileLegacyLoad calls so the T8 -// end-to-end test can assert the overlay refresh re-emits the full-load signal. +// countingMetrics tallies reconciler eviction + entry-count signals so tests +// can assert eviction behavior. type countingMetrics struct { metricsmanager.MetricsMock mu sync.Mutex - legacyLoads map[string]int // key = kind+"|"+completeness evictions map[string]int entriesByKnd map[string]float64 } func newCountingMetrics() *countingMetrics { return &countingMetrics{ - legacyLoads: map[string]int{}, evictions: map[string]int{}, entriesByKnd: map[string]float64{}, } } -func (m *countingMetrics) ReportContainerProfileLegacyLoad(kind, completeness string) { - m.mu.Lock() - defer m.mu.Unlock() - m.legacyLoads[kind+"|"+completeness]++ -} func (m *countingMetrics) ReportContainerProfileReconcilerEviction(reason string) { m.mu.Lock() defer m.mu.Unlock() @@ -126,11 +113,6 @@ func (m *countingMetrics) SetContainerProfileCacheEntries(kind string, count flo defer m.mu.Unlock() m.entriesByKnd[kind] = count } -func (m *countingMetrics) legacyLoad(kind, completeness string) int { - m.mu.Lock() - defer m.mu.Unlock() - return m.legacyLoads[kind+"|"+completeness] -} func (m *countingMetrics) eviction(reason string) int { m.mu.Lock() defer m.mu.Unlock() @@ -341,110 +323,6 @@ func TestReconcilerExitsOnCtxCancel(t *testing.T) { // test is only that iteration stopped early. } -// TestRefreshFastSkipWhenAllRVsMatch — delta #4. When CP RV and both overlay -// RVs match the cached values, refreshOneEntry returns without rebuilding. -func TestRefreshFastSkipWhenAllRVsMatch(t *testing.T) { - cp := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{ - Name: "cp", Namespace: "default", ResourceVersion: "100", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }} - ap := &v1beta1.ApplicationProfile{ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "50"}} - nn := &v1beta1.NetworkNeighborhood{ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "60"}} - client := &countingProfileClient{cp: cp, ap: ap, nn: nn} - k8s := newControllableK8sCache() - metrics := newCountingMetrics() - c := newReconcilerCache(t, client, k8s, metrics) - - id := "c1" - entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, - UserNNRef: &namespacedName{Namespace: "default", Name: "override"}, - RV: "100", - UserAPRV: "50", - UserNNRV: "60", - } - c.entries.Set(id, entry) - - c.refreshAllEntries(context.Background()) - - // Fetched CP once + overlays once each to check RVs; then fast-skipped. - assert.Equal(t, int64(1), client.cpCalls.Load(), "CP should be fetched once") - assert.Equal(t, int64(1), client.apCalls.Load(), "AP should be fetched once for RV check") - assert.Equal(t, int64(1), client.nnCalls.Load(), "NN should be fetched once for RV check") - - stored, ok := c.entries.Load(id) - require.True(t, ok) - // Same pointer: the entry was NOT rebuilt. - assert.Same(t, entry, stored, "entry must not be replaced 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)) -} - -// TestRefreshRebuildsOnUserAPChange — entry has stale UserAPRV; refresh sees -// a newer AP RV and rebuilds. -func TestRefreshRebuildsOnUserAPChange(t *testing.T) { - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp", Namespace: "default", ResourceVersion: "100", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, - } - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "51"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Capabilities: []string{"NET_BIND_SERVICE"}, - }}, - }, - } - client := &countingProfileClient{cp: cp, ap: ap} - k8s := newControllableK8sCache() - metrics := newCountingMetrics() - c := newReconcilerCache(t, client, k8s, metrics) - - id := "c1" - entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, - 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") - 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") -} - // TestRefreshRebuildsOnCPChange — CP RV changed; entry rebuilds with fresh CP. func TestRefreshRebuildsOnCPChange(t *testing.T) { cp := &v1beta1.ContainerProfile{ @@ -473,82 +351,6 @@ func TestRefreshRebuildsOnCPChange(t *testing.T) { assert.Equal(t, "101", stored.RV, "RV must update to the fresh CP's version") } -// TestT8_EndToEndRefreshUpdatesProjection — delta #5. Mutate the user-AP in -// the stubbed storage so its RV + execs change; assert the cached projection -// reflects the new execs AND that the legacy-load metric was re-emitted. -func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp", Namespace: "default", ResourceVersion: "100", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - Spec: v1beta1.ContainerProfileSpec{ - Execs: []v1beta1.ExecCalls{{Path: "/bin/base", Args: []string{"a"}}}, - }, - } - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "50"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/old", Args: []string{"x"}}}, - }}, - }, - } - client := &countingProfileClient{cp: cp, ap: ap} - k8s := newControllableK8sCache() - metrics := newCountingMetrics() - c := newReconcilerCache(t, client, k8s, metrics) - - id := "c1" - entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, - RV: "100", - UserAPRV: "50", - } - c.entries.Set(id, entry) - - // Mutate storage: new AP RV + new execs. - client.ap = &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "override", Namespace: "default", ResourceVersion: "51"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/new", Args: []string{"y"}}}, - }}, - }, - } - - c.SetProjectionSpec(objectcache.RuleProjectionSpec{ - Execs: objectcache.FieldSpec{InUse: true, All: true}, - Hash: "test-execs", - }) - c.refreshAllEntries(context.Background()) - - stored, ok := c.entries.Load(id) - require.True(t, ok) - assert.Equal(t, "51", stored.UserAPRV, "refresh must record the new user-AP RV") - - // The projection must include the new exec (merged on top of the base CP's exec). - var paths []string - 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") - assert.NotContains(t, paths, "/bin/old", "stale user-AP exec must NOT be in the projection") - - assert.GreaterOrEqual(t, metrics.legacyLoad(kindApplication, completenessFull), 1, - "refresh with user-AP overlay must emit full-load metric") -} - // TestRefreshNoEntryWhenCPGetFails — storage error on CP keeps the existing // entry unchanged (no deletion). func TestRefreshNoEntryWhenCPGetFails(t *testing.T) { @@ -569,140 +371,78 @@ func TestRefreshNoEntryWhenCPGetFails(t *testing.T) { assert.Same(t, entry, stored, "entry pointer must not change when CP fetch fails") } -// TestRefreshPreservesEntryOnTransientOverlayError — overlay fetch errors must -// not strip overlay data from the cache. If a user-managed or user-defined -// AP/NN GET returns an error while the entry already has a non-empty cached RV -// for that overlay, refreshOneEntry must keep the old entry unchanged (same -// pointer) rather than rebuilding without the overlay and clearing its RV. -// Regression test for the refreshRPC timeout → silent nil → spurious rebuild path. -func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { +// TestRefreshPreservesEntryOnTransientUserCPError — a transient error fetching +// the user-defined (label-referenced) ContainerProfile must not strip the +// authored overlay from the cache. When refreshOneEntry re-fetches the +// user-defined CP (because entry.UserCPRef is set) and the GET returns an error +// while the entry already holds a non-empty UserCPRV, refreshOneEntry must keep +// the old entry unchanged (same pointer) rather than rebuilding without the +// authored profile and clearing its RV. Regression test for the refreshRPC +// timeout → silent nil → spurious rebuild path, migrated from the removed +// legacy "ug-" user-managed overlay to the user-defined CP mechanism. +func TestRefreshPreservesEntryOnTransientUserCPError(t *testing.T) { + // Base (learned) CP is terminal (Completed) and its RV matches the entry, so + // the base fetch succeeds without an early return and refreshOneEntry reaches + // the user-defined CP fetch. cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "cp", Namespace: "default", ResourceVersion: "100"}, - Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, - } - - type overlayFields struct { - workloadName string - userManagedAPRV string - userManagedNNRV string - userAPRef *namespacedName - userAPRV string - userNNRef *namespacedName - userNNRV string - } - tests := []struct { - name string - apErr bool - nnErr bool - overlay overlayFields - }{ - { - name: "user-managed AP timeout preserves entry", - apErr: true, - overlay: overlayFields{ - workloadName: "nginx", - userManagedAPRV: "9", - }, - }, - { - name: "user-managed NN timeout preserves entry", - nnErr: true, - overlay: overlayFields{ - workloadName: "nginx", - userManagedNNRV: "7", - }, - }, - { - name: "user-defined AP timeout preserves entry", - apErr: true, - overlay: overlayFields{ - userAPRef: &namespacedName{Namespace: "default", Name: "override"}, - userAPRV: "50", - }, - }, - { - name: "user-defined NN timeout preserves entry", - nnErr: true, - overlay: overlayFields{ - userNNRef: &namespacedName{Namespace: "default", Name: "override"}, - userNNRV: "60", + ObjectMeta: metav1.ObjectMeta{ + Name: "cp", Namespace: "default", ResourceVersion: "100", + Annotations: map[string]string{ + helpersv1.CompletionMetadataKey: helpersv1.Full, + helpersv1.StatusMetadataKey: helpersv1.Completed, }, }, + Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_PTRACE"}}, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - apErr := error(nil) - if tc.apErr { - apErr = assertErr{} - } - nnErr := error(nil) - if tc.nnErr { - nnErr = assertErr{} - } - client := &overlayErrorClient{cp: cp, apErr: apErr, nnErr: nnErr} - k8s := newControllableK8sCache() - c := newReconcilerCache(t, client, k8s, nil) - - id := "c1" - entry := &CachedContainerProfile{ - Projected: Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - RV: "100", - WorkloadName: tc.overlay.workloadName, - UserManagedAPRV: tc.overlay.userManagedAPRV, - UserManagedNNRV: tc.overlay.userManagedNNRV, - UserAPRef: tc.overlay.userAPRef, - UserAPRV: tc.overlay.userAPRV, - UserNNRef: tc.overlay.userNNRef, - UserNNRV: tc.overlay.userNNRV, - } - c.entries.Set(id, entry) - - c.refreshAllEntries(context.Background()) - - stored, ok := c.entries.Load(id) - require.True(t, ok, "overlay error must not delete the entry") - assert.Same(t, entry, stored, "entry pointer must not change when overlay fetch fails transiently") - // Overlay RVs must be unchanged (not cleared to ""). - assert.Equal(t, tc.overlay.userManagedAPRV, stored.UserManagedAPRV) - assert.Equal(t, tc.overlay.userManagedNNRV, stored.UserManagedNNRV) - assert.Equal(t, tc.overlay.userAPRV, stored.UserAPRV) - assert.Equal(t, tc.overlay.userNNRV, stored.UserNNRV) - }) + // The user-defined CP fetch (by UserCPRef.Name) fails transiently. + client := &userCPErrorClient{cp: cp, userName: "override", userCPErr: assertErr{}} + k8s := newControllableK8sCache() + c := newReconcilerCache(t, client, k8s, nil) + + id := "c1" + entry := &CachedContainerProfile{ + Projected: Apply(nil, cp, nil), + State: &objectcache.ProfileState{Name: cp.Name}, + ContainerName: "nginx", + PodName: "nginx-abc", + Namespace: "default", + PodUID: "uid-1", + CPName: "cp", + RV: "100", + WorkloadName: "nginx", + UserCPRef: &namespacedName{Namespace: "default", Name: "override"}, + UserCPRV: "9", } + c.entries.Set(id, entry) + + c.refreshAllEntries(context.Background()) + + stored, ok := c.entries.Load(id) + require.True(t, ok, "user-defined CP error must not delete the entry") + assert.Same(t, entry, stored, "entry pointer must not change when user-defined CP fetch fails transiently") + // The authored RV must be unchanged (not cleared to ""). + assert.Equal(t, "9", stored.UserCPRV, "UserCPRV must be unchanged after a transient user-defined CP fetch error") } -// overlayErrorClient returns a valid CP but fails AP/NN calls with the -// configured errors. Used to test overlay error-preservation logic. -type overlayErrorClient struct { - cp *v1beta1.ContainerProfile - apErr error - nnErr error +// userCPErrorClient returns a valid base CP for any name except userName, whose +// fetch fails with userCPErr. Used to test user-defined CP error-preservation: +// the base/learned CP fetch succeeds while the label-referenced authored CP GET +// fails transiently. +type userCPErrorClient struct { + cp *v1beta1.ContainerProfile + userName string + userCPErr error } -var _ storage.ProfileClient = (*overlayErrorClient)(nil) +var _ storage.ProfileClient = (*userCPErrorClient)(nil) -func (o *overlayErrorClient) GetContainerProfile(_ context.Context, _, _ string) (*v1beta1.ContainerProfile, error) { +func (o *userCPErrorClient) GetContainerProfile(_ context.Context, _, name string) (*v1beta1.ContainerProfile, error) { + if name == o.userName { + return nil, o.userCPErr + } return o.cp, nil } -func (o *overlayErrorClient) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - return nil, o.apErr -} -func (o *overlayErrorClient) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - return nil, o.nnErr -} -func (o *overlayErrorClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (o *overlayErrorClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // --- helpers --- @@ -745,18 +485,6 @@ var _ storage.ProfileClient = (*failingProfileClient)(nil) func (f *failingProfileClient) GetContainerProfile(_ context.Context, _, _ string) (*v1beta1.ContainerProfile, error) { return nil, f.cpErr } -func (f *failingProfileClient) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - return nil, nil -} -func (f *failingProfileClient) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - return nil, nil -} -func (f *failingProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (f *failingProfileClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // silence unused-import linter: helpersv1 is referenced only via the const in // containerprofilecache.go (used by some entries). Import explicitly so the @@ -837,18 +565,6 @@ func (b *blockingProfileClient) GetContainerProfile(ctx context.Context, _, _ st return nil, ctx.Err() } } -func (b *blockingProfileClient) GetApplicationProfile(_ context.Context, _, _ string) (*v1beta1.ApplicationProfile, error) { - return nil, nil -} -func (b *blockingProfileClient) GetNetworkNeighborhood(_ context.Context, _, _ string) (*v1beta1.NetworkNeighborhood, error) { - return nil, nil -} -func (b *blockingProfileClient) ListApplicationProfiles(_ context.Context, _ string, _ int64, _ string) (*v1beta1.ApplicationProfileList, error) { - return &v1beta1.ApplicationProfileList{}, nil -} -func (b *blockingProfileClient) ListNetworkNeighborhoods(_ context.Context, _ string, _ int64, _ string) (*v1beta1.NetworkNeighborhoodList, error) { - return &v1beta1.NetworkNeighborhoodList{}, nil -} // TestRetryPendingEntries_CPCreatedAfterAdd exercises the bug that slipped // through PR #788 component tests: at EventTypeAddContainer the CP may not @@ -888,8 +604,11 @@ func TestRetryPendingEntries_CPCreatedAfterAdd(t *testing.T) { 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") + // Two GETs total: this container carries no user-defined-profile label, so + // each populate attempt issues exactly one GetContainerProfile call for the + // base CP (there is no legacy "ug-" overlay fetch anymore). addContainer + // performs one attempt (base 404), the retry performs the second (base 200). + assert.Equal(t, 2, client.getCPCalls, "each tick re-GETs the base CP exactly once") } // TestPendingEntriesAreNotGCedBeforeRetry verifies we no longer drop pending @@ -983,38 +702,6 @@ func TestPartialCP_PreRunning_Accepted(t *testing.T) { assert.Equal(t, 0, c.pending.Len(), "not pending when accepted") } -// TestOverlayLabel_TransientFetchFailure_RefsRetained verifies that when -// UserDefinedProfileMetadataKey is set but the user-AP/NN fetch fails, the -// entry still records UserAPRef / UserNNRef so the refresh loop can re-fetch -// on subsequent ticks instead of permanently dropping the overlay. -func TestOverlayLabel_TransientFetchFailure_RefsRetained(t *testing.T) { - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp-with-overlay", Namespace: "default", ResourceVersion: "1", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - } - // Overlay fetch returns an error; the base CP is fine. - client := &fakeProfileClient{cp: cp, apErr: assertErrNotFound("override"), nnErr: assertErrNotFound("override")} - c, k8s := newTestCache(t, client) - - id := "container-transient-overlay" - primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") - - // Build the container with the overlay label set. - ct := eventContainer(id) - ct.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "override"} - - require.NoError(t, c.addContainer(ct, context.Background())) - - entry, ok := c.entries.Load(id) - require.True(t, ok, "entry stored with base CP even if overlay fetch failed") - require.NotNil(t, entry.UserAPRef, "UserAPRef retained for refresh retry") - require.NotNil(t, entry.UserNNRef, "UserNNRef retained for refresh retry") - assert.Equal(t, "override", entry.UserAPRef.Name) - assert.Equal(t, "override", entry.UserNNRef.Name) -} - // TestRefreshDoesNotResurrectDeletedEntry verifies the Phase-4 reviewer race: // refreshAllEntries snapshots entries without a lock; if deleteContainer // removes the entry before refreshOneEntry takes the lock, the refresh must @@ -1048,42 +735,6 @@ func TestRefreshDoesNotResurrectDeletedEntry(t *testing.T) { assert.Nil(t, c.GetProjectedContainerProfile(id), "refresh must not resurrect deleted entry") } -// TestUserDefinedProfileOnly_NoBaseCP verifies that a container with only a -// user-defined AP/NN (no base CP yet) still gets a cache entry, mirroring the -// legacy behavior where user-defined profiles were stored directly. -func TestUserDefinedProfileOnly_NoBaseCP(t *testing.T) { - userAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{Name: "user-override", Namespace: "default", ResourceVersion: "10"}, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - {Name: "nginx", Capabilities: []string{"CAP_NET_ADMIN"}}, - }, - }, - } - // Base CP fetch fails (404); only the overlay exists. - 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) - ct.K8s.PodLabels = map[string]string{helpersv1.UserDefinedProfileMetadataKey: "user-override"} - - require.NoError(t, c.addContainer(ct, context.Background())) - - 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. - _, 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 // PreRunningContainer flag. func primePreRunningSharedData(t *testing.T, k8s *objectcache.K8sObjectCacheMock, containerID, wlid string) { @@ -1286,73 +937,6 @@ func TestNotifyContainerTerminal_Completed(t *testing.T) { assert.Equal(t, helpersv1.Completed, stored.State.Status) } -// TestUserManagedProfileMerged exercises the user-managed merge path -// (Test_12_MergingProfilesTest / Test_13_MergingNetworkNeighborhoodTest): -// a user-managed AP published at "ug-" is merged on top of -// the base CP. Anomalies NOT in the union of base + user-managed should -// produce alerts; anomalies present in either source should not. -func TestUserManagedProfileMerged(t *testing.T) { - // Base CP has exec "/bin/X"; user-managed AP adds "/bin/Y". - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp-base", - Namespace: "default", - ResourceVersion: "1", - Annotations: map[string]string{ - helpersv1.CompletionMetadataKey: helpersv1.Full, - helpersv1.StatusMetadataKey: helpersv1.Completed, - }, - }, - Spec: v1beta1.ContainerProfileSpec{ - Execs: []v1beta1.ExecCalls{{Path: "/bin/X"}}, - }, - } - userManagedAP := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ug-nginx", - Namespace: "default", - ResourceVersion: "9", - Annotations: map[string]string{ - helpersv1.CompletionMetadataKey: helpersv1.Full, - helpersv1.StatusMetadataKey: helpersv1.Completed, - }, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/Y"}}, - }}, - }, - } - client := &fakeProfileClient{ - cp: cp, - userManagedAP: userManagedAP, - } - 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.GetProjectedContainerProfile(id) - require.NotNil(t, cached, "entry populated") - _, 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. - entry, ok := c.entries.Load(id) - 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 diff --git a/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go b/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go deleted file mode 100644 index 4bf4496ef6..0000000000 --- a/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package containerprofilecache_test - -// TestT8_EndToEndRefreshUpdatesProjection mirrors the same-named unit test from -// reconciler_test.go using only the public / test-helper API so it can live at -// the integration test level (tests/containerprofilecache/). -// -// Scenario: an entry backed by CP (RV=100) + user-AP overlay (RV=50) is seeded -// via SeedEntryWithOverlayForTest. Storage is mutated to serve a new AP -// (RV=51, different execs). A single RefreshAllEntriesForTest call must rebuild -// the projection so the cached execs reflect the new AP, not the stale one. - -import ( - "context" - "testing" - "time" - - helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" - "github.com/kubescape/node-agent/pkg/config" - "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" -) - -func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { - cp := &v1beta1.ContainerProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cp", - Namespace: "default", - ResourceVersion: "100", - Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed}, - }, - Spec: v1beta1.ContainerProfileSpec{ - Execs: []v1beta1.ExecCalls{{Path: "/bin/base", Args: []string{"a"}}}, - }, - } - apV1 := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "override", - Namespace: "default", - ResourceVersion: "50", - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/old", Args: []string{"x"}}}, - }}, - }, - } - apV2 := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: "override", - Namespace: "default", - ResourceVersion: "51", - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{{ - Name: "nginx", - Execs: []v1beta1.ExecCalls{{Path: "/bin/new", Args: []string{"y"}}}, - }}, - }, - } - - store := newFakeStorage(cp) - store.mu.Lock() - store.ap = apV1 - store.mu.Unlock() - - k8s := newFakeK8sCache() - cfg := config.Config{ - ProfilesCacheRefreshRate: 30 * time.Second, - StorageRPCBudget: 500 * time.Millisecond, - } - cache := cpc.NewContainerProfileCache(cfg, store, k8s, nil) - - const id = "c1" - // Seed a projected entry with a stale UserAPRV so refresh sees the RV change. - cache.SeedEntryWithOverlayForTest(id, &cpc.CachedContainerProfile{ - Projected: cpc.Apply(nil, cp, nil), - State: &objectcache.ProfileState{Name: cp.Name}, - ContainerName: "nginx", - PodName: "nginx-abc", - Namespace: "default", - PodUID: "uid-1", - CPName: "cp", - RV: "100", - UserAPRV: "50", // stale — triggers rebuild when storage returns RV=51 - }, "default", "override", "", "") - - // Advance storage to apV2 (RV=51). The reconciler will see the RV mismatch - // and rebuild the projection from cp + apV2. - store.mu.Lock() - store.ap = apV2 - store.mu.Unlock() - - cache.SetProjectionSpec(objectcache.RuleProjectionSpec{ - Execs: objectcache.FieldSpec{InUse: true, All: true}, - Hash: "test-execs", - }) - cache.RefreshAllEntriesForTest(context.Background()) - - pcp := cache.GetProjectedContainerProfile(id) - require.NotNil(t, pcp, "entry must remain after refresh") - - var paths []string - 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") - assert.NotContains(t, paths, "/bin/old", "stale user-AP exec must NOT survive the rebuild") -} diff --git a/pkg/objectcache/containerprofilecache/testdata/golden/network_all.json b/pkg/objectcache/containerprofilecache/testdata/golden/network_all.json new file mode 100644 index 0000000000..3b833c5cfd --- /dev/null +++ b/pkg/objectcache/containerprofilecache/testdata/golden/network_all.json @@ -0,0 +1,84 @@ +{ + "specHash": "949a9d7ed7a4054f", + "syncChecksum": "", + "opens": { + "All": true, + "Values": null, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "execs": { + "All": true, + "Values": null, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "endpoints": { + "All": true, + "Values": null, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "capabilities": { + "All": true, + "Values": null, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "syscalls": { + "All": true, + "Values": null, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "egressDomains": { + "All": true, + "Values": { + "c.example.com": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "egressAddresses": { + "All": true, + "Values": { + "*": {}, + "203.0.113.0/24": {}, + "203.0.113.7": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "ingressDomains": { + "All": true, + "Values": { + "a.internal": {}, + "b.internal": {}, + "old.internal": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "ingressAddresses": { + "All": true, + "Values": { + "*": {}, + "192.168.0.0/16": {}, + "192.168.1.10": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "execsByPath": null, + "policyByRuleId": null, + "callStacks": null +} diff --git a/pkg/objectcache/containerprofilecache/testdata/golden/rich_filtered.json b/pkg/objectcache/containerprofilecache/testdata/golden/rich_filtered.json new file mode 100644 index 0000000000..dccf5126c1 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/testdata/golden/rich_filtered.json @@ -0,0 +1,156 @@ +{ + "specHash": "fc5f9fe03f7ce1db", + "syncChecksum": "sync-abc123", + "opens": { + "All": false, + "Values": { + "/etc/app.conf": {}, + "/etc/passwd": {} + }, + "Patterns": [ + "/data/⋯/config", + "/var/log/*" + ], + "PrefixHits": { + "/data/": true, + "/var/": true + }, + "SuffixHits": { + ".conf": true + } + }, + "execs": { + "All": true, + "Values": { + "/bin/curl": {}, + "/bin/echo": {}, + "/bin/ls": {}, + "/usr/bin/app": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "endpoints": { + "All": false, + "Values": { + "/api/v1/health": {} + }, + "Patterns": null, + "PrefixHits": { + "/api/": true + }, + "SuffixHits": {} + }, + "capabilities": { + "All": false, + "Values": { + "NET_ADMIN": {}, + "SYS_PTRACE": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "syscalls": { + "All": true, + "Values": { + "openat": {}, + "read": {}, + "write": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "egressDomains": { + "All": true, + "Values": { + "cdn.example.com": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "egressAddresses": { + "All": true, + "Values": { + "*": {}, + "0.0.0.0/0": {}, + "8.8.8.8": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "ingressDomains": { + "All": true, + "Values": { + "client.internal": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "ingressAddresses": { + "All": true, + "Values": { + "*": {}, + "10.0.0.5": {}, + "10.1.0.0/16": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "execsByPath": { + "/bin/curl": [ + [ + "*", + "https://example.com" + ] + ], + "/bin/echo": [ + [] + ], + "/bin/ls": [ + [ + "-la", + "/tmp" + ] + ], + "/usr/bin/app": [ + [ + "run", + "⋯" + ] + ] + }, + "policyByRuleId": { + "R0001": { + "processAllowed": [ + "cat", + "ls" + ] + }, + "R0002": { + "containerAllowed": true + } + }, + "callStacks": [ + { + "callID": "cs-exec-1", + "pathCount": 1, + "pathDepths": [ + 2 + ] + }, + { + "callID": "cs-open-1", + "pathCount": 1, + "pathDepths": [ + 3 + ] + } + ] +} diff --git a/pkg/objectcache/containerprofilecache/testdata/golden/rich_passthrough.json b/pkg/objectcache/containerprofilecache/testdata/golden/rich_passthrough.json new file mode 100644 index 0000000000..a343f73b67 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/testdata/golden/rich_passthrough.json @@ -0,0 +1,150 @@ +{ + "specHash": "1e9bdb48627eec55", + "syncChecksum": "sync-abc123", + "opens": { + "All": true, + "Values": { + "/etc/app.conf": {}, + "/etc/passwd": {} + }, + "Patterns": [ + "/data/⋯/config", + "/var/log/*" + ], + "PrefixHits": {}, + "SuffixHits": {} + }, + "execs": { + "All": true, + "Values": { + "/bin/curl": {}, + "/bin/echo": {}, + "/bin/ls": {}, + "/usr/bin/app": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "endpoints": { + "All": true, + "Values": { + "/api/v1/health": {}, + "/metrics": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "capabilities": { + "All": true, + "Values": { + "NET_ADMIN": {}, + "SYS_PTRACE": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "syscalls": { + "All": true, + "Values": { + "openat": {}, + "read": {}, + "write": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "egressDomains": { + "All": true, + "Values": { + "cdn.example.com": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "egressAddresses": { + "All": true, + "Values": { + "*": {}, + "0.0.0.0/0": {}, + "8.8.8.8": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "ingressDomains": { + "All": true, + "Values": { + "client.internal": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "ingressAddresses": { + "All": true, + "Values": { + "*": {}, + "10.0.0.5": {}, + "10.1.0.0/16": {} + }, + "Patterns": null, + "PrefixHits": {}, + "SuffixHits": {} + }, + "execsByPath": { + "/bin/curl": [ + [ + "*", + "https://example.com" + ] + ], + "/bin/echo": [ + [] + ], + "/bin/ls": [ + [ + "-la", + "/tmp" + ] + ], + "/usr/bin/app": [ + [ + "run", + "⋯" + ] + ] + }, + "policyByRuleId": { + "R0001": { + "processAllowed": [ + "cat", + "ls" + ] + }, + "R0002": { + "containerAllowed": true + } + }, + "callStacks": [ + { + "callID": "cs-exec-1", + "pathCount": 1, + "pathDepths": [ + 2 + ] + }, + { + "callID": "cs-open-1", + "pathCount": 1, + "pathDepths": [ + 3 + ] + } + ] +} diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index 058005dae6..789eccb9ec 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -18,23 +18,14 @@ import ( // RuleObjectCacheMock is a test double for RuleObjectCache. // -// Setter partition contract — SetApplicationProfile and SetNetworkNeighborhood -// both write into cpByContainerName entries but own non-overlapping fields: -// -// SetApplicationProfile → Architectures, Capabilities, Execs, Opens, Syscalls, -// SeccompProfile, Endpoints, ImageID, ImageTag, -// PolicyByRuleId, IdentifiedCallStacks -// SetNetworkNeighborhood → LabelSelector, Ingress, Egress -// -// Calling both setters produces a fully-populated ContainerProfile with no -// field conflict. Both setters apply a first-container-wins rule for r.cp -// (backward-compat pointer for single-container tests); the per-container map -// cpByContainerName is authoritative for multi-container tests. +// The unified ContainerProfile is the only profile surface. Tests seed it with +// SetContainerProfile (single-container, backward-compat pointer r.cp) or by +// populating cpByContainerName directly for multi-container cases; the +// per-container map is authoritative when a WatchedContainerData InstanceID is +// registered for the queried containerID. type RuleObjectCacheMock struct { - profile *v1beta1.ApplicationProfile podSpec *corev1.PodSpec podStatus *corev1.PodStatus - nn *v1beta1.NetworkNeighborhood cp *v1beta1.ContainerProfile cpByContainerName map[string]*v1beta1.ContainerProfile dnsCache map[string]string @@ -44,60 +35,10 @@ type RuleObjectCacheMock struct { projectionSpec objectcache.RuleProjectionSpec } -func (r *RuleObjectCacheMock) GetApplicationProfile(string) *v1beta1.ApplicationProfile { - return r.profile -} - func (r *RuleObjectCacheMock) GetCallStackSearchTree(string) *callstackcache.CallStackSearchTree { return nil } -func (r *RuleObjectCacheMock) SetApplicationProfile(profile *v1beta1.ApplicationProfile) { - r.profile = profile - if profile == nil { - return - } - if r.cpByContainerName == nil { - r.cpByContainerName = make(map[string]*v1beta1.ContainerProfile) - } - apply := func(c *v1beta1.ApplicationProfileContainer) { - cp, ok := r.cpByContainerName[c.Name] - if !ok { - cp = &v1beta1.ContainerProfile{} - r.cpByContainerName[c.Name] = cp - } - cp.Spec.Architectures = profile.Spec.Architectures - cp.Spec.Capabilities = c.Capabilities - cp.Spec.Execs = c.Execs - cp.Spec.Opens = c.Opens - cp.Spec.Syscalls = c.Syscalls - cp.Spec.SeccompProfile = c.SeccompProfile - cp.Spec.Endpoints = c.Endpoints - cp.Spec.ImageID = c.ImageID - cp.Spec.ImageTag = c.ImageTag - cp.Spec.PolicyByRuleId = c.PolicyByRuleId - cp.Spec.IdentifiedCallStacks = c.IdentifiedCallStacks - } - for i := range profile.Spec.Containers { - apply(&profile.Spec.Containers[i]) - } - for i := range profile.Spec.InitContainers { - apply(&profile.Spec.InitContainers[i]) - } - for i := range profile.Spec.EphemeralContainers { - apply(&profile.Spec.EphemeralContainers[i]) - } - // r.cp = first container's entry (backward compat for single-container tests). - switch { - case len(profile.Spec.Containers) > 0: - r.cp = r.cpByContainerName[profile.Spec.Containers[0].Name] - case len(profile.Spec.InitContainers) > 0: - r.cp = r.cpByContainerName[profile.Spec.InitContainers[0].Name] - case len(profile.Spec.EphemeralContainers) > 0: - r.cp = r.cpByContainerName[profile.Spec.EphemeralContainers[0].Name] - } -} - func (r *RuleObjectCacheMock) GetContainerProfile(containerID string) *v1beta1.ContainerProfile { if r.ContainerIDToSharedData != nil && containerID != "" { data, ok := r.ContainerIDToSharedData.Load(containerID) @@ -322,48 +263,6 @@ func (r *RuleObjectCacheMock) K8sObjectCache() objectcache.K8sObjectCache { return r } -func (r *RuleObjectCacheMock) GetNetworkNeighborhood(string) *v1beta1.NetworkNeighborhood { - return r.nn -} - -func (r *RuleObjectCacheMock) SetNetworkNeighborhood(nn *v1beta1.NetworkNeighborhood) { - r.nn = nn - if nn == nil { - return - } - if r.cpByContainerName == nil { - r.cpByContainerName = make(map[string]*v1beta1.ContainerProfile) - } - apply := func(c *v1beta1.NetworkNeighborhoodContainer) { - cp, ok := r.cpByContainerName[c.Name] - if !ok { - cp = &v1beta1.ContainerProfile{} - r.cpByContainerName[c.Name] = cp - } - cp.Spec.LabelSelector = nn.Spec.LabelSelector - cp.Spec.Ingress = c.Ingress - cp.Spec.Egress = c.Egress - } - for i := range nn.Spec.Containers { - apply(&nn.Spec.Containers[i]) - } - for i := range nn.Spec.InitContainers { - apply(&nn.Spec.InitContainers[i]) - } - for i := range nn.Spec.EphemeralContainers { - apply(&nn.Spec.EphemeralContainers[i]) - } - // r.cp = first container's entry (backward compat for single-container tests). - switch { - case len(nn.Spec.Containers) > 0: - r.cp = r.cpByContainerName[nn.Spec.Containers[0].Name] - case len(nn.Spec.InitContainers) > 0: - r.cp = r.cpByContainerName[nn.Spec.InitContainers[0].Name] - case len(nn.Spec.EphemeralContainers) > 0: - r.cp = r.cpByContainerName[nn.Spec.EphemeralContainers[0].Name] - } -} - func (r *RuleObjectCacheMock) DnsCache() objectcache.DnsCache { return r } @@ -398,11 +297,3 @@ func (r *RuleObjectCacheMock) DeleteHandler(_ context.Context, _ runtime.Object) func (r *RuleObjectCacheMock) ContainerCallback(_ containercollection.PubSubEvent) { return } - -func (r *RuleObjectCacheMock) GetApplicationProfileState(_ string) *objectcache.ProfileState { - return nil -} - -func (r *RuleObjectCacheMock) GetNetworkNeighborhoodState(_ string) *objectcache.ProfileState { - return nil -} diff --git a/pkg/rulemanager/cel/cel.go b/pkg/rulemanager/cel/cel.go index b064323df9..a4a527787b 100644 --- a/pkg/rulemanager/cel/cel.go +++ b/pkg/rulemanager/cel/cel.go @@ -14,10 +14,10 @@ import ( "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/containerprofile" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/containerprofilenetwork" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/k8s" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/net" - "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/networkneighborhood" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/parse" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/process" typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" @@ -62,8 +62,8 @@ func NewCEL(objectCache objectcache.ObjectCache, cfg config.Config, mm ...metric cel.CustomTypeProvider(tp), ext.Strings(), k8s.K8s(objectCache.K8sObjectCache(), cfg), - applicationprofile.AP(objectCache, cfg, mm...), - networkneighborhood.NN(objectCache, cfg, mm...), + containerprofile.CP(objectCache, cfg, mm...), + containerprofilenetwork.CPNetwork(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/containerprofile/ap.go similarity index 70% rename from pkg/rulemanager/cel/libraries/applicationprofile/ap.go rename to pkg/rulemanager/cel/libraries/containerprofile/ap.go index ce86d7ab88..a77a691ea5 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/ap.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "github.com/google/cel-go/cel" @@ -13,7 +13,7 @@ import ( ) func New(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) libraries.Library { - lib := &apLibrary{ + lib := &containerProfileLibrary{ objectCache: objectCache, functionCache: cache.NewFunctionCache(cache.FunctionCacheConfig{ MaxSize: config.CelConfigCache.MaxSize, @@ -28,11 +28,11 @@ func New(objectCache objectcache.ObjectCache, config config.Config, mm ...metric return lib } -func AP(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) cel.EnvOption { +func CP(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) cel.EnvOption { return cel.Lib(New(objectCache, config, mm...)) } -type apLibrary struct { +type containerProfileLibrary struct { objectCache objectcache.ObjectCache functionCache *cache.FunctionCache preStopCache *PreStopHookCache @@ -40,30 +40,30 @@ type apLibrary struct { detailedMetrics bool } -func (l *apLibrary) LibraryName() string { - return "ap" +func (l *containerProfileLibrary) LibraryName() string { + return "cp" } -func (l *apLibrary) Types() []*cel.Type { +func (l *containerProfileLibrary) Types() []*cel.Type { return []*cel.Type{} } -func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { +func (l *containerProfileLibrary) Declarations() map[string][]cel.FunctionOpt { return map[string][]cel.FunctionOpt{ - "ap.was_executed": { + "cp.was_executed": { cel.Overload( - "ap_was_executed", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_executed", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.was_executed") } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasExecuted(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_executed", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.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 @@ -71,20 +71,20 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { }), ), }, - "ap.was_executed_with_args": { + "cp.was_executed_with_args": { cel.Overload( - "ap_was_executed_with_args", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, + "cp_was_executed_with_args", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.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 @@ -92,229 +92,229 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { }), ), }, - "ap.was_path_opened": { + "cp.was_path_opened": { cel.Overload( - "ap_was_path_opened", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_path_opened", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_path_opened", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_path_opened_with_flags": { + "cp.was_path_opened_with_flags": { cel.Overload( - "ap_was_path_opened_with_flags", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, + "cp_was_path_opened_with_flags", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_path_opened_with_flags", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_path_opened_with_suffix": { + "cp.was_path_opened_with_suffix": { cel.Overload( - "ap_was_path_opened_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_path_opened_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_path_opened_with_suffix", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_path_opened_with_prefix": { + "cp.was_path_opened_with_prefix": { cel.Overload( - "ap_was_path_opened_with_prefix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_path_opened_with_prefix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_path_opened_with_prefix", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_syscall_used": { + "cp.was_syscall_used": { cel.Overload( - "ap_was_syscall_used", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_syscall_used", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_syscall_used", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_capability_used": { + "cp.was_capability_used": { cel.Overload( - "ap_was_capability_used", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_capability_used", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_capability_used", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_endpoint_accessed": { + "cp.was_endpoint_accessed": { cel.Overload( - "ap_was_endpoint_accessed", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_endpoint_accessed", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_endpoint_accessed", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_endpoint_accessed_with_method": { + "cp.was_endpoint_accessed_with_method": { cel.Overload( - "ap_was_endpoint_accessed_with_method", []*cel.Type{cel.StringType, cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_endpoint_accessed_with_method", []*cel.Type{cel.StringType, cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_endpoint_accessed_with_method", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_endpoint_accessed_with_methods": { + "cp.was_endpoint_accessed_with_methods": { cel.Overload( - "ap_was_endpoint_accessed_with_methods", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, + "cp_was_endpoint_accessed_with_methods", []*cel.Type{cel.StringType, cel.StringType, cel.ListType(cel.StringType)}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_endpoint_accessed_with_methods", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_endpoint_accessed_with_prefix": { + "cp.was_endpoint_accessed_with_prefix": { cel.Overload( - "ap_was_endpoint_accessed_with_prefix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_endpoint_accessed_with_prefix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_endpoint_accessed_with_prefix", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_endpoint_accessed_with_suffix": { + "cp.was_endpoint_accessed_with_suffix": { cel.Overload( - "ap_was_endpoint_accessed_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_endpoint_accessed_with_suffix", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_endpoint_accessed_with_suffix", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "ap.was_host_accessed": { + "cp.was_host_accessed": { cel.Overload( - "ap_was_host_accessed", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_host_accessed", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_host_accessed", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -323,7 +323,7 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { } } -func (l *apLibrary) CompileOptions() []cel.EnvOption { +func (l *containerProfileLibrary) CompileOptions() []cel.EnvOption { options := []cel.EnvOption{} for name, overloads := range l.Declarations() { options = append(options, cel.Function(name, overloads...)) @@ -331,75 +331,75 @@ func (l *apLibrary) CompileOptions() []cel.EnvOption { return options } -func (l *apLibrary) ProgramOptions() []cel.ProgramOption { +func (l *containerProfileLibrary) ProgramOptions() []cel.ProgramOption { return []cel.ProgramOption{} } -func (l *apLibrary) CostEstimator() checker.CostEstimator { - return &apCostEstimator{} +func (l *containerProfileLibrary) CostEstimator() checker.CostEstimator { + return &containerProfileCostEstimator{} } -// apCostEstimator implements the checker.CostEstimator for the 'ap' library. -type apCostEstimator struct{} +// containerProfileCostEstimator implements the checker.CostEstimator for the 'cp' library. +type containerProfileCostEstimator struct{} -func (e *apCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { +func (e *containerProfileCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { cost := int64(0) switch function { - case "ap.was_executed": + case "cp.was_executed": // Cache lookup + O(n) linear search through execs list cost = 15 - case "ap.was_executed_with_args": + case "cp.was_executed_with_args": // Cache lookup + O(n) linear search + O(m) slice comparison for args cost = 30 - case "ap.was_path_opened": + case "cp.was_path_opened": // Cache lookup + O(n) linear search + dynamic path comparison cost = 25 - case "ap.was_path_opened_with_flags": + case "cp.was_path_opened_with_flags": // Cache lookup + O(n) search + dynamic path comparison + O(f*p) flag comparison cost = 40 - case "ap.was_path_opened_with_suffix": + case "cp.was_path_opened_with_suffix": // Cache lookup + O(n) linear search + O(n*len(suffix)) string suffix checks cost = 20 - case "ap.was_path_opened_with_prefix": + case "cp.was_path_opened_with_prefix": // Cache lookup + O(n) linear search + O(n*len(prefix)) string prefix checks cost = 20 - case "ap.was_syscall_used": + case "cp.was_syscall_used": // Cache lookup + O(n) slice.Contains search through syscalls cost = 12 - case "ap.was_capability_used": + case "cp.was_capability_used": // Cache lookup + O(n) slice.Contains search through capabilities cost = 12 - case "ap.was_endpoint_accessed": + case "cp.was_endpoint_accessed": // Cache lookup + O(n) linear search through endpoints + dynamic path comparison cost = 25 - case "ap.was_endpoint_accessed_with_method": + case "cp.was_endpoint_accessed_with_method": // Cache lookup + O(n) search + dynamic path comparison + O(m) method check cost = 30 - case "ap.was_endpoint_accessed_with_methods": + case "cp.was_endpoint_accessed_with_methods": // Cache lookup + O(n) search + dynamic path comparison + O(m*k) method comparison cost = 35 - case "ap.was_endpoint_accessed_with_prefix": + case "cp.was_endpoint_accessed_with_prefix": // Cache lookup + O(n) linear search + O(n*len(prefix)) string prefix checks cost = 20 - case "ap.was_endpoint_accessed_with_suffix": + case "cp.was_endpoint_accessed_with_suffix": // Cache lookup + O(n) linear search + O(n*len(suffix)) string suffix checks cost = 20 - case "ap.was_host_accessed": + case "cp.was_host_accessed": // Cache lookup + O(n) endpoint search + URL parsing + O(m) network neighbor search cost = 35 - case "ap.was_internal_endpoint_accessed": + case "cp.was_internal_endpoint_accessed": // Cache lookup + O(n) linear search through endpoints checking internal flag cost = 15 - case "ap.was_external_endpoint_accessed": + case "cp.was_external_endpoint_accessed": // Cache lookup + O(n) linear search through endpoints checking internal flag cost = 15 - case "ap.was_endpoint_accessed_with_direction": + case "cp.was_endpoint_accessed_with_direction": // Cache lookup + O(n) linear search through endpoints + string comparison cost = 18 - case "ap.was_endpoint_accessed_with_header": + case "cp.was_endpoint_accessed_with_header": // Cache lookup + O(n) search + JSON unmarshal + header map lookup cost = 40 - case "ap.was_endpoint_accessed_with_header_value": + case "cp.was_endpoint_accessed_with_header_value": // Cache lookup + O(n) search + JSON unmarshal + header map lookup + slice.Contains cost = 45 default: @@ -409,10 +409,10 @@ func (e *apCostEstimator) EstimateCallCost(function, overloadID string, target * return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: uint64(cost), Max: uint64(cost)}} } -func (e *apCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate { +func (e *containerProfileCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate { return nil // Not providing size estimates for now. } // Ensure the implementation satisfies the interface -var _ checker.CostEstimator = (*apCostEstimator)(nil) -var _ libraries.Library = (*apLibrary)(nil) +var _ checker.CostEstimator = (*containerProfileCostEstimator)(nil) +var _ libraries.Library = (*containerProfileLibrary)(nil) diff --git a/pkg/rulemanager/cel/libraries/containerprofile/ap_smoke_test.go b/pkg/rulemanager/cel/libraries/containerprofile/ap_smoke_test.go new file mode 100644 index 0000000000..bf99412074 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/containerprofile/ap_smoke_test.go @@ -0,0 +1,96 @@ +package containerprofile + +import ( + "sort" + "testing" + + "github.com/google/cel-go/cel" + "github.com/kubescape/node-agent/pkg/config" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/stretchr/testify/assert" +) + +// TestDeclarationsCompileEachOverload builds a CEL env with the cp library +// and compiles + programs a representative call for every function declared +// by the library. It is a registration-regression guard: if an overload is +// wired with the wrong argument types (or dropped/renamed), the matching +// expression fails to compile or program, failing the test. No cluster or +// profile is required — only the type-checker and program binder run. +// +// The exprByFunc map is asserted to cover EXACTLY the set of functions in +// Declarations(), so adding or removing a declared function without updating +// this smoke test is itself a failure. +func TestDeclarationsCompileEachOverload(t *testing.T) { + // One well-typed call site per declared function. + exprByFunc := map[string]string{ + "cp.was_executed": `cp.was_executed(containerID, s)`, + "cp.was_executed_with_args": `cp.was_executed_with_args(containerID, s, strs)`, + "cp.was_path_opened": `cp.was_path_opened(containerID, s)`, + "cp.was_path_opened_with_flags": `cp.was_path_opened_with_flags(containerID, s, strs)`, + "cp.was_path_opened_with_suffix": `cp.was_path_opened_with_suffix(containerID, s)`, + "cp.was_path_opened_with_prefix": `cp.was_path_opened_with_prefix(containerID, s)`, + "cp.was_syscall_used": `cp.was_syscall_used(containerID, s)`, + "cp.was_capability_used": `cp.was_capability_used(containerID, s)`, + "cp.was_endpoint_accessed": `cp.was_endpoint_accessed(containerID, s)`, + "cp.was_endpoint_accessed_with_method": `cp.was_endpoint_accessed_with_method(containerID, s, s)`, + "cp.was_endpoint_accessed_with_methods": `cp.was_endpoint_accessed_with_methods(containerID, s, strs)`, + "cp.was_endpoint_accessed_with_prefix": `cp.was_endpoint_accessed_with_prefix(containerID, s)`, + "cp.was_endpoint_accessed_with_suffix": `cp.was_endpoint_accessed_with_suffix(containerID, s)`, + "cp.was_host_accessed": `cp.was_host_accessed(containerID, s)`, + } + + objCache := objectcachev1.RuleObjectCacheMock{} + lib := New(&objCache, config.Config{}) + + // The smoke test must track the library's declared surface exactly. + declared := make([]string, 0) + for name := range lib.(*containerProfileLibrary).Declarations() { + declared = append(declared, name) + } + covered := make([]string, 0, len(exprByFunc)) + for name := range exprByFunc { + covered = append(covered, name) + } + sort.Strings(declared) + sort.Strings(covered) + assert.Equal(t, declared, covered, + "exprByFunc must cover exactly the functions in Declarations() — a drift means a function was added/removed without updating this smoke test") + + env, err := cel.NewEnv( + cel.Variable("containerID", cel.StringType), + cel.Variable("s", cel.StringType), + cel.Variable("strs", cel.ListType(cel.StringType)), + CP(&objCache, config.Config{}), + ) + if err != nil { + t.Fatalf("failed to create env: %v", err) + } + + // Activation for the eval pass: an empty objectCache has no profile, so + // every helper resolves to false via ConvertProfileNotAvailableErrToBool. + // Eval exercises each declared function's binding closure end-to-end. + activation := map[string]interface{}{ + "containerID": "cid", + "s": "x", + "strs": []string{"a", "b"}, + } + + for name, expr := range exprByFunc { + t.Run(name, func(t *testing.T) { + ast, issues := env.Compile(expr) + if issues != nil && issues.Err() != nil { + t.Fatalf("compile %q failed: %v", expr, issues.Err()) + } + program, err := env.Program(ast) + if err != nil { + t.Fatalf("program %q failed: %v", expr, err) + } + out, _, err := program.Eval(activation) + if err != nil { + t.Fatalf("eval %q failed: %v", expr, err) + } + // With no profile in the cache every helper degrades to false. + assert.Equal(t, false, out.Value(), "expr %q should evaluate to false with no profile", expr) + }) + } +} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/cache_test.go b/pkg/rulemanager/cel/libraries/containerprofile/cache_test.go similarity index 86% rename from pkg/rulemanager/cel/libraries/applicationprofile/cache_test.go rename to pkg/rulemanager/cel/libraries/containerprofile/cache_test.go index 12edc69127..7f25b05d2d 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/cache_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/cache_test.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "testing" @@ -29,9 +29,8 @@ func TestApplicationProfileCaching(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", @@ -46,11 +45,11 @@ func TestApplicationProfileCaching(t *testing.T) { }, Syscalls: []string{"open", "read", "write"}, Capabilities: []string{"CAP_NET_ADMIN"}, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) // Create library with cache - lib := &apLibrary{ + lib := &containerProfileLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -73,7 +72,7 @@ func TestApplicationProfileCaching(t *testing.T) { }{ { name: "was_path_opened caching", - expression: `ap.was_path_opened(containerID, path)`, + expression: `cp.was_path_opened(containerID, path)`, vars: map[string]interface{}{ "containerID": "test-container-id", "path": "/etc/passwd", @@ -82,7 +81,7 @@ func TestApplicationProfileCaching(t *testing.T) { }, { name: "was_executed caching", - expression: `ap.was_executed(containerID, path)`, + expression: `cp.was_executed(containerID, path)`, vars: map[string]interface{}{ "containerID": "test-container-id", "path": "/bin/ls", @@ -91,7 +90,7 @@ func TestApplicationProfileCaching(t *testing.T) { }, { name: "was_executed_with_args caching", - expression: `ap.was_executed_with_args(containerID, path, args)`, + expression: `cp.was_executed_with_args(containerID, path, args)`, vars: map[string]interface{}{ "containerID": "test-container-id", "path": "/bin/ls", @@ -101,7 +100,7 @@ func TestApplicationProfileCaching(t *testing.T) { }, { name: "was_syscall_used caching", - expression: `ap.was_syscall_used(containerID, syscall)`, + expression: `cp.was_syscall_used(containerID, syscall)`, vars: map[string]interface{}{ "containerID": "test-container-id", "syscall": "open", @@ -110,7 +109,7 @@ func TestApplicationProfileCaching(t *testing.T) { }, { name: "was_capability_used caching", - expression: `ap.was_capability_used(containerID, capability)`, + expression: `cp.was_capability_used(containerID, capability)`, vars: map[string]interface{}{ "containerID": "test-container-id", "capability": "CAP_NET_ADMIN", @@ -170,9 +169,8 @@ func TestApplicationProfileCacheDifferentArguments(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", @@ -183,10 +181,10 @@ func TestApplicationProfileCacheDifferentArguments(t *testing.T) { Flags: []string{"O_WRONLY"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) - lib := &apLibrary{ + lib := &containerProfileLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -198,7 +196,7 @@ func TestApplicationProfileCacheDifferentArguments(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) + ast, issues := env.Compile(`cp.was_path_opened(containerID, path)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) @@ -254,24 +252,23 @@ func TestApplicationProfileCacheExpiration(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", Flags: []string{"O_RDONLY"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) // Create cache with short TTL for testing config := cache.FunctionCacheConfig{ MaxSize: 100, TTL: 50 * time.Millisecond, } - lib := &apLibrary{ + lib := &containerProfileLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(config), } @@ -283,7 +280,7 @@ func TestApplicationProfileCacheExpiration(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) + ast, issues := env.Compile(`cp.was_path_opened(containerID, path)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) @@ -331,19 +328,18 @@ func TestApplicationProfileCachePerformance(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", Flags: []string{"O_RDONLY"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) - lib := &apLibrary{ + lib := &containerProfileLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -355,7 +351,7 @@ func TestApplicationProfileCachePerformance(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) + ast, issues := env.Compile(`cp.was_path_opened(containerID, path)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) @@ -408,19 +404,18 @@ func TestApplicationProfileCacheClearCache(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", Flags: []string{"O_RDONLY"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) - lib := &apLibrary{ + lib := &containerProfileLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -432,7 +427,7 @@ func TestApplicationProfileCacheClearCache(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) + ast, issues := env.Compile(`cp.was_path_opened(containerID, path)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/capability.go b/pkg/rulemanager/cel/libraries/containerprofile/capability.go similarity index 87% rename from pkg/rulemanager/cel/libraries/applicationprofile/capability.go rename to pkg/rulemanager/cel/libraries/containerprofile/capability.go index eb3919f9ac..440250635c 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/capability.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/capability.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "github.com/google/cel-go/common/types" @@ -7,7 +7,7 @@ import ( "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" ) -func (l *apLibrary) wasCapabilityUsed(containerID, capabilityName ref.Val) ref.Val { +func (l *containerProfileLibrary) wasCapabilityUsed(containerID, capabilityName ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/capability_test.go b/pkg/rulemanager/cel/libraries/containerprofile/capability_test.go similarity index 84% rename from pkg/rulemanager/cel/libraries/applicationprofile/capability_test.go rename to pkg/rulemanager/cel/libraries/containerprofile/capability_test.go index 6ca09da371..94f73ef943 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/capability_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/capability_test.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "testing" @@ -28,22 +28,21 @@ func TestCapabilityInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Capabilities: []string{ "NET_ADMIN", "SYS_ADMIN", "SETUID", "SETGID", }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("capabilityName", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -77,7 +76,7 @@ func TestCapabilityInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_capability_used(containerID, capabilityName)`) + ast, issues := env.Compile(`cp.was_capability_used(containerID, capabilityName)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -96,7 +95,7 @@ func TestCapabilityInProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_capability_used result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "cp.was_capability_used result should match expected value") }) } } @@ -107,13 +106,13 @@ func TestCapabilityNoProfile(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("capabilityName", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_capability_used(containerID, capabilityName)`) + ast, issues := env.Compile(`cp.was_capability_used(containerID, capabilityName)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -132,7 +131,7 @@ func TestCapabilityNoProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_capability_used should return false when no profile is available") + assert.False(t, actualResult, "cp.was_capability_used should return false when no profile is available") } func TestCapabilityCompilation(t *testing.T) { @@ -141,14 +140,14 @@ func TestCapabilityCompilation(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("capabilityName", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_capability_used(containerID, capabilityName)`) + ast, issues := env.Compile(`cp.was_capability_used(containerID, capabilityName)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/containerprofile/exec.go similarity index 97% rename from pkg/rulemanager/cel/libraries/applicationprofile/exec.go rename to pkg/rulemanager/cel/libraries/containerprofile/exec.go index c6ac58eaf8..c759ff3431 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/exec.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "github.com/google/cel-go/common/types" @@ -12,7 +12,7 @@ import ( "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" ) -func (l *apLibrary) wasExecuted(containerID, path ref.Val) ref.Val { +func (l *containerProfileLibrary) wasExecuted(containerID, path ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -55,7 +55,7 @@ func (l *apLibrary) wasExecuted(containerID, path ref.Val) ref.Val { return types.Bool(false) } -func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val { +func (l *containerProfileLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -155,7 +155,7 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.Bool(false) } -func (l *apLibrary) isExecInPodSpec(containerID, path ref.Val) ref.Val { +func (l *containerProfileLibrary) isExecInPodSpec(containerID, path ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } diff --git a/pkg/rulemanager/cel/libraries/containerprofile/exec_podspec_test.go b/pkg/rulemanager/cel/libraries/containerprofile/exec_podspec_test.go new file mode 100644 index 0000000000..70b60aa3e8 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/containerprofile/exec_podspec_test.go @@ -0,0 +1,180 @@ +package containerprofile + +import ( + "testing" + + "github.com/google/cel-go/common/types" + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/objectcache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +// newExecPodSpecCache builds a RuleObjectCacheMock with shared container data +// for "test-container-id" (container name "test-container") and the supplied +// PodSpec. Used to exercise isExecInPodSpec, which is the podspec-exempt +// branch feeding wasExecuted / wasExecutedWithArgs. +func newExecPodSpecCache(podSpec *corev1.PodSpec) *objectcachev1.RuleObjectCacheMock { + objCache := &objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: {{Name: "test-container"}}, + }, + }) + if podSpec != nil { + objCache.SetPodSpec(podSpec) + } + return objCache +} + +// TestIsExecInPodSpec covers the allow-path of the podspec exemption: +// an exec whose path equals a container's Command entry (or a lifecycle +// hook command) is exempt (true); a path that matches nothing is not +// exempt (false); and the two lookup-failure guards (missing pod spec, +// unresolvable container name) both answer false. +func TestIsExecInPodSpec(t *testing.T) { + podSpec := &corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test-container", + Command: []string{"/bin/sh", "/entrypoint.sh"}, + Lifecycle: &corev1.Lifecycle{ + PreStop: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"/bin/prestop"}}, + }, + PostStart: &corev1.LifecycleHandler{ + Exec: &corev1.ExecAction{Command: []string{"/bin/poststart"}}, + }, + }, + }, + { + Name: "other-container", + Command: []string{"/bin/other"}, + }, + }, + InitContainers: []corev1.Container{ + {Name: "test-container", Command: []string{"/bin/init"}}, + }, + } + + t.Run("path matches container command -> exempt", func(t *testing.T) { + lib := &containerProfileLibrary{objectCache: newExecPodSpecCache(podSpec), preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + got := lib.isExecInPodSpec(types.String("test-container-id"), types.String("/bin/sh")) + assert.Equal(t, types.Bool(true), got) + }) + + t.Run("second command entry matches -> exempt", func(t *testing.T) { + lib := &containerProfileLibrary{objectCache: newExecPodSpecCache(podSpec), preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + got := lib.isExecInPodSpec(types.String("test-container-id"), types.String("/entrypoint.sh")) + assert.Equal(t, types.Bool(true), got) + }) + + t.Run("prestop hook command matches -> exempt", func(t *testing.T) { + lib := &containerProfileLibrary{objectCache: newExecPodSpecCache(podSpec), preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + got := lib.isExecInPodSpec(types.String("test-container-id"), types.String("/bin/prestop")) + assert.Equal(t, types.Bool(true), got) + }) + + t.Run("poststart hook command matches -> exempt", func(t *testing.T) { + lib := &containerProfileLibrary{objectCache: newExecPodSpecCache(podSpec), preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + got := lib.isExecInPodSpec(types.String("test-container-id"), types.String("/bin/poststart")) + assert.Equal(t, types.Bool(true), got) + }) + + t.Run("path not in matching container -> not exempt", func(t *testing.T) { + // /bin/init belongs to the init container of the same name, but the + // primary container match returns first and short-circuits to false. + lib := &containerProfileLibrary{objectCache: newExecPodSpecCache(podSpec), preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + got := lib.isExecInPodSpec(types.String("test-container-id"), types.String("/bin/nonexistent")) + assert.Equal(t, types.Bool(false), got) + }) + + t.Run("pod spec lookup failure -> false", func(t *testing.T) { + // Shared data present but no pod spec installed: GetPodSpec errors. + lib := &containerProfileLibrary{objectCache: newExecPodSpecCache(nil), preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + got := lib.isExecInPodSpec(types.String("test-container-id"), types.String("/bin/sh")) + assert.Equal(t, types.Bool(false), got) + }) + + t.Run("container name lookup failure -> false", func(t *testing.T) { + // Shared data with empty ContainerInfos -> GetContainerName == "". + objCache := &objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + objCache.SetSharedContainerData("test-container-id", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{}, + }) + objCache.SetPodSpec(podSpec) + lib := &containerProfileLibrary{objectCache: objCache, preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + got := lib.isExecInPodSpec(types.String("test-container-id"), types.String("/bin/sh")) + assert.Equal(t, types.Bool(false), got) + }) + + t.Run("nil object cache -> error", func(t *testing.T) { + lib := &containerProfileLibrary{objectCache: nil} + got := lib.isExecInPodSpec(types.String("test-container-id"), types.String("/bin/sh")) + assert.True(t, types.IsError(got)) + }) +} + +// TestWasExecutedPodSpecExempt pins the podspec-exempt fall-through of +// wasExecuted: a profile that does NOT list the exec path still answers +// true when that path is a container Command entry in the pod spec. +func TestWasExecutedPodSpecExempt(t *testing.T) { + objCache := newExecPodSpecCache(&corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test-container", Command: []string{"/bin/sh"}}, + }, + }) + // Profile present (so projection succeeds) but path absent from Execs. + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/ls", Args: []string{"-la"}}}, + } + objCache.SetContainerProfile(profile) + + lib := &containerProfileLibrary{objectCache: objCache, preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + + // /bin/sh is not in the profile Execs but IS the pod spec command. + assert.Equal(t, types.Bool(true), + lib.wasExecuted(types.String("test-container-id"), types.String("/bin/sh")), + "exec exempt via pod spec command") + + // /bin/other is in neither the profile nor the pod spec. + assert.Equal(t, types.Bool(false), + lib.wasExecuted(types.String("test-container-id"), types.String("/bin/other")), + "exec not exempt and not profiled") +} + +// TestWasExecutedWithArgsPodSpecExempt pins the same fall-through for the +// args-carrying variant: unprofiled path + pod-spec command entry -> true. +func TestWasExecutedWithArgsPodSpecExempt(t *testing.T) { + objCache := newExecPodSpecCache(&corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "test-container", Command: []string{"/bin/sh"}}, + }, + }) + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/ls", Args: []string{"-la"}}}, + } + objCache.SetContainerProfile(profile) + + lib := &containerProfileLibrary{objectCache: objCache, preStopCache: NewPreStopHookCache(DefaultPreStopCacheSize, DefaultPreStopCacheTTL)} + + args := types.DefaultTypeAdapter.NativeToValue([]string{"-c", "echo hi"}) + assert.Equal(t, types.Bool(true), + lib.wasExecutedWithArgs(types.String("test-container-id"), types.String("/bin/sh"), args), + "exec-with-args exempt via pod spec command (args ignored on exemption)") + + noArgs := types.DefaultTypeAdapter.NativeToValue([]string{}) + assert.Equal(t, types.Bool(false), + lib.wasExecutedWithArgs(types.String("test-container-id"), types.String("/bin/other"), noArgs), + "exec-with-args not exempt and not profiled") +} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/containerprofile/exec_test.go similarity index 89% rename from pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go rename to pkg/rulemanager/cel/libraries/containerprofile/exec_test.go index 944ac23ddc..860e1757d9 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/exec_test.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "testing" @@ -29,9 +29,8 @@ func TestExecInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Execs: []v1beta1.ExecCalls{ { Path: "/bin/ls", @@ -42,13 +41,13 @@ func TestExecInProfile(t *testing.T) { Args: []string{"https://example.com"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -82,7 +81,7 @@ func TestExecInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_executed(containerID, path)`) + ast, issues := env.Compile(`cp.was_executed(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -101,7 +100,7 @@ func TestExecInProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_executed result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "cp.was_executed result should match expected value") }) } } @@ -112,13 +111,13 @@ func TestExecNoProfile(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_executed(containerID, path)`) + ast, issues := env.Compile(`cp.was_executed(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -137,7 +136,7 @@ func TestExecNoProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_executed should return false when no profile is available") + assert.False(t, actualResult, "cp.was_executed should return false when no profile is available") } func TestExecWithArgsInProfile(t *testing.T) { @@ -156,9 +155,8 @@ func TestExecWithArgsInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Execs: []v1beta1.ExecCalls{ { Path: "/bin/ls", @@ -173,14 +171,14 @@ func TestExecWithArgsInProfile(t *testing.T) { Args: []string{"hello", "world"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), cel.Variable("args", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -246,7 +244,7 @@ func TestExecWithArgsInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`) + ast, issues := env.Compile(`cp.was_executed_with_args(containerID, path, args)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -266,7 +264,7 @@ func TestExecWithArgsInProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_executed_with_args result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "cp.was_executed_with_args result should match expected value") }) } } @@ -278,13 +276,13 @@ func TestExecWithArgsNoProfile(t *testing.T) { cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), cel.Variable("args", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`) + ast, issues := env.Compile(`cp.was_executed_with_args(containerID, path, args)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -304,7 +302,7 @@ func TestExecWithArgsNoProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_executed_with_args should return false when no profile is available") + assert.False(t, actualResult, "cp.was_executed_with_args should return false when no profile is available") } // TestExecWithArgsWildcardInProfile exercises wildcard tokens inside a @@ -336,9 +334,8 @@ func TestExecWithArgsWildcardInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Execs: []v1beta1.ExecCalls{ // curl any URL: --user must be literal, value is one position. { @@ -361,14 +358,14 @@ func TestExecWithArgsWildcardInProfile(t *testing.T) { Args: []string{"hello", dynamicpathdetector.ExecArgsWildcard}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), cel.Variable("args", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -404,7 +401,7 @@ func TestExecWithArgsWildcardInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`) + ast, issues := env.Compile(`cp.was_executed_with_args(containerID, path, args)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -438,14 +435,14 @@ func TestExecWithArgsCompilation(t *testing.T) { cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), cel.Variable("args", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_executed_with_args(containerID, path, args)`) + ast, issues := env.Compile(`cp.was_executed_with_args(containerID, path, args)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/http.go b/pkg/rulemanager/cel/libraries/containerprofile/http.go similarity index 90% rename from pkg/rulemanager/cel/libraries/applicationprofile/http.go rename to pkg/rulemanager/cel/libraries/containerprofile/http.go index 45cfb19a5b..10b4664ffc 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/http.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/http.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "net/url" @@ -15,7 +15,7 @@ import ( ) // wasEndpointAccessed checks if a specific HTTP endpoint was accessed -func (l *apLibrary) wasEndpointAccessed(containerID, endpoint ref.Val) ref.Val { +func (l *containerProfileLibrary) wasEndpointAccessed(containerID, endpoint ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -49,7 +49,7 @@ func (l *apLibrary) wasEndpointAccessed(containerID, endpoint ref.Val) ref.Val { } // wasEndpointAccessedWithMethod checks if a specific HTTP endpoint was accessed with a specific method -func (l *apLibrary) wasEndpointAccessedWithMethod(containerID, endpoint, method ref.Val) ref.Val { +func (l *containerProfileLibrary) wasEndpointAccessedWithMethod(containerID, endpoint, method ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -87,7 +87,7 @@ func (l *apLibrary) wasEndpointAccessedWithMethod(containerID, endpoint, method } // wasEndpointAccessedWithMethods checks if a specific HTTP endpoint was accessed with any of the specified methods -func (l *apLibrary) wasEndpointAccessedWithMethods(containerID, endpoint, methods ref.Val) ref.Val { +func (l *containerProfileLibrary) wasEndpointAccessedWithMethods(containerID, endpoint, methods ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -126,7 +126,7 @@ func (l *apLibrary) wasEndpointAccessedWithMethods(containerID, endpoint, method } // wasEndpointAccessedWithPrefix checks if any HTTP endpoint with the specified prefix was accessed -func (l *apLibrary) wasEndpointAccessedWithPrefix(containerID, prefix ref.Val) ref.Val { +func (l *containerProfileLibrary) wasEndpointAccessedWithPrefix(containerID, prefix ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -163,7 +163,7 @@ func (l *apLibrary) wasEndpointAccessedWithPrefix(containerID, prefix ref.Val) r hit, declared := cp.Endpoints.PrefixHits[prefixStr] if !declared { if l.metrics != nil { - l.metrics.IncProjectionUndeclaredLiteral("ap.was_endpoint_accessed_with_prefix") + l.metrics.IncProjectionUndeclaredLiteral("cp.was_endpoint_accessed_with_prefix") } return types.Bool(false) } @@ -171,7 +171,7 @@ func (l *apLibrary) wasEndpointAccessedWithPrefix(containerID, prefix ref.Val) r } // wasEndpointAccessedWithSuffix checks if any HTTP endpoint with the specified suffix was accessed -func (l *apLibrary) wasEndpointAccessedWithSuffix(containerID, suffix ref.Val) ref.Val { +func (l *containerProfileLibrary) wasEndpointAccessedWithSuffix(containerID, suffix ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -208,7 +208,7 @@ func (l *apLibrary) wasEndpointAccessedWithSuffix(containerID, suffix ref.Val) r hit, declared := cp.Endpoints.SuffixHits[suffixStr] if !declared { if l.metrics != nil { - l.metrics.IncProjectionUndeclaredLiteral("ap.was_endpoint_accessed_with_suffix") + l.metrics.IncProjectionUndeclaredLiteral("cp.was_endpoint_accessed_with_suffix") } return types.Bool(false) } @@ -216,7 +216,7 @@ func (l *apLibrary) wasEndpointAccessedWithSuffix(containerID, suffix ref.Val) r } // wasHostAccessed checks if a specific host was accessed via HTTP endpoints or network connections -func (l *apLibrary) wasHostAccessed(containerID, host ref.Val) ref.Val { +func (l *containerProfileLibrary) wasHostAccessed(containerID, host ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } diff --git a/pkg/rulemanager/cel/libraries/containerprofile/http_test.go b/pkg/rulemanager/cel/libraries/containerprofile/http_test.go new file mode 100644 index 0000000000..a21988662b --- /dev/null +++ b/pkg/rulemanager/cel/libraries/containerprofile/http_test.go @@ -0,0 +1,294 @@ +package containerprofile + +import ( + "testing" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" +) + +// cpLibForEndpoints wires a containerProfileLibrary to a mock objectCache +// that returns the supplied ProjectedContainerProfile for any containerID. +// Reuses mockObjectCacheForPattern (defined in open_test.go) so the HTTP +// evaluators can be exercised as pure functions over a projected CP without +// a cluster. +func cpLibForEndpoints(pcp *objectcache.ProjectedContainerProfile) *containerProfileLibrary { + return &containerProfileLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}} +} + +// endpointsPCP builds a pass-through (All=true) ProjectedContainerProfile +// whose Endpoints surface carries the given concrete values and patterns. +func endpointsPCP(values []string, patterns []string) *objectcache.ProjectedContainerProfile { + vals := make(map[string]struct{}, len(values)) + for _, v := range values { + vals[v] = struct{}{} + } + return &objectcache.ProjectedContainerProfile{ + Endpoints: objectcache.ProjectedField{ + All: true, + Values: vals, + Patterns: patterns, + }, + } +} + +func asBool(t *testing.T, v ref.Val) bool { + t.Helper() + b, ok := v.Value().(bool) + if !ok { + t.Fatalf("expected bool result, got %T (%v)", v.Value(), v) + } + return b +} + +// TestWasEndpointAccessed pins path membership: a concrete value or a +// dynamic pattern in the projected Endpoints surface answers true; anything +// else (including an empty-endpoints CP) answers false. +func TestWasEndpointAccessed(t *testing.T) { + pcp := endpointsPCP( + []string{"/v1/api/users", "http://api.example.com/health"}, + []string{"/v1/api/orders/" + dynamicpathdetector.DynamicIdentifier}, + ) + empty := endpointsPCP(nil, nil) + + testCases := []struct { + name string + pcp *objectcache.ProjectedContainerProfile + endpoint string + want bool + }{ + {"concrete value matches", pcp, "/v1/api/users", true}, + {"url value matches", pcp, "http://api.example.com/health", true}, + {"dynamic pattern matches", pcp, "/v1/api/orders/42", true}, + {"no match", pcp, "/v1/api/secrets", false}, + {"empty-endpoints CP", empty, "/v1/api/users", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lib := cpLibForEndpoints(tc.pcp) + got := asBool(t, lib.wasEndpointAccessed(types.String("cid"), types.String(tc.endpoint))) + if got != tc.want { + t.Errorf("wasEndpointAccessed(%q) = %v, want %v", tc.endpoint, got, tc.want) + } + }) + } +} + +// TestWasEndpointAccessedWithMethod pins the v1 degradation: method is +// type-checked but NOT matched (EndpointMethodsByPath is out of scope for +// projection-v1), so a method mismatch on a matching path still answers +// true. Path membership alone decides the result. +func TestWasEndpointAccessedWithMethod(t *testing.T) { + pcp := endpointsPCP([]string{"/v1/api/users"}, nil) + empty := endpointsPCP(nil, nil) + + testCases := []struct { + name string + pcp *objectcache.ProjectedContainerProfile + endpoint string + method string + want bool + }{ + {"path+method match", pcp, "/v1/api/users", "GET", true}, + {"method mismatch still true (v1 path-only)", pcp, "/v1/api/users", "DELETE", true}, + {"path no match", pcp, "/v1/api/secrets", "GET", false}, + {"empty-endpoints CP", empty, "/v1/api/users", "GET", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lib := cpLibForEndpoints(tc.pcp) + got := asBool(t, lib.wasEndpointAccessedWithMethod( + types.String("cid"), types.String(tc.endpoint), types.String(tc.method))) + if got != tc.want { + t.Errorf("wasEndpointAccessedWithMethod(%q,%q) = %v, want %v", tc.endpoint, tc.method, got, tc.want) + } + }) + } +} + +// TestWasEndpointAccessedWithMethods mirrors the single-method variant for +// the plural (list-of-methods) overload. Methods are parsed but not matched +// in v1; path membership decides. +func TestWasEndpointAccessedWithMethods(t *testing.T) { + pcp := endpointsPCP([]string{"/v1/api/users"}, nil) + empty := endpointsPCP(nil, nil) + + testCases := []struct { + name string + pcp *objectcache.ProjectedContainerProfile + endpoint string + methods []string + want bool + }{ + {"path match, methods parsed", pcp, "/v1/api/users", []string{"GET", "POST"}, true}, + {"methods mismatch still true (v1 path-only)", pcp, "/v1/api/users", []string{"DELETE"}, true}, + {"path no match", pcp, "/v1/api/secrets", []string{"GET"}, false}, + {"empty methods list, path match", pcp, "/v1/api/users", []string{}, true}, + {"empty-endpoints CP", empty, "/v1/api/users", []string{"GET"}, false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lib := cpLibForEndpoints(tc.pcp) + methods := types.DefaultTypeAdapter.NativeToValue(tc.methods) + got := asBool(t, lib.wasEndpointAccessedWithMethods( + types.String("cid"), types.String(tc.endpoint), methods)) + if got != tc.want { + t.Errorf("wasEndpointAccessedWithMethods(%q,%v) = %v, want %v", tc.endpoint, tc.methods, got, tc.want) + } + }) + } +} + +// TestWasEndpointAccessedWithPrefix exercises BOTH branches: +// - pass-through (Endpoints.All=true): concrete Values are scanned with +// strings.HasPrefix; patterns are also scanned in this branch. +// - projection-active (Endpoints.All=false): PrefixHits is authoritative; +// an absent key is treated as undeclared → false. +func TestWasEndpointAccessedWithPrefix(t *testing.T) { + passthrough := endpointsPCP([]string{"/v1/api/users", "/v1/api/orders"}, []string{"/metrics/scrape"}) + + projected := &objectcache.ProjectedContainerProfile{ + Endpoints: objectcache.ProjectedField{ + All: false, + PrefixHits: map[string]bool{"/v1/api": true, "/admin": false}, + }, + } + empty := endpointsPCP(nil, nil) + + testCases := []struct { + name string + pcp *objectcache.ProjectedContainerProfile + prefix string + want bool + }{ + {"passthrough value prefix match", passthrough, "/v1/api", true}, + {"passthrough pattern prefix match", passthrough, "/metrics", true}, + {"passthrough boundary: full string as prefix", passthrough, "/v1/api/users", true}, + {"passthrough empty prefix matches all", passthrough, "", true}, + {"passthrough no match", passthrough, "/nope", false}, + {"projected prefix hit true", projected, "/v1/api", true}, + {"projected prefix hit false", projected, "/admin", false}, + {"projected undeclared prefix", projected, "/unknown", false}, + {"empty-endpoints CP", empty, "/v1/api", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lib := cpLibForEndpoints(tc.pcp) + got := asBool(t, lib.wasEndpointAccessedWithPrefix(types.String("cid"), types.String(tc.prefix))) + if got != tc.want { + t.Errorf("wasEndpointAccessedWithPrefix(%q) = %v, want %v", tc.prefix, got, tc.want) + } + }) + } +} + +// TestWasEndpointAccessedWithSuffix mirrors the prefix test across both the +// pass-through scan branch and the projection-active SuffixHits branch. +func TestWasEndpointAccessedWithSuffix(t *testing.T) { + passthrough := endpointsPCP([]string{"/v1/api/users.json", "/v1/api/report.csv"}, []string{"/health.check"}) + + projected := &objectcache.ProjectedContainerProfile{ + Endpoints: objectcache.ProjectedField{ + All: false, + SuffixHits: map[string]bool{".json": true, ".xml": false}, + }, + } + empty := endpointsPCP(nil, nil) + + testCases := []struct { + name string + pcp *objectcache.ProjectedContainerProfile + suffix string + want bool + }{ + {"passthrough value suffix match", passthrough, ".json", true}, + {"passthrough pattern suffix match", passthrough, ".check", true}, + {"passthrough boundary: full string as suffix", passthrough, "/v1/api/users.json", true}, + {"passthrough empty suffix matches all", passthrough, "", true}, + {"passthrough no match", passthrough, ".xml", false}, + {"projected suffix hit true", projected, ".json", true}, + {"projected suffix hit false", projected, ".xml", false}, + {"projected undeclared suffix", projected, ".yaml", false}, + {"empty-endpoints CP", empty, ".json", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lib := cpLibForEndpoints(tc.pcp) + got := asBool(t, lib.wasEndpointAccessedWithSuffix(types.String("cid"), types.String(tc.suffix))) + if got != tc.want { + t.Errorf("wasEndpointAccessedWithSuffix(%q) = %v, want %v", tc.suffix, got, tc.want) + } + }) + } +} + +// TestWasHostAccessed pins host extraction from endpoints: +// - URL-shaped endpoints match on parsed Host / Hostname. +// - non-URL endpoints match on whole-token equality or a host+"/" / +// host+":" boundary — a short host must NOT match a mid-path segment. +func TestWasHostAccessed(t *testing.T) { + pcp := endpointsPCP( + []string{ + "http://api.example.com/v1/health", + "db.internal:5432", + "metrics.svc/scrape", + "/v1/api/users", + }, + nil, + ) + empty := endpointsPCP(nil, nil) + + testCases := []struct { + name string + pcp *objectcache.ProjectedContainerProfile + host string + want bool + }{ + {"url host match", pcp, "api.example.com", true}, + {"host:port boundary match", pcp, "db.internal", true}, + {"host/path boundary match", pcp, "metrics.svc", true}, + {"short host must not match path segment", pcp, "api", false}, + {"no match", pcp, "unknown.host", false}, + {"empty-endpoints CP", empty, "api.example.com", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + lib := cpLibForEndpoints(tc.pcp) + got := asBool(t, lib.wasHostAccessed(types.String("cid"), types.String(tc.host))) + if got != tc.want { + t.Errorf("wasHostAccessed(%q) = %v, want %v", tc.host, got, tc.want) + } + }) + } +} + +// TestHTTPEvaluatorsNilObjectCache confirms every HTTP evaluator returns a +// CEL error (not a panic) when the library has no objectCache wired. +func TestHTTPEvaluatorsNilObjectCache(t *testing.T) { + lib := &containerProfileLibrary{objectCache: nil} + cid := types.String("cid") + s := types.String("x") + list := types.DefaultTypeAdapter.NativeToValue([]string{"GET"}) + + checks := []ref.Val{ + lib.wasEndpointAccessed(cid, s), + lib.wasEndpointAccessedWithMethod(cid, s, s), + lib.wasEndpointAccessedWithMethods(cid, s, list), + lib.wasEndpointAccessedWithPrefix(cid, s), + lib.wasEndpointAccessedWithSuffix(cid, s), + lib.wasHostAccessed(cid, s), + } + for i, r := range checks { + if !types.IsError(r) { + t.Errorf("evaluator #%d with nil objectCache: expected error, got %v", i, r) + } + } +} diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go b/pkg/rulemanager/cel/libraries/containerprofile/integration_test.go similarity index 76% rename from pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go rename to pkg/rulemanager/cel/libraries/containerprofile/integration_test.go index 885ace3f4c..024fb5f1ad 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/integration_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/integration_test.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "testing" @@ -28,9 +28,8 @@ func TestIntegrationWithAllFunctions(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Execs: []v1beta1.ExecCalls{ { Path: "/bin/bash", @@ -63,12 +62,12 @@ func TestIntegrationWithAllFunctions(t *testing.T) { "SYS_ADMIN", "SETUID", }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -81,37 +80,37 @@ func TestIntegrationWithAllFunctions(t *testing.T) { }{ { name: "Check suspicious execution pattern", - expression: `ap.was_executed_with_args(containerID, "/bin/bash", ["/bin/bash", "-c", "curl http://example.com"])`, + expression: `cp.was_executed_with_args(containerID, "/bin/bash", ["/bin/bash", "-c", "curl http://example.com"])`, expectedResult: true, }, { name: "Check file access pattern", - expression: `ap.was_path_opened_with_flags(containerID, "/etc/passwd", ["O_RDONLY"])`, + expression: `cp.was_path_opened_with_flags(containerID, "/etc/passwd", ["O_RDONLY"])`, expectedResult: true, }, { name: "Check dangerous syscall usage", - expression: `ap.was_syscall_used(containerID, "execve")`, + expression: `cp.was_syscall_used(containerID, "execve")`, expectedResult: true, }, { name: "Check dangerous capability usage", - expression: `ap.was_capability_used(containerID, "SYS_ADMIN")`, + expression: `cp.was_capability_used(containerID, "SYS_ADMIN")`, expectedResult: true, }, { name: "Complex security check - suspicious behavior", - expression: `ap.was_executed_with_args(containerID, "/bin/bash", ["/bin/bash", "-c", "curl http://example.com"]) && ap.was_path_opened(containerID, "/etc/passwd") && ap.was_syscall_used(containerID, "execve")`, + expression: `cp.was_executed_with_args(containerID, "/bin/bash", ["/bin/bash", "-c", "curl http://example.com"]) && cp.was_path_opened(containerID, "/etc/passwd") && cp.was_syscall_used(containerID, "execve")`, expectedResult: true, }, { name: "Complex security check - dangerous capabilities", - expression: `ap.was_capability_used(containerID, "NET_ADMIN") || ap.was_capability_used(containerID, "SYS_ADMIN")`, + expression: `cp.was_capability_used(containerID, "NET_ADMIN") || cp.was_capability_used(containerID, "SYS_ADMIN")`, expectedResult: true, }, { name: "Check non-existent operations", - expression: `ap.was_executed(containerID, "/bin/nonexistent") || ap.was_syscall_used(containerID, "nonexistent_syscall")`, + expression: `cp.was_executed(containerID, "/bin/nonexistent") || cp.was_syscall_used(containerID, "nonexistent_syscall")`, expectedResult: false, }, } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open.go b/pkg/rulemanager/cel/libraries/containerprofile/open.go similarity index 91% rename from pkg/rulemanager/cel/libraries/applicationprofile/open.go rename to pkg/rulemanager/cel/libraries/containerprofile/open.go index 62a4abedfa..45d89634de 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/open.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "strings" @@ -11,7 +11,7 @@ import ( "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" ) -func (l *apLibrary) wasPathOpened(containerID, path ref.Val) ref.Val { +func (l *containerProfileLibrary) wasPathOpened(containerID, path ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -53,7 +53,7 @@ func (l *apLibrary) wasPathOpened(containerID, path ref.Val) ref.Val { // (composite-key projection would balloon the cache footprint). When the // flags-projection slice is added in a future spec revision, this helper // becomes the path-AND-flag matcher and v1 callers continue to work. -func (l *apLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref.Val { +func (l *containerProfileLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -92,7 +92,7 @@ func (l *apLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref return types.Bool(false) } -func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val { +func (l *containerProfileLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -133,14 +133,14 @@ func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val hit, declared := cp.Opens.SuffixHits[suffixStr] if !declared { if l.metrics != nil { - l.metrics.IncProjectionUndeclaredLiteral("ap.was_path_opened_with_suffix") + l.metrics.IncProjectionUndeclaredLiteral("cp.was_path_opened_with_suffix") } return types.Bool(false) } return types.Bool(hit) } -func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val { +func (l *containerProfileLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -178,10 +178,9 @@ func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val hit, declared := cp.Opens.PrefixHits[prefixStr] if !declared { if l.metrics != nil { - l.metrics.IncProjectionUndeclaredLiteral("ap.was_path_opened_with_prefix") + l.metrics.IncProjectionUndeclaredLiteral("cp.was_path_opened_with_prefix") } return types.Bool(false) } return types.Bool(hit) } - diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open_bench_test.go b/pkg/rulemanager/cel/libraries/containerprofile/open_bench_test.go similarity index 90% rename from pkg/rulemanager/cel/libraries/applicationprofile/open_bench_test.go rename to pkg/rulemanager/cel/libraries/containerprofile/open_bench_test.go index ec65e754dd..4d54866084 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open_bench_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/open_bench_test.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "strconv" @@ -14,9 +14,9 @@ import ( // // - values_only: 50 concrete entries, no Patterns // - patterns_concrete: 50 concrete entries + 10 Patterns whose tail -// is literal (the typical /var/log/⋯/foo.log shape) +// is literal (the typical /var/log/⋯/foo.log shape) // - patterns_wildcard: 50 concrete entries + 10 Patterns ending in a -// wildcard segment (the permissive-arm shape) +// wildcard segment (the permissive-arm shape) // // Captures Matthias's upstream PR #811 contract numbers for the PR // description. @@ -52,7 +52,7 @@ func BenchmarkWasPathOpenedWithSuffix_AllMode(b *testing.B) { Patterns: sh.patterns, }, } - lib := &apLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}} + lib := &containerProfileLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}} suffix := types.String(".log") cid := types.String("bench-cid") b.ReportAllocs() @@ -95,7 +95,7 @@ func BenchmarkWasPathOpenedWithPrefix_AllMode(b *testing.B) { Patterns: sh.patterns, }, } - lib := &apLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}} + lib := &containerProfileLibrary{objectCache: &mockObjectCacheForPattern{pcp: pcp}} prefix := types.String("/var/") cid := types.String("bench-cid") b.ReportAllocs() diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go b/pkg/rulemanager/cel/libraries/containerprofile/open_test.go similarity index 89% rename from pkg/rulemanager/cel/libraries/applicationprofile/open_test.go rename to pkg/rulemanager/cel/libraries/containerprofile/open_test.go index 6e72428f07..8907194fb9 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/open_test.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "testing" @@ -47,7 +47,7 @@ func TestWasPathOpenedWithSuffix_PatternsNotScanned(t *testing.T) { }, } objCache := &mockObjectCacheForPattern{pcp: pcp} - lib := &apLibrary{objectCache: objCache} + lib := &containerProfileLibrary{objectCache: objCache} // 1) With concrete in Values: returns true. got := lib.wasPathOpenedWithSuffix(types.String("test-cid"), types.String(".log")) @@ -78,7 +78,7 @@ func TestWasPathOpenedWithPrefix_PatternsNotScanned(t *testing.T) { }, } objCache := &mockObjectCacheForPattern{pcp: pcp} - lib := &apLibrary{objectCache: objCache} + lib := &containerProfileLibrary{objectCache: objCache} got := lib.wasPathOpenedWithPrefix(types.String("test-cid"), types.String("/var/")) if b, _ := got.Value().(bool); !b { @@ -130,9 +130,8 @@ func TestOpenInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", @@ -143,13 +142,13 @@ func TestOpenInProfile(t *testing.T) { Flags: []string{"O_WRONLY", "O_CREAT"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -183,7 +182,7 @@ func TestOpenInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) + ast, issues := env.Compile(`cp.was_path_opened(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -202,7 +201,7 @@ func TestOpenInProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "cp.was_path_opened result should match expected value") }) } } @@ -213,13 +212,13 @@ func TestOpenNoProfile(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) + ast, issues := env.Compile(`cp.was_path_opened(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -238,7 +237,7 @@ func TestOpenNoProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_path_opened should return false when no profile is available") + assert.False(t, actualResult, "cp.was_path_opened should return false when no profile is available") } func TestOpenCompilation(t *testing.T) { @@ -247,14 +246,14 @@ func TestOpenCompilation(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_path_opened(containerID, path)`) + ast, issues := env.Compile(`cp.was_path_opened(containerID, path)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -282,9 +281,8 @@ func TestOpenWithSuffixInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", @@ -303,13 +301,13 @@ func TestOpenWithSuffixInProfile(t *testing.T) { Flags: []string{"O_RDONLY"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("suffix", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -367,7 +365,7 @@ func TestOpenWithSuffixInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_path_opened_with_suffix(containerID, suffix)`) + ast, issues := env.Compile(`cp.was_path_opened_with_suffix(containerID, suffix)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -386,7 +384,7 @@ func TestOpenWithSuffixInProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened_with_suffix result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "cp.was_path_opened_with_suffix result should match expected value") }) } } @@ -397,13 +395,13 @@ func TestOpenWithSuffixNoProfile(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("suffix", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_path_opened_with_suffix(containerID, suffix)`) + ast, issues := env.Compile(`cp.was_path_opened_with_suffix(containerID, suffix)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -422,7 +420,7 @@ func TestOpenWithSuffixNoProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_path_opened_with_suffix should return false when no profile is available") + assert.False(t, actualResult, "cp.was_path_opened_with_suffix should return false when no profile is available") } func TestOpenWithPrefixInProfile(t *testing.T) { @@ -441,9 +439,8 @@ func TestOpenWithPrefixInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", @@ -462,13 +459,13 @@ func TestOpenWithPrefixInProfile(t *testing.T) { Flags: []string{"O_RDONLY"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("prefix", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -538,7 +535,7 @@ func TestOpenWithPrefixInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_path_opened_with_prefix(containerID, prefix)`) + ast, issues := env.Compile(`cp.was_path_opened_with_prefix(containerID, prefix)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -557,7 +554,7 @@ func TestOpenWithPrefixInProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened_with_prefix result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "cp.was_path_opened_with_prefix result should match expected value") }) } } @@ -568,13 +565,13 @@ func TestOpenWithPrefixNoProfile(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("prefix", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_path_opened_with_prefix(containerID, prefix)`) + ast, issues := env.Compile(`cp.was_path_opened_with_prefix(containerID, prefix)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -593,7 +590,7 @@ func TestOpenWithPrefixNoProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_path_opened_with_prefix should return false when no profile is available") + assert.False(t, actualResult, "cp.was_path_opened_with_prefix should return false when no profile is available") } func TestOpenWithSuffixCompilation(t *testing.T) { @@ -602,14 +599,14 @@ func TestOpenWithSuffixCompilation(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("suffix", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_path_opened_with_suffix(containerID, suffix)`) + ast, issues := env.Compile(`cp.was_path_opened_with_suffix(containerID, suffix)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -627,14 +624,14 @@ func TestOpenWithPrefixCompilation(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("prefix", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_path_opened_with_prefix(containerID, prefix)`) + ast, issues := env.Compile(`cp.was_path_opened_with_prefix(containerID, prefix)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -661,9 +658,8 @@ func TestOpenWithFlagsInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Opens: []v1beta1.OpenCalls{ { Path: "/etc/passwd", @@ -678,14 +674,14 @@ func TestOpenWithFlagsInProfile(t *testing.T) { Flags: []string{"O_RDWR", "O_APPEND"}, }, }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), cel.Variable("flags", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -752,7 +748,7 @@ func TestOpenWithFlagsInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) + ast, issues := env.Compile(`cp.was_path_opened_with_flags(containerID, path, flags)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -772,7 +768,7 @@ func TestOpenWithFlagsInProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_path_opened_with_flags result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "cp.was_path_opened_with_flags result should match expected value") }) } } @@ -784,13 +780,13 @@ func TestOpenWithFlagsNoProfile(t *testing.T) { cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), cel.Variable("flags", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) + ast, issues := env.Compile(`cp.was_path_opened_with_flags(containerID, path, flags)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -810,7 +806,7 @@ func TestOpenWithFlagsNoProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_path_opened_with_flags should return false when no profile is available") + assert.False(t, actualResult, "cp.was_path_opened_with_flags should return false when no profile is available") } func TestOpenWithFlagsCompilation(t *testing.T) { @@ -820,14 +816,14 @@ func TestOpenWithFlagsCompilation(t *testing.T) { cel.Variable("containerID", cel.StringType), cel.Variable("path", cel.StringType), cel.Variable("flags", cel.ListType(cel.StringType)), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_path_opened_with_flags(containerID, path, flags)`) + ast, issues := env.Compile(`cp.was_path_opened_with_flags(containerID, path, flags)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -838,4 +834,3 @@ func TestOpenWithFlagsCompilation(t *testing.T) { t.Fatalf("failed to create program: %v", err) } } - diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/prestop_cache.go b/pkg/rulemanager/cel/libraries/containerprofile/prestop_cache.go similarity index 98% rename from pkg/rulemanager/cel/libraries/applicationprofile/prestop_cache.go rename to pkg/rulemanager/cel/libraries/containerprofile/prestop_cache.go index 349d083843..48a6c5cc4e 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/prestop_cache.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/prestop_cache.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "sync" diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/prestop_cache_test.go b/pkg/rulemanager/cel/libraries/containerprofile/prestop_cache_test.go similarity index 99% rename from pkg/rulemanager/cel/libraries/applicationprofile/prestop_cache_test.go rename to pkg/rulemanager/cel/libraries/containerprofile/prestop_cache_test.go index 173cfedfca..57995b3ac5 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/prestop_cache_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/prestop_cache_test.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "sync" diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/syscall.go b/pkg/rulemanager/cel/libraries/containerprofile/syscall.go similarity index 87% rename from pkg/rulemanager/cel/libraries/applicationprofile/syscall.go rename to pkg/rulemanager/cel/libraries/containerprofile/syscall.go index 3ef066f83f..3307eb3511 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/syscall.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/syscall.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "github.com/google/cel-go/common/types" @@ -7,7 +7,7 @@ import ( "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" ) -func (l *apLibrary) wasSyscallUsed(containerID, syscallName ref.Val) ref.Val { +func (l *containerProfileLibrary) wasSyscallUsed(containerID, syscallName ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/syscall_test.go b/pkg/rulemanager/cel/libraries/containerprofile/syscall_test.go similarity index 85% rename from pkg/rulemanager/cel/libraries/applicationprofile/syscall_test.go rename to pkg/rulemanager/cel/libraries/containerprofile/syscall_test.go index 9b9743a8dc..089d4754da 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/syscall_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofile/syscall_test.go @@ -1,4 +1,4 @@ -package applicationprofile +package containerprofile import ( "testing" @@ -30,22 +30,21 @@ func TestSyscallInProfile(t *testing.T) { }, }) - profile := &v1beta1.ApplicationProfile{} - profile.Spec.Containers = append(profile.Spec.Containers, v1beta1.ApplicationProfileContainer{ - Name: "test-container", + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ Syscalls: []string{ "open", "read", "write", "close", }, - }) - objCache.SetApplicationProfile(profile) + } + objCache.SetContainerProfile(profile) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("syscallName", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) @@ -79,7 +78,7 @@ func TestSyscallInProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ast, issues := env.Compile(`ap.was_syscall_used(containerID, syscallName)`) + ast, issues := env.Compile(`cp.was_syscall_used(containerID, syscallName)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -98,7 +97,7 @@ func TestSyscallInProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.Equal(t, tc.expectedResult, actualResult, "ap.was_syscall_used result should match expected value") + assert.Equal(t, tc.expectedResult, actualResult, "cp.was_syscall_used result should match expected value") }) } } @@ -109,13 +108,13 @@ func TestSyscallNoProfile(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("syscallName", cel.StringType), - AP(&objCache, config.Config{}), + CP(&objCache, config.Config{}), ) if err != nil { t.Fatalf("failed to create env: %v", err) } - ast, issues := env.Compile(`ap.was_syscall_used(containerID, syscallName)`) + ast, issues := env.Compile(`cp.was_syscall_used(containerID, syscallName)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } @@ -134,7 +133,7 @@ func TestSyscallNoProfile(t *testing.T) { } actualResult := result.Value().(bool) - assert.False(t, actualResult, "ap.was_syscall_used should return false when no profile is available") + assert.False(t, actualResult, "cp.was_syscall_used should return false when no profile is available") } func TestSyscallCompilation(t *testing.T) { @@ -143,7 +142,7 @@ func TestSyscallCompilation(t *testing.T) { env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), cel.Variable("syscallName", cel.StringType), - AP(&objCache, config.Config{ + CP(&objCache, config.Config{ CelConfigCache: cache.FunctionCacheConfig{ MaxSize: 1000, TTL: 1 * time.Minute, @@ -155,7 +154,7 @@ func TestSyscallCompilation(t *testing.T) { } // Test that the function compiles correctly - ast, issues := env.Compile(`ap.was_syscall_used(containerID, syscallName)`) + ast, issues := env.Compile(`cp.was_syscall_used(containerID, syscallName)`) if issues != nil { t.Fatalf("failed to compile expression: %v", issues.Err()) } diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/cache_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/cache_test.go similarity index 86% rename from pkg/rulemanager/cel/libraries/networkneighborhood/cache_test.go rename to pkg/rulemanager/cel/libraries/containerprofilenetwork/cache_test.go index 1dd27afe95..87fdb6584e 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/cache_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/cache_test.go @@ -1,4 +1,4 @@ -package networkneighborhood +package containerprofilenetwork import ( "testing" @@ -30,9 +30,8 @@ func TestNetworkNeighborhoodCaching(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", @@ -70,11 +69,11 @@ func TestNetworkNeighborhoodCaching(t *testing.T) { }, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) // Create library with cache - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -97,7 +96,7 @@ func TestNetworkNeighborhoodCaching(t *testing.T) { }{ { name: "was_address_in_egress caching", - expression: `nn.was_address_in_egress(containerID, address)`, + expression: `cp.was_address_in_egress(containerID, address)`, vars: map[string]interface{}{ "containerID": "test-container-id", "address": "192.168.1.100", @@ -106,7 +105,7 @@ func TestNetworkNeighborhoodCaching(t *testing.T) { }, { name: "was_address_in_ingress caching", - expression: `nn.was_address_in_ingress(containerID, address)`, + expression: `cp.was_address_in_ingress(containerID, address)`, vars: map[string]interface{}{ "containerID": "test-container-id", "address": "172.16.0.10", @@ -115,7 +114,7 @@ func TestNetworkNeighborhoodCaching(t *testing.T) { }, { name: "is_domain_in_egress caching", - expression: `nn.is_domain_in_egress(containerID, domain)`, + expression: `cp.is_domain_in_egress(containerID, domain)`, vars: map[string]interface{}{ "containerID": "test-container-id", "domain": "api.example.com", @@ -124,7 +123,7 @@ func TestNetworkNeighborhoodCaching(t *testing.T) { }, { name: "is_domain_in_ingress caching", - expression: `nn.is_domain_in_ingress(containerID, domain)`, + expression: `cp.is_domain_in_ingress(containerID, domain)`, vars: map[string]interface{}{ "containerID": "test-container-id", "domain": "loadbalancer.example.com", @@ -133,7 +132,7 @@ func TestNetworkNeighborhoodCaching(t *testing.T) { }, { name: "was_address_port_protocol_in_egress caching", - expression: `nn.was_address_port_protocol_in_egress(containerID, address, port, protocol)`, + expression: `cp.was_address_port_protocol_in_egress(containerID, address, port, protocol)`, vars: map[string]interface{}{ "containerID": "test-container-id", "address": "192.168.1.100", @@ -144,7 +143,7 @@ func TestNetworkNeighborhoodCaching(t *testing.T) { }, { name: "was_address_port_protocol_in_ingress caching", - expression: `nn.was_address_port_protocol_in_ingress(containerID, address, port, protocol)`, + expression: `cp.was_address_port_protocol_in_ingress(containerID, address, port, protocol)`, vars: map[string]interface{}{ "containerID": "test-container-id", "address": "172.16.0.10", @@ -206,9 +205,8 @@ func TestNetworkNeighborhoodCacheDifferentArguments(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", @@ -219,10 +217,10 @@ func TestNetworkNeighborhoodCacheDifferentArguments(t *testing.T) { DNSNames: []string{"database.internal"}, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -234,7 +232,7 @@ func TestNetworkNeighborhoodCacheDifferentArguments(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`nn.was_address_in_egress(containerID, address)`) + ast, issues := env.Compile(`cp.was_address_in_egress(containerID, address)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) @@ -290,24 +288,23 @@ func TestNetworkNeighborhoodCacheExpiration(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", DNSNames: []string{"api.example.com"}, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) // Create cache with short TTL for testing config := cache.FunctionCacheConfig{ MaxSize: 100, TTL: 50 * time.Millisecond, } - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(config), } @@ -319,7 +316,7 @@ func TestNetworkNeighborhoodCacheExpiration(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`nn.was_address_in_egress(containerID, address)`) + ast, issues := env.Compile(`cp.was_address_in_egress(containerID, address)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) @@ -367,19 +364,18 @@ func TestNetworkNeighborhoodCachePerformance(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", DNSNames: []string{"api.example.com"}, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -391,7 +387,7 @@ func TestNetworkNeighborhoodCachePerformance(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`nn.was_address_in_egress(containerID, address)`) + ast, issues := env.Compile(`cp.was_address_in_egress(containerID, address)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) @@ -444,9 +440,8 @@ func TestNetworkNeighborhoodCacheMultipleFunctions(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", @@ -459,10 +454,10 @@ func TestNetworkNeighborhoodCacheMultipleFunctions(t *testing.T) { DNSNames: []string{"loadbalancer.example.com"}, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -478,10 +473,10 @@ func TestNetworkNeighborhoodCacheMultipleFunctions(t *testing.T) { expression string expected bool }{ - {`nn.was_address_in_egress(containerID, "192.168.1.100")`, true}, - {`nn.was_address_in_ingress(containerID, "172.16.0.10")`, true}, - {`nn.is_domain_in_egress(containerID, "api.example.com")`, true}, - {`nn.is_domain_in_ingress(containerID, "loadbalancer.example.com")`, true}, + {`cp.was_address_in_egress(containerID, "192.168.1.100")`, true}, + {`cp.was_address_in_ingress(containerID, "172.16.0.10")`, true}, + {`cp.is_domain_in_egress(containerID, "api.example.com")`, true}, + {`cp.is_domain_in_ingress(containerID, "loadbalancer.example.com")`, true}, } for i, tc := range testExpressions { @@ -531,19 +526,18 @@ func TestNetworkNeighborhoodCacheClearCache(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", DNSNames: []string{"api.example.com"}, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -555,7 +549,7 @@ func TestNetworkNeighborhoodCacheClearCache(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`nn.was_address_in_egress(containerID, address)`) + ast, issues := env.Compile(`cp.was_address_in_egress(containerID, address)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) @@ -605,19 +599,18 @@ func TestNetworkNeighborhoodCacheKeyGeneration(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", DNSNames: []string{"api.example.com"}, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -629,7 +622,7 @@ func TestNetworkNeighborhoodCacheKeyGeneration(t *testing.T) { ) assert.NoError(t, err) - ast, issues := env.Compile(`nn.was_address_in_egress(containerID, address)`) + ast, issues := env.Compile(`cp.was_address_in_egress(containerID, address)`) assert.NoError(t, issues.Err()) program, err := env.Program(ast) diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/fixtures_test.go similarity index 94% rename from pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go rename to pkg/rulemanager/cel/libraries/containerprofilenetwork/fixtures_test.go index 7058ca1804..2601f256e0 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/fixtures_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/fixtures_test.go @@ -1,4 +1,4 @@ -package networkneighborhood +package containerprofilenetwork import ( "os" @@ -15,7 +15,7 @@ import ( // TestFixturesParse validates that every YAML fixture under // tests/resources/network-wildcards/ parses against the v1beta1 -// NetworkNeighborhood schema. This is the user-facing-examples gate: +// ContainerProfile schema. This is the user-facing-examples gate: // the fixtures double as authoritative syntax documentation, so a // fixture that fails to parse is a documentation bug. // @@ -45,14 +45,14 @@ func TestFixturesParse(t *testing.T) { // are templates, runtime substitutes a real namespace. data = []byte(strings.ReplaceAll(string(data), "{{NAMESPACE}}", "test-ns")) - var nn v1beta1.NetworkNeighborhood + var cp v1beta1.ContainerProfile // Strict mode: any unknown field in a fixture is a typo // against the v1beta1 schema. Documentation must not drift // from the runtime types. - err = yaml.UnmarshalStrict(data, &nn) + err = yaml.UnmarshalStrict(data, &cp) require.NoError(t, err, "fixture %s must parse against v1beta1 schema (strict)", name) - require.Equal(t, "NetworkNeighborhood", nn.Kind, "fixture %s wrong kind", name) - require.NotEmpty(t, nn.Spec.Containers, "fixture %s should declare at least one container", name) + require.Equal(t, "ContainerProfile", cp.Kind, "fixture %s wrong kind", name) + require.True(t, len(cp.Spec.Egress) > 0 || len(cp.Spec.Ingress) > 0, "fixture %s should declare at least one egress or ingress entry", name) }) parsed++ } @@ -160,8 +160,8 @@ func TestFixturesMatchExpectedBehaviour(t *testing.T) { // it was declared on. CR (node-agent#41) flagged that the prior // version only checked egress; this asserts ingress too. ipBothChecks: []ipBothCheck{ - {observed: "8.8.8.8", wantEgress: true, wantIngress: false}, // egress-only - {observed: "10.244.5.5", wantEgress: false, wantIngress: true}, // ingress-only + {observed: "8.8.8.8", wantEgress: true, wantIngress: false}, // egress-only + {observed: "10.244.5.5", wantEgress: false, wantIngress: true}, // ingress-only }, }, } diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/integration_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/integration_test.go similarity index 74% rename from pkg/rulemanager/cel/libraries/networkneighborhood/integration_test.go rename to pkg/rulemanager/cel/libraries/containerprofilenetwork/integration_test.go index 00e6bff710..e515a5fd73 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/integration_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/integration_test.go @@ -1,4 +1,4 @@ -package networkneighborhood +package containerprofilenetwork import ( "testing" @@ -31,9 +31,8 @@ func TestIntegrationWithAllNetworkFunctions(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", @@ -103,12 +102,12 @@ func TestIntegrationWithAllNetworkFunctions(t *testing.T) { }, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) env, err := cel.NewEnv( cel.Variable("containerID", cel.StringType), - NN(&objCache, config.Config{ + CPNetwork(&objCache, config.Config{ CelConfigCache: cache.FunctionCacheConfig{ MaxSize: 1000, TTL: 1 * time.Minute, @@ -126,127 +125,127 @@ func TestIntegrationWithAllNetworkFunctions(t *testing.T) { }{ { name: "Check egress address", - expression: `nn.was_address_in_egress(containerID, "192.168.1.100")`, + expression: `cp.was_address_in_egress(containerID, "192.168.1.100")`, expectedResult: true, }, { name: "Check ingress address", - expression: `nn.was_address_in_ingress(containerID, "172.16.0.10")`, + expression: `cp.was_address_in_ingress(containerID, "172.16.0.10")`, expectedResult: true, }, { name: "Check egress domain", - expression: `nn.is_domain_in_egress(containerID, "api.example.com")`, + expression: `cp.is_domain_in_egress(containerID, "api.example.com")`, expectedResult: true, }, { name: "Check ingress domain", - expression: `nn.is_domain_in_ingress(containerID, "loadbalancer.example.com")`, + expression: `cp.is_domain_in_ingress(containerID, "loadbalancer.example.com")`, expectedResult: true, }, { name: "Complex network check - external communication", - expression: `nn.was_address_in_egress(containerID, "8.8.8.8") && nn.is_domain_in_egress(containerID, "dns.google.com")`, + expression: `cp.was_address_in_egress(containerID, "8.8.8.8") && cp.is_domain_in_egress(containerID, "dns.google.com")`, expectedResult: true, }, { name: "Complex network check - internal communication", - expression: `nn.was_address_in_egress(containerID, "10.0.0.50") && nn.is_domain_in_egress(containerID, "database.internal")`, + expression: `cp.was_address_in_egress(containerID, "10.0.0.50") && cp.is_domain_in_egress(containerID, "database.internal")`, expectedResult: true, }, { name: "Complex network check - load balancer access", - expression: `nn.was_address_in_ingress(containerID, "172.16.0.10") && nn.is_domain_in_ingress(containerID, "lb.example.com")`, + expression: `cp.was_address_in_ingress(containerID, "172.16.0.10") && cp.is_domain_in_ingress(containerID, "lb.example.com")`, expectedResult: true, }, { name: "Check non-existent network communication", - expression: `nn.was_address_in_egress(containerID, "192.168.1.200") || nn.is_domain_in_ingress(containerID, "nonexistent.example.com")`, + expression: `cp.was_address_in_egress(containerID, "192.168.1.200") || cp.is_domain_in_ingress(containerID, "nonexistent.example.com")`, expectedResult: false, }, { name: "Mixed valid and invalid checks", - expression: `nn.was_address_in_egress(containerID, "192.168.1.100") && nn.was_address_in_egress(containerID, "192.168.1.200")`, + expression: `cp.was_address_in_egress(containerID, "192.168.1.100") && cp.was_address_in_egress(containerID, "192.168.1.200")`, expectedResult: false, }, { name: "Multiple valid egress checks", - expression: `nn.was_address_in_egress(containerID, "192.168.1.100") || nn.was_address_in_egress(containerID, "10.0.0.50")`, + expression: `cp.was_address_in_egress(containerID, "192.168.1.100") || cp.was_address_in_egress(containerID, "10.0.0.50")`, expectedResult: true, }, { name: "Check egress address with port and protocol - TCP 80", - expression: `nn.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "TCP")`, + expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "TCP")`, expectedResult: true, }, { name: "Check egress address with port and protocol - TCP 443", - expression: `nn.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 443, "TCP")`, + expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 443, "TCP")`, expectedResult: true, }, { name: "Check egress address with port and protocol - UDP 53", - expression: `nn.was_address_port_protocol_in_egress(containerID, "8.8.8.8", 53, "UDP")`, + expression: `cp.was_address_port_protocol_in_egress(containerID, "8.8.8.8", 53, "UDP")`, expectedResult: true, }, { name: "Check egress address with port and protocol - database", - expression: `nn.was_address_port_protocol_in_egress(containerID, "10.0.0.50", 5432, "TCP")`, + expression: `cp.was_address_port_protocol_in_egress(containerID, "10.0.0.50", 5432, "TCP")`, expectedResult: true, }, { name: "Check ingress address with port and protocol - TCP 8080", - expression: `nn.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 8080, "TCP")`, + expression: `cp.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 8080, "TCP")`, expectedResult: true, }, { name: "Check ingress address with port and protocol - TCP 9090", - expression: `nn.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 9090, "TCP")`, + expression: `cp.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 9090, "TCP")`, expectedResult: true, }, { name: "Check ingress address with port and protocol - monitoring", - expression: `nn.was_address_port_protocol_in_ingress(containerID, "10.0.0.20", 3000, "TCP")`, + expression: `cp.was_address_port_protocol_in_ingress(containerID, "10.0.0.20", 3000, "TCP")`, 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")`, + expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 9999, "TCP")`, 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")`, + expression: `cp.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 9999, "TCP")`, 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")`, + expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "UDP")`, 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")`, + expression: `cp.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 8080, "UDP")`, expectedResult: true, }, { name: "Complex network check with port and protocol - egress", - expression: `nn.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "TCP") && nn.was_address_port_protocol_in_egress(containerID, "8.8.8.8", 53, "UDP")`, + expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "TCP") && cp.was_address_port_protocol_in_egress(containerID, "8.8.8.8", 53, "UDP")`, expectedResult: true, }, { name: "Complex network check with port and protocol - ingress", - expression: `nn.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 8080, "TCP") && nn.was_address_port_protocol_in_ingress(containerID, "10.0.0.20", 3000, "TCP")`, + expression: `cp.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 8080, "TCP") && cp.was_address_port_protocol_in_ingress(containerID, "10.0.0.20", 3000, "TCP")`, 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")`, + expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "TCP") && cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 9999, "TCP")`, expectedResult: true, }, } diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go similarity index 91% rename from pkg/rulemanager/cel/libraries/networkneighborhood/network.go rename to pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go index fd65571056..3d97e85f2e 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go @@ -1,4 +1,4 @@ -package networkneighborhood +package containerprofilenetwork import ( "net" @@ -83,7 +83,7 @@ func matchDNSField(field *objectcache.ProjectedField, observed string) bool { return networkmatch.MatchDNS(entries, observed) } -func (l *nnLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { +func (l *containerProfileNetworkLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -102,7 +102,7 @@ func (l *nnLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { return types.Bool(matchIPField(&cp.EgressAddresses, addressStr)) } -func (l *nnLibrary) wasAddressInIngress(containerID, address ref.Val) ref.Val { +func (l *containerProfileNetworkLibrary) wasAddressInIngress(containerID, address ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -121,7 +121,7 @@ func (l *nnLibrary) wasAddressInIngress(containerID, address ref.Val) ref.Val { return types.Bool(matchIPField(&cp.IngressAddresses, addressStr)) } -func (l *nnLibrary) isDomainInEgress(containerID, domain ref.Val) ref.Val { +func (l *containerProfileNetworkLibrary) isDomainInEgress(containerID, domain ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -140,7 +140,7 @@ func (l *nnLibrary) isDomainInEgress(containerID, domain ref.Val) ref.Val { return types.Bool(matchDNSField(&cp.EgressDomains, domainStr)) } -func (l *nnLibrary) isDomainInIngress(containerID, domain ref.Val) ref.Val { +func (l *containerProfileNetworkLibrary) isDomainInIngress(containerID, domain ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -159,7 +159,7 @@ func (l *nnLibrary) isDomainInIngress(containerID, domain ref.Val) ref.Val { return types.Bool(matchDNSField(&cp.IngressDomains, domainStr)) } -func (l *nnLibrary) wasAddressPortProtocolInEgress(containerID, address, port, protocol ref.Val) ref.Val { +func (l *containerProfileNetworkLibrary) wasAddressPortProtocolInEgress(containerID, address, port, protocol ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } @@ -191,7 +191,7 @@ func (l *nnLibrary) wasAddressPortProtocolInEgress(containerID, address, port, p return types.Bool(matchIPField(&cp.EgressAddresses, addressStr)) } -func (l *nnLibrary) wasAddressPortProtocolInIngress(containerID, address, port, protocol ref.Val) ref.Val { +func (l *containerProfileNetworkLibrary) wasAddressPortProtocolInIngress(containerID, address, port, protocol ref.Val) ref.Val { if l.objectCache == nil { return types.NewErr("objectCache is nil") } diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_coverage_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_coverage_test.go new file mode 100644 index 0000000000..a54e3fffb5 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_coverage_test.go @@ -0,0 +1,145 @@ +package containerprofilenetwork + +import ( + "testing" + + "github.com/google/cel-go/common/types" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" +) + +// TestIsDomainInEgress_NoMatchWildcardAndEmpty exercises the non-happy +// paths of isDomainInEgress that the existing happy-only tests miss: +// a domain absent from the neighborhood, a leading-wildcard match, a +// "*" catch-all, an empty-neighborhood profile, and a container whose +// profile is unavailable (ProfileNotAvailableErr -> false). +func TestIsDomainInEgress_NoMatchWildcardAndEmpty(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"*.example.com.", "static.internal."}}, + }, nil) + + cases := []struct { + name string + cid string + domain string + want bool + }{ + {"leading wildcard matches one label", "cid", "api.example.com.", true}, + {"literal domain matches", "cid", "static.internal.", true}, + {"wildcard rejects two labels", "cid", "v1.api.example.com.", false}, + {"no match", "cid", "evil.other.com.", false}, + {"profile unavailable -> false", "missing-cid", "api.example.com.", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := lib.isDomainInEgress(types.String(tc.cid), types.String(tc.domain)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "domain %q", tc.domain) + }) + } +} + +// TestIsDomainInIngress_NoMatchWildcardAndEmpty mirrors the egress domain +// coverage for the ingress direction: leading-wildcard match, no-match, +// empty-neighborhood and profile-unavailable. +func TestIsDomainInIngress_NoMatchWildcardAndEmpty(t *testing.T) { + lib := buildLibWithContainer(t, nil, []v1beta1.NetworkNeighbor{ + {DNSNames: []string{"*.svc.cluster.local."}}, + }) + + // Leading wildcard: exactly one label matches. + res := lib.isDomainInIngress(types.String("cid"), types.String("redis.svc.cluster.local.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(true), res, "leading wildcard should match one label") + + // No match against the wildcard. + res = lib.isDomainInIngress(types.String("cid"), types.String("redis.other.local.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res, "domain outside wildcard should not match") + + // Empty neighborhood: no ingress declared at all -> false. + emptyLib := buildLibWithContainer(t, nil, nil) + res = emptyLib.isDomainInIngress(types.String("cid"), types.String("anything.example.com.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res, "empty ingress neighborhood should not match") + + // Profile unavailable -> false. + res = lib.isDomainInIngress(types.String("missing-cid"), types.String("x.example.com.")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res, "profile unavailable should be false") +} + +// TestWasAddressInEgress_NoMatchAndEmpty covers no-match, empty-neighborhood +// and profile-unavailable branches for the egress address matcher. +func TestWasAddressInEgress_NoMatchAndEmpty(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.0.0.5"}}, + }, nil) + + cases := []struct { + name string + cid string + address string + want bool + }{ + {"exact match", "cid", "10.0.0.5", true}, + {"no match", "cid", "10.0.0.6", false}, + {"empty observed address", "cid", "", false}, + {"profile unavailable -> false", "missing-cid", "10.0.0.5", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := lib.wasAddressInEgress(types.String(tc.cid), types.String(tc.address)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "address %q", tc.address) + }) + } + + // Empty egress neighborhood -> false. + emptyLib := buildLibWithContainer(t, nil, nil) + res := emptyLib.wasAddressInEgress(types.String("cid"), types.String("10.0.0.5")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res, "empty egress neighborhood should not match") +} + +// TestWasAddressInIngress_NoMatchAndEmpty covers no-match, empty-neighborhood +// and profile-unavailable branches for the ingress address matcher. +func TestWasAddressInIngress_NoMatchAndEmpty(t *testing.T) { + lib := buildLibWithContainer(t, nil, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"172.16.0.9"}}, + }) + + cases := []struct { + name string + cid string + address string + want bool + }{ + {"exact match", "cid", "172.16.0.9", true}, + {"no match", "cid", "172.16.0.10", false}, + {"profile unavailable -> false", "missing-cid", "172.16.0.9", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := lib.wasAddressInIngress(types.String(tc.cid), types.String(tc.address)) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(tc.want), res, "address %q", tc.address) + }) + } + + emptyLib := buildLibWithContainer(t, nil, nil) + res := emptyLib.wasAddressInIngress(types.String("cid"), types.String("172.16.0.9")) + res = cache.ConvertProfileNotAvailableErrToBool(res, false) + assert.Equal(t, types.Bool(false), res, "empty ingress neighborhood should not match") +} + +// TestNetworkMatchersNilObjectCache confirms the address/domain matchers +// return a CEL error (not a panic) when no objectCache is wired. +func TestNetworkMatchersNilObjectCache(t *testing.T) { + lib := &containerProfileNetworkLibrary{objectCache: nil} + assert.True(t, types.IsError(lib.wasAddressInEgress(types.String("cid"), types.String("1.2.3.4")))) + assert.True(t, types.IsError(lib.wasAddressInIngress(types.String("cid"), types.String("1.2.3.4")))) + assert.True(t, types.IsError(lib.isDomainInEgress(types.String("cid"), types.String("x.com.")))) + assert.True(t, types.IsError(lib.isDomainInIngress(types.String("cid"), types.String("x.com.")))) +} diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go similarity index 93% rename from pkg/rulemanager/cel/libraries/networkneighborhood/network_test.go rename to pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go index 8703ed4bab..10321073cc 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go @@ -1,4 +1,4 @@ -package networkneighborhood +package containerprofilenetwork import ( "testing" @@ -29,9 +29,8 @@ func TestWasAddressPortProtocolInEgress(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", @@ -59,10 +58,10 @@ func TestWasAddressPortProtocolInEgress(t *testing.T) { }, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -166,9 +165,8 @@ func TestWasAddressPortProtocolInIngress(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Ingress: []v1beta1.NetworkNeighbor{ { IPAddress: "172.16.0.10", @@ -196,10 +194,10 @@ func TestWasAddressPortProtocolInIngress(t *testing.T) { }, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -288,7 +286,7 @@ func TestWasAddressPortProtocolInIngress(t *testing.T) { } func TestWasAddressPortProtocolWithNilObjectCache(t *testing.T) { - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: nil, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -315,7 +313,7 @@ func TestWasAddressPortProtocolWithInvalidTypes(t *testing.T) { ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), } - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -373,9 +371,8 @@ func TestWasAddressPortProtocolWithNilPort(t *testing.T) { }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "test-container", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: []v1beta1.NetworkNeighbor{ { IPAddress: "192.168.1.100", @@ -400,10 +397,10 @@ func TestWasAddressPortProtocolWithNilPort(t *testing.T) { }, }, }, - }) - objCache.SetNetworkNeighborhood(nn) + } + objCache.SetContainerProfile(nn) - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/nn.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/nn.go similarity index 66% rename from pkg/rulemanager/cel/libraries/networkneighborhood/nn.go rename to pkg/rulemanager/cel/libraries/containerprofilenetwork/nn.go index fbcf95c60c..1adf6908f0 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/nn.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/nn.go @@ -1,4 +1,4 @@ -package networkneighborhood +package containerprofilenetwork import ( "github.com/google/cel-go/cel" @@ -13,7 +13,7 @@ import ( ) func New(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) libraries.Library { - lib := &nnLibrary{ + lib := &containerProfileNetworkLibrary{ objectCache: objectCache, functionCache: cache.NewFunctionCache(cache.FunctionCacheConfig{ MaxSize: config.CelConfigCache.MaxSize, @@ -27,136 +27,136 @@ func New(objectCache objectcache.ObjectCache, config config.Config, mm ...metric return lib } -func NN(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) cel.EnvOption { +func CPNetwork(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) cel.EnvOption { return cel.Lib(New(objectCache, config, mm...)) } -type nnLibrary struct { +type containerProfileNetworkLibrary struct { objectCache objectcache.ObjectCache functionCache *cache.FunctionCache metrics metricsmanager.MetricsManager detailedMetrics bool } -func (l *nnLibrary) LibraryName() string { - return "nn" +func (l *containerProfileNetworkLibrary) LibraryName() string { + return "cpnetwork" } -func (l *nnLibrary) Types() []*cel.Type { +func (l *containerProfileNetworkLibrary) Types() []*cel.Type { return []*cel.Type{} } -func (l *nnLibrary) Declarations() map[string][]cel.FunctionOpt { +func (l *containerProfileNetworkLibrary) Declarations() map[string][]cel.FunctionOpt { return map[string][]cel.FunctionOpt{ - "nn.was_address_in_egress": { + "cp.was_address_in_egress": { cel.Overload( - "nn_was_address_in_egress", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_address_in_egress", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_address_in_egress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "nn.was_address_in_ingress": { + "cp.was_address_in_ingress": { cel.Overload( - "nn_was_address_in_ingress", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_was_address_in_ingress", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.was_address_in_ingress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "nn.is_domain_in_egress": { + "cp.is_domain_in_egress": { cel.Overload( - "nn_is_domain_in_egress", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_is_domain_in_egress", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.is_domain_in_egress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "nn.is_domain_in_ingress": { + "cp.is_domain_in_ingress": { cel.Overload( - "nn_is_domain_in_ingress", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, + "cp_is_domain_in_ingress", []*cel.Type{cel.StringType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.is_domain_in_ingress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), ), }, - "nn.was_address_port_protocol_in_egress": { + "cp.was_address_port_protocol_in_egress": { cel.Overload( - "nn_was_address_port_protocol_in_egress", []*cel.Type{cel.StringType, cel.StringType, cel.IntType, cel.StringType}, cel.BoolType, + "cp_was_address_port_protocol_in_egress", []*cel.Type{cel.StringType, cel.StringType, cel.IntType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.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) }), ), }, - "nn.was_address_port_protocol_in_ingress": { + "cp.was_address_port_protocol_in_ingress": { cel.Overload( - "nn_was_address_port_protocol_in_ingress", []*cel.Type{cel.StringType, cel.StringType, cel.IntType, cel.StringType}, cel.BoolType, + "cp_was_address_port_protocol_in_ingress", []*cel.Type{cel.StringType, cel.StringType, cel.IntType, cel.StringType}, cel.BoolType, cel.FunctionBinding(func(values ...ref.Val) ref.Val { 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") + l.metrics.IncHelperCall("cp.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", cache.HashForContainerProfile(l.objectCache)) + cachedFunc := l.functionCache.WithCache(wrapperFunc, "cp.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) }), @@ -165,7 +165,7 @@ func (l *nnLibrary) Declarations() map[string][]cel.FunctionOpt { } } -func (l *nnLibrary) CompileOptions() []cel.EnvOption { +func (l *containerProfileNetworkLibrary) CompileOptions() []cel.EnvOption { options := []cel.EnvOption{} for name, overloads := range l.Declarations() { options = append(options, cel.Function(name, overloads...)) @@ -173,37 +173,37 @@ func (l *nnLibrary) CompileOptions() []cel.EnvOption { return options } -func (l *nnLibrary) ProgramOptions() []cel.ProgramOption { +func (l *containerProfileNetworkLibrary) ProgramOptions() []cel.ProgramOption { return []cel.ProgramOption{} } -func (l *nnLibrary) CostEstimator() checker.CostEstimator { - return &nnCostEstimator{} +func (l *containerProfileNetworkLibrary) CostEstimator() checker.CostEstimator { + return &containerProfileNetworkCostEstimator{} } -// nnCostEstimator implements the checker.CostEstimator for the 'nn' library. -type nnCostEstimator struct{} +// containerProfileNetworkCostEstimator implements the checker.CostEstimator for the 'cpnetwork' library. +type containerProfileNetworkCostEstimator struct{} -func (e *nnCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { +func (e *containerProfileNetworkCostEstimator) EstimateCallCost(function, overloadID string, target *checker.AstNode, args []checker.AstNode) *checker.CallEstimate { cost := int64(0) switch function { - case "nn.was_address_in_egress", "nn.was_address_in_ingress": + case "cp.was_address_in_egress", "cp.was_address_in_ingress": // Cache lookup + O(n) linear search through egress/ingress list cost = 20 - case "nn.is_domain_in_egress", "nn.is_domain_in_ingress": + case "cp.is_domain_in_egress", "cp.is_domain_in_ingress": // Cache lookup + O(n) list iteration + O(m) slice.Contains on DNS names per entry cost = 35 - case "nn.was_address_port_protocol_in_egress", "nn.was_address_port_protocol_in_ingress": + case "cp.was_address_port_protocol_in_egress", "cp.was_address_port_protocol_in_ingress": // Cache lookup + O(n) address search + O(p) nested port/protocol matching cost = 45 } return &checker.CallEstimate{CostEstimate: checker.CostEstimate{Min: uint64(cost), Max: uint64(cost)}} } -func (e *nnCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate { +func (e *containerProfileNetworkCostEstimator) EstimateSize(element checker.AstNode) *checker.SizeEstimate { return nil // Not providing size estimates for now. } // Ensure the implementation satisfies the interface -var _ checker.CostEstimator = (*nnCostEstimator)(nil) -var _ libraries.Library = (*nnLibrary)(nil) +var _ checker.CostEstimator = (*containerProfileNetworkCostEstimator)(nil) +var _ libraries.Library = (*containerProfileNetworkLibrary)(nil) diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/wildcard_test.go similarity index 94% rename from pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go rename to pkg/rulemanager/cel/libraries/containerprofilenetwork/wildcard_test.go index 0739dcc2b5..e0a16c2299 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/wildcard_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/wildcard_test.go @@ -1,4 +1,4 @@ -package networkneighborhood +package containerprofilenetwork import ( "testing" @@ -14,7 +14,7 @@ import ( ) // Helper: build a ready-to-use library with a single-container profile. -func buildLibWithContainer(t *testing.T, neighbors []v1beta1.NetworkNeighbor, ingressNeighbors []v1beta1.NetworkNeighbor) *nnLibrary { +func buildLibWithContainer(t *testing.T, neighbors []v1beta1.NetworkNeighbor, ingressNeighbors []v1beta1.NetworkNeighbor) *containerProfileNetworkLibrary { t.Helper() objCache := objectcachev1.RuleObjectCacheMock{ ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), @@ -25,14 +25,13 @@ func buildLibWithContainer(t *testing.T, neighbors []v1beta1.NetworkNeighbor, in objectcache.Container: {{Name: "c"}}, }, }) - nn := &v1beta1.NetworkNeighborhood{} - nn.Spec.Containers = append(nn.Spec.Containers, v1beta1.NetworkNeighborhoodContainer{ - Name: "c", + nn := &v1beta1.ContainerProfile{} + nn.Spec = v1beta1.ContainerProfileSpec{ Egress: neighbors, Ingress: ingressNeighbors, - }) - objCache.SetNetworkNeighborhood(nn) - return &nnLibrary{ + } + objCache.SetContainerProfile(nn) + return &containerProfileNetworkLibrary{ objectCache: &objCache, functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } @@ -49,9 +48,9 @@ func TestWasAddressInEgress_WildcardCIDRMatch(t *testing.T) { observed string want bool }{ - {"10.1.2.3", true}, // inside CIDR + {"10.1.2.3", true}, // inside CIDR {"10.255.255.254", true}, - {"11.0.0.1", false}, // outside + {"11.0.0.1", false}, // outside } for _, tc := range cases { t.Run(tc.observed, func(t *testing.T) { @@ -98,9 +97,9 @@ func TestWasAddressInEgress_BothSingularAndPlural(t *testing.T) { }, nil) for addr, want := range map[string]bool{ - "8.8.8.8": true, // deprecated singular hit - "10.1.2.3": true, // new CIDR hit - "1.2.3.4": false, // neither + "8.8.8.8": true, // deprecated singular hit + "10.1.2.3": true, // new CIDR hit + "1.2.3.4": false, // neither } { res := lib.wasAddressInEgress(types.String("cid"), types.String(addr)) res = cache.ConvertProfileNotAvailableErrToBool(res, false) @@ -144,7 +143,7 @@ func TestIsDomainInEgress_MidEllipsis(t *testing.T) { }{ {"kubernetes.default.svc.cluster.local.", true}, {"kubernetes.kube-system.svc.cluster.local.", true}, - {"redis.default.svc.cluster.local.", false}, // wrong service prefix + {"redis.default.svc.cluster.local.", false}, // wrong service prefix {"kubernetes.foo.bar.svc.cluster.local.", false}, // two labels mid } for _, tc := range cases { @@ -187,8 +186,8 @@ func TestWasAddressInEgress_DeprecatedIPAddress_AcceptsWildcardAndCIDR(t *testin {"10.0.0.0/8", "10.1.2.3", true}, {"10.0.0.0/8", "10.255.255.255", true}, {"10.0.0.0/8", "11.0.0.1", false}, - {"0.0.0.0/0", "203.0.113.7", true}, // any-IPv4 via CIDR - {"::/0", "2001:db8::1", true}, // any-IPv6 via CIDR + {"0.0.0.0/0", "203.0.113.7", true}, // any-IPv4 via CIDR + {"::/0", "2001:db8::1", true}, // any-IPv6 via CIDR // Literal still works {"192.168.1.1", "192.168.1.1", true}, {"192.168.1.1", "192.168.1.2", false}, @@ -215,10 +214,10 @@ func TestWasAddressInEgress_DeprecatedIPAddress_IPv6Canonicalisation(t *testing. observed string want bool }{ - {"2001:db8::1", "2001:db8::1", true}, // identical - {"2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001", true}, // expanded form same address - {"10.0.0.1", "::ffff:10.0.0.1", true}, // IPv4-mapped IPv6 - {"10.0.0.1", "10.0.0.2", false}, // genuine miss + {"2001:db8::1", "2001:db8::1", true}, // identical + {"2001:db8::1", "2001:0db8:0000:0000:0000:0000:0000:0001", true}, // expanded form same address + {"10.0.0.1", "::ffff:10.0.0.1", true}, // IPv4-mapped IPv6 + {"10.0.0.1", "10.0.0.2", false}, // genuine miss } for _, tc := range cases { t.Run(tc.profileIP+"_vs_"+tc.observed, func(t *testing.T) { diff --git a/pkg/rulemanager/profilehelper/profilehelper_test.go b/pkg/rulemanager/profilehelper/profilehelper_test.go new file mode 100644 index 0000000000..a72fb14c34 --- /dev/null +++ b/pkg/rulemanager/profilehelper/profilehelper_test.go @@ -0,0 +1,116 @@ +package profilehelper + +import ( + "testing" + + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/objectcache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func newMock() *objectcachev1.RuleObjectCacheMock { + return &objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } +} + +// TestGetProjectedContainerProfile covers the success and no-profile paths. +func TestGetProjectedContainerProfile(t *testing.T) { + t.Run("profile available", func(t *testing.T) { + objCache := newMock() + objCache.SetSharedContainerData("cid", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: {{Name: "c"}}, + }, + }) + profile := &v1beta1.ContainerProfile{} + profile.Spec = v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{{Path: "/bin/ls"}}, + } + objCache.SetContainerProfile(profile) + + pcp, checksum, err := GetProjectedContainerProfile(objCache, "cid") + require.NoError(t, err) + require.NotNil(t, pcp) + assert.Equal(t, pcp.SyncChecksum, checksum) + _, ok := pcp.Execs.Values["/bin/ls"] + assert.True(t, ok, "projected profile should carry the exec path") + }) + + t.Run("no profile available", func(t *testing.T) { + objCache := newMock() + pcp, _, err := GetProjectedContainerProfile(objCache, "cid") + assert.Error(t, err) + assert.Nil(t, pcp) + }) +} + +// TestGetPodSpec covers success, missing shared data, and missing pod spec. +func TestGetPodSpec(t *testing.T) { + t.Run("pod spec available", func(t *testing.T) { + objCache := newMock() + objCache.SetSharedContainerData("cid", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: {{Name: "c"}}, + }, + }) + want := &corev1.PodSpec{Containers: []corev1.Container{{Name: "c"}}} + objCache.SetPodSpec(want) + + got, err := GetPodSpec(objCache, "cid") + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("shared data not found", func(t *testing.T) { + objCache := newMock() // no shared data registered + got, err := GetPodSpec(objCache, "cid") + assert.Error(t, err) + assert.Nil(t, got) + }) + + t.Run("pod spec not found", func(t *testing.T) { + objCache := newMock() + objCache.SetSharedContainerData("cid", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + }) + // no SetPodSpec -> GetPodSpec returns nil -> error + got, err := GetPodSpec(objCache, "cid") + assert.Error(t, err) + assert.Nil(t, got) + }) +} + +// TestGetContainerName covers name resolution and both lookup-failure paths. +func TestGetContainerName(t *testing.T) { + t.Run("name resolved", func(t *testing.T) { + objCache := newMock() + objCache.SetSharedContainerData("cid", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{ + objectcache.Container: {{Name: "my-container"}}, + }, + }) + assert.Equal(t, "my-container", GetContainerName(objCache, "cid")) + }) + + t.Run("no shared data", func(t *testing.T) { + objCache := newMock() + assert.Equal(t, "", GetContainerName(objCache, "cid")) + }) + + t.Run("empty container infos", func(t *testing.T) { + objCache := newMock() + objCache.SetSharedContainerData("cid", &objectcache.WatchedContainerData{ + ContainerType: objectcache.Container, + ContainerInfos: map[objectcache.ContainerType][]objectcache.ContainerInfo{}, + }) + assert.Equal(t, "", GetContainerName(objCache, "cid")) + }) +} diff --git a/pkg/rulemanager/rule_manager_hasfinal_test.go b/pkg/rulemanager/rule_manager_hasfinal_test.go new file mode 100644 index 0000000000..0e1c08d956 --- /dev/null +++ b/pkg/rulemanager/rule_manager_hasfinal_test.go @@ -0,0 +1,115 @@ +package rulemanager + +import ( + "context" + "errors" + "testing" + + containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection" + 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/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" +) + +// stateCPCFake is a ContainerProfileCache whose GetContainerProfileState is +// driven by a per-containerID map, so the enforce/learn gate in +// HasFinalApplicationProfile can be exercised across profile states without a +// cluster. Only GetContainerProfileState is meaningful; the rest satisfy the +// interface via the embedded (nil) ContainerProfileCache and are never called +// by HasFinalApplicationProfile. +type stateCPCFake struct { + objectcache.ContainerProfileCache + states map[string]*objectcache.ProfileState +} + +func (f *stateCPCFake) GetContainerProfileState(containerID string) *objectcache.ProfileState { + return f.states[containerID] +} + +func (f *stateCPCFake) GetProjectedContainerProfile(string) *objectcache.ProjectedContainerProfile { + return nil +} +func (f *stateCPCFake) GetCallStackSearchTree(string) *callstackcache.CallStackSearchTree { return nil } +func (f *stateCPCFake) SetProjectionSpec(objectcache.RuleProjectionSpec) {} +func (f *stateCPCFake) ContainerCallback(containercollection.PubSubEvent) {} +func (f *stateCPCFake) Start(context.Context) {} + +// stateObjectCacheFake is an ObjectCache that only wires ContainerProfileCache; +// HasFinalApplicationProfile touches nothing else. +type stateObjectCacheFake struct { + objectcache.ObjectCache + cpc objectcache.ContainerProfileCache +} + +func (f *stateObjectCacheFake) ContainerProfileCache() objectcache.ContainerProfileCache { + return f.cpc +} + +func podWithContainerID(id string) *corev1.Pod { + return &corev1.Pod{ + Status: corev1.PodStatus{ + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "c", ContainerID: id}, + }, + }, + } +} + +// TestHasFinalApplicationProfile pins the profile-complete enforce/learn gate: +// a Completed+Full state enforces (true); any non-terminal state, an errored +// state, or a nil state leaves it in learn mode (false). +func TestHasFinalApplicationProfile(t *testing.T) { + const cid = "containerd://abc123" + trimmed := "abc123" // utils.TrimRuntimePrefix(cid) + + testCases := []struct { + name string + state *objectcache.ProfileState + want bool + }{ + { + name: "completed and full -> enforce", + state: &objectcache.ProfileState{Status: helpersv1.Completed, Completion: helpersv1.Full}, + want: true, + }, + { + name: "completed but partial -> learn", + state: &objectcache.ProfileState{Status: helpersv1.Completed, Completion: helpersv1.Partial}, + want: false, + }, + { + name: "non-terminal status -> learn", + state: &objectcache.ProfileState{Status: helpersv1.Initializing, Completion: helpersv1.Full}, + want: false, + }, + { + name: "errored state -> learn", + state: &objectcache.ProfileState{Error: errors.New("profile not found")}, + want: false, + }, + { + name: "nil state -> learn", + state: nil, + want: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cpc := &stateCPCFake{states: map[string]*objectcache.ProfileState{trimmed: tc.state}} + rm := &RuleManager{objectCache: &stateObjectCacheFake{cpc: cpc}} + got := rm.HasFinalApplicationProfile(podWithContainerID(cid)) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestHasFinalApplicationProfileNoContainers confirms a pod with no container +// statuses is never final. +func TestHasFinalApplicationProfileNoContainers(t *testing.T) { + cpc := &stateCPCFake{states: map[string]*objectcache.ProfileState{}} + rm := &RuleManager{objectCache: &stateObjectCacheFake{cpc: cpc}} + assert.False(t, rm.HasFinalApplicationProfile(&corev1.Pod{})) +} diff --git a/pkg/seccompmanager/v1/seccomp_manager_test.go b/pkg/seccompmanager/v1/seccomp_manager_test.go index a1c9ba0992..1d2362f58d 100644 --- a/pkg/seccompmanager/v1/seccomp_manager_test.go +++ b/pkg/seccompmanager/v1/seccomp_manager_test.go @@ -10,22 +10,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// func TestName(t *testing.T) { -// var ap v1beta1.ApplicationProfile -// file, err := os.ReadFile("../../../mocks/testdata/nginx_applicationprofiles.json") -// assert.NoError(t, err) -// err = json.Unmarshal(file, &ap) -// assert.NoError(t, err) -// ap.Spec.Containers[0].SeccompProfile.Path = "default/replicaset-nginx-bf5d5cf98-nginx.json" -// file2, err := os.ReadFile("../../../seccomp/default/replicaset-nginx-bf5d5cf98-nginx.json") -// assert.NoError(t, err) -// err = json.Unmarshal(file2, &ap.Spec.Containers[0].SeccompProfile.Spec) -// assert.NoError(t, err) -// bytes, err := json.Marshal(ap) -// assert.NoError(t, err) -// err = os.WriteFile("../../../mocks/testdata/nginx_applicationprofiles.json", bytes, 0644) -// } - //func TestName(t *testing.T) { // sp := v1beta1.SeccompProfile{ // TypeMeta: metav1.TypeMeta{ diff --git a/pkg/storage/storage_interface.go b/pkg/storage/storage_interface.go index e8f3e80dc4..3c84c016e9 100644 --- a/pkg/storage/storage_interface.go +++ b/pkg/storage/storage_interface.go @@ -10,11 +10,7 @@ import ( ) type ProfileClient interface { - GetApplicationProfile(ctx context.Context, namespace, name string) (*v1beta1.ApplicationProfile, error) - GetNetworkNeighborhood(ctx context.Context, namespace, name string) (*v1beta1.NetworkNeighborhood, error) GetContainerProfile(ctx context.Context, namespace, name string) (*v1beta1.ContainerProfile, error) - ListApplicationProfiles(ctx context.Context, namespace string, limit int64, cont string) (*v1beta1.ApplicationProfileList, error) - ListNetworkNeighborhoods(ctx context.Context, namespace string, limit int64, cont string) (*v1beta1.NetworkNeighborhoodList, error) } // ProfileCreator defines the interface for creating container profiles diff --git a/pkg/storage/storage_mock.go b/pkg/storage/storage_mock.go index 955431d281..55401ed4fe 100644 --- a/pkg/storage/storage_mock.go +++ b/pkg/storage/storage_mock.go @@ -47,15 +47,6 @@ func (sc *StorageHttpClientMock) GetContainerProfile(_ context.Context, namespac return nil, nil } -func (sc *StorageHttpClientMock) GetApplicationProfile(_ context.Context, _, _ string) (*spdxv1beta1.ApplicationProfile, error) { - //TODO implement me - panic("implement me") -} - -func (sc *StorageHttpClientMock) GetNetworkNeighborhood(_ context.Context, _, _ string) (*spdxv1beta1.NetworkNeighborhood, error) { - //TODO implement me - panic("implement me") -} func (sc *StorageHttpClientMock) GetSBOMMeta(_ string) (*v1beta1.SBOMSyft, error) { return sc.mockSBOM, nil } @@ -64,16 +55,6 @@ func (sc *StorageHttpClientMock) GetStorageClient() beta1.SpdxV1beta1Interface { return nil } -func (sc *StorageHttpClientMock) ListApplicationProfiles(_ context.Context, namespace string, limit int64, cont string) (*spdxv1beta1.ApplicationProfileList, error) { - //TODO implement me - panic("implement me") -} - -func (sc *StorageHttpClientMock) ListNetworkNeighborhoods(_ context.Context, namespace string, limit int64, cont string) (*spdxv1beta1.NetworkNeighborhoodList, error) { - //TODO implement me - panic("implement me") -} - func (sc *StorageHttpClientMock) ReplaceSBOM(SBOM *v1beta1.SBOMSyft) (*v1beta1.SBOMSyft, error) { sc.SyftSBOMs = append(sc.SyftSBOMs, SBOM) return SBOM, nil diff --git a/pkg/storage/v1/applicationprofile.go b/pkg/storage/v1/applicationprofile.go deleted file mode 100644 index 39f0543288..0000000000 --- a/pkg/storage/v1/applicationprofile.go +++ /dev/null @@ -1,19 +0,0 @@ -package storage - -import ( - "context" - - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func (sc *Storage) GetApplicationProfile(ctx context.Context, namespace, name string) (*v1beta1.ApplicationProfile, error) { - return sc.storageClient.ApplicationProfiles(namespace).Get(ctx, name, metav1.GetOptions{}) -} - -func (sc *Storage) ListApplicationProfiles(ctx context.Context, namespace string, limit int64, cont string) (*v1beta1.ApplicationProfileList, error) { - return sc.storageClient.ApplicationProfiles(namespace).List(ctx, metav1.ListOptions{ - Limit: limit, - Continue: cont, - }) -} diff --git a/pkg/storage/v1/networkneighborhood.go b/pkg/storage/v1/networkneighborhood.go deleted file mode 100644 index cec12b97e4..0000000000 --- a/pkg/storage/v1/networkneighborhood.go +++ /dev/null @@ -1,19 +0,0 @@ -package storage - -import ( - "context" - - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func (sc *Storage) GetNetworkNeighborhood(ctx context.Context, namespace, name string) (*v1beta1.NetworkNeighborhood, error) { - return sc.storageClient.NetworkNeighborhoods(namespace).Get(ctx, name, metav1.GetOptions{}) -} - -func (sc *Storage) ListNetworkNeighborhoods(ctx context.Context, namespace string, limit int64, cont string) (*v1beta1.NetworkNeighborhoodList, error) { - return sc.storageClient.NetworkNeighborhoods(namespace).List(ctx, metav1.ListOptions{ - Limit: limit, - Continue: cont, - }) -} diff --git a/pkg/storage/v1/storage.go b/pkg/storage/v1/storage.go index e9828df2f3..e5b5e65858 100644 --- a/pkg/storage/v1/storage.go +++ b/pkg/storage/v1/storage.go @@ -61,9 +61,10 @@ func CreateStorage(namespace string) (*Storage, error) { return nil, fmt.Errorf("failed to create K8S Aggregated API Client with err: %v", err) } - // wait for storage to be ready + // wait for storage to be ready. Probe ContainerProfiles — the unified profile + // resource — not ApplicationProfiles, which no longer exists in storage. if err := backoff.RetryNotify(func() error { - _, err := clientset.SpdxV1beta1().ApplicationProfiles("default").List(context.Background(), metav1.ListOptions{}) + _, err := clientset.SpdxV1beta1().ContainerProfiles("default").List(context.Background(), metav1.ListOptions{}) return err }, backoff.WithMaxRetries(backoff.NewConstantBackOff(5*time.Second), 60), func(err error, d time.Duration) { logger.L().Info("waiting for storage to be ready", helpers.Error(err), helpers.String("retry in", d.String())) diff --git a/pkg/watcher/dynamicwatcher/watch.go b/pkg/watcher/dynamicwatcher/watch.go index c9a3fbc29f..015bf713e8 100644 --- a/pkg/watcher/dynamicwatcher/watch.go +++ b/pkg/watcher/dynamicwatcher/watch.go @@ -10,7 +10,6 @@ import ( "github.com/kubescape/node-agent/pkg/cooldownqueue" "github.com/kubescape/node-agent/pkg/k8sclient" "github.com/kubescape/node-agent/pkg/watcher" - "github.com/kubescape/storage/pkg/apis/softwarecomposition" spdxv1beta1 "github.com/kubescape/storage/pkg/generated/clientset/versioned/typed/softwarecomposition/v1beta1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/pager" @@ -150,18 +149,6 @@ func (wh *WatchHandler) Stop(_ context.Context) { func (wh *WatchHandler) chooseWatcher(res schema.GroupVersionResource, opts metav1.ListOptions) (watch.Interface, error) { switch res.Resource { - case "applicationprofiles": - if wh.storageClient == nil { - return nil, fmt.Errorf("storage client is nil: %w", errNotImplemented) - } - opts.ResourceVersion = softwarecomposition.ResourceVersionFullSpec - return wh.storageClient.ApplicationProfiles("").Watch(context.Background(), opts) - case "networkneighborhoods": - if wh.storageClient == nil { - return nil, fmt.Errorf("storage client is nil: %w", errNotImplemented) - } - opts.ResourceVersion = softwarecomposition.ResourceVersionFullSpec - return wh.storageClient.NetworkNeighborhoods("").Watch(context.Background(), opts) case "pods": return wh.k8sClient.GetKubernetesClient().CoreV1().Pods("").Watch(context.Background(), opts) case "runtimerulealertbindings": @@ -236,18 +223,6 @@ func (wh *WatchHandler) watchRetry(_ context.Context, res schema.GroupVersionRes func (wh *WatchHandler) chooseLister(res schema.GroupVersionResource, opts metav1.ListOptions) (runtime.Object, error) { switch res.Resource { - case "applicationprofiles": - if wh.storageClient == nil { - return nil, fmt.Errorf("storage client is nil: %w", errNotImplemented) - } - opts.ResourceVersion = softwarecomposition.ResourceVersionFullSpec - return wh.storageClient.ApplicationProfiles("").List(context.Background(), opts) - case "networkneighborhoods": - if wh.storageClient == nil { - return nil, fmt.Errorf("storage client is nil: %w", errNotImplemented) - } - opts.ResourceVersion = softwarecomposition.ResourceVersionFullSpec - return wh.storageClient.NetworkNeighborhoods("").List(context.Background(), opts) case "pods": return wh.k8sClient.GetKubernetesClient().CoreV1().Pods("").List(context.Background(), opts) case "runtimerulealertbindings": diff --git a/pkg/watcher/dynamicwatcher/watch_test.go b/pkg/watcher/dynamicwatcher/watch_test.go index 24cf1e624e..a99f8ad17c 100644 --- a/pkg/watcher/dynamicwatcher/watch_test.go +++ b/pkg/watcher/dynamicwatcher/watch_test.go @@ -29,9 +29,7 @@ import ( ) var ( - resourcePod = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} - resourceNetworkNeighborhood = schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "networkneighborhoods"} - resourceApplicationProfile = schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "applicationprofiles"} + resourcePod = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} ) func init() { @@ -120,11 +118,7 @@ func startTest(t *testing.T, tc testObj) { // create objects for i := range tc.createObjects { var err error - if ap, ok := tc.createObjects[i].(*v1beta1.ApplicationProfile); ok { - _, err = wh.storageClient.ApplicationProfiles(ap.Namespace).Create(ctx, ap, metav1.CreateOptions{}) - } else if nn, ok := tc.createObjects[i].(*v1beta1.NetworkNeighborhood); ok { - _, err = wh.storageClient.NetworkNeighborhoods(nn.Namespace).Create(ctx, nn, metav1.CreateOptions{}) - } else if pod, ok := tc.createObjects[i].(*corev1.Pod); ok { + if pod, ok := tc.createObjects[i].(*corev1.Pod); ok { _, err = wh.k8sClient.GetKubernetesClient().CoreV1().Pods(pod.Namespace).Create(ctx, pod, metav1.CreateOptions{}) } else if sp, ok := tc.createObjects[i].(*v1beta1.SeccompProfile); ok { _, err = wh.storageClient.SeccompProfiles(sp.Namespace).Create(ctx, sp, metav1.CreateOptions{}) @@ -150,11 +144,7 @@ func startTest(t *testing.T, tc testObj) { for _, o := range tc.modifiedObjects { o.(metav1.Object).SetLabels(labels) var err error - if ap, ok := o.(*v1beta1.ApplicationProfile); ok { - _, err = wh.storageClient.ApplicationProfiles(ap.Namespace).Update(ctx, ap, metav1.UpdateOptions{}) - } else if nn, ok := o.(*v1beta1.NetworkNeighborhood); ok { - _, err = wh.storageClient.NetworkNeighborhoods(nn.Namespace).Update(ctx, nn, metav1.UpdateOptions{}) - } else if pod, ok := o.(*corev1.Pod); ok { + if pod, ok := o.(*corev1.Pod); ok { _, err = wh.k8sClient.GetKubernetesClient().CoreV1().Pods(pod.Namespace).Update(ctx, pod, metav1.UpdateOptions{}) } else if sp, ok := o.(*v1beta1.SeccompProfile); ok { _, err = wh.storageClient.SeccompProfiles(sp.Namespace).Update(ctx, sp, metav1.UpdateOptions{}) @@ -179,11 +169,7 @@ func startTest(t *testing.T, tc testObj) { // delete objects for _, o := range tc.deleteObjects { var err error - if ap, ok := o.(*v1beta1.ApplicationProfile); ok { - err = wh.storageClient.ApplicationProfiles(ap.Namespace).Delete(ctx, ap.Name, metav1.DeleteOptions{}) - } else if nn, ok := o.(*v1beta1.NetworkNeighborhood); ok { - err = wh.storageClient.NetworkNeighborhoods(nn.Namespace).Delete(ctx, nn.Name, metav1.DeleteOptions{}) - } else if pod, ok := o.(*corev1.Pod); ok { + if pod, ok := o.(*corev1.Pod); ok { err = wh.k8sClient.GetKubernetesClient().CoreV1().Pods(pod.Namespace).Delete(ctx, pod.Name, metav1.DeleteOptions{}) } else if sp, ok := o.(*v1beta1.SeccompProfile); ok { err = wh.storageClient.SeccompProfiles(sp.Namespace).Delete(ctx, sp.Name, metav1.DeleteOptions{}) @@ -209,114 +195,24 @@ func getKey(obj runtime.Object) string { return obj.GetObjectKind().GroupVersionKind().Kind + "/" + obj.(metav1.Object).GetName() } +// TestStart_1 exercises the watcher against Pods — the only resource type this +// handler serves after the storage CRDs (ApplicationProfile/NetworkNeighborhood) +// were removed from the storage backend. Storage-group resources are now routed +// away from this handler and rejected with errNotImplemented; that guard is +// covered by TestChooseLister/Watcher_*_StorageResource. func TestStart_1(t *testing.T) { tt := []testObj{ - { - name: "list ApplicationProfiles", - resources: []schema.GroupVersionResource{resourceApplicationProfile}, - preCreatedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindAP, mocks.TestCollection)}, - }, - { - name: "list NetworkNeighborhoods", - resources: []schema.GroupVersionResource{resourceNetworkNeighborhood}, - preCreatedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindNN, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindNN, mocks.TestCollection)}, - }, { name: "watch Pods", resources: []schema.GroupVersionResource{resourcePod}, createObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindPod, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindPod, mocks.TestCollection)}, }, { - name: "watch ApplicationProfiles", - resources: []schema.GroupVersionResource{resourceApplicationProfile}, - createObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindAP, mocks.TestCollection)}, - }, - { - name: "watch NetworkNeighborhoods", - resources: []schema.GroupVersionResource{resourceNetworkNeighborhood}, - createObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindNN, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindNN, mocks.TestCollection)}, - }, - } - - for _, tc := range tt { - t.Run(tc.name, func(t *testing.T) { - startTest(t, tc) - }) - } -} - -func TestStart_2(t *testing.T) { - tt := []testObj{ - { - name: "list and modify", - resources: []schema.GroupVersionResource{resourceApplicationProfile}, - preCreatedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - modifiedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - }, - { - name: "watch and modify", - resources: []schema.GroupVersionResource{resourceApplicationProfile}, - createObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - modifiedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - }, - } - - for _, tc := range tt { - t.Run(tc.name, func(t *testing.T) { - startTest(t, tc) - }) - } -} - -func TestStart_3(t *testing.T) { - tt := []testObj{ - - { - name: "list and watch", - resources: []schema.GroupVersionResource{resourceApplicationProfile}, - preCreatedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestCollection)}, - createObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - }, - { - name: "list and delete", - resources: []schema.GroupVersionResource{resourceApplicationProfile}, - preCreatedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - deleteObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - }, - } - - for _, tc := range tt { - t.Run(tc.name, func(t *testing.T) { - startTest(t, tc) - }) - } -} -func TestStart_4(t *testing.T) { - tt := []testObj{ - { - name: "watch, modify, and delete", - resources: []schema.GroupVersionResource{resourceApplicationProfile}, - createObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - modifiedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - deleteObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx)}, - }, - } - - for _, tc := range tt { - t.Run(tc.name, func(t *testing.T) { - startTest(t, tc) - }) - } -} - -func TestStart_5(t *testing.T) { - tt := []testObj{ - { - name: "multi watch, modify, and delete", - resources: []schema.GroupVersionResource{resourceApplicationProfile, resourceNetworkNeighborhood, resourcePod}, - createObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindAP, mocks.TestCollection), mocks.GetRuntime(mocks.TestKindNN, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindNN, mocks.TestCollection), mocks.GetRuntime(mocks.TestKindPod, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindPod, mocks.TestCollection)}, - modifiedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindAP, mocks.TestCollection), mocks.GetRuntime(mocks.TestKindNN, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindNN, mocks.TestCollection), mocks.GetRuntime(mocks.TestKindPod, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindPod, mocks.TestCollection)}, - deleteObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindAP, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindAP, mocks.TestCollection), mocks.GetRuntime(mocks.TestKindNN, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindNN, mocks.TestCollection), mocks.GetRuntime(mocks.TestKindPod, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindPod, mocks.TestCollection)}, + name: "watch, modify, and delete Pods", + resources: []schema.GroupVersionResource{resourcePod}, + createObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindPod, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindPod, mocks.TestCollection)}, + modifiedObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindPod, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindPod, mocks.TestCollection)}, + deleteObjects: []runtime.Object{mocks.GetRuntime(mocks.TestKindPod, mocks.TestNginx), mocks.GetRuntime(mocks.TestKindPod, mocks.TestCollection)}, }, } diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 640f4fba94..f45f007b6a 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -18,7 +18,7 @@ spec: uniqueId: "event.comm + '_' + event.exepath" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)))" + expression: "!cp.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)))" profileDependency: 0 profileDataRequired: execs: all @@ -61,7 +61,7 @@ spec: event.path.startsWith('/var/run/secrets/kubernetes.io/serviceaccount') || event.path.startsWith('/tmp')) && - !ap.was_path_opened(event.containerId, event.path) + !cp.was_path_opened(event.containerId, event.path) profileDependency: 0 profileDataRequired: opens: @@ -98,7 +98,7 @@ spec: uniqueId: "event.syscallName" ruleExpression: - eventType: "syscall" - expression: "!ap.was_syscall_used(event.containerId, event.syscallName)" + expression: "!cp.was_syscall_used(event.containerId, event.syscallName)" profileDependency: 0 profileDataRequired: syscalls: all @@ -122,7 +122,7 @@ spec: uniqueId: "event.comm + '_' + event.capName" ruleExpression: - eventType: "capabilities" - expression: "!ap.was_capability_used(event.containerId, event.capName)" + expression: "!cp.was_capability_used(event.containerId, event.capName)" profileDependency: 0 profileDataRequired: capabilities: all @@ -146,7 +146,7 @@ spec: uniqueId: "event.comm + '_' + event.name" ruleExpression: - eventType: "dns" - expression: "!event.name.endsWith('.svc.cluster.local.') && !nn.is_domain_in_egress(event.containerId, event.name)" + expression: "!event.name.endsWith('.svc.cluster.local.') && !cp.is_domain_in_egress(event.containerId, event.name)" profileDependency: 0 profileDataRequired: egressDomains: all @@ -175,7 +175,7 @@ spec: (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_suffix(event.containerId, '/token') + !cp.was_path_opened_with_suffix(event.containerId, '/token') state: includePrefixes: - /run/secrets @@ -203,9 +203,9 @@ spec: uniqueId: "eventType == 'exec' ? 'exec_' + event.comm : 'network_' + event.dstAddr" ruleExpression: - eventType: "exec" - expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !ap.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)))" + expression: "(event.comm == 'kubectl' || event.exepath.endsWith('/kubectl')) && !cp.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)))" - eventType: "network" - expression: "event.pktType == 'OUTGOING' && k8s.is_api_server_address(event.dstAddr) && !nn.was_address_in_egress(event.containerId, event.dstAddr)" + expression: "event.pktType == 'OUTGOING' && k8s.is_api_server_address(event.dstAddr) && !cp.was_address_in_egress(event.containerId, event.dstAddr)" profileDependency: 0 profileDataRequired: execs: all @@ -233,7 +233,7 @@ spec: expression: > event.path.startsWith('/proc/') && event.path.endsWith('/environ') && - !ap.was_path_opened_with_suffix(event.containerId, '/environ') + !cp.was_path_opened_with_suffix(event.containerId, '/environ') state: includePrefixes: - /proc @@ -262,7 +262,7 @@ spec: uniqueId: "event.comm + '_' + 'bpf' + '_' + string(event.cmd)" ruleExpression: - eventType: "bpf" - expression: "event.cmd == uint(5) && !ap.was_syscall_used(event.containerId, 'bpf')" + expression: "event.cmd == uint(5) && !cp.was_syscall_used(event.containerId, 'bpf')" profileDependency: 1 profileDataRequired: syscalls: @@ -287,7 +287,7 @@ spec: uniqueId: "event.comm + '_' + event.path" ruleExpression: - eventType: "open" - expression: "event.path.startsWith('/etc/shadow') && !ap.was_path_opened(event.containerId, event.path)" + expression: "event.path.startsWith('/etc/shadow') && !cp.was_path_opened(event.containerId, event.path)" profileDependency: 1 profileDataRequired: opens: @@ -313,7 +313,7 @@ spec: uniqueId: "event.dstAddr + '_' + string(event.dstPort) + '_' + event.proto" ruleExpression: - eventType: "network" - expression: "event.pktType == 'OUTGOING' && !net.is_private_ip(event.dstAddr) && !nn.was_address_in_egress(event.containerId, event.dstAddr)" + expression: "event.pktType == 'OUTGOING' && !net.is_private_ip(event.dstAddr) && !cp.was_address_in_egress(event.containerId, event.dstAddr)" profileDependency: 0 profileDataRequired: egressAddresses: all @@ -332,13 +332,13 @@ spec: - name: "Unexpected process arguments" enabled: true id: "R0040" - description: "Detects an exec event whose path IS in the application profile but whose argv vector does not match any recorded argv pattern for that path. Consumes ap.was_executed_with_args, which walks the ExecsByPath projection surface and delegates argv comparison to dynamicpathdetector.MatchExecArgs (storage). Stays silent when the path is unknown (R0001 covers that case) and when the argv vector matches any recorded pattern (including the trailing zero-or-more form and the single-arg form); a '*' in a recorded arg is a literal character, not a wildcard." + description: "Detects an exec event whose path IS in the application profile but whose argv vector does not match any recorded argv pattern for that path. Consumes cp.was_executed_with_args, which walks the ExecsByPath projection surface and delegates argv comparison to dynamicpathdetector.MatchExecArgs (storage). Stays silent when the path is unknown (R0001 covers that case) and when the argv vector matches any recorded pattern (including the trailing zero-or-more form and the single-arg form); a '*' in a recorded arg is a literal character, not a wildcard." expressions: message: "'Unexpected process arguments: ' + event.comm + ' with PID ' + string(event.pid) + ' argv=' + event.args.map(a, string(a)).join(' ')" uniqueId: "event.comm + '_' + event.exepath + '_' + event.args.map(a, string(a)).join(' ')" ruleExpression: - eventType: "exec" - expression: "ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && !ap.was_executed_with_args(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath), event.args)" + expression: "cp.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath)) && !cp.was_executed_with_args(event.containerId, parse.get_exec_path(event.args, event.comm, event.exepath), event.args)" profileDependency: 0 profileDataRequired: execs: all @@ -391,7 +391,7 @@ spec: expression: > (event.upperlayer == true || event.pupperlayer == true) && - !ap.was_executed(event.containerId, (event.exepath != "" ? event.exepath : parse.get_exec_path(event.args, event.comm))) + !cp.was_executed(event.containerId, (event.exepath != "" ? event.exepath : parse.get_exec_path(event.args, event.comm))) profileDependency: 1 profileDataRequired: execs: all @@ -440,7 +440,7 @@ spec: uniqueId: "event.comm + '_' + event.dstIp + '_' + string(dyn(event.dstPort))" ruleExpression: - 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)" + expression: "dyn(event.srcPort) >= 32768 && dyn(event.srcPort) <= 60999 && !(dyn(event.dstPort) in [22, 2022]) && !cp.was_address_in_egress(event.containerId, event.dstIp)" profileDependency: 1 profileDataRequired: egressAddresses: all @@ -466,7 +466,7 @@ spec: uniqueId: "event.comm" ruleExpression: - eventType: "exec" - expression: "!ap.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : 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) || (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)).startsWith(mount))" + expression: "!cp.was_executed(event.containerId, (event.exepath != \"\" ? event.exepath : 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) || (event.exepath != \"\" ? event.exepath : parse.get_exec_path(event.args, event.comm)).startsWith(mount))" profileDependency: 1 profileDataRequired: execs: all @@ -512,7 +512,7 @@ spec: uniqueId: "event.comm + '_' + 'unshare'" ruleExpression: - eventType: "unshare" - expression: "event.pcomm != 'runc' && !ap.was_syscall_used(event.containerId, 'unshare')" + expression: "event.pcomm != 'runc' && !cp.was_syscall_used(event.containerId, 'unshare')" profileDependency: 1 profileDataRequired: syscalls: @@ -585,7 +585,7 @@ spec: uniqueId: "event.comm + '_' + string(event.dstPort)" ruleExpression: - eventType: "network" - expression: "event.proto == 'TCP' && event.pktType == 'OUTGOING' && event.dstPort in [3333, 45700] && !nn.was_address_in_egress(event.containerId, event.dstAddr)" + expression: "event.proto == 'TCP' && event.pktType == 'OUTGOING' && event.dstPort in [3333, 45700] && !cp.was_address_in_egress(event.containerId, event.dstAddr)" state: ports: - 3333 @@ -615,7 +615,7 @@ spec: uniqueId: "event.comm + '_' + event.oldPath" ruleExpression: - eventType: "symlink" - expression: "(event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) && !ap.was_path_opened(event.containerId, event.oldPath)" + expression: "(event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) && !cp.was_path_opened(event.containerId, event.oldPath)" profileDependency: 1 profileDataRequired: opens: @@ -667,7 +667,7 @@ spec: uniqueId: "event.comm + '_' + event.oldPath" ruleExpression: - eventType: "hardlink" - expression: "(event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) && !ap.was_path_opened(event.containerId, event.oldPath)" + expression: "(event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) && !cp.was_path_opened(event.containerId, event.oldPath)" profileDependency: 1 profileDataRequired: opens: diff --git a/tests/component_bulking_test.go b/tests/component_bulking_test.go index bac2b2b5e6..071d030f1b 100644 --- a/tests/component_bulking_test.go +++ b/tests/component_bulking_test.go @@ -169,7 +169,7 @@ func Test_25_AlertBulkingBasic(t *testing.T) { time.Sleep(10 * time.Second) // Wait for application profile to complete - err = wl.WaitForApplicationProfileCompletion(80) + err = wl.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") time.Sleep(30 * time.Second) diff --git a/tests/component_test.go b/tests/component_test.go index 1477a17496..07563292e6 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "os" "path" "reflect" "slices" @@ -15,8 +16,6 @@ import ( "testing" "time" - "github.com/kubescape/go-logger" - "github.com/kubescape/go-logger/helpers" helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/k8s-interface/k8sinterface" "github.com/kubescape/node-agent/pkg/utils" @@ -33,7 +32,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" - "k8s.io/utils/ptr" + "sigs.k8s.io/yaml" ) func tearDownTest(t *testing.T, startTime time.Time) { @@ -73,22 +72,17 @@ func Test_01_BasicAlertTest(t *testing.T) { // network activity from nginx container _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") - err = wl.WaitForApplicationProfileCompletion(80) + err = wl.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") - err = wl.WaitForNetworkNeighborhoodCompletion(80) + err = wl.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for network neighborhood to be completed") time.Sleep(30 * time.Second) - appProfile, _ := wl.GetApplicationProfile() - appProfileJson, _ := json.Marshal(appProfile) + profiles, _ := wl.GetContainerProfiles() + profilesJson, _ := json.Marshal(profiles) - networkNeighborhood, _ := wl.GetNetworkNeighborhood() - networkNeighborhoodJson, _ := json.Marshal(networkNeighborhood) - - t.Logf("network neighborhood: %v", string(networkNeighborhoodJson)) - - t.Logf("application profile: %v", string(appProfileJson)) + t.Logf("container profiles: %v", string(profilesJson)) _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // no alert expected _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // alert expected @@ -110,13 +104,17 @@ func Test_01_BasicAlertTest(t *testing.T) { // Verify UID fields are populated in alerts testutils.AssertUIDFieldsPopulated(t, alerts, wl.Namespace) - // check network neighborhood - nn, _ := wl.GetNetworkNeighborhood() - testutils.AssertNetworkNeighborhoodContains(t, nn, "nginx", []string{"kubernetes.io."}, []string{}) - testutils.AssertNetworkNeighborhoodNotContains(t, nn, "server", []string{"kubernetes.io."}, []string{}) + // check per-container network surface (one ContainerProfile per container) + nginxCP, err := wl.GetContainerProfile("nginx") + require.NoError(t, err, "Error getting nginx container profile") + serverCP, err := wl.GetContainerProfile("server") + require.NoError(t, err, "Error getting server container profile") + + testutils.AssertContainerProfileContains(t, nginxCP, []string{"kubernetes.io."}, []string{}) + testutils.AssertContainerProfileNotContains(t, serverCP, []string{"kubernetes.io."}, []string{}) - testutils.AssertNetworkNeighborhoodContains(t, nn, "server", []string{"ebpf.io."}, []string{}) - testutils.AssertNetworkNeighborhoodNotContains(t, nn, "nginx", []string{"ebpf.io."}, []string{}) + testutils.AssertContainerProfileContains(t, serverCP, []string{"ebpf.io."}, []string{}) + testutils.AssertContainerProfileNotContains(t, nginxCP, []string{"ebpf.io."}, []string{}) } // enableR0002ForTest applies an override Rules CRD that enables R0002 ("Files @@ -154,7 +152,7 @@ func Test_02_AllAlertsFromMaliciousApp(t *testing.T) { require.NoError(t, err, "Error waiting for workload to be ready") // Wait for the application profile to be created and completed - err = wl.WaitForApplicationProfileCompletion(150) + err = wl.WaitForContainerProfileCompletion(150) require.NoError(t, err, "Error waiting for application profile to be completed") // Wait for the alerts to be generated @@ -230,7 +228,7 @@ func Test_03_BasicLoadActivities(t *testing.T) { require.NoError(t, err, "Error waiting for workload to be ready") // Wait for the application profile to be created and completed - err = wl.WaitForApplicationProfileCompletion(80) + err = wl.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") // Create loader @@ -278,7 +276,7 @@ func Test_04_MemoryLeak(t *testing.T) { for _, wl := range workloads { err := wl.WaitForReady(80) require.NoError(t, err, "Error waiting for workload to be ready") - err = wl.WaitForApplicationProfileCompletion(80) + err = wl.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") } @@ -314,7 +312,7 @@ func Test_05_MemoryLeak_10K_Alerts(t *testing.T) { err = nginx.WaitForReady(80) require.NoError(t, err, "Error waiting for workload to be ready") - err = nginx.WaitForApplicationProfileCompletion(80) + err = nginx.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") // wait for 300 seconds for the GC to run, so the memory leak can be detected @@ -364,14 +362,14 @@ func Test_06_KillProcessInTheMiddle(t *testing.T) { require.NoError(t, err, "Error waiting for workload to be ready") // Give time for the nginx application profile to be ready - require.NoError(t, nginx.WaitForApplicationProfile(80, "ready")) + require.NoError(t, nginx.WaitForContainerProfile(80, "ready")) // Exec into the nginx pod and kill the process _, _, err = nginx.ExecIntoPod([]string{"bash", "-c", "kill -9 1"}, "") require.NoError(t, err, "Error executing remote command") // Wait for the application profile to be 'completed' - err = nginx.WaitForApplicationProfileCompletion(20) + err = nginx.WaitForContainerProfileCompletion(20) require.NoError(t, err, "Error waiting for application profile to be completed") } @@ -400,65 +398,67 @@ func Test_07_RuleBindingApplyTest(t *testing.T) { assert.NotEqualf(t, 0, exitCode, "Expected error when applying rule binding '%s'", file) } -func Test_08_ApplicationProfilePatching(t *testing.T) { +// Test_08_ContainerProfilePatching pins how a ContainerProfile behaves under a +// JSON-patch. A ContainerProfile is per-container with a FLAT spec (unlike the +// former ApplicationProfile, which nested containers under /spec/containers//), +// so patch paths target /spec/ directly. The contract exercised here: +// - `add /spec//-` appends one element (a syscall, a capability, an exec); +// - `replace /spec/` overwrites the whole field; +// - lifecycle annotations (kubescape.io/status, kubescape.io/completion) are +// patchable, bounded by the completed-immutability invariant (see Test_15): +// a completed profile cannot be patched back to learning, but a forward/lateral +// transition such as initializing→ready is allowed; +// - the storage layer accepts a JSONPatchType patch, persists it, and the +// patched fields read back. +func Test_08_ContainerProfilePatching(t *testing.T) { k8sClient := k8sinterface.NewKubernetesApi() storageclient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) t.Log("Creating namespace") ns := testutils.NewRandomNamespace() - name := "replicaset-checkoutservice-59596bf8d8" - applicationProfile := &v1beta1.ApplicationProfile{ + // One profile per container; the (former ApplicationProfile) surfaces live + // directly on the flat Spec, so patches target /spec/. + name := "replicaset-checkoutservice-59596bf8d8-server" + containerProfile := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: name, - Labels: map[string]string{ - "kubescape.io/instance-template-hash": "59596bf8d8", - "kubescape.io/workload-api-group": "apps", - "kubescape.io/workload-api-version": "v1", - "kubescape.io/workload-kind": "Deployment", - "kubescape.io/workload-name": "checkoutservice", - "kubescape.io/workload-namespace": "node-agent-test-veum", - "kubescape.io/workload-resource-version": "667544", - }, + // A learned CP carries lifecycle annotations; the patch below + // replaces them, so they must pre-exist. Annotations: map[string]string{ "kubescape.io/completion": "complete", "kubescape.io/status": "initializing", }, }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "server", - Syscalls: []string{ - "capget", "capset", "chdir", "close", "epoll_ctl", "faccessat2", - "fcntl", "fstat", "fstatfs", "futex", "getdents64", "getppid", - "nanosleep", "newfstatat", "openat", "prctl", "read", "setgid", - "setgroups", "setuid", "write", - }, - }, + Spec: v1beta1.ContainerProfileSpec{ + Syscalls: []string{ + "capget", "capset", "chdir", "close", "epoll_ctl", "faccessat2", + "fcntl", "fstat", "fstatfs", "futex", "getdents64", "getppid", + "nanosleep", "newfstatat", "openat", "prctl", "read", "setgid", + "setgroups", "setuid", "write", }, }, - Status: v1beta1.ApplicationProfileStatus{}, + Status: v1beta1.ContainerProfileStatus{}, } - _, err := storageclient.ApplicationProfiles(ns.Name).Create(context.TODO(), applicationProfile, metav1.CreateOptions{}) + _, err := storageclient.ContainerProfiles(ns.Name).Create(context.TODO(), containerProfile, metav1.CreateOptions{}) require.NoError(t, err) - // patch the application profile + // patch the container profile patchOperations := []utils.PatchOperation{ - {Op: "replace", Path: "/spec/containers/0/capabilities", Value: []string{"NET_ADMIN"}}, - {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETGID"}, - {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETPCAP"}, - {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SETUID"}, - {Op: "add", Path: "/spec/containers/0/capabilities/-", Value: "SYS_ADMIN"}, - {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "accept4"}, - {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "arch_prctl"}, - {Op: "add", Path: "/spec/containers/0/syscalls/-", Value: "bind"}, - {Op: "replace", Path: "/spec/containers/0/execs", Value: []map[string]interface{}{{ + {Op: "replace", Path: "/spec/capabilities", Value: []string{"NET_ADMIN"}}, + {Op: "add", Path: "/spec/capabilities/-", Value: "SETGID"}, + {Op: "add", Path: "/spec/capabilities/-", Value: "SETPCAP"}, + {Op: "add", Path: "/spec/capabilities/-", Value: "SETUID"}, + {Op: "add", Path: "/spec/capabilities/-", Value: "SYS_ADMIN"}, + {Op: "add", Path: "/spec/syscalls/-", Value: "accept4"}, + {Op: "add", Path: "/spec/syscalls/-", Value: "arch_prctl"}, + {Op: "add", Path: "/spec/syscalls/-", Value: "bind"}, + {Op: "replace", Path: "/spec/execs", Value: []map[string]interface{}{{ "path": "/checkoutservice", "args": []string{"/checkoutservice"}, }}}, - {Op: "add", Path: "/spec/containers/0/execs/-", Value: map[string]interface{}{ + {Op: "add", Path: "/spec/execs/-", Value: map[string]interface{}{ "path": "/bin/grpc_health_probe", "args": []string{"/bin/grpc_health_probe", "-addr=:5050"}, }}, @@ -469,10 +469,47 @@ func Test_08_ApplicationProfilePatching(t *testing.T) { patch, err := json.Marshal(patchOperations) require.NoError(t, err) - // TODO use Storage abstraction? - _, err = storageclient.ApplicationProfiles(ns.Name).Patch(context.Background(), name, types.JSONPatchType, patch, v1.PatchOptions{}) - - assert.NoError(t, err) + // Resilient to transient API errors: retry the patch until storage accepts it. + require.Eventually(t, func() bool { + _, patchErr := storageclient.ContainerProfiles(ns.Name).Patch( + context.Background(), name, types.JSONPatchType, patch, metav1.PatchOptions{}) + return patchErr == nil + }, 30*time.Second, 1*time.Second, "JSON-patch of the ContainerProfile must be accepted by storage") + + // Read back and prove the patch persisted on the flat spec. Poll, since the + // write may not be immediately visible. + var patched *v1beta1.ContainerProfile + require.Eventually(t, func() bool { + got, getErr := storageclient.ContainerProfiles(ns.Name).Get( + context.Background(), name, metav1.GetOptions{}) + if getErr != nil { + return false + } + patched = got + return patched.Annotations["kubescape.io/status"] == "ready" + }, 30*time.Second, 1*time.Second, "patched ContainerProfile must read back with the updated status") + + // replace reset /spec/capabilities to [NET_ADMIN], then four adds appended. + assert.ElementsMatch(t, []string{"NET_ADMIN", "SETGID", "SETPCAP", "SETUID", "SYS_ADMIN"}, + patched.Spec.Capabilities, "replace + add /- on /spec/capabilities") + + // add /spec/syscalls/- appended without dropping the learned syscalls. + assert.Subset(t, patched.Spec.Syscalls, []string{"accept4", "arch_prctl", "bind"}, + "add /spec/syscalls/- must append") + assert.Contains(t, patched.Spec.Syscalls, "read", "existing syscalls must survive the patch") + + // replace reset /spec/execs to checkoutservice, then one add appended the probe. + execPaths := make([]string, 0, len(patched.Spec.Execs)) + for _, e := range patched.Spec.Execs { + execPaths = append(execPaths, e.Path) + } + assert.ElementsMatch(t, []string{"/checkoutservice", "/bin/grpc_health_probe"}, execPaths, + "replace + add /- on /spec/execs") + + // lifecycle annotations updated; initializing→ready is a legal transition + // (a completed→learning regression would instead be reverted — see Test_15). + assert.Equal(t, "ready", patched.Annotations["kubescape.io/status"]) + assert.Equal(t, "complete", patched.Annotations["kubescape.io/completion"]) } func Test_09_FalsePositiveTest(t *testing.T) { @@ -503,7 +540,7 @@ func Test_09_FalsePositiveTest(t *testing.T) { t.Log("Waiting for all application profiles to be completed") for _, wl := range deployments { - err = wl.WaitForApplicationProfileCompletion(80) + err = wl.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") } @@ -576,7 +613,7 @@ func Test_11_EndpointTest(t *testing.T) { err = endpointTraffic.WaitForReady(80) require.NoError(t, err, "Error waiting for workload to be ready") - require.NoError(t, endpointTraffic.WaitForApplicationProfile(80, "ready")) + require.NoError(t, endpointTraffic.WaitForContainerProfile(80, "ready")) // Merge methods _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80"}, "") @@ -596,11 +633,11 @@ func Test_11_EndpointTest(t *testing.T) { _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80/users/99", "--header", "Connection:1234r"}, "") _, _, err = endpointTraffic.ExecIntoPod([]string{"wget", "http://127.0.0.1:80/users/12", "--header", "Connection:ziz"}, "") - err = endpointTraffic.WaitForApplicationProfileCompletion(80) + err = endpointTraffic.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") - applicationProfile, err := endpointTraffic.GetApplicationProfile() - require.NoError(t, err, "Error getting application profile") + containerProfile, err := endpointTraffic.GetContainerProfile("endpoint-traffic") + require.NoError(t, err, "Error getting container profile") headers := map[string][]string{"Connection": {"close"}, "Host": {"127.0.0.1:80"}} rawJSON, err := json.Marshal(headers) @@ -626,7 +663,7 @@ func Test_11_EndpointTest(t *testing.T) { Headers: rawJSON, } - savedEndpoints := applicationProfile.Spec.Containers[0].Endpoints + savedEndpoints := containerProfile.Spec.Endpoints for i := range savedEndpoints { @@ -655,411 +692,7 @@ func Test_11_EndpointTest(t *testing.T) { break } } - assert.Truef(t, found, "Expected endpoint %v not found in the application profile", expectedEndpoint) - } -} - -func Test_12_MergingProfilesTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // PHASE 1: Setup workload and initial profile - ns := testutils.NewRandomNamespace() - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Failed to create workload") - require.NoError(t, wl.WaitForReady(80), "Workload failed to be ready") - // require.NoError(t, wl.WaitForApplicationProfile(80, "ready"), "Application profile not ready") - time.Sleep(10 * time.Second) - - // Generate initial profile data - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") - require.NoError(t, err, "Failed to exec into nginx container") - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") - require.NoError(t, err, "Failed to exec into server container") - - require.NoError(t, wl.WaitForApplicationProfileCompletion(160), "Profile failed to complete") - time.Sleep(10 * time.Second) // Allow profile processing - - // Log initial profile state - initialProfile, err := wl.GetApplicationProfile() - require.NoError(t, err, "Failed to get initial profile") - initialProfileJSON, _ := json.Marshal(initialProfile) - t.Logf("Initial application profile:\n%s", string(initialProfileJSON)) - - // PHASE 2: Verify initial alerts - t.Log("Testing initial alert generation...") - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: alert - // time.Sleep(2 * time.Minute) // Wait for alert generation - time.Sleep(30 * time.Second) // Wait for alert generation - - initialAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get initial alerts") - - // Record initial alert count - initialAlertCount := 0 - for _, alert := range initialAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - initialAlertCount++ - } - } - - testutils.AssertContains(t, initialAlerts, "Unexpected process launched", "ls", "server", []bool{true}) - testutils.AssertNotContains(t, initialAlerts, "Unexpected process launched", "ls", "nginx", []bool{true, false}) - - // PHASE 3: Apply user-managed profile - t.Log("Applying user-managed profile...") - // Create the user-managed profile - userProfile := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("ug-%s", initialProfile.Name), - Namespace: initialProfile.Namespace, - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - }, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "nginx", - Execs: []v1beta1.ExecCalls{ - { - Path: "/usr/bin/ls", - Args: []string{"/usr/bin/ls", "-l"}, - }, - }, - SeccompProfile: v1beta1.SingleSeccompProfile{ - Spec: v1beta1.SingleSeccompProfileSpec{ - DefaultAction: "", - }, - }, - }, - { - Name: "server", - Execs: []v1beta1.ExecCalls{ - { - Path: "/bin/ls", - Args: []string{"/bin/ls", "-l"}, - }, - { - Path: "/bin/grpc_health_probe", - Args: []string{"-addr=:9555"}, - }, - }, - SeccompProfile: v1beta1.SingleSeccompProfile{ - Spec: v1beta1.SingleSeccompProfileSpec{ - DefaultAction: "", - }, - }, - }, - }, - }, - } - - // Log the profile we're about to create - userProfileJSON, err := json.MarshalIndent(userProfile, "", " ") - require.NoError(t, err, "Failed to marshal user profile") - t.Logf("Creating user profile:\n%s", string(userProfileJSON)) - - // Get k8s client - k8sClient := k8sinterface.NewKubernetesApi() - - // Create the user-managed profile - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err = storageClient.ApplicationProfiles(ns.Name).Create(context.Background(), userProfile, metav1.CreateOptions{}) - require.NoError(t, err, "Failed to create user profile") - - // PHASE 4: Verify merged profile behavior - t.Log("Verifying merged profile behavior...") - time.Sleep(1 * time.Minute) // Allow merge to complete - - // Test merged profile behavior - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: no alert (user profile should suppress alert) - time.Sleep(1 * time.Minute) // Wait for potential alerts - - // Verify alert counts - finalAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get final alerts") - - // Only count new alerts (after the initial count) - newAlertCount := 0 - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - newAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, newAlertCount) - - if newAlertCount > initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were generated after merge (Initial: %d, Final: %d)", initialAlertCount, newAlertCount) - } - - // The new cache doesn't listen to patches - // PHASE 5: Check PATCH (removing the ls command from the user profile of the server container and triggering an alert) - // t.Log("Patching user profile to remove ls command from server container...") - // patchOperations := []utils.PatchOperation{ - // {Op: "remove", Path: "/spec/containers/1/execs/0"}, - // } - - // patch, err := json.Marshal(patchOperations) - // require.NoError(t, err, "Failed to marshal patch operations") - - // _, err = storageClient.ApplicationProfiles(ns.Name).Patch(context.Background(), userProfile.Name, types.JSONPatchType, patch, metav1.PatchOptions{}) - // require.NoError(t, err, "Failed to patch user profile") - - // // Verify patched profile behavior - // time.Sleep(15 * time.Second) // Allow merge to complete - - // // Log the profile that was patched - // patchedProfile, err := wl.GetApplicationProfile() - // require.NoError(t, err, "Failed to get patched profile") - // t.Logf("Patched application profile:\n%v", patchedProfile) - - // // Test patched profile behavior - // wl.ExecIntoPod([]string{"ls", "-l"}, "nginx") // Expected: no alert - // wl.ExecIntoPod([]string{"ls", "-l"}, "server") // Expected: alert (ls command removed from user profile) - // time.Sleep(10 * time.Second) // Wait for potential alerts - - // // Verify alert counts - // finalAlerts, err = testutils.GetAlerts(wl.Namespace) - // require.NoError(t, err, "Failed to get final alerts") - - // // Only count new alerts (after the initial count) - // newAlertCount = 0 - // for _, alert := range finalAlerts { - // if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - // newAlertCount++ - // } - // } - - // t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, newAlertCount) - - // if newAlertCount <= initialAlertCount { - // t.Logf("Full alert details:") - // for _, alert := range finalAlerts { - // if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "Unexpected process launched" { - // t.Logf("Alert: %+v", alert) - // } - // } - // t.Errorf("New alerts were not generated after patch (Initial: %d, Final: %d)", initialAlertCount, newAlertCount) - // } -} - -func Test_13_MergingNetworkNeighborhoodTest(t *testing.T) { - start := time.Now() - defer tearDownTest(t, start) - - // PHASE 1: Setup workload and initial network neighborhood - ns := testutils.NewRandomNamespace() - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Failed to create workload") - require.NoError(t, wl.WaitForReady(80), "Workload failed to be ready") - require.NoError(t, wl.WaitForNetworkNeighborhood(80, "ready"), "Network neighborhood not ready") - - // Generate initial network data - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") - require.NoError(t, err, "Failed to exec wget in server container") - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") - require.NoError(t, err, "Failed to exec curl in nginx container") - - require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(80), "Network neighborhood failed to complete") - time.Sleep(10 * time.Second) // Allow network neighborhood processing - - // Log initial network neighborhood state - initialNN, err := wl.GetNetworkNeighborhood() - require.NoError(t, err, "Failed to get initial network neighborhood") - initialNNJSON, _ := json.Marshal(initialNN) - t.Logf("Initial network neighborhood:\n%s", string(initialNNJSON)) - - // PHASE 2: Verify initial alerts - t.Log("Testing initial alert generation...") - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert (original rule) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"wget", "httpforever.com", "-T", "2", "-t", "1"}, "server") // Expected: alert (not allowed) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert (original rule) - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: alert (not allowed) - time.Sleep(30 * time.Second) // Wait for alert generation - - initialAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get initial alerts") - - // Record initial alert count - initialAlertCount := 0 - for _, alert := range initialAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - initialAlertCount++ - } - } - - // Verify initial alerts - testutils.AssertContains(t, initialAlerts, "DNS Anomalies in container", "wget", "server", []bool{true}) - testutils.AssertContains(t, initialAlerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) - - // PHASE 3: Apply user-managed network neighborhood - t.Log("Applying user-managed network neighborhood...") - userNN := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("ug-%s", initialNN.Name), - Namespace: initialNN.Namespace, - Annotations: map[string]string{ - "kubescape.io/managed-by": "User", - }, - }, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "multiple-containers-app", - }, - }, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - { - Name: "nginx", - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "nginx-github", - Type: "external", - DNSNames: []string{"github.com."}, - Ports: []v1beta1.NetworkPort{ - { - Name: "TCP-80", - Protocol: "TCP", - Port: ptr.To(int32(80)), - }, - { - Name: "TCP-443", - Protocol: "TCP", - Port: ptr.To(int32(443)), - }, - }, - }, - }, - }, - { - Name: "server", - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "server-example", - Type: "external", - DNSNames: []string{"info.cern.ch."}, - Ports: []v1beta1.NetworkPort{ - { - Name: "TCP-80", - Protocol: "TCP", - Port: ptr.To(int32(80)), - }, - { - Name: "TCP-443", - Protocol: "TCP", - Port: ptr.To(int32(443)), - }, - }, - }, - }, - }, - }, - }, - } - - // Create user-managed network neighborhood - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err = storageClient.NetworkNeighborhoods(ns.Name).Create(context.Background(), userNN, metav1.CreateOptions{}) - require.NoError(t, err, "Failed to create user network neighborhood") - - // PHASE 4: Verify merged behavior (no new alerts) - t.Log("Verifying merged network neighborhood behavior...") - time.Sleep(60 * time.Second) // Allow merge to complete - - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert (original) - // Try multiple times to ensure alert is removed - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: no alert (user added) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert (original) - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: no alert (user added) - time.Sleep(30 * time.Second) // Wait for potential alerts - - mergedAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get alerts after merge") - - // Count new alerts after merge - newAlertCount := 0 - for _, alert := range mergedAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - newAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, After merge: %d", initialAlertCount, newAlertCount) - - if newAlertCount > initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range mergedAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were generated after merge (Initial: %d, After merge: %d)", initialAlertCount, newAlertCount) - } - - // PHASE 5: Remove permission via patch and verify alerts return - t.Log("Patching user network neighborhood to remove info.cern.ch from server container...") - patchOperations := []utils.PatchOperation{ - {Op: "remove", Path: "/spec/containers/1/egress/0"}, - } - - patch, err := json.Marshal(patchOperations) - require.NoError(t, err, "Failed to marshal patch operations") - - _, err = storageClient.NetworkNeighborhoods(ns.Name).Patch(context.Background(), userNN.Name, types.JSONPatchType, patch, metav1.PatchOptions{}) - require.NoError(t, err, "Failed to patch user network neighborhood") - - time.Sleep(60 * time.Second) // Allow merge to complete - - // Test alerts after patch - _, _, err = wl.ExecIntoPod([]string{"wget", "ebpf.io", "-T", "2", "-t", "1"}, "server") // Expected: no alert - // Try multiple times to ensure alert is removed - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"wget", "info.cern.ch", "-T", "2", "-t", "1"}, "server") // Expected: alert (removed) - _, _, err = wl.ExecIntoPod([]string{"curl", "kubernetes.io", "-m", "2"}, "nginx") // Expected: no alert - _, _, err = wl.ExecIntoPod([]string{"curl", "github.com", "-m", "2"}, "nginx") // Expected: no alert - time.Sleep(30 * time.Second) // Wait for alerts - - finalAlerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Failed to get final alerts") - - // Count final alerts - finalAlertCount := 0 - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - finalAlertCount++ - } - } - - t.Logf("Alert counts - Initial: %d, Final: %d", initialAlertCount, finalAlertCount) - - if finalAlertCount <= initialAlertCount { - t.Logf("Full alert details:") - for _, alert := range finalAlerts { - if ruleName, ok := alert.Labels["rule_name"]; ok && ruleName == "DNS Anomalies in container" && alert.Labels["container_name"] == "server" { - t.Logf("Alert: %+v", alert) - } - } - t.Errorf("New alerts were not generated after patch (Initial: %d, Final: %d)", initialAlertCount, finalAlertCount) + assert.Truef(t, found, "Expected endpoint %v not found in the container profile", expectedEndpoint) } } @@ -1076,7 +709,7 @@ func Test_14_RulePoliciesTest(t *testing.T) { } // Wait for application profile to be ready - assert.NoError(t, endpointTraffic.WaitForApplicationProfile(80, "ready")) + assert.NoError(t, endpointTraffic.WaitForContainerProfile(80, "ready")) time.Sleep(10 * time.Second) // Add to rule policy symlink @@ -1093,20 +726,16 @@ func Test_14_RulePoliciesTest(t *testing.T) { _, _, err = endpointTraffic.ExecIntoPod([]string{"rm", "/tmp/a"}, "") assert.NoError(t, err) - err = endpointTraffic.WaitForApplicationProfileCompletion(80) - if err != nil { - t.Errorf("Error waiting for application profile to be completed: %v", err) - } + require.NoError(t, endpointTraffic.WaitForContainerProfileCompletion(80), + "Error waiting for container profile to be completed") - applicationProfile, err := endpointTraffic.GetApplicationProfile() - if err != nil { - t.Errorf("Error getting application profile: %v", err) - } + containerProfile, err := endpointTraffic.GetContainerProfile("endpoint-traffic") + require.NoError(t, err, "Error getting container profile") - symlinkPolicy := applicationProfile.Spec.Containers[0].PolicyByRuleId["R1010"] + symlinkPolicy := containerProfile.Spec.PolicyByRuleId["R1010"] assert.Equal(t, []string{"ln"}, symlinkPolicy.AllowedProcesses) - hardlinkPolicy := applicationProfile.Spec.Containers[0].PolicyByRuleId["R1012"] + hardlinkPolicy := containerProfile.Spec.PolicyByRuleId["R1012"] assert.Len(t, hardlinkPolicy.AllowedProcesses, 0) fmt.Println("After completed....") @@ -1150,9 +779,9 @@ func Test_15_CompletedApCannotBecomeReadyAgain(t *testing.T) { _ = k8sClient.KubernetesClient.CoreV1().Namespaces().Delete(context.Background(), ns.Name, v1.DeleteOptions{}) }() - // create an application profile with completed status + // create a container profile with completed status name := "test" - ap1, err := storageclient.ApplicationProfiles(ns.Name).Create(context.TODO(), &v1beta1.ApplicationProfile{ + cp1, err := storageclient.ContainerProfiles(ns.Name).Create(context.TODO(), &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: name, Annotations: map[string]string{ @@ -1162,9 +791,9 @@ func Test_15_CompletedApCannotBecomeReadyAgain(t *testing.T) { }, }, v1.CreateOptions{}) require.NoError(t, err) - require.Equal(t, helpersv1.Completed, ap1.Annotations[helpersv1.StatusMetadataKey]) + require.Equal(t, helpersv1.Completed, cp1.Annotations[helpersv1.StatusMetadataKey]) - // patch the application profile with ready status + // patch the container profile with learning status patchOperations := []utils.PatchOperation{ { Op: "replace", @@ -1174,12 +803,14 @@ func Test_15_CompletedApCannotBecomeReadyAgain(t *testing.T) { } patch, err := json.Marshal(patchOperations) require.NoError(t, err) - ap2, err := storageclient.ApplicationProfiles(ns.Name).Patch(context.Background(), name, types.JSONPatchType, patch, v1.PatchOptions{}) + cp2, err := storageclient.ContainerProfiles(ns.Name).Patch(context.Background(), name, types.JSONPatchType, patch, v1.PatchOptions{}) assert.NoError(t, err) // patch should succeed - assert.Equal(t, helpersv1.Completed, ap2.Annotations[helpersv1.StatusMetadataKey]) // but the status should not change + assert.Equal(t, helpersv1.Completed, cp2.Annotations[helpersv1.StatusMetadataKey]) // but the status should not change } func Test_16_ApNotStuckOnRestart(t *testing.T) { + const containerName = "nginx" + ns := testutils.NewRandomNamespace() wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) @@ -1187,27 +818,104 @@ func Test_16_ApNotStuckOnRestart(t *testing.T) { require.NoError(t, wl.WaitForReady(80)) - time.Sleep(30 * time.Second) - - _, _, _ = wl.ExecIntoPod([]string{"service", "nginx", "stop"}, "") // suppose to get error - // wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "nginx-deployment") - // require.NoError(t, err, "Error re-fetching workload after stop") - // require.NoError(t, wl.WaitForReady(80)) - // require.NoError(t, wl.WaitForApplicationProfileCompletion(160)) + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - time.Sleep(160 * time.Second) + // A container restart spawns transient per-instance ContainerProfiles named + // "-<32 hex>" that briefly flip failed/ready around the restart; + // the stable MERGED profile that node-agent actually enforces has no such + // suffix. The completion gate below therefore keys off the merged profile + // only — not "all matching profiles completed" (WaitForContainerProfileCompletion), + // which would hang on a lingering transient failed/ready per-instance profile. + isMerged := func(name string) bool { + i := strings.LastIndex(name, "-") + if i < 0 || len(name)-i-1 != 32 { + return true + } + for _, c := range name[i+1:] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return true + } + } + return false + } + mergedCompleted := func() (string, bool) { + cps, e := storageClient.ContainerProfiles(ns.Name).List(context.Background(), metav1.ListOptions{}) + if e != nil { + return "", false + } + for _, c := range cps.Items { + if c.Labels["kubescape.io/workload-container-name"] != containerName || !isMerged(c.Name) { + continue + } + if c.Annotations[helpersv1.StatusMetadataKey] == helpersv1.Completed { + return c.Name, true + } + } + return "", false + } + logCPs := func() { + cps, e := storageClient.ContainerProfiles(ns.Name).List(context.Background(), metav1.ListOptions{}) + if e != nil { + t.Logf(" ", e) + return + } + for _, c := range cps.Items { + t.Logf(" CP %s status=%q completion=%q merged=%v", c.Name, + c.Annotations[helpersv1.StatusMetadataKey], + c.Annotations[helpersv1.CompletionMetadataKey], isMerged(c.Name)) + } + } - // Wait for cache to be updated - time.Sleep(15 * time.Second) + // Let the container run briefly, then stop nginx (PID 1) so the kubelet + // restarts the container — the "does the profile get stuck on restart?" + // scenario under test. + time.Sleep(30 * time.Second) + _, _, _ = wl.ExecIntoPod([]string{"service", "nginx", "stop"}, "") // expected to error: this kills the container + + require.NoError(t, wl.WaitForReady(80), "workload did not become ready again after restart") + + // GATE — replaces the former fixed time.Sleep(160s)+time.Sleep(15s). Wait + // for the merged ContainerProfile to reach 'completed' (i.e. enforcing) + // AFTER the restart. That is the real precondition for the violation below + // to alert; the fixed sleep raced this and dropped the alert whenever the + // completion (or its storage write, under load) ran past the timer. Bounded + // deadline + dump the ContainerProfiles on timeout — never the 20m panic. + restartReadyAt := time.Now() + var mergedName string + completionDeadline := time.Now().Add(5 * time.Minute) + for { + if n, ok := mergedCompleted(); ok { + mergedName = n + break + } + if time.Now().After(completionDeadline) { + t.Logf("timeout waiting for merged ContainerProfile to complete after restart — current ContainerProfiles:") + logCPs() + t.Fatalf("merged ContainerProfile for container %q did not reach %q within 5m after restart", containerName, helpersv1.Completed) + } + time.Sleep(5 * time.Second) + } + completedAt := time.Now() + t.Logf("merged ContainerProfile %q reached %q %s after restart-ready", mergedName, helpersv1.Completed, completedAt.Sub(restartReadyAt).Round(time.Second)) + // A completed/enforcing profile now exists; run a process that is NOT in it. + t.Logf("exec 'ls -l' now — %s after profile completion", time.Since(completedAt).Round(time.Second)) _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") require.NoError(t, err) - // Wait for the alert to be generated - time.Sleep(30 * time.Second) - - alerts, err := testutils.GetAlerts(wl.Namespace) - require.NoError(t, err, "Error getting alerts") + // Poll for the alert (replaces the fixed time.Sleep(30s)+single GetAlerts). + var alerts []testutils.Alert + require.Eventually(t, func() bool { + alerts, _ = testutils.GetAlerts(wl.Namespace) + for _, a := range alerts { + if a.Labels["rule_name"] == "Unexpected process launched" && + a.Labels["comm"] == "ls" && a.Labels["container_name"] == containerName { + return true + } + } + return false + }, 90*time.Second, 5*time.Second, "expected 'Unexpected process launched' alert for 'ls' in container 'nginx'") testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) } @@ -1220,13 +928,13 @@ func Test_17_ApCompletedToPartialUpdateTest(t *testing.T) { time.Sleep(30 * time.Second) require.NoError(t, wl.WaitForReady(80)) - require.NoError(t, wl.WaitForNetworkNeighborhood(80, "ready")) + require.NoError(t, wl.WaitForContainerProfile(80, "ready")) err = testutils.RestartDaemonSet("kubescape", "node-agent") require.NoError(t, err, "Error restarting daemonset") - require.NoError(t, wl.WaitForApplicationProfileCompletion(160)) - require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(160)) + require.NoError(t, wl.WaitForContainerProfileCompletion(160)) + require.NoError(t, wl.WaitForContainerProfileCompletion(160)) time.Sleep(30 * time.Second) @@ -1249,7 +957,7 @@ func Test_18_ShortLivedJobTest(t *testing.T) { require.NoError(t, err, "Error creating workload") // Application profile should be created and completed - err = wl.WaitForApplicationProfileCompletion(80) + err = wl.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") } @@ -1272,11 +980,11 @@ func Test_19_AlertOnPartialProfileTest(t *testing.T) { require.NoError(t, err, "Error restarting daemonset") // Wait for the application profile to be completed - err = wl.WaitForApplicationProfileCompletion(160) + err = wl.WaitForContainerProfileCompletion(160) require.NoError(t, err, "Error waiting for application profile to be completed") - profile, err := wl.GetApplicationProfile() - require.NoError(t, err, "Error getting application profile") + profile, err := wl.GetContainerProfile("nginx") + require.NoError(t, err, "Error getting container profile") require.Equal(t, helpersv1.Partial, profile.Annotations[helpersv1.CompletionMetadataKey]) @@ -1293,178 +1001,320 @@ func Test_19_AlertOnPartialProfileTest(t *testing.T) { testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) } +// Test_20_AlertOnPartialThenLearnProcessTest exercises process-execution +// ENFORCEMENT against an AUTHORED (user-defined) ContainerProfile, deterministically. +// +// SEMANTIC NOTE (flagged for review): this is NOT the old natural-learning / +// daemonset-restart / re-learn / blacklist dance. It authors the profile +// directly and then UPDATES it in place, so what it proves is profile +// ENFORCEMENT of an authored partial -> full profile, not that learning +// eventually captures the process. The core contract is preserved: a process +// NOT in the profile alerts (R0001); the SAME process, once added to the +// profile, does not. +// +// Determinism comes from a POSITIVE reload gate. The single update that ADDS +// the subject binary (ls) also REMOVES a canary binary (id). Because id was +// allowed before and forbidden after, it begins to fire R0001 the instant +// node-agent reloads the new revision — an alert-APPEARS signal (never a race +// on proving a negative) that confirms the reload took effect before we assert +// that ls has gone silent. Subject and canary are told apart by the alert +// `comm` label (real debian binaries => comm == binary basename). func Test_20_AlertOnPartialThenLearnProcessTest(t *testing.T) { start := time.Now() defer tearDownTest(t, start) - ns := testutils.NewRandomNamespace() - - // Create a workload - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) - require.NoError(t, err, "Error creating workload") - - // Wait for the workload to be ready - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Restart the daemonset - err = testutils.RestartDaemonSet("kubescape", "node-agent") - require.NoError(t, err, "Error restarting daemonset") - - // Wait for the application profile to be completed (partial) - err = wl.WaitForApplicationProfileCompletion(160) - require.NoError(t, err, "Error waiting for application profile to be completed") - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - // Generate an alert by executing a command (should trigger alert on partial profile) - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err, "Error executing command in pod") - - // Wait for the alert to be generated - time.Sleep(15 * time.Second) - alerts, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") - testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) - - profile, err := wl.GetApplicationProfile() - require.NoError(t, err, "Error getting application profile") - - // Restart the deployment to reset the profile learning - err = testutils.RestartDeployment(ns.Name, wl.WorkloadObj.GetName()) - require.NoError(t, err, "Error restarting deployment") - - wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "nginx-deployment") - require.NoError(t, err, "Error re-fetching workload after restart") + const ( + overlayName = "partial20-overlay" + containerName = "app" + ) - // Wait for the workload to be ready after restart - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready after restart") - - // Execute the same command during learning phase (should be learned in profile) - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err, "Error executing command in pod during learning") - - // Wait for the application profile to be completed (with ls command learned) - err = wl.WaitForApplicationProfileCompletionWithBlacklist(160, []string{profile.Name}) - require.NoError(t, err, "Error waiting for application profile to be completed after learning") + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - // Wait for cache to be updated - time.Sleep(15 * time.Second) + // Authored profile: allow the pod's baseline exec (sleep) and the canary + // (id) but NOT the subject (ls). + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: overlayName, Namespace: ns.Name}, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + {Path: "/usr/bin/sleep"}, + {Path: "/usr/bin/id"}, + }, + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "partial20"}}, + }, + } + _, err := storageClient.ContainerProfiles(ns.Name).Create(context.Background(), cp, metav1.CreateOptions{}) + require.NoError(t, err, "create authored ContainerProfile") + require.Eventually(t, func() bool { + _, e := storageClient.ContainerProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + return e == nil + }, 30*time.Second, time.Second, "authored CP must be in storage before pod deploy") - // Execute the same command again - should NOT trigger an alert now - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") - require.NoError(t, err, "Error executing command in pod after learning") + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/partial-process-deployment.yaml")) + require.NoError(t, err, "create workload") + require.NoError(t, wl.WaitForReady(80), "workload ready") - // Wait to see if any alert is generated - time.Sleep(15 * time.Second) - alertsAfter, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts after learning") - - // Should not contain new alert for ls command after learning - count := 0 - for _, alert := range alertsAfter { - if alert.Labels["rule_name"] == "Unexpected process launched" && alert.Labels["container_name"] == "nginx" && alert.Labels["process_name"] == "ls" { - count++ + // Count R0001 alerts for a given process comm in this container. + countR0001 := func(comm string) int { + alerts, _ := testutils.GetAlerts(ns.Name) + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == "R0001" && + a.Labels["container_name"] == containerName && + a.Labels["comm"] == comm { + n++ + } } + return n } - if count > 1 { - t.Errorf("Unexpected alerts found after learning: %d", count) + // On any stuck wait, dump the namespace's ContainerProfiles (name + status + // + exec count) so a stuck state is visible immediately. + logCPs := func() { + cps, e := storageClient.ContainerProfiles(ns.Name).List(context.Background(), metav1.ListOptions{}) + if e != nil { + t.Logf(" ", e) + return + } + for _, c := range cps.Items { + t.Logf(" CP %s status=%q execs=%d", c.Name, + c.Annotations[helpersv1.StatusMetadataKey], len(c.Spec.Execs)) + } } + // Bounded poll: fail fast (never the 20m global panic) and dump CPs on + // timeout. `cond` polls the real condition (alert present). + waitFor := func(cond func() bool, timeout time.Duration, desc string) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Second) + } + t.Logf("timeout waiting for %s — current ContainerProfiles:", desc) + logCPs() + t.Fatalf("timeout after %s waiting for %s", timeout, desc) + } + + // Give node-agent time to project the authored profile before generating + // events (matches Test_28; evaluating an unloaded profile is unreliable). + time.Sleep(30 * time.Second) + + // PHASE 1 — subject NOT in the profile must alert. Doubles as the + // profile-load gate: once the authored CP is loaded, ls (not allowed) + // fires R0001. + waitFor(func() bool { + wl.ExecIntoPod([]string{"/usr/bin/ls", "-l"}, containerName) + return countR0001("ls") > 0 + }, 3*time.Minute, "R0001 for ls (subject not in authored profile)") + t.Logf("phase1: R0001(ls)=%d on partial profile (expected >0)", countR0001("ls")) + + // UPDATE — add the subject (ls), remove the canary (id). One atomic + // revision so the canary flip proves the ls addition also loaded. + cur, err := storageClient.ContainerProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + require.NoError(t, err, "get CP for update") + cur.Spec.Execs = []v1beta1.ExecCalls{ + {Path: "/usr/bin/sleep"}, + {Path: "/usr/bin/ls"}, + } + _, err = storageClient.ContainerProfiles(ns.Name).Update(context.Background(), cur, metav1.UpdateOptions{}) + require.NoError(t, err, "update CP: add ls, remove id") + + // Propagation delay before the reload gate (not an assertion gate). + time.Sleep(20 * time.Second) + + // RELOAD GATE (positive) — the removed canary (id) must now alert, which + // proves node-agent reloaded the new revision (which also contains ls). + waitFor(func() bool { + wl.ExecIntoPod([]string{"/usr/bin/id"}, containerName) + return countR0001("id") > 0 + }, 3*time.Minute, "R0001 for id (canary removed on update => proves reload)") + t.Logf("reload confirmed: R0001(id)=%d", countR0001("id")) + + // PHASE 2 — the SAME subject, now in the profile, must NOT produce a NEW + // R0001. Cooldown headroom (per-container/per-rule, count 10) is untouched + // by the id-based gate, so a failed reload here would still let ls alert + // and be caught — this is a real enforcement check, not a vacuous pass. + before := countR0001("ls") + // Guard against phase-1 self-exhaustion: if the per-container/per-rule R0001 + // cooldown budget (cap 10) were already spent, ls could not alert in phase 2 + // regardless of enforcement, making the "no NEW R0001" check below vacuous. + require.Less(t, before, 10, + "phase 1 exhausted the R0001 ls cooldown budget (before=%d, cap=10); phase 2 would pass vacuously", before) + _, _, err = wl.ExecIntoPod([]string{"/usr/bin/ls", "-l"}, containerName) + require.NoError(t, err, "exec ls after profile update") + _, _, err = wl.ExecIntoPod([]string{"/usr/bin/ls", "-l"}, containerName) + require.NoError(t, err, "exec ls after profile update") + time.Sleep(20 * time.Second) // settle so any alert would have surfaced + after := countR0001("ls") + if after != before { + logCPs() + } + require.Equal(t, before, after, + "ls is now in the authored profile: no NEW R0001 expected (before=%d after=%d)", before, after) } +// Test_21_AlertOnPartialThenLearnNetworkTest exercises network-egress +// ENFORCEMENT against an AUTHORED (user-defined) ContainerProfile, +// deterministically. +// +// SEMANTIC NOTE (flagged for review): like Test_20 this replaces natural +// learning with an authored profile updated in place, so it proves egress +// ENFORCEMENT of an authored partial -> full profile, not that learning +// captures the destination. Core contract preserved: a destination NOT in the +// egress list alerts; the SAME destination, once added, does not. +// +// The subject and the reload canary use DISTINCT rules so they never confuse +// each other (alerts carry no destination label, only the rule + comm): +// - Subject: raw-IP TCP egress to 1.1.1.1:80 -> R0011 (no DNS, stable IP). +// - Reload canary: DNS lookup of fusioncore.ai -> R0005. +// The single update ADDS 1.1.1.1 to egress and REMOVES fusioncore.ai, so +// nslookup fusioncore.ai starts firing R0005 the instant the new revision +// loads — the positive reload gate — while the subject IP goes silent. Each +// step mirrors a proven Test_28 subtest (28c: curl 1.1.1.1 -> R0011; 28b: +// unknown domain -> R0005; 28a: listed destination -> no alert). func Test_21_AlertOnPartialThenLearnNetworkTest(t *testing.T) { start := time.Now() defer tearDownTest(t, start) - ns := testutils.NewRandomNamespace() - - // Create a workload using deployment-multiple-containers.yaml (same as Test_22) - wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/deployment-multiple-containers.yaml")) - require.NoError(t, err, "Error creating workload") + const ( + overlayName = "partial21-overlay" + containerName = "curl" + subjectIP = "1.1.1.1" + canaryDomain = "fusioncore.ai" + fusioncoreIP = "162.0.217.171" + ) + port80 := int32(80) - // Wait for the workload to be ready - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready") - - // Restart the daemonset - err = testutils.RestartDaemonSet("kubescape", "node-agent") - require.NoError(t, err, "Error restarting daemonset") - - // Wait for the network neighborhood to be completed (partial) - err = wl.WaitForNetworkNeighborhoodCompletion(160) - require.NoError(t, err, "Error waiting for network neighborhood to be completed") - - // Wait for cache to be updated - time.Sleep(15 * time.Second) - - // Generate an alert by making a network request (should trigger alert on partial profile) - // Using curl with timeout and targeting nginx container (same as Test_22) - _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") - require.NoError(t, err, "Error executing network command in pod") - - // Wait for the alert to be generated - time.Sleep(15 * time.Second) - alerts, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts") - testutils.AssertContains(t, alerts, "DNS Anomalies in container", "curl", "nginx", []bool{true}) + ns := testutils.NewRandomNamespace() + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - nn, err := wl.GetNetworkNeighborhood() - require.NoError(t, err, "Error getting network neighborhood") + // Authored profile: egress allows the canary domain (fusioncore.ai) only; + // the subject IP (1.1.1.1) is NOT allowed. Execs/syscalls are listed only + // to keep unrelated rules quiet — the assertions key on R0011/R0005. + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: overlayName, Namespace: ns.Name}, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep"}, + {Path: "/usr/bin/curl"}, + {Path: "/usr/bin/nslookup"}, + {Path: "/usr/bin/wget"}, + }, + Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev", "execve"}, + LabelSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "partial21"}}, + Egress: []v1beta1.NetworkNeighbor{ + { + Identifier: "canary-egress", + Type: v1beta1.CommunicationTypeEgress, + DNS: canaryDomain + ".", + DNSNames: []string{canaryDomain + "."}, + IPAddress: fusioncoreIP, + Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: v1beta1.ProtocolTCP, Port: &port80}}, + }, + }, + }, + } + _, err := storageClient.ContainerProfiles(ns.Name).Create(context.Background(), cp, metav1.CreateOptions{}) + require.NoError(t, err, "create authored ContainerProfile") + require.Eventually(t, func() bool { + _, e := storageClient.ContainerProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + return e == nil + }, 30*time.Second, time.Second, "authored CP must be in storage before pod deploy") - // Restart the deployment to reset the profile learning - err = testutils.RestartDeployment(ns.Name, wl.WorkloadObj.GetName()) - require.NoError(t, err, "Error restarting deployment") + wl, err := testutils.NewTestWorkload(ns.Name, + path.Join(utils.CurrentDir(), "resources/partial-network-deployment.yaml")) + require.NoError(t, err, "create workload") + require.NoError(t, wl.WaitForReady(80), "workload ready") - // Print we restarted the deployment - logger.L().Info("restarted deployment", helpers.String("name", wl.WorkloadObj.GetName()), helpers.String("namespace", wl.WorkloadObj.GetNamespace())) + countRule := func(ruleID string) int { + alerts, _ := testutils.GetAlerts(ns.Name) + n := 0 + for _, a := range alerts { + if a.Labels["rule_id"] == ruleID && a.Labels["container_name"] == containerName { + n++ + } + } + return n + } + logCPs := func() { + cps, e := storageClient.ContainerProfiles(ns.Name).List(context.Background(), metav1.ListOptions{}) + if e != nil { + t.Logf(" ", e) + return + } + for _, c := range cps.Items { + t.Logf(" CP %s status=%q egress=%d", c.Name, + c.Annotations[helpersv1.StatusMetadataKey], len(c.Spec.Egress)) + } + } + waitFor := func(cond func() bool, timeout time.Duration, desc string) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Second) + } + t.Logf("timeout waiting for %s — current ContainerProfiles:", desc) + logCPs() + t.Fatalf("timeout after %s waiting for %s", timeout, desc) + } - // Sleep to allow the restart to complete + // Let node-agent project the authored profile before generating traffic. time.Sleep(30 * time.Second) - wl, err = testutils.NewTestWorkloadFromK8sIdentifiers(ns.Name, wl.UnstructuredObj.GroupVersionKind().Kind, "multiple-containers-deployment") - require.NoError(t, err, "Error re-fetching workload after restart") - - // Wait for the workload to be ready after restart - err = wl.WaitForReady(80) - require.NoError(t, err, "Error waiting for workload to be ready after restart") - - // Execute the same network command during learning phase (should be learned in profile) - _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") - require.NoError(t, err, "Error executing network command in pod during learning") - - // Print the workload details we are using - logger.L().Info("workload details", helpers.String("name", wl.WorkloadObj.GetName()), helpers.String("namespace", wl.WorkloadObj.GetNamespace())) - // Print the metadata of the workload - logger.L().Info("workload metadata", helpers.Interface("metadata", wl.WorkloadObj.GetAnnotations()), helpers.Interface("labels", wl.WorkloadObj.GetLabels())) - - // Wait for the network neighborhood to be completed (with curl command learned) - err = wl.WaitForNetworkNeighborhoodCompletionWithBlacklist(160, []string{nn.Name}) - require.NoError(t, err, "Error waiting for network neighborhood to be completed after learning") - - // Wait for cache to be updated - time.Sleep(15 * time.Second) + // PHASE 1 — subject IP NOT in egress must alert (R0011). Doubles as the + // profile-load gate. + waitFor(func() bool { + wl.ExecIntoPod([]string{"curl", "-sm5", "http://" + subjectIP}, containerName) + return countRule("R0011") > 0 + }, 3*time.Minute, "R0011 for curl "+subjectIP+" (subject IP not in egress)") + t.Logf("phase1: R0011=%d on partial profile (expected >0)", countRule("R0011")) + + // UPDATE — add the subject IP to egress, remove the canary domain. + cur, err := storageClient.ContainerProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + require.NoError(t, err, "get CP for update") + cur.Spec.Egress = []v1beta1.NetworkNeighbor{ + { + Identifier: "subject-egress", + Type: v1beta1.CommunicationTypeEgress, + IPAddress: subjectIP, + Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: v1beta1.ProtocolTCP, Port: &port80}}, + }, + } + _, err = storageClient.ContainerProfiles(ns.Name).Update(context.Background(), cur, metav1.UpdateOptions{}) + require.NoError(t, err, "update CP: add subject IP, remove canary domain") - // Execute the same network command again - should NOT trigger an alert now - _, _, err = wl.ExecIntoPod([]string{"curl", "google.com", "-m", "5"}, "nginx") - require.NoError(t, err, "Error executing network command in pod after learning") + // Propagation delay before the reload gate (not an assertion gate). + time.Sleep(20 * time.Second) - // Wait to see if any alert is generated - time.Sleep(15 * time.Second) - alertsAfter, err := testutils.GetAlerts(ns.Name) - require.NoError(t, err, "Error getting alerts after learning") - - // Should not contain new alert for curl command after learning - count := 0 - for _, alert := range alertsAfter { - if alert.Labels["rule_name"] == "DNS Anomalies in container" && alert.Labels["container_name"] == "nginx" && alert.Labels["process_name"] == "curl" { - count++ - } - } - if count > 1 { - t.Errorf("Unexpected alerts found after learning: %d", count) - } + // RELOAD GATE (positive) — the removed canary domain must now fire R0005, + // proving node-agent reloaded the new revision (which also allows the + // subject IP). R0005 (DNS) is a distinct rule from the subject's R0011, so + // the two signals never cross-talk. + waitFor(func() bool { + wl.ExecIntoPod([]string{"nslookup", canaryDomain}, containerName) + return countRule("R0005") > 0 + }, 3*time.Minute, "R0005 for nslookup "+canaryDomain+" (canary domain removed => proves reload)") + t.Logf("reload confirmed: R0005=%d", countRule("R0005")) + + // PHASE 2 — the SAME subject IP, now in egress, must NOT produce a NEW + // R0011. + before := countRule("R0011") + wl.ExecIntoPod([]string{"curl", "-sm5", "http://" + subjectIP}, containerName) + wl.ExecIntoPod([]string{"curl", "-sm5", "http://" + subjectIP}, containerName) + time.Sleep(20 * time.Second) // settle so any alert would have surfaced + after := countRule("R0011") + if after != before { + logCPs() + } + require.Equal(t, before, after, + "%s is now in the authored egress: no NEW R0011 expected (before=%d after=%d)", subjectIP, before, after) } func Test_22_AlertOnPartialNetworkProfileTest(t *testing.T) { @@ -1486,7 +1336,7 @@ func Test_22_AlertOnPartialNetworkProfileTest(t *testing.T) { require.NoError(t, err, "Failed to restart daemonset") // Wait for the network neighborhood to be completed - err = wl.WaitForNetworkNeighborhoodCompletion(160) + err = wl.WaitForContainerProfileCompletion(160) require.NoError(t, err, "Error waiting for network neighborhood to be completed") // Wait for cache to be updated @@ -1512,7 +1362,7 @@ func Test_23_RuleCooldownTest(t *testing.T) { wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) require.NoError(t, err, "Error creating workload") - require.NoError(t, wl.WaitForApplicationProfileCompletion(80)) + require.NoError(t, wl.WaitForContainerProfileCompletion(80)) // Wait for cache time.Sleep(30 * time.Second) @@ -1558,7 +1408,7 @@ func Test_24_ProcessTreeDepthTest(t *testing.T) { err = endpointTraffic.WaitForReady(80) require.NoError(t, err, "Error waiting for workload to be ready") - err = endpointTraffic.WaitForApplicationProfileCompletion(80) + err = endpointTraffic.WaitForContainerProfileCompletion(80) require.NoError(t, err, "Error waiting for application profile to be completed") // wait for cache @@ -1652,46 +1502,38 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { t.Log("======================================") }() - // deployWithProfile creates a user-defined ApplicationProfile with the - // given Opens list, polls until it is retrievable from storage, then - // deploys nginx with the kubescape.io/user-defined-profile label - // pointing at it, and waits for the pod to be ready. + // deployWithProfile creates a user-defined ContainerProfile with the given + // Opens list, then deploys nginx bound to it via the + // kubescape.io/user-defined-profile label and waits for readiness. deployWithProfile := func(t *testing.T, opens []v1beta1.OpenCalls) *testutils.TestWorkload { t.Helper() ns := testutils.NewRandomNamespace() - profile := &v1beta1.ApplicationProfile{ + profile := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: profileName, Namespace: ns.Name, }, - Spec: v1beta1.ApplicationProfileSpec{ + Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "nginx", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/cat", Args: []string{"/bin/cat"}}, - }, - Opens: opens, - }, + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, }, + Opens: opens, }, } k8sClient := k8sinterface.NewKubernetesApi() storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err := storageClient.ApplicationProfiles(ns.Name).Create( + _, err := storageClient.ContainerProfiles(ns.Name).Create( context.Background(), profile, metav1.CreateOptions{}) - require.NoError(t, err, "create user-defined profile %q in ns %s", profileName, ns.Name) + require.NoError(t, err, "create user-defined ContainerProfile %q in ns %s", profileName, ns.Name) - // Poll until the profile is retrievable from storage before deploying. - // Node-agent does a single fetch on container start with no retry. require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get( context.Background(), profileName, v1.GetOptions{}) - return apErr == nil - }, 30*time.Second, 1*time.Second, "AP must be retrievable from storage before deploying the pod") + return cpErr == nil + }, 30*time.Second, 1*time.Second, "CP must be retrievable from storage before deploying the pod") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-user-profile-deployment.yaml")) @@ -1747,20 +1589,21 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { path.Join(utils.CurrentDir(), "resources/nginx-deployment.yaml")) require.NoError(t, err) require.NoError(t, wl.WaitForReady(80)) - require.NoError(t, wl.WaitForApplicationProfileCompletion(80)) + require.NoError(t, wl.WaitForContainerProfileCompletion(80)) - profile, err := wl.GetApplicationProfile() - require.NoError(t, err, "get application profile") + profiles, err := wl.GetContainerProfiles() + require.NoError(t, err, "get container profiles") passed := true - for _, container := range profile.Spec.Containers { - for _, open := range container.Opens { + for _, profile := range profiles { + containerName := profile.Labels["kubescape.io/workload-container-name"] + for _, open := range profile.Spec.Opens { if !strings.HasPrefix(open.Path, "/") { - t.Errorf("recorded path must be absolute: got %q (container %s)", open.Path, container.Name) + t.Errorf("recorded path must be absolute: got %q (container %s)", open.Path, containerName) passed = false } if open.Path == "." { - t.Errorf("recorded path must not be relative dot: got %q (container %s)", open.Path, container.Name) + t.Errorf("recorded path must not be relative dot: got %q (container %s)", open.Path, containerName) passed = false } } @@ -1870,75 +1713,69 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { wildcardProfileName := "fusioncore-profile-wildcards" // Create the profile matching known-application-profile-wildcards.yaml. - profile := &v1beta1.ApplicationProfile{ + profile := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: wildcardProfileName, Namespace: ns.Name, }, - Spec: v1beta1.ApplicationProfileSpec{ + Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - ImageID: "docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058", - ImageTag: "docker.io/curlimages/curl:8.5.0", - Capabilities: []string{ - "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", - "CAP_SETGID", "CAP_SETPCAP", "CAP_SETUID", "CAP_SYS_ADMIN", - }, - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep", Args: []string{"/bin/sleep", "infinity"}}, - {Path: "/bin/cat", Args: []string{"/bin/cat"}}, - {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-sm2", "fusioncore.ai"}}, - }, - Opens: []v1beta1.OpenCalls{ - {Path: "/etc/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, - {Path: "/etc/ssl/openssl.cnf", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, - {Path: "/home/*", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, - {Path: "/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, - {Path: "/usr/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, - {Path: "/usr/local/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, - {Path: "/proc/*/cgroup", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/proc/*/kernel/cap_last_cap", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/proc/*/mountinfo", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/proc/*/task/*/fd", Flags: []string{"O_RDONLY", "O_DIRECTORY", "O_CLOEXEC"}}, - {Path: "/sys/fs/cgroup/cpu.max", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", Flags: []string{"O_RDONLY"}}, - {Path: "/7/setgroups", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - {Path: "/runc", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - }, - Syscalls: []string{ - "arch_prctl", "bind", "brk", "capget", "capset", "chdir", - "clone", "close", "close_range", "connect", "epoll_ctl", - "epoll_pwait", "execve", "exit", "exit_group", "faccessat2", - "fchown", "fcntl", "fstat", "fstatfs", "futex", "getcwd", - "getdents64", "getegid", "geteuid", "getgid", "getpeername", - "getppid", "getsockname", "getsockopt", "gettid", "getuid", - "ioctl", "membarrier", "mmap", "mprotect", "munmap", - "nanosleep", "newfstatat", "open", "openat", "openat2", - "pipe", "poll", "prctl", "read", "recvfrom", "recvmsg", - "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "sendto", - "set_tid_address", "setgid", "setgroups", "setsockopt", - "setuid", "sigaltstack", "socket", "statx", "tkill", - "unknown", "write", "writev", - }, - }, + ImageID: "docker.io/curlimages/curl@sha256:08e466006f0860e54fc299378de998935333e0e130a15f6f98482e9f8dab3058", + ImageTag: "docker.io/curlimages/curl:8.5.0", + Capabilities: []string{ + "CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", + "CAP_SETGID", "CAP_SETPCAP", "CAP_SETUID", "CAP_SYS_ADMIN", + }, + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/sleep", Args: []string{"/bin/sleep", "infinity"}}, + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-sm2", "fusioncore.ai"}}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/etc/ssl/openssl.cnf", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, + {Path: "/home/*", Flags: []string{"O_RDONLY", "O_LARGEFILE"}}, + {Path: "/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/usr/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/usr/local/lib/*", Flags: []string{"O_RDONLY", "O_LARGEFILE", "O_CLOEXEC"}}, + {Path: "/proc/*/cgroup", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/kernel/cap_last_cap", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/mountinfo", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/proc/*/task/*/fd", Flags: []string{"O_RDONLY", "O_DIRECTORY", "O_CLOEXEC"}}, + {Path: "/sys/fs/cgroup/cpu.max", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", Flags: []string{"O_RDONLY"}}, + {Path: "/7/setgroups", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + {Path: "/runc", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, + }, + Syscalls: []string{ + "arch_prctl", "bind", "brk", "capget", "capset", "chdir", + "clone", "close", "close_range", "connect", "epoll_ctl", + "epoll_pwait", "execve", "exit", "exit_group", "faccessat2", + "fchown", "fcntl", "fstat", "fstatfs", "futex", "getcwd", + "getdents64", "getegid", "geteuid", "getgid", "getpeername", + "getppid", "getsockname", "getsockopt", "gettid", "getuid", + "ioctl", "membarrier", "mmap", "mprotect", "munmap", + "nanosleep", "newfstatat", "open", "openat", "openat2", + "pipe", "poll", "prctl", "read", "recvfrom", "recvmsg", + "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "sendto", + "set_tid_address", "setgid", "setgroups", "setsockopt", + "setuid", "sigaltstack", "socket", "statx", "tkill", + "unknown", "write", "writev", }, }, } k8sClient := k8sinterface.NewKubernetesApi() storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err := storageClient.ApplicationProfiles(ns.Name).Create( + _, err := storageClient.ContainerProfiles(ns.Name).Create( context.Background(), profile, metav1.CreateOptions{}) - require.NoError(t, err, "create wildcard profile %q in ns %s", wildcardProfileName, ns.Name) + require.NoError(t, err, "create wildcard ContainerProfile %q in ns %s", wildcardProfileName, ns.Name) - // Poll until the profile is retrievable from storage before deploying. require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get( context.Background(), wildcardProfileName, v1.GetOptions{}) - return apErr == nil - }, 30*time.Second, 1*time.Second, "AP must be retrievable before deploying the pod") + return cpErr == nil + }, 30*time.Second, 1*time.Second, "CP must be retrievable before deploying the pod") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/curl-user-profile-wildcards-deployment.yaml")) @@ -1988,6 +1825,11 @@ func Test_27_ApplicationProfileOpens(t *testing.T) { func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { start := time.Now() defer tearDownTest(t, start) + // R0002 file-access monitoring is opt-in (monitored prefixes incl. /etc/); + // without this the rule never evaluates opens and every "expect alert" + // anchoring case silently passes as a no-alert. Test_27 enables it the same + // way; Test_33 was missing it (it had never run in CI to expose the gap). + defer enableR0002ForTest(t)() const ruleName = "Files Access Anomalies in container" const profileName = "nginx-regex-profile" @@ -2036,42 +1878,35 @@ func Test_33_AnalyzeOpensWildcardAnchoring(t *testing.T) { t.Helper() ns := testutils.NewRandomNamespace() - profile := &v1beta1.ApplicationProfile{ + profile := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: profileName, Namespace: ns.Name, }, - Spec: v1beta1.ApplicationProfileSpec{ + Spec: v1beta1.ContainerProfileSpec{ Architectures: []string{"amd64"}, - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "nginx", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/cat", Args: []string{"/bin/cat"}}, - }, - Opens: []v1beta1.OpenCalls{ - {Path: profilePath, Flags: []string{"O_RDONLY"}}, - // Dynamic linker fires this on every exec — keep - // it whitelisted so it doesn't drown out the - // signal we actually care about. - {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, - }, - }, + Execs: []v1beta1.ExecCalls{ + {Path: "/bin/cat", Args: []string{"/bin/cat"}}, + }, + Opens: []v1beta1.OpenCalls{ + {Path: profilePath, Flags: []string{"O_RDONLY"}}, + // Dynamic linker fires this on every exec — keep it whitelisted. + {Path: "/etc/ld.so.cache", Flags: []string{"O_RDONLY", "O_CLOEXEC"}}, }, }, } k8sClient := k8sinterface.NewKubernetesApi() storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - _, err := storageClient.ApplicationProfiles(ns.Name).Create( + _, err := storageClient.ContainerProfiles(ns.Name).Create( context.Background(), profile, metav1.CreateOptions{}) - require.NoError(t, err, "create user-defined profile %q in ns %s", profileName, ns.Name) + require.NoError(t, err, "create user-defined ContainerProfile %q in ns %s", profileName, ns.Name) require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get( context.Background(), profileName, v1.GetOptions{}) - return apErr == nil - }, 30*time.Second, 1*time.Second, "AP must be retrievable from storage before deploying the pod") + return cpErr == nil + }, 30*time.Second, 1*time.Second, "CP must be retrievable from storage before deploying the pod") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-user-profile-deployment.yaml")) @@ -2264,149 +2099,64 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { k8sClient := k8sinterface.NewKubernetesApi() storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - ap := &v1beta1.ApplicationProfile{ + cp := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: overlayName, Namespace: ns.Name, }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - // Profile shape: Path AND Args[0] both use the - // absolute-path symlink form (/bin/sh, - // /usr/bin/nslookup, ...). With the symlink- - // faithful precedence in parse.get_exec_path - // (fix 9a6eb359), the rule queries the - // symlink-as-invoked path that the kernel - // preserves in argv[0]. Recording-side - // resolveExecPath uses the same precedence so - // auto-learned profiles get the same key. - // - // Storage's CompareExecArgs is a strict - // positional compare — no special argv[0] - // normalisation — so Args[0] MUST be the same - // string as runtime argv[0]. For - // kubectl-exec'd processes that's the absolute - // path the caller invoked. - // - // pod startup: sleep - {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, - // sh -c - {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, - // echo hello - {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, - // curl -s - {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, - // curl -s file:///etc/hosts file:///etc/hostname - // — a ⋯ in a NON-trailing position: it matches exactly - // one arg, and the LITERAL args after it must still - // anchor. (file:// URLs are used as the post-⋯ literals - // so curl reads local files and exits 0.) - {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier, "file:///etc/hosts", "file:///etc/hostname"}}, - // Busybox-symlink mirror entries. The curl image's - // /bin/{sleep,sh,echo} are symlinks to /bin/busybox, - // so the kernel's resolved /proc//exe — what - // IG captures as event.exepath — is /bin/busybox. - // parse.get_exec_path(args, comm, exepath) returns - // exepath first, so ap.was_executed queries arrive - // at the rule keyed on /bin/busybox, not the - // symlink form. Without a matching profile entry - // keyed on /bin/busybox, R0001 fires before R0040 - // ever evaluates and the test trips its R0001 - // precondition. The symlink-form entries above are - // retained for environments where exepath resolves - // to the as-invoked path (non-symlinked utilities; - // fexecve / argv[0] fallback in resolveExecPath). - {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, - {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, - {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, - // Literal "*" arg: echo invoked with a GENUINE literal "*" - // (e.g. an unexpanded glob), recorded verbatim. Under the - // symbol contract a "*" in argv is DATA, not a wildcard, so - // this entry matches ONLY `echo star *` and must NOT broaden - // to `echo star `. CT-level mirror of storage's - // TestAP_LiteralStarVsDynamic. (busybox + symlink forms.) - {Path: "/bin/echo", Args: []string{"/bin/echo", "star", "*"}}, - {Path: "/bin/busybox", Args: []string{"/bin/echo", "star", "*"}}, - }, - Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev", "execve"}, - }, + Spec: v1beta1.ContainerProfileSpec{ + Execs: []v1beta1.ExecCalls{ + // storage's CompareExecArgs is a strict positional compare, so + // Args[0] must equal runtime argv[0] (the absolute path invoked). + // pod startup: sleep + {Path: "/bin/sleep", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, + // sh -c + {Path: "/bin/sh", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, + // echo hello + {Path: "/bin/echo", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, + // curl -s + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier}}, + // curl -s file:///etc/hosts file:///etc/hostname + // — a ⋯ in a NON-trailing position: it matches exactly + // one arg, and the LITERAL args after it must still + // anchor. (file:// URLs are used as the post-⋯ literals + // so curl reads local files and exits 0.) + {Path: "/usr/bin/curl", Args: []string{"/usr/bin/curl", "-s", dynamicpathdetector.DynamicIdentifier, "file:///etc/hosts", "file:///etc/hostname"}}, + // Busybox-symlink mirrors: the curl image's /bin/{sleep,sh,echo} + // resolve to /bin/busybox (exepath), which the rule keys on. These + // entries are required or R0001 fires before R0040 is reached. + {Path: "/bin/busybox", Args: []string{"/bin/sleep", dynamicpathdetector.ExecArgsWildcard}}, + {Path: "/bin/busybox", Args: []string{"/bin/sh", "-c", dynamicpathdetector.ExecArgsWildcard}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "hello", dynamicpathdetector.ExecArgsWildcard}}, + // Literal "*" is DATA, not a wildcard: matches only `echo star *`, + // never `echo star ` (busybox + symlink forms). + {Path: "/bin/echo", Args: []string{"/bin/echo", "star", "*"}}, + {Path: "/bin/busybox", Args: []string{"/bin/echo", "star", "*"}}, }, - }, - } - _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create AP") - - // User-supplied SBOB pattern (mirrors Test_28): the pod carries BOTH - // kubescape.io/user-defined-profile and kubescape.io/user-defined-network. - // Node-agent uses the single overlay name as the lookup key for BOTH - // the user ApplicationProfile and the user NetworkNeighborhood, so the - // NN must exist under the same name and be created before the pod. - // User-authored objects carry managed-by=User + a terminal - // status/completion and the workload-binding labels. - nn := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, - Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, - Labels: map[string]string{ - helpersv1.ApiGroupMetadataKey: "apps", - helpersv1.ApiVersionMetadataKey: "v1", - helpersv1.RelatedKindMetadataKey: "Deployment", - helpersv1.RelatedNameMetadataKey: "curl-32", - helpersv1.RelatedNamespaceMetadataKey: ns.Name, - }, - }, - Spec: v1beta1.NetworkNeighborhoodSpec{ + Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev", "execve"}, LabelSelector: metav1.LabelSelector{ MatchLabels: map[string]string{"app": "curl-32"}, }, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - {Name: "curl"}, - }, }, } - _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( - context.Background(), nn, metav1.CreateOptions{}) - require.NoError(t, err, "create NN") + _, err := storageClient.ContainerProfiles(ns.Name).Create( + context.Background(), cp, metav1.CreateOptions{}) + require.NoError(t, err, "create user-defined ContainerProfile") require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get( - context.Background(), overlayName, v1.GetOptions{}) - _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get( - context.Background(), overlayName, v1.GetOptions{}) - return apErr == nil && nnErr == nil - }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + _, cpErr := storageClient.ContainerProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) + return cpErr == nil + }, 30*time.Second, 1*time.Second, "user-defined CP must be in storage before pod deploy") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/curl-exec-arg-wildcards-deployment.yaml")) require.NoError(t, err) require.NoError(t, wl.WaitForReady(80)) - // Deterministic profile-load gate (replaces a fixed sleep that raced the - // asynchronous overlay load). node-agent must observe the pod, resolve - // the kubescape.io/user-defined-profile annotation to UserAPRef, fetch - // the user AP and build the projection before the argv-comparison rule - // (R0040) can evaluate at all; until then refreshOneEntry reports the CP - // "not-available" and R0040 is suppressed — which makes every POSITIVE - // subtest pass VACUOUSLY (no profile -> no R0040 -> ==0) and every - // NEGATIVE subtest time out. The fixed 30s sleep did not reliably cover - // that window (observed: all negatives failing on a slow load). - // - // The canary is a deterministic argv MISMATCH: [echo, ] matches - // neither [echo, hello, ⋯⋯] nor [echo, star, *], so once the overlay is - // projected it MUST fire R0040. R0040's cooldown key is uniqueId = - // comm+exepath+argv, so this distinct argv never suppresses a subtest's - // own R0040. We retry until it fires, then return the post-gate R0040 - // count as a baseline so subtests assert on the DELTA, not absolutes — - // closing the vacuous-positive hole. + // Profile-load gate: wait until the user-defined CP is projected before + // asserting. The canary is a deterministic argv mismatch ([echo, ]) + // that must fire R0040 once the profile loads; we retry until it does and + // return the post-gate R0040 count so subtests assert on the delta. countR0040 := func(alerts []testutils.Alert) int { n := 0 for _, a := range alerts { @@ -2750,110 +2500,51 @@ func Test_32_UnexpectedProcessArguments(t *testing.T) { }) } +// applyUserDefinedContainerProfile reads a ContainerProfile example yaml (the +// copy-pasteable authoring example), stamps it into ns, and creates it. A +// user-managed CP carries only name + spec — the pod's user-defined-profile +// label is what binds it; no lifecycle annotations are needed. +func applyUserDefinedContainerProfile(t *testing.T, ns, resourcePath string) *v1beta1.ContainerProfile { + t.Helper() + b, err := os.ReadFile(path.Join(utils.CurrentDir(), resourcePath)) + require.NoError(t, err, "read %s", resourcePath) + var cp v1beta1.ContainerProfile + require.NoError(t, yaml.Unmarshal(b, &cp), "unmarshal %s", resourcePath) + cp.Namespace = ns + cp.ResourceVersion = "" + k8sClient := k8sinterface.NewKubernetesApi() + storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) + _, err = storageClient.ContainerProfiles(ns).Create(context.Background(), &cp, metav1.CreateOptions{}) + require.NoError(t, err, "create ContainerProfile from %s", resourcePath) + require.Eventually(t, func() bool { + _, e := storageClient.ContainerProfiles(ns).Get(context.Background(), cp.Name, v1.GetOptions{}) + return e == nil + }, 30*time.Second, time.Second, "CP from %s must be in storage before pod deploy", resourcePath) + return &cp +} + func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { start := time.Now() defer tearDownTest(t, start) - // setup creates a namespace with user-defined AP + NN + pod. - // The NN allows only fusioncore.ai (162.0.217.171) on TCP/80. + // setup deploys a pod bound to an authored ContainerProfile whose egress + // allows only fusioncore.ai (162.0.217.171) on TCP/80. setup := func(t *testing.T) *testutils.TestWorkload { t.Helper() ns := testutils.NewRandomNamespace() - k8sClient := k8sinterface.NewKubernetesApi() - storageClient := spdxv1beta1client.NewForConfigOrDie(k8sClient.K8SConfig) - // Upstream ContainerProfileCache (kubescape/node-agent#788) reads ONE - // pod label `kubescape.io/user-defined-profile=` and uses - // as the lookup key for BOTH the user AP and the user NN. - // AP and NN MUST therefore share that single name. const overlayName = "curl-28-overlay" - ap := &v1beta1.ApplicationProfile{ - ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, - Namespace: ns.Name, - }, - Spec: v1beta1.ApplicationProfileSpec{ - Containers: []v1beta1.ApplicationProfileContainer{ - { - Name: "curl", - Execs: []v1beta1.ExecCalls{ - {Path: "/bin/sleep"}, - {Path: "/usr/bin/curl"}, - {Path: "/usr/bin/nslookup"}, - {Path: "/usr/bin/wget"}, - }, - Syscalls: []string{"socket", "connect", "sendto", "recvfrom", "read", "write", "close", "openat", "mmap", "mprotect", "munmap", "fcntl", "ioctl", "poll", "epoll_create1", "epoll_ctl", "epoll_wait", "bind", "listen", "accept4", "getsockopt", "setsockopt", "getsockname", "getpid", "fstat", "rt_sigaction", "rt_sigprocmask", "writev"}, - }, - }, - }, - } - _, err := storageClient.ApplicationProfiles(ns.Name).Create( - context.Background(), ap, metav1.CreateOptions{}) - require.NoError(t, err, "create AP") - - nn := &v1beta1.NetworkNeighborhood{ - ObjectMeta: metav1.ObjectMeta{ - Name: overlayName, - Namespace: ns.Name, - Annotations: map[string]string{ - helpersv1.ManagedByMetadataKey: helpersv1.ManagedByUserValue, - helpersv1.StatusMetadataKey: helpersv1.Completed, - helpersv1.CompletionMetadataKey: helpersv1.Full, - }, - Labels: map[string]string{ - helpersv1.ApiGroupMetadataKey: "apps", - helpersv1.ApiVersionMetadataKey: "v1", - helpersv1.RelatedKindMetadataKey: "Deployment", - helpersv1.RelatedNameMetadataKey: "curl-28", - helpersv1.RelatedNamespaceMetadataKey: ns.Name, - }, - }, - Spec: v1beta1.NetworkNeighborhoodSpec{ - LabelSelector: metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "curl-28"}, - }, - Containers: []v1beta1.NetworkNeighborhoodContainer{ - { - Name: "curl", - Egress: []v1beta1.NetworkNeighbor{ - { - Identifier: "fusioncore-egress", - Type: "external", - DNS: "fusioncore.ai.", - DNSNames: []string{"fusioncore.ai."}, - IPAddress: "162.0.217.171", - Ports: []v1beta1.NetworkPort{ - {Name: "TCP-80", Protocol: "TCP", Port: ptr.To(int32(80))}, - }, - }, - }, - }, - }, - }, - } - _, err = storageClient.NetworkNeighborhoods(ns.Name).Create( - context.Background(), nn, metav1.CreateOptions{}) - require.NoError(t, err, "create NN") - - require.Eventually(t, func() bool { - _, apErr := storageClient.ApplicationProfiles(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) - _, nnErr := storageClient.NetworkNeighborhoods(ns.Name).Get(context.Background(), overlayName, v1.GetOptions{}) - return apErr == nil && nnErr == nil - }, 30*time.Second, 1*time.Second, "AP+NN must be in storage before pod deploy") + // The user authors ONE ContainerProfile (merging the former AP + NN + // surfaces); the pod's kubescape.io/user-defined-profile label names it. + _ = applyUserDefinedContainerProfile(t, ns.Name, "resources/containerprofile-user-defined-network.yaml") wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), "resources/nginx-user-defined-deployment.yaml")) require.NoError(t, err) require.NoError(t, wl.WaitForReady(80)) - // Cache-load latency on the upstream ContainerProfileCache is bursty - // — 15s is enough on a quiet runner but not on a loaded one. The - // failure mode is alert metadata `errorMessage:"waiting for profile - // update"`, which means the rule manager evaluated against an - // unloaded NN and fired R0005/R0011 spuriously. 30s covers the - // observed worst-case in CI without pushing total test time too - // far. Real fix would be to poll a cache-loaded signal, but no - // such signal is exposed today. + // Give node-agent time to load the profile before generating traffic; + // evaluating against an unloaded profile fires R0005/R0011 spuriously. time.Sleep(30 * time.Second) return wl } @@ -3249,13 +2940,13 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { // The collapsed CIDR lands in the plural `ipAddresses` field, which exists only // on PR#348 storage, so the result is read via the DYNAMIC client (never // referenced at compile time). Compiles on plain upstream; passes only on PR#348. -// nnCollapseGVR / ccCollapseGVR name the CIDR-collapse resources. The collapsed +// cpCollapseGVR / ccCollapseGVR name the CIDR-collapse resources. The collapsed // value lands in the plural ipAddresses field, which exists only on PR#348 -// storage, so learnt NNs are read via the dynamic client (never referenced at -// compile time). This file compiles on plain upstream and passes only on PR#348 -// storage carrying the collapse dedup fix. +// storage, so learnt ContainerProfiles are read via the dynamic client (never +// referenced at compile time). This file compiles on plain upstream and passes +// only on PR#348 storage carrying the collapse dedup fix. var ( - nnCollapseGVR = schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "networkneighborhoods"} + cpCollapseGVR = schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "containerprofiles"} ccCollapseGVR = schema.GroupVersionResource{Group: "spdx.softwarecomposition.kubescape.io", Version: "v1beta1", Resource: "collapseconfigurations"} ) @@ -3286,7 +2977,7 @@ func applyCollapseFloor(t *testing.T, dyn dynamic.Interface, floorBits int64) { } // deployCIDRLearner deploys an egress fan-out workload and waits for its pod to -// be ready; the caller later waits for the learnt NN to finalise. +// be ready; the caller later waits for the learnt ContainerProfile to finalise. func deployCIDRLearner(t *testing.T, resource string) *testutils.TestWorkload { ns := testutils.NewRandomNamespace() wl, err := testutils.NewTestWorkload(ns.Name, path.Join(utils.CurrentDir(), resource)) @@ -3295,28 +2986,23 @@ func deployCIDRLearner(t *testing.T, resource string) *testutils.TestWorkload { return wl } -// collectLearntCollapse waits for the workload's NetworkNeighborhood to finalise -// (completion: complete), reads it via the dynamic client, and returns the -// sorted, de-duplicated set of 52.216.0.0/16 egress CIDRs plus any bare host /32 -// left behind in that range. -// collectLearntCollapse waits for the workload's NetworkNeighborhood to finalise -// and returns the sorted, de-duplicated set of learnt egress CIDRs (plural -// ipAddresses values carrying a "/") and any bare host ipAddress left behind. +// collectLearntCollapse waits for the workload's ContainerProfiles to finalise +// (completion: complete), reads each via the dynamic client, and returns the +// sorted, de-duplicated set of learnt egress CIDRs (plural ipAddresses values +// carrying a "/") and any bare host ipAddress left behind. The unified +// ContainerProfile carries egress on its flat spec (one profile per container), +// so entries are read from spec.egress directly rather than spec.containers[]. func collectLearntCollapse(t *testing.T, dyn dynamic.Interface, wl *testutils.TestWorkload) (cidrs, bare []string) { - require.NoError(t, wl.WaitForNetworkNeighborhoodCompletion(120), "network neighborhood did not complete learning") - nnTyped, err := wl.GetNetworkNeighborhood() - require.NoError(t, err, "get learnt network neighborhood") - got, err := dyn.Resource(nnCollapseGVR).Namespace(nnTyped.Namespace).Get(context.Background(), nnTyped.Name, metav1.GetOptions{}) - require.NoError(t, err, "dynamic get network neighborhood %s/%s", nnTyped.Namespace, nnTyped.Name) + require.NoError(t, wl.WaitForContainerProfileCompletion(120), "container profile did not complete learning") + profiles, err := wl.GetContainerProfiles() + require.NoError(t, err, "get learnt container profiles") seen := map[string]struct{}{} - conts, _, _ := unstructured.NestedSlice(got.Object, "spec", "containers") - for _, c := range conts { - cm, ok := c.(map[string]interface{}) - if !ok { - continue - } - eg, _, _ := unstructured.NestedSlice(cm, "egress") + for _, cp := range profiles { + got, err := dyn.Resource(cpCollapseGVR).Namespace(cp.Namespace).Get(context.Background(), cp.Name, metav1.GetOptions{}) + require.NoError(t, err, "dynamic get container profile %s/%s", cp.Namespace, cp.Name) + + eg, _, _ := unstructured.NestedSlice(got.Object, "spec", "egress") for _, e := range eg { em, ok := e.(map[string]interface{}) if !ok { @@ -3595,3 +3281,78 @@ func Test_35_ExecTTYFieldTest(t *testing.T) { assert.Greater(t, total(alerts, "R9904"), 0, "R9904 must fire: !has(event.ttyMajor) proves ttyMajor is a registered field that is honestly absent, not a compile failure") } + +// Test_36_MultiContainerPerContainerBinding shows per-container binding: a +// multi-container pod shares ONE kubescape.io/user-defined-profile label, but +// each container resolves its own authored CP as "