Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion admission/exporter/http_exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -104,6 +106,7 @@ func (exporter *HTTPExporter) sendAlertLimitReached() {
},
RuntimeAlertK8sDetails: apitypes.RuntimeAlertK8sDetails{
ClusterName: exporter.ClusterName,
ClusterUID: exporter.ClusterUID,
NodeName: "Operator",
},
}
Expand All @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions admission/exporter/http_exporter_test.go
Original file line number Diff line number Diff line change
@@ -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)
70 changes: 70 additions & 0 deletions admission/rules/v1/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package rules
import (
"context"
"fmt"
"strings"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -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 ""
}
2 changes: 2 additions & 0 deletions admission/rules/v1/r2000_exec_to_pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
54 changes: 54 additions & 0 deletions admission/rules/v1/r2000_exec_to_pod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"))
}
4 changes: 3 additions & 1 deletion admission/rules/v1/r2001_portforward.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down
3 changes: 3 additions & 0 deletions admission/rules/v1/r2001_portforward_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
4 changes: 3 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
21 changes: 21 additions & 0 deletions utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading