diff --git a/admission/exporter/http_exporter.go b/admission/exporter/http_exporter.go index e034efe..1c4cda7 100644 --- a/admission/exporter/http_exporter.go +++ b/admission/exporter/http_exporter.go @@ -32,6 +32,7 @@ type HTTPExporter struct { config HTTPExporterConfig Host string `json:"host"` ClusterName string `json:"clusterName"` + ClusterUID string `json:"clusterUID"` httpClient *http.Client // alertCount is the number of alerts sent in the last minute, used to limit the number of alerts sent, so we don't overload the system or reach the rate limit alertCount int @@ -77,13 +78,14 @@ func (config *HTTPExporterConfig) Validate() error { } // InitHTTPExporter initializes an HTTPExporter with the given URL, headers, timeout, and method -func InitHTTPExporter(config HTTPExporterConfig, clusterName string, cloudMetadata *apitypes.CloudMetadata) (*HTTPExporter, error) { +func InitHTTPExporter(config HTTPExporterConfig, clusterName string, cloudMetadata *apitypes.CloudMetadata, clusterUID string) (*HTTPExporter, error) { if err := config.Validate(); err != nil { return nil, err } return &HTTPExporter{ ClusterName: clusterName, + ClusterUID: clusterUID, config: config, httpClient: &http.Client{ Timeout: time.Duration(config.TimeoutSeconds) * time.Second, @@ -104,6 +106,7 @@ func (exporter *HTTPExporter) sendAlertLimitReached() { }, RuntimeAlertK8sDetails: apitypes.RuntimeAlertK8sDetails{ ClusterName: exporter.ClusterName, + ClusterUID: exporter.ClusterUID, NodeName: "Operator", }, } @@ -122,6 +125,7 @@ func (exporter *HTTPExporter) SendAdmissionAlert(ruleFailure rules.RuleFailure) // populate the RuntimeAlert struct with the data from the failedRule k8sDetails := ruleFailure.GetRuntimeAlertK8sDetails() k8sDetails.ClusterName = exporter.ClusterName + k8sDetails.ClusterUID = exporter.ClusterUID httpAlert := apitypes.RuntimeAlert{ Message: ruleFailure.GetRuleAlert().RuleDescription, diff --git a/admission/exporter/http_exporter_test.go b/admission/exporter/http_exporter_test.go new file mode 100644 index 0000000..3db5fb0 --- /dev/null +++ b/admission/exporter/http_exporter_test.go @@ -0,0 +1,82 @@ +package exporters + +import ( + "testing" + + apitypes "github.com/armosec/armoapi-go/armotypes" + "github.com/kubescape/operator/admission/rules" + rulesv1 "github.com/kubescape/operator/admission/rules/v1" + "github.com/kubescape/operator/objectcache" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" +) + +func TestInitHTTPExporter_ClusterUID(t *testing.T) { + config := HTTPExporterConfig{ + URL: "http://localhost:8080", + Method: "POST", + } + exporter, err := InitHTTPExporter(config, "test-cluster", nil, "test-cluster-uid") + assert.NoError(t, err) + assert.Equal(t, "test-cluster", exporter.ClusterName) + assert.Equal(t, "test-cluster-uid", exporter.ClusterUID) +} + +func TestSendAdmissionAlert_ClusterUIDPropagated(t *testing.T) { + config := HTTPExporterConfig{ + URL: "http://localhost:8080", + Method: "POST", + } + exporter, err := InitHTTPExporter(config, "test-cluster", nil, "test-cluster-uid") + assert.NoError(t, err) + + event := admission.NewAttributesRecord( + &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "PodExecOptions", + "apiVersion": "v1", + "command": []interface{}{"bash"}, + "container": "test-container", + }, + }, + nil, + schema.GroupVersionKind{Kind: "PodExecOptions"}, + "test-namespace", + "test-pod", + schema.GroupVersionResource{Resource: "pods"}, + "exec", + admission.Create, + nil, + false, + &user.DefaultInfo{Name: "test-user", Groups: []string{"test-group"}}, + ) + + rule := rulesv1.CreateRuleR2000ExecToPod() + ruleFailure := rule.ProcessEvent(event, objectcache.KubernetesCacheMockImpl{}) + assert.NotNil(t, ruleFailure) + + // Simulate what SendAdmissionAlert does internally to verify ClusterUID injection + k8sDetails := ruleFailure.GetRuntimeAlertK8sDetails() + k8sDetails.ClusterName = exporter.ClusterName + k8sDetails.ClusterUID = exporter.ClusterUID + + alert := apitypes.RuntimeAlert{ + AlertType: apitypes.AlertTypeAdmission, + BaseRuntimeAlert: ruleFailure.GetBaseRuntimeAlert(), + AdmissionAlert: ruleFailure.GetAdmissionsAlert(), + RuntimeAlertK8sDetails: k8sDetails, + RuleAlert: ruleFailure.GetRuleAlert(), + RuleID: ruleFailure.GetRuleId(), + } + + assert.Equal(t, "test-cluster", alert.RuntimeAlertK8sDetails.ClusterName) + assert.Equal(t, "test-cluster-uid", alert.RuntimeAlertK8sDetails.ClusterUID) + assert.Equal(t, "nginx:1.14.2", alert.RuntimeAlertK8sDetails.Image) + assert.Equal(t, "nginx@sha256:abc123def456", alert.RuntimeAlertK8sDetails.ImageDigest) +} + +// Verify RuleFailure interface used in tests +var _ rules.RuleFailure = (*rulesv1.GenericRuleFailure)(nil) diff --git a/admission/rules/v1/helpers.go b/admission/rules/v1/helpers.go index 86cec66..fc8fa8f 100644 --- a/admission/rules/v1/helpers.go +++ b/admission/rules/v1/helpers.go @@ -3,6 +3,7 @@ package rules import ( "context" "fmt" + "strings" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -144,3 +145,72 @@ func GetContainerID(pod *corev1.Pod, containerName string) string { return "" } + +// GetContainerImage returns the container image name for the given container name from the pod spec. +// When containerName is empty, falls back to the first container (matching Kubernetes default behavior). +func GetContainerImage(pod *corev1.Pod, containerName string) string { + if pod == nil { + return "" + } + + if containerName == "" { + if len(pod.Spec.Containers) > 0 { + return pod.Spec.Containers[0].Image + } + return "" + } + + for _, c := range pod.Spec.Containers { + if c.Name == containerName { + return c.Image + } + } + for _, c := range pod.Spec.InitContainers { + if c.Name == containerName { + return c.Image + } + } + for _, c := range pod.Spec.EphemeralContainers { + if c.Name == containerName { + return c.Image + } + } + return "" +} + +// GetContainerImageDigest returns the image digest (from ImageID) for the given container name from the pod status. +// The "docker-pullable://" prefix is stripped so the result is a clean registry digest reference. +// When containerName is empty, falls back to the first container (matching Kubernetes default behavior). +func GetContainerImageDigest(pod *corev1.Pod, containerName string) string { + if pod == nil { + return "" + } + + extractDigest := func(imageID string) string { + return strings.TrimPrefix(imageID, "docker-pullable://") + } + + if containerName == "" { + if len(pod.Status.ContainerStatuses) > 0 { + return extractDigest(pod.Status.ContainerStatuses[0].ImageID) + } + return "" + } + + for _, cs := range pod.Status.ContainerStatuses { + if cs.Name == containerName { + return extractDigest(cs.ImageID) + } + } + for _, cs := range pod.Status.InitContainerStatuses { + if cs.Name == containerName { + return extractDigest(cs.ImageID) + } + } + for _, cs := range pod.Status.EphemeralContainerStatuses { + if cs.Name == containerName { + return extractDigest(cs.ImageID) + } + } + return "" +} diff --git a/admission/rules/v1/r2000_exec_to_pod.go b/admission/rules/v1/r2000_exec_to_pod.go index 0764307..427c728 100644 --- a/admission/rules/v1/r2000_exec_to_pod.go +++ b/admission/rules/v1/r2000_exec_to_pod.go @@ -138,6 +138,8 @@ func (rule *R2000ExecToPod) ProcessEvent(event admission.Attributes, access obje NodeName: nodeName, ContainerName: containerName, ContainerID: containerID, + Image: GetContainerImage(pod, containerName), + ImageDigest: GetContainerImageDigest(pod, containerName), }, RuleID: R2000ID, RuntimeProcessDetails: apitypes.ProcessTree{ diff --git a/admission/rules/v1/r2000_exec_to_pod_test.go b/admission/rules/v1/r2000_exec_to_pod_test.go index fd56c0e..69c09af 100644 --- a/admission/rules/v1/r2000_exec_to_pod_test.go +++ b/admission/rules/v1/r2000_exec_to_pod_test.go @@ -5,6 +5,7 @@ import ( "github.com/kubescape/operator/objectcache" "github.com/zeebo/assert" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/admission" @@ -58,6 +59,8 @@ func TestR2000(t *testing.T) { assert.Equal(t, "test-pod", result.GetRuntimeAlertK8sDetails().PodName) assert.Equal(t, "test-namespace", result.GetRuntimeAlertK8sDetails().Namespace) assert.Equal(t, "containerd://abcdef1234567890", result.GetRuntimeAlertK8sDetails().ContainerID) + assert.Equal(t, "nginx:1.14.2", result.GetRuntimeAlertK8sDetails().Image) + assert.Equal(t, "nginx@sha256:abc123def456", result.GetRuntimeAlertK8sDetails().ImageDigest) } func TestR2000_EmptyContainerName(t *testing.T) { @@ -106,4 +109,55 @@ func TestR2000_EmptyContainerName(t *testing.T) { assert.Equal(t, "test-replicaset-uid-12345", result.GetRuntimeAlertK8sDetails().WorkloadUID) assert.Equal(t, "test-workload", result.GetRuntimeAlertK8sDetails().WorkloadName) assert.Equal(t, "ReplicaSet", result.GetRuntimeAlertK8sDetails().WorkloadKind) + // Image fields should fall back to first container when container name is empty + assert.Equal(t, "nginx:1.14.2", result.GetRuntimeAlertK8sDetails().Image) + assert.Equal(t, "nginx@sha256:abc123def456", result.GetRuntimeAlertK8sDetails().ImageDigest) +} + +func TestGetContainerImage(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "web", Image: "nginx:1.14.2"}, + {Name: "sidecar", Image: "envoy:1.0"}, + }, + InitContainers: []corev1.Container{ + {Name: "init", Image: "busybox:latest"}, + }, + }, + } + + assert.Equal(t, "nginx:1.14.2", GetContainerImage(pod, "web")) + assert.Equal(t, "envoy:1.0", GetContainerImage(pod, "sidecar")) + assert.Equal(t, "busybox:latest", GetContainerImage(pod, "init")) + // Empty name falls back to first container + assert.Equal(t, "nginx:1.14.2", GetContainerImage(pod, "")) + // Unknown container returns empty + assert.Equal(t, "", GetContainerImage(pod, "unknown")) + // Nil pod returns empty + assert.Equal(t, "", GetContainerImage(nil, "web")) +} + +func TestGetContainerImageDigest(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "web", ImageID: "docker-pullable://nginx@sha256:abc123"}, + {Name: "sidecar", ImageID: "envoy@sha256:def456"}, + }, + InitContainerStatuses: []corev1.ContainerStatus{ + {Name: "init", ImageID: "docker-pullable://busybox@sha256:789"}, + }, + }, + } + + assert.Equal(t, "nginx@sha256:abc123", GetContainerImageDigest(pod, "web")) + assert.Equal(t, "envoy@sha256:def456", GetContainerImageDigest(pod, "sidecar")) + assert.Equal(t, "busybox@sha256:789", GetContainerImageDigest(pod, "init")) + // Empty name falls back to first container + assert.Equal(t, "nginx@sha256:abc123", GetContainerImageDigest(pod, "")) + // Unknown container returns empty + assert.Equal(t, "", GetContainerImageDigest(pod, "unknown")) + // Nil pod returns empty + assert.Equal(t, "", GetContainerImageDigest(nil, "web")) } diff --git a/admission/rules/v1/r2001_portforward.go b/admission/rules/v1/r2001_portforward.go index e0052c8..85c20f5 100644 --- a/admission/rules/v1/r2001_portforward.go +++ b/admission/rules/v1/r2001_portforward.go @@ -69,7 +69,7 @@ func (rule *R2001PortForward) ProcessEvent(event admission.Attributes, access ob client := access.GetClientset() - _, workloadKind, workloadName, workloadNamespace, workloadUID, nodeName, err := GetControllerDetails(event, client) + pod, workloadKind, workloadName, workloadNamespace, workloadUID, nodeName, err := GetControllerDetails(event, client) if err != nil { logger.L().Error("Failed to get parent workload details", helpers.Error(err)) return nil @@ -114,6 +114,8 @@ func (rule *R2001PortForward) ProcessEvent(event admission.Attributes, access ob WorkloadKind: workloadKind, WorkloadUID: workloadUID, NodeName: nodeName, + Image: GetContainerImage(pod, ""), + ImageDigest: GetContainerImageDigest(pod, ""), }, RuleID: R2001ID, } diff --git a/admission/rules/v1/r2001_portforward_test.go b/admission/rules/v1/r2001_portforward_test.go index 21db353..4841267 100644 --- a/admission/rules/v1/r2001_portforward_test.go +++ b/admission/rules/v1/r2001_portforward_test.go @@ -49,4 +49,7 @@ func TestR2001(t *testing.T) { assert.Equal(t, "Port forward detected on pod test-pod", result.GetRuleAlert().RuleDescription) assert.Equal(t, "test-pod", result.GetRuntimeAlertK8sDetails().PodName) assert.Equal(t, "test-namespace", result.GetRuntimeAlertK8sDetails().Namespace) + // Image fields should fall back to first container (no container name for port-forward) + assert.Equal(t, "nginx:1.14.2", result.GetRuntimeAlertK8sDetails().Image) + assert.Equal(t, "nginx@sha256:abc123def456", result.GetRuntimeAlertK8sDetails().ImageDigest) } diff --git a/main.go b/main.go index d4ea502..f058f5e 100644 --- a/main.go +++ b/main.go @@ -125,9 +125,11 @@ func main() { logger.L().Ctx(ctx).Info("cloud metadata retrieved successfully", helpers.Interface("cloudMetadata", cloudMetadata)) } + clusterUID := utils.GetClusterUID(k8sApi.GetKubernetesClient()) + var exporter exporters.Exporter if exporterConfig := operatorConfig.HttpExporterConfig(); exporterConfig != nil { - exporter, err = exporters.InitHTTPExporter(*exporterConfig, operatorConfig.ClusterName(), cloudMetadata) + exporter, err = exporters.InitHTTPExporter(*exporterConfig, operatorConfig.ClusterName(), cloudMetadata, clusterUID) if err != nil { logger.L().Ctx(ctx).Fatal("failed to initialize HTTP exporter", helpers.Error(err)) } diff --git a/utils/utils.go b/utils/utils.go index c403875..e20cabf 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -7,9 +7,14 @@ import ( "net/http" "slices" "strings" + "time" "github.com/armosec/armoapi-go/apis" "github.com/armosec/utils-go/httputils" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" pkgwlid "github.com/armosec/utils-k8s-go/wlid" "github.com/kubescape/k8s-interface/instanceidhandler" instanceidhandlerv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1" @@ -170,6 +175,22 @@ func getImageFromSpec(instanceID instanceidhandler.IInstanceID, containers []cor return "" } +// GetClusterUID retrieves the UID of the kube-system namespace to use as a stable cluster identifier. +// If the namespace cannot be accessed (e.g., due to RBAC restrictions), it returns an empty string and logs a warning. +func GetClusterUID(k8sClient kubernetes.Interface) string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + namespace, err := k8sClient.CoreV1().Namespaces().Get(ctx, "kube-system", metav1.GetOptions{}) + if err != nil { + logger.L().Ctx(ctx).Warning("failed to get kube-system namespace for ClusterUID", helpers.Error(err)) + return "" + } + + clusterUID := string(namespace.UID) + logger.L().Ctx(ctx).Info("successfully retrieved ClusterUID", helpers.String("clusterUID", clusterUID)) + return clusterUID +} + func PodHasParent(pod *corev1.Pod) bool { if pod == nil { return false