diff --git a/admission/cel/cel.go b/admission/cel/cel.go new file mode 100644 index 00000000..3ae75e19 --- /dev/null +++ b/admission/cel/cel.go @@ -0,0 +1,231 @@ +package cel + +import ( + "fmt" + "reflect" + "sync" + + armotypes "github.com/armosec/armoapi-go/armotypes" + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/ext" +) + +// cachedProgram stores either a compiled CEL program or the compilation error +// that prevented it. The error is cached intentionally so repeated evaluation +// of a broken expression re-surfaces the failure rather than silently treating +// it as "no match" — a misconfigured rule must be loud, not invisible. +type cachedProgram struct { + prog cel.Program + err error +} + +// AdmissionCEL owns a CEL environment configured for evaluating expressions +// against AdmissionCelEvent values. Compiled programs are cached so repeated +// evaluation of the same expression avoids re-compilation. +type AdmissionCEL struct { + env *cel.Env + programCache map[string]cachedProgram + cacheMu sync.RWMutex +} + +// NewAdmissionCEL creates a CEL environment with the AdmissionCelEvent and +// AdmissionCelUserInfo types registered as native Go types. The resulting +// environment exposes two variables: +// +// event — cel.AdmissionCelEvent +// eventType — string +func NewAdmissionCEL() (*AdmissionCEL, error) { + env, err := cel.NewEnv( + ext.NativeTypes( + reflect.TypeOf(&AdmissionCelEvent{}), + reflect.TypeOf(&AdmissionCelUserInfo{}), + ), + cel.Variable("event", cel.ObjectType("cel.AdmissionCelEvent")), + cel.Variable("eventType", cel.StringType), + // Map fields are injected as top-level variables because cel-go's + // NativeTypes does not support map[string]interface{} struct fields. + cel.Variable("object", cel.MapType(cel.StringType, cel.DynType)), + cel.Variable("oldObject", cel.MapType(cel.StringType, cel.DynType)), + cel.Variable("options", cel.MapType(cel.StringType, cel.DynType)), + // Per-binding parameter overrides (RuntimeAlertRuleBindingRule.Parameters). + // Rules can reference these as params["key"]. Always present (empty map + // when no binding parameters apply) so expressions remain compilable. + cel.Variable("params", cel.MapType(cel.StringType, cel.DynType)), + ext.Strings(), + ) + if err != nil { + return nil, fmt.Errorf("creating CEL environment: %w", err) + } + return &AdmissionCEL{ + env: env, + programCache: make(map[string]cachedProgram), + }, nil +} + +// CreateEvalContext builds a map suitable for passing to program.Eval. +// The returned map is safe to reuse across sequential EvaluateRuleWithContext +// calls for the same event. The map fields (Object, OldObject, Options) are +// injected as separate top-level variables because cel-go's native type system +// does not support map[string]interface{} struct fields. +// +// The "params" key is initialized to an empty map. The evaluator overrides it +// with the active binding's parameters before evaluating each rule, so a +// single context can serve multiple rules with different parameter sets. +func (c *AdmissionCEL) CreateEvalContext(event *AdmissionCelEvent) map[string]any { + ctx := map[string]any{ + "event": event, + "eventType": string(armotypes.EventTypeK8sAdmission), + "params": map[string]any{}, + } + if event.Object != nil { + ctx["object"] = event.Object + } else { + ctx["object"] = map[string]any{} + } + if event.OldObject != nil { + ctx["oldObject"] = event.OldObject + } else { + ctx["oldObject"] = map[string]any{} + } + if event.Options != nil { + ctx["options"] = event.Options + } else { + ctx["options"] = map[string]any{} + } + return ctx +} + +// EvaluateRuleWithContext evaluates all expressions whose EventType matches the +// provided eventType. Returns true only when every matching expression +// evaluates to true (AND semantics). If no expressions match the provided +// eventType, returns false — the rule has no opinion for this event type, so +// it should not fire. +func (c *AdmissionCEL) EvaluateRuleWithContext(evalContext map[string]any, eventType armotypes.EventType, expressions []armotypes.RuleExpression) (bool, error) { + matched := false + for _, expr := range expressions { + if expr.EventType != eventType { + continue + } + matched = true + + out, err := c.evaluateProgram(expr.Expression, evalContext) + if err != nil { + return false, err + } + + boolVal, ok := out.Value().(bool) + if !ok { + return false, fmt.Errorf("expression returned %T, expected bool", out.Value()) + } + if !boolVal { + return false, nil // short-circuit + } + } + + if !matched { + return false, nil + } + return true, nil +} + +// EvaluateStringExpression compiles and evaluates a CEL expression that is +// expected to return a string (e.g. a message template). +func (c *AdmissionCEL) EvaluateStringExpression(evalContext map[string]any, expression string) (string, error) { + out, err := c.evaluateProgram(expression, evalContext) + if err != nil { + return "", err + } + strVal, ok := out.Value().(string) + if !ok { + return "", fmt.Errorf("expression returned %T, expected string", out.Value()) + } + return strVal, nil +} + +// ProgramCacheSize returns the number of entries in the program cache +// (including nil entries for compile failures). +func (c *AdmissionCEL) ProgramCacheSize() int { + c.cacheMu.RLock() + defer c.cacheMu.RUnlock() + return len(c.programCache) +} + +// RetainOnly drops every cached program whose expression is not in the +// provided active set. Call this from the rule sync path after replacing the +// rule list so programs compiled for now-removed rules are released — the +// cache grows monotonically otherwise. +// +// Passing nil or an empty slice clears the entire cache. +func (c *AdmissionCEL) RetainOnly(activeExpressions []string) { + active := make(map[string]struct{}, len(activeExpressions)) + for _, expr := range activeExpressions { + active[expr] = struct{}{} + } + + c.cacheMu.Lock() + defer c.cacheMu.Unlock() + for expr := range c.programCache { + if _, keep := active[expr]; !keep { + delete(c.programCache, expr) + } + } +} + +// evaluateProgram compiles (or retrieves from cache) and evaluates a CEL +// expression against the provided context. Cached compile failures are +// returned as errors on every call — never silently swallowed. +func (c *AdmissionCEL) evaluateProgram(expression string, evalContext map[string]any) (ref.Val, error) { + prog, err := c.getOrCreateProgram(expression) + if err != nil { + return nil, err + } + + out, _, err := prog.Eval(evalContext) + if err != nil { + return nil, fmt.Errorf("evaluating expression %q: %w", expression, err) + } + return out, nil +} + +// getOrCreateProgram returns a cached program or compiles one. Compile errors +// are cached and re-returned on every subsequent call for the same expression: +// a broken rule should fail loudly on every event, not be silently ignored +// after the first attempt. +func (c *AdmissionCEL) getOrCreateProgram(expression string) (cel.Program, error) { + c.cacheMu.RLock() + if cp, exists := c.programCache[expression]; exists { + c.cacheMu.RUnlock() + return cp.prog, cp.err + } + c.cacheMu.RUnlock() + + return c.compileAndCache(expression) +} + +func (c *AdmissionCEL) compileAndCache(expression string) (cel.Program, error) { + c.cacheMu.Lock() + defer c.cacheMu.Unlock() + + // Double-check after acquiring write lock. + if cp, exists := c.programCache[expression]; exists { + return cp.prog, cp.err + } + + ast, issues := c.env.Compile(expression) + if issues != nil && issues.Err() != nil { + err := fmt.Errorf("compiling expression %q: %w", expression, issues.Err()) + c.programCache[expression] = cachedProgram{err: err} + return nil, err + } + + prog, err := c.env.Program(ast, cel.EvalOptions(cel.OptOptimize)) + if err != nil { + wrapped := fmt.Errorf("creating program for %q: %w", expression, err) + c.programCache[expression] = cachedProgram{err: wrapped} + return nil, wrapped + } + + c.programCache[expression] = cachedProgram{prog: prog} + return prog, nil +} diff --git a/admission/cel/cel_test.go b/admission/cel/cel_test.go new file mode 100644 index 00000000..db320405 --- /dev/null +++ b/admission/cel/cel_test.go @@ -0,0 +1,325 @@ +package cel + +import ( + "testing" + + armotypes "github.com/armosec/armoapi-go/armotypes" +) + +func newExecEvent() *AdmissionCelEvent { + attrs := newTestAttributes("PodExecOptions", "my-pod", "default", "CONNECT", "exec", + map[string]interface{}{ + "command": []interface{}{"/bin/sh"}, + "container": "main", + }) + return NewAdmissionCelEvent(attrs) +} + +func TestMatchSimpleKind(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + ok, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodExecOptions"`}, + }) + if err != nil { + t.Fatalf("EvaluateRuleWithContext: %v", err) + } + if !ok { + t.Error("expected true for matching kind") + } +} + +func TestNoMatch_DifferentKind(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + ok, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "Pod"`}, + }) + if err != nil { + t.Fatalf("EvaluateRuleWithContext: %v", err) + } + if ok { + t.Error("expected false for non-matching kind") + } +} + +func TestSkipDifferentEventType(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + // Expression is for "exec" event type, but we evaluate with "k8s-admission". + // No expressions match, so the result should be false. + ok, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeExec, Expression: `event.Kind == "PodExecOptions"`}, + }) + if err != nil { + t.Fatalf("EvaluateRuleWithContext: %v", err) + } + if ok { + t.Error("expected false when no expressions match event type") + } +} + +func TestMultipleExpressions_AllTrue(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + ok, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodExecOptions"`}, + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Namespace == "default"`}, + }) + if err != nil { + t.Fatalf("EvaluateRuleWithContext: %v", err) + } + if !ok { + t.Error("expected true when all expressions match") + } +} + +func TestMultipleExpressions_ShortCircuit(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + // First expression is false — should short-circuit without evaluating the second. + ok, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "Pod"`}, + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Namespace == "default"`}, + }) + if err != nil { + t.Fatalf("EvaluateRuleWithContext: %v", err) + } + if ok { + t.Error("expected false when first expression is false") + } +} + +func TestUserInfoAccess(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + ok, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.UserInfo.Username == "test-user"`}, + }) + if err != nil { + t.Fatalf("EvaluateRuleWithContext: %v", err) + } + if !ok { + t.Error("expected true for matching username") + } +} + +func TestObjectFieldAccess(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + // Object/OldObject/Options are top-level map variables, not struct fields, + // because cel-go NativeTypes doesn't support map[string]interface{}. + ok, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `object["container"] == "main"`}, + }) + if err != nil { + t.Fatalf("EvaluateRuleWithContext: %v", err) + } + if !ok { + t.Error("expected true for matching object field") + } +} + +func TestMessageExpression(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + msg, err := engine.EvaluateStringExpression(ctx, `"Exec to pod: " + event.Name`) + if err != nil { + t.Fatalf("EvaluateStringExpression: %v", err) + } + want := "Exec to pod: my-pod" + if msg != want { + t.Errorf("message = %q, want %q", msg, want) + } +} + +func TestCompileError(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + _, err = engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.nonExistentField == "x"`}, + }) + if err == nil { + t.Fatal("expected error for non-existent field, got nil") + } +} + +func TestProgramCaching(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + expr := `event.Kind == "PodExecOptions"` + + // Evaluate twice — cache should hold exactly one entry for this expression. + for i := 0; i < 2; i++ { + _, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: expr}, + }) + if err != nil { + t.Fatalf("iteration %d: %v", i, err) + } + } + + if size := engine.ProgramCacheSize(); size != 1 { + t.Errorf("ProgramCacheSize = %d, want 1", size) + } +} + +func TestRetainOnly(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + // Seed three programs into the cache. + exprs := []string{ + `event.Kind == "PodExecOptions"`, + `event.Kind == "PodPortForwardOptions"`, + `event.Kind == "NetworkPolicy"`, + } + for _, expr := range exprs { + _, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: expr}, + }) + if err != nil { + t.Fatalf("eval %q: %v", expr, err) + } + } + if size := engine.ProgramCacheSize(); size != 3 { + t.Fatalf("after seed: ProgramCacheSize = %d, want 3", size) + } + + // Retain only the first two — the third must be evicted. + engine.RetainOnly(exprs[:2]) + if size := engine.ProgramCacheSize(); size != 2 { + t.Errorf("after RetainOnly([0:2]): ProgramCacheSize = %d, want 2", size) + } + + // Re-evaluating a retained expression must not recompile (count stays). + _, err = engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: exprs[0]}, + }) + if err != nil { + t.Fatalf("re-eval retained expr: %v", err) + } + if size := engine.ProgramCacheSize(); size != 2 { + t.Errorf("after re-eval retained: ProgramCacheSize = %d, want 2", size) + } + + // Re-evaluating an evicted expression must recompile (count goes up). + _, err = engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: exprs[2]}, + }) + if err != nil { + t.Fatalf("re-eval evicted expr: %v", err) + } + if size := engine.ProgramCacheSize(); size != 3 { + t.Errorf("after re-eval evicted: ProgramCacheSize = %d, want 3", size) + } + + // Clearing the active set evicts everything. + engine.RetainOnly(nil) + if size := engine.ProgramCacheSize(); size != 0 { + t.Errorf("after RetainOnly(nil): ProgramCacheSize = %d, want 0", size) + } +} + +// TestCompileFailure_ReturnsErrorEveryCall verifies that a broken CEL +// expression surfaces a compilation error on every evaluation, not just the +// first one. The previous implementation cached compile failures as nil and +// silently treated them as "no match", hiding misconfigured rules from +// operators. +func TestCompileFailure_ReturnsErrorEveryCall(t *testing.T) { + engine, err := NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + event := newExecEvent() + ctx := engine.CreateEvalContext(event) + + // Syntactically invalid CEL — cannot compile. + badExpr := `event.Kind === "Pod"` // CEL has no === operator + exprs := []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: badExpr}, + } + + for i := 0; i < 3; i++ { + _, err := engine.EvaluateRuleWithContext(ctx, armotypes.EventTypeK8sAdmission, exprs) + if err == nil { + t.Fatalf("call %d: expected compile error, got nil — broken rules must not be silently ignored", i+1) + } + } + + // Cache must contain exactly one entry (the failed compile is cached so we + // don't re-attempt every event), but every lookup must still return the + // error. + if size := engine.ProgramCacheSize(); size != 1 { + t.Errorf("ProgramCacheSize = %d after 3 failed evals, want 1 (one cached failure)", size) + } + + // EvaluateStringExpression must follow the same contract. + if _, err := engine.EvaluateStringExpression(ctx, badExpr); err == nil { + t.Error("EvaluateStringExpression: expected compile error, got nil") + } +} diff --git a/admission/cel/event.go b/admission/cel/event.go new file mode 100644 index 00000000..d2023c24 --- /dev/null +++ b/admission/cel/event.go @@ -0,0 +1,87 @@ +package cel + +import ( + "k8s.io/apiserver/pkg/admission" +) + +// AdmissionCelUserInfo holds user identity fields extracted from an admission +// request. Exported fields are registered with the CEL type system so +// expressions can reference e.g. event.UserInfo.Username. +type AdmissionCelUserInfo struct { + Username string + Groups []string + UID string +} + +// AdmissionCelEvent is a plain Go struct that mirrors the interesting fields +// of admission.Attributes. It is registered with cel-go's native type system +// so CEL expressions can access fields like event.kind, event.namespace, etc. +type AdmissionCelEvent struct { + Kind string + Group string + Version string + Name string + Namespace string + Operation string + Subresource string + Resource string + DryRun bool + UserInfo AdmissionCelUserInfo + Object map[string]interface{} + OldObject map[string]interface{} + Options map[string]interface{} +} + +// unstructuredContent is the interface implemented by *unstructured.Unstructured. +type unstructuredContent interface { + UnstructuredContent() map[string]interface{} +} + +// NewAdmissionCelEvent extracts all relevant fields from admission.Attributes +// into an AdmissionCelEvent suitable for CEL evaluation. +func NewAdmissionCelEvent(attrs admission.Attributes) *AdmissionCelEvent { + event := &AdmissionCelEvent{ + Kind: attrs.GetKind().Kind, + Group: attrs.GetKind().Group, + Version: attrs.GetKind().Version, + Name: attrs.GetName(), + Namespace: attrs.GetNamespace(), + Operation: string(attrs.GetOperation()), + Subresource: attrs.GetSubresource(), + Resource: attrs.GetResource().Resource, + DryRun: attrs.IsDryRun(), + } + + // Extract user info. + if ui := attrs.GetUserInfo(); ui != nil { + event.UserInfo = AdmissionCelUserInfo{ + Username: ui.GetName(), + Groups: ui.GetGroups(), + UID: ui.GetUID(), + } + } + + // Extract Object content. + if obj := attrs.GetObject(); obj != nil { + if u, ok := obj.(unstructuredContent); ok { + event.Object = u.UnstructuredContent() + } + } + + // Extract OldObject content (populated for UPDATE and DELETE). + if old := attrs.GetOldObject(); old != nil { + if u, ok := old.(unstructuredContent); ok { + event.OldObject = u.UnstructuredContent() + } + } + + // Options mirrors Object — useful for subresource events (exec, portforward) + // where the "object" is really the options struct. + if opts := attrs.GetOperationOptions(); opts != nil { + if u, ok := opts.(unstructuredContent); ok { + event.Options = u.UnstructuredContent() + } + } + + return event +} diff --git a/admission/cel/event_test.go b/admission/cel/event_test.go new file mode 100644 index 00000000..fcc78eee --- /dev/null +++ b/admission/cel/event_test.go @@ -0,0 +1,148 @@ +package cel + +import ( + "testing" + + "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" +) + +// newTestAttributes builds an admission.Attributes suitable for tests. +func newTestAttributes(kind, name, namespace, operation, subresource string, obj map[string]interface{}) admission.Attributes { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: kind} + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + var u *unstructured.Unstructured + if obj != nil { + u = &unstructured.Unstructured{Object: obj} + } + userInfo := &user.DefaultInfo{ + Name: "test-user", + Groups: []string{"system:masters"}, + UID: "uid-123", + } + return admission.NewAttributesRecord(u, nil, gvk, namespace, name, gvr, subresource, + admission.Operation(operation), nil, false, userInfo) +} + +func TestNewAdmissionCelEvent_AllFields(t *testing.T) { + obj := map[string]interface{}{ + "command": []interface{}{"/bin/sh"}, + "container": "main", + } + attrs := newTestAttributes("PodExecOptions", "my-pod", "default", "CONNECT", "exec", obj) + event := NewAdmissionCelEvent(attrs) + + if event.Kind != "PodExecOptions" { + t.Errorf("Kind = %q, want %q", event.Kind, "PodExecOptions") + } + if event.Group != "" { + t.Errorf("Group = %q, want empty", event.Group) + } + if event.Version != "v1" { + t.Errorf("Version = %q, want %q", event.Version, "v1") + } + if event.Name != "my-pod" { + t.Errorf("Name = %q, want %q", event.Name, "my-pod") + } + if event.Namespace != "default" { + t.Errorf("Namespace = %q, want %q", event.Namespace, "default") + } + if event.Operation != "CONNECT" { + t.Errorf("Operation = %q, want %q", event.Operation, "CONNECT") + } + if event.Subresource != "exec" { + t.Errorf("Subresource = %q, want %q", event.Subresource, "exec") + } + if event.Resource != "pods" { + t.Errorf("Resource = %q, want %q", event.Resource, "pods") + } + if event.DryRun { + t.Error("DryRun = true, want false") + } + + // UserInfo + if event.UserInfo.Username != "test-user" { + t.Errorf("UserInfo.Username = %q, want %q", event.UserInfo.Username, "test-user") + } + if len(event.UserInfo.Groups) != 1 || event.UserInfo.Groups[0] != "system:masters" { + t.Errorf("UserInfo.Groups = %v, want [system:masters]", event.UserInfo.Groups) + } + if event.UserInfo.UID != "uid-123" { + t.Errorf("UserInfo.UID = %q, want %q", event.UserInfo.UID, "uid-123") + } + + // Object + if event.Object == nil { + t.Fatal("Object is nil, want non-nil") + } + if event.Object["container"] != "main" { + t.Errorf("Object[container] = %v, want %q", event.Object["container"], "main") + } +} + +func TestNewAdmissionCelEvent_NilOldObject(t *testing.T) { + attrs := newTestAttributes("Pod", "test-pod", "kube-system", "CREATE", "", map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + }) + event := NewAdmissionCelEvent(attrs) + + if event.OldObject != nil { + t.Errorf("OldObject = %v, want nil", event.OldObject) + } +} + +func TestNewAdmissionCelEvent_NilObject(t *testing.T) { + // Build attributes with no object at all. + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + userInfo := &user.DefaultInfo{Name: "anon"} + attrs := admission.NewAttributesRecord(nil, nil, gvk, "default", "test", gvr, "", + admission.Operation("DELETE"), nil, false, userInfo) + + event := NewAdmissionCelEvent(attrs) + + if event.Object != nil { + t.Errorf("Object = %v, want nil", event.Object) + } + if event.OldObject != nil { + t.Errorf("OldObject = %v, want nil", event.OldObject) + } +} + +func TestNewAdmissionCelEvent_WithOldObject(t *testing.T) { + oldObj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{"name": "old-pod"}, + } + newObj := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{"name": "new-pod"}, + } + + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + uNew := &unstructured.Unstructured{Object: newObj} + uOld := &unstructured.Unstructured{Object: oldObj} + userInfo := &user.DefaultInfo{Name: "admin"} + + attrs := admission.NewAttributesRecord(uNew, uOld, gvk, "default", "my-pod", gvr, "", + admission.Operation("UPDATE"), nil, false, userInfo) + + event := NewAdmissionCelEvent(attrs) + + if event.OldObject == nil { + t.Fatal("OldObject is nil, want non-nil") + } + meta, ok := event.OldObject["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("OldObject[metadata] is not a map") + } + if meta["name"] != "old-pod" { + t.Errorf("OldObject.metadata.name = %v, want %q", meta["name"], "old-pod") + } +} diff --git a/admission/cel/integration_test.go b/admission/cel/integration_test.go new file mode 100644 index 00000000..c7358701 --- /dev/null +++ b/admission/cel/integration_test.go @@ -0,0 +1,275 @@ +package cel_test + +import ( + "testing" + + armotypes "github.com/armosec/armoapi-go/armotypes" + admissioncel "github.com/kubescape/operator/admission/cel" + celrules "github.com/kubescape/operator/admission/rules/cel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "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" +) + +// newIntegrationEngine creates a real AdmissionCEL engine, failing the test on error. +func newIntegrationEngine(t *testing.T) *admissioncel.AdmissionCEL { + t.Helper() + engine, err := admissioncel.NewAdmissionCEL() + require.NoError(t, err, "NewAdmissionCEL") + return engine +} + +// newIntegrationCreator returns a CelRuleCreator backed by the provided engine. +func newIntegrationCreator(t *testing.T, engine *admissioncel.AdmissionCEL) *celrules.CelRuleCreator { + t.Helper() + return celrules.NewCelRuleCreator(engine) +} + +// buildAttrs constructs an admission.Attributes record suitable for integration tests. +func buildAttrs( + kind, name, namespace, subresource string, + op admission.Operation, + obj map[string]interface{}, + username string, +) admission.Attributes { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: kind} + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + var u *unstructured.Unstructured + if obj != nil { + u = &unstructured.Unstructured{Object: obj} + } + userInfo := &user.DefaultInfo{ + Name: username, + Groups: []string{"system:authenticated"}, + UID: "uid-integration", + } + return admission.NewAttributesRecord(u, nil, gvk, namespace, name, gvr, subresource, + op, nil, false, userInfo) +} + +// TestIntegration_ExecToPod_CELRule validates the full pipeline for a PodExecOptions event. +func TestIntegration_ExecToPod_CELRule(t *testing.T) { + engine := newIntegrationEngine(t) + creator := newIntegrationCreator(t, engine) + + rule := armotypes.RuntimeRule{ + ID: "R2000", + Name: "Exec to pod", + Description: "Detect exec to pod via admission webhook", + Severity: armotypes.RuleSeverityLow, + Expressions: armotypes.RuleExpressions{ + Message: `"Exec to pod: " + event.Name + " by " + event.UserInfo.Username`, + UniqueID: `event.Namespace + "/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodExecOptions"`}, + }, + }, + } + creator.SyncRules([]armotypes.RuntimeRule{rule}) + + evaluator := creator.CreateRuleByID("R2000") + require.NotNil(t, evaluator, "expected non-nil evaluator for R2000") + + attrs := buildAttrs( + "PodExecOptions", + "web-server", + "production", + "exec", + admission.Connect, + map[string]interface{}{ + "command": []interface{}{"/bin/bash"}, + "container": "app", + }, + "developer@example.com", + ) + + failure := evaluator.ProcessEvent(attrs, nil) + + require.NotNil(t, failure, "expected RuleFailure, got nil") + assert.Equal(t, "R2000", failure.GetRuleId()) + + base := failure.GetBaseRuntimeAlert() + assert.Equal(t, "Exec to pod: web-server by developer@example.com", base.AlertName) + assert.Equal(t, armotypes.RuleSeverityLow, base.Severity) + assert.Equal(t, "production/web-server", base.UniqueID) + assert.False(t, base.Timestamp.IsZero(), "Timestamp must not be zero") + + ruleAlert := failure.GetRuleAlert() + assert.Equal(t, "Detect exec to pod via admission webhook", ruleAlert.RuleDescription) + + admAlert := failure.GetAdmissionsAlert() + assert.Equal(t, "web-server", admAlert.ObjectName) + assert.Equal(t, "production", admAlert.RequestNamespace) + assert.Equal(t, "exec", admAlert.Subresource) + assert.Equal(t, "PodExecOptions", admAlert.Kind.Kind) + assert.Equal(t, admission.Connect, admAlert.Operation) + require.NotNil(t, admAlert.UserInfo, "AdmissionAlert.UserInfo must not be nil") + assert.Equal(t, "developer@example.com", admAlert.UserInfo.Name) +} + +// TestIntegration_PortForward_CELRule validates the full pipeline for a PodPortForwardOptions event. +func TestIntegration_PortForward_CELRule(t *testing.T) { + engine := newIntegrationEngine(t) + creator := newIntegrationCreator(t, engine) + + rule := armotypes.RuntimeRule{ + ID: "R2001", + Name: "Port forward to pod", + Description: "Detect port-forward to pod via admission webhook", + Severity: armotypes.RuleSeverityLow, + Expressions: armotypes.RuleExpressions{ + Message: `"Port forward to pod: " + event.Name`, + UniqueID: `event.Namespace + "/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodPortForwardOptions"`}, + }, + }, + } + creator.SyncRules([]armotypes.RuntimeRule{rule}) + + evaluator := creator.CreateRuleByID("R2001") + require.NotNil(t, evaluator, "expected non-nil evaluator for R2001") + + attrs := buildAttrs( + "PodPortForwardOptions", + "db-pod", + "staging", + "portforward", + admission.Connect, + map[string]interface{}{ + "ports": []interface{}{5432}, + }, + "ops@example.com", + ) + + failure := evaluator.ProcessEvent(attrs, nil) + + require.NotNil(t, failure, "expected RuleFailure, got nil") + assert.Equal(t, "R2001", failure.GetRuleId()) + + base := failure.GetBaseRuntimeAlert() + assert.Equal(t, "Port forward to pod: db-pod", base.AlertName) + assert.Equal(t, armotypes.RuleSeverityLow, base.Severity) + assert.Equal(t, "staging/db-pod", base.UniqueID) + assert.False(t, base.Timestamp.IsZero(), "Timestamp must not be zero") + + admAlert := failure.GetAdmissionsAlert() + assert.Equal(t, "PodPortForwardOptions", admAlert.Kind.Kind) + assert.Equal(t, "db-pod", admAlert.ObjectName) + assert.Equal(t, "staging", admAlert.RequestNamespace) +} + +// TestIntegration_NoMatch_DifferentKind ensures no failure is returned when the +// event kind does not match the rule's expression. +func TestIntegration_NoMatch_DifferentKind(t *testing.T) { + engine := newIntegrationEngine(t) + creator := newIntegrationCreator(t, engine) + + // Rule only fires on PodExecOptions. + rule := armotypes.RuntimeRule{ + ID: "R2000", + Name: "Exec to pod", + Severity: armotypes.RuleSeverityLow, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodExecOptions"`}, + }, + }, + } + creator.SyncRules([]armotypes.RuntimeRule{rule}) + + evaluator := creator.CreateRuleByID("R2000") + require.NotNil(t, evaluator) + + // Build a Deployment CREATE event — completely different kind. + gvk := schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"} + gvr := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{"name": "my-deploy", "namespace": "default"}, + }} + userInfo := &user.DefaultInfo{Name: "admin"} + attrs := admission.NewAttributesRecord(obj, nil, gvk, "default", "my-deploy", gvr, "", + admission.Create, nil, false, userInfo) + + failure := evaluator.ProcessEvent(attrs, nil) + assert.Nil(t, failure, "expected nil failure for non-matching Deployment CREATE event") +} + +// TestIntegration_GenericAdmissionRule demonstrates that the CEL engine works for +// arbitrary admission events beyond exec/portforward. +func TestIntegration_GenericAdmissionRule(t *testing.T) { + engine := newIntegrationEngine(t) + creator := newIntegrationCreator(t, engine) + + rule := armotypes.RuntimeRule{ + ID: "R2002", + Name: "Pod created", + Description: "Detect any Pod creation event", + Severity: armotypes.RuleSeverityLow, + Expressions: armotypes.RuleExpressions{ + Message: `"Pod created: " + event.Namespace + "/" + event.Name`, + UniqueID: `event.Namespace + "/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + { + EventType: armotypes.EventTypeK8sAdmission, + Expression: `event.Operation == "CREATE" && event.Kind == "Pod"`, + }, + }, + }, + } + creator.SyncRules([]armotypes.RuntimeRule{rule}) + + evaluator := creator.CreateRuleByID("R2002") + require.NotNil(t, evaluator, "expected non-nil evaluator for R2002") + + attrs := buildAttrs( + "Pod", + "my-app", + "default", + "", + admission.Create, + map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{"name": "my-app", "namespace": "default"}, + }, + "ci-bot@example.com", + ) + + failure := evaluator.ProcessEvent(attrs, nil) + + require.NotNil(t, failure, "expected RuleFailure for Pod CREATE event") + assert.Equal(t, "R2002", failure.GetRuleId()) + + base := failure.GetBaseRuntimeAlert() + assert.Equal(t, "Pod created: default/my-app", base.AlertName) + assert.Equal(t, armotypes.RuleSeverityLow, base.Severity) + assert.Equal(t, "default/my-app", base.UniqueID) + + admAlert := failure.GetAdmissionsAlert() + assert.Equal(t, "Pod", admAlert.Kind.Kind) + assert.Equal(t, "my-app", admAlert.ObjectName) + assert.Equal(t, "default", admAlert.RequestNamespace) + assert.Equal(t, admission.Create, admAlert.Operation) + + // Ensure the same rule does NOT fire for a Deployment create. + attrsDeployment := buildAttrs( + "Deployment", + "my-deploy", + "default", + "", + admission.Create, + map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + }, + "ci-bot@example.com", + ) + noFailure := evaluator.ProcessEvent(attrsDeployment, nil) + assert.Nil(t, noFailure, "rule R2002 must not fire for Deployment CREATE") +} diff --git a/admission/exporter/http_exporter_test.go b/admission/exporter/http_exporter_test.go index 3db5fb0b..0902a785 100644 --- a/admission/exporter/http_exporter_test.go +++ b/admission/exporter/http_exporter_test.go @@ -6,12 +6,7 @@ import ( 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) { @@ -33,32 +28,19 @@ func TestSendAdmissionAlert_ClusterUIDPropagated(t *testing.T) { 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", - }, + // Build a minimal rule failure to verify ClusterUID injection by the exporter. + ruleFailure := &rulesv1.GenericRuleFailure{ + BaseRuntimeAlert: apitypes.BaseRuntimeAlert{}, + RuleAlert: apitypes.RuleAlert{}, + AdmissionAlert: apitypes.AdmissionAlert{}, + RuntimeAlertK8sDetails: apitypes.RuntimeAlertK8sDetails{ + Image: "nginx:1.14.2", + ImageDigest: "nginx@sha256:abc123def456", }, - 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) + RuleID: "R2000", + } - // Simulate what SendAdmissionAlert does internally to verify ClusterUID injection + // Simulate what SendAdmissionAlert does internally to verify ClusterUID injection. k8sDetails := ruleFailure.GetRuntimeAlertK8sDetails() k8sDetails.ClusterName = exporter.ClusterName k8sDetails.ClusterUID = exporter.ClusterUID diff --git a/admission/rulebinding/cache/cache.go b/admission/rulebinding/cache/cache.go index 482164c5..3fa5dba9 100644 --- a/admission/rulebinding/cache/cache.go +++ b/admission/rulebinding/cache/cache.go @@ -13,7 +13,6 @@ import ( "github.com/kubescape/node-agent/pkg/watcher" "github.com/kubescape/operator/admission/rulebinding" "github.com/kubescape/operator/admission/rules" - rulesv1 "github.com/kubescape/operator/admission/rules/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" @@ -38,16 +37,23 @@ type RBCache struct { ignoreRuleBindings bool } -func NewCache(k8sClient k8sclient.K8sClientInterface, ignoreRuleBindings bool) *RBCache { +func NewCache(k8sClient k8sclient.K8sClientInterface, ruleCreator rules.RuleCreator, ignoreRuleBindings bool) *RBCache { return &RBCache{ k8sClient: k8sClient, - ruleCreator: rulesv1.NewRuleCreator(), + ruleCreator: ruleCreator, rbNameToRB: maps.SafeMap[string, typesv1.RuntimeAlertRuleBinding]{}, watchResources: resourcesToWatch(), ignoreRuleBindings: ignoreRuleBindings, } } +func (c *RBCache) RefreshRules() { + for _, rb := range c.rbNameToRB.Values() { + rbName := uniqueName(&rb) + c.rbNameToRules.Set(rbName, c.createRules(rb.Spec.Rules)) + } +} + // ----------------- watcher.WatchResources methods ----------------- func (c *RBCache) WatchResources() []watcher.WatchResource { @@ -131,16 +137,16 @@ func (c *RBCache) AddNotifier(n *chan rulebindingmanager.RuleBindingNotify) { // ------------------ watcher.Watcher methods ----------------------- func (c *RBCache) AddHandler(_ context.Context, obj runtime.Object) { - var rbs []rulebindingmanager.RuleBindingNotify - if un, ok := obj.(*unstructured.Unstructured); ok { - ruleBinding, err := unstructuredToRuleBinding(un) - if err != nil { - logger.L().Error("failed to convert unstructured to rule binding", helpers.Error(err)) - return - } - rbs = c.addRuleBinding(ruleBinding) + un, ok := obj.(*unstructured.Unstructured) + if !ok || !isRuleBinding(un) { + return + } + ruleBinding, err := unstructuredToRuleBinding(un) + if err != nil { + logger.L().Error("failed to convert unstructured to rule binding", helpers.Error(err)) + return } - // notify + rbs := c.addRuleBinding(ruleBinding) for n := range c.notifiers { for i := range rbs { *c.notifiers[n] <- rbs[i] @@ -149,16 +155,16 @@ func (c *RBCache) AddHandler(_ context.Context, obj runtime.Object) { } func (c *RBCache) ModifyHandler(_ context.Context, obj runtime.Object) { - var rbs []rulebindingmanager.RuleBindingNotify - if un, ok := obj.(*unstructured.Unstructured); ok { - ruleBinding, err := unstructuredToRuleBinding(un) - if err != nil { - logger.L().Error("failed to convert unstructured to rule binding", helpers.Error(err)) - return - } - rbs = c.modifiedRuleBinding(ruleBinding) + un, ok := obj.(*unstructured.Unstructured) + if !ok || !isRuleBinding(un) { + return + } + ruleBinding, err := unstructuredToRuleBinding(un) + if err != nil { + logger.L().Error("failed to convert unstructured to rule binding", helpers.Error(err)) + return } - // notify + rbs := c.modifiedRuleBinding(ruleBinding) for n := range c.notifiers { for i := range rbs { *c.notifiers[n] <- rbs[i] @@ -167,11 +173,11 @@ func (c *RBCache) ModifyHandler(_ context.Context, obj runtime.Object) { } func (c *RBCache) DeleteHandler(_ context.Context, obj runtime.Object) { - var rbs []rulebindingmanager.RuleBindingNotify - if un, ok := obj.(*unstructured.Unstructured); ok { - rbs = c.deleteRuleBinding(uniqueName(un)) + un, ok := obj.(*unstructured.Unstructured) + if !ok || !isRuleBinding(un) { + return } - // notify + rbs := c.deleteRuleBinding(uniqueName(un)) for n := range c.notifiers { for i := range rbs { *c.notifiers[n] <- rbs[i] diff --git a/admission/rulebinding/cache/cache_test.go b/admission/rulebinding/cache/cache_test.go index 60ec750a..b1fee7ba 100644 --- a/admission/rulebinding/cache/cache_test.go +++ b/admission/rulebinding/cache/cache_test.go @@ -35,7 +35,7 @@ func TestNewCache(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { k8sAPI := utils.NewK8sInterfaceFake(nil) - cache := NewCache(k8sAPI, false) + cache := NewCache(k8sAPI, &rules.RuleCreatorMock{}, false) assert.NotNil(t, cache) assert.Equal(t, k8sAPI, cache.k8sClient) @@ -204,6 +204,52 @@ func TestRuntimeObjAddHandler(t *testing.T) { } } +// TestHandlersIgnoreNonRuleBindingKinds verifies that AddHandler, ModifyHandler, +// and DeleteHandler silently drop events whose Kind is not RuntimeRuleAlertBinding. +// The dynamic watcher dispatches every event to every adaptor, so without this +// filter the RBCache would attempt to parse Rules CRD payloads as bindings and +// emit spurious "cannot convert int64 to string" errors. +func TestHandlersIgnoreNonRuleBindingKinds(t *testing.T) { + rulesEvent := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "kubescape.io/v1", + "kind": "Rules", + "metadata": map[string]interface{}{ + "name": "admission-test-rules", + "namespace": "kubescape", + }, + "spec": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "id": "R2000", + "severity": int64(8), // would fail conversion to string + }, + }, + }, + }, + } + + t.Run("AddHandler ignores Rules CRD", func(t *testing.T) { + c := NewCacheMock() + c.AddHandler(context.Background(), rulesEvent) + assert.Equal(t, 0, c.rbNameToRB.Len(), "no rule binding should be stored") + }) + + t.Run("ModifyHandler ignores Rules CRD", func(t *testing.T) { + c := NewCacheMock() + c.ModifyHandler(context.Background(), rulesEvent) + assert.Equal(t, 0, c.rbNameToRB.Len()) + }) + + t.Run("DeleteHandler ignores Rules CRD", func(t *testing.T) { + c := NewCacheMock() + // Seed a binding so we can detect spurious deletes. + c.rbNameToRB.Set("kubescape/admission-test-rules", typesv1.RuntimeAlertRuleBinding{}) + c.DeleteHandler(context.Background(), rulesEvent) + assert.Equal(t, 1, c.rbNameToRB.Len(), "the seeded binding must not be deleted by a Rules CRD event") + }) +} + func TestListRulesForObjectIgnoreBindings(t *testing.T) { c := &RBCache{ k8sClient: k8sinterface.NewKubernetesApiMock(), diff --git a/admission/rulebinding/cache/helpers.go b/admission/rulebinding/cache/helpers.go index 8667c947..6c079f20 100644 --- a/admission/rulebinding/cache/helpers.go +++ b/admission/rulebinding/cache/helpers.go @@ -9,10 +9,25 @@ import ( k8sruntime "k8s.io/apimachinery/pkg/runtime" ) +// ruleBindingKind is the Kind value the CRD reports for RuntimeAlertRuleBinding +// objects. The dynamic watcher dispatches events from all watched GVRs to every +// adaptor, so handlers in this package use this constant to drop events for +// other CRDs (notably Rules) before attempting type conversion. +const ruleBindingKind = "RuntimeRuleAlertBinding" + func uniqueName(obj metav1.Object) string { return utils.CreateK8sPodID(obj.GetNamespace(), obj.GetName()) } +// isRuleBinding reports whether the given unstructured object is a +// RuntimeAlertRuleBinding. Returns false for nil or other Kinds. +func isRuleBinding(obj *unstructured.Unstructured) bool { + if obj == nil { + return false + } + return obj.GetKind() == ruleBindingKind +} + func unstructuredToRuleBinding(obj *unstructured.Unstructured) (*typesv1.RuntimeAlertRuleBinding, error) { rb := &typesv1.RuntimeAlertRuleBinding{} if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, rb); err != nil { diff --git a/admission/rulebinding/cache/helpers_test.go b/admission/rulebinding/cache/helpers_test.go index a4d3dd76..5e3c07c9 100644 --- a/admission/rulebinding/cache/helpers_test.go +++ b/admission/rulebinding/cache/helpers_test.go @@ -83,6 +83,45 @@ func TestUnstructuredToRuleBinding(t *testing.T) { } } +func TestIsRuleBinding(t *testing.T) { + tests := []struct { + name string + obj *unstructured.Unstructured + want bool + }{ + { + name: "RuntimeRuleAlertBinding kind", + obj: &unstructured.Unstructured{Object: map[string]interface{}{ + "kind": "RuntimeRuleAlertBinding", + }}, + want: true, + }, + { + name: "Rules kind (cross-talk from RulesWatcher)", + obj: &unstructured.Unstructured{Object: map[string]interface{}{ + "kind": "Rules", + }}, + want: false, + }, + { + name: "missing kind", + obj: &unstructured.Unstructured{Object: map[string]interface{}{}}, + want: false, + }, + { + name: "nil object", + obj: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isRuleBinding(tt.obj)) + }) + } +} + func TestUniqueName(t *testing.T) { tests := []struct { name string diff --git a/admission/rules/cel/creator.go b/admission/rules/cel/creator.go new file mode 100644 index 00000000..fc346417 --- /dev/null +++ b/admission/rules/cel/creator.go @@ -0,0 +1,168 @@ +package cel + +import ( + "sync" + + armotypes "github.com/armosec/armoapi-go/armotypes" + admissioncel "github.com/kubescape/operator/admission/cel" + "github.com/kubescape/operator/admission/rules" +) + +// CelRuleCreator implements rules.RuleCreator backed by a []armotypes.RuntimeRule +// that can be replaced atomically via SyncRules. +type CelRuleCreator struct { + mu sync.RWMutex + rules []armotypes.RuntimeRule + kindFilter *KindFilter + celEngine *admissioncel.AdmissionCEL +} + +var _ rules.RuleCreator = (*CelRuleCreator)(nil) + +// NewCelRuleCreator returns a new CelRuleCreator with no rules loaded yet. +func NewCelRuleCreator(celEngine *admissioncel.AdmissionCEL) *CelRuleCreator { + return &CelRuleCreator{ + celEngine: celEngine, + // Empty creator accepts nothing — the kind filter starts non-wildcard + // with an empty set so the validator drops events until the first + // SyncRules call. (No rules => nothing to evaluate anyway.) + kindFilter: &KindFilter{kinds: map[string]struct{}{}}, + } +} + +// SyncRules replaces the internal rule set with a copy of the provided slice. +// It is safe to call concurrently. After the swap, the CEL engine's program +// cache is pruned to the expressions still referenced by the new rule set so +// memory does not grow monotonically as rules are added and removed. +func (c *CelRuleCreator) SyncRules(rules []armotypes.RuntimeRule) { + copied := make([]armotypes.RuntimeRule, len(rules)) + copy(copied, rules) + filter := buildKindFilter(copied) + active := collectExpressions(copied) + + c.mu.Lock() + c.rules = copied + c.kindFilter = filter + c.mu.Unlock() + + if c.celEngine != nil { + c.celEngine.RetainOnly(active) + } +} + +// collectExpressions returns every CEL expression string that the engine may +// compile and cache for the given rules: each RuleExpression, plus the +// per-rule Message and UniqueID templates. +// +// No initial capacity is reserved: the per-rule expression count is small and +// unbounded multiplication (e.g. len(rs)*3) trips CodeQL's overflow check. +// Go's slice growth handles append amortized fine for this size. +func collectExpressions(rs []armotypes.RuntimeRule) []string { + var out []string + for _, r := range rs { + if r.Expressions.Message != "" { + out = append(out, r.Expressions.Message) + } + if r.Expressions.UniqueID != "" { + out = append(out, r.Expressions.UniqueID) + } + for _, expr := range r.Expressions.RuleExpression { + if expr.Expression != "" { + out = append(out, expr.Expression) + } + } + } + return out +} + +// KindFilter returns the current set of Kinds at least one loaded rule could +// match. Used by the validator to skip evaluation for unrelated admission +// events. The returned filter is a snapshot; callers must not mutate it. +func (c *CelRuleCreator) KindFilter() *KindFilter { + c.mu.RLock() + defer c.mu.RUnlock() + return c.kindFilter +} + +// Accepts reports whether at least one currently-loaded rule could match an +// admission event of the given Kind. Always reads the latest filter snapshot, +// so it stays correct across SyncRules calls without callers having to refresh. +func (c *CelRuleCreator) Accepts(kind string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.kindFilter.Accepts(kind) +} + +// CreateRuleByID returns a RuleEvaluator for the rule with the given ID, or nil +// if no matching rule exists. +func (c *CelRuleCreator) CreateRuleByID(id string) rules.RuleEvaluator { + c.mu.RLock() + defer c.mu.RUnlock() + + for _, r := range c.rules { + if r.ID == id { + return newCelRuleEvaluator(r, c.celEngine) + } + } + return nil +} + +// CreateRuleByName returns a RuleEvaluator for the first rule whose Name matches, +// or nil if no matching rule exists. +func (c *CelRuleCreator) CreateRuleByName(name string) rules.RuleEvaluator { + c.mu.RLock() + defer c.mu.RUnlock() + + for _, r := range c.rules { + if r.Name == name { + return newCelRuleEvaluator(r, c.celEngine) + } + } + return nil +} + +// CreateRulesByTags returns evaluators for all rules that have at least one tag +// in common with the requested tags set. +func (c *CelRuleCreator) CreateRulesByTags(tags []string) []rules.RuleEvaluator { + if len(tags) == 0 { + return nil + } + + tagSet := make(map[string]struct{}, len(tags)) + for _, t := range tags { + tagSet[t] = struct{}{} + } + + c.mu.RLock() + defer c.mu.RUnlock() + + var result []rules.RuleEvaluator + for _, r := range c.rules { + if ruleMatchesTags(r, tagSet) { + result = append(result, newCelRuleEvaluator(r, c.celEngine)) + } + } + return result +} + +// CreateAllRules returns evaluators for every loaded rule. +func (c *CelRuleCreator) CreateAllRules() []rules.RuleEvaluator { + c.mu.RLock() + defer c.mu.RUnlock() + + result := make([]rules.RuleEvaluator, len(c.rules)) + for i, r := range c.rules { + result[i] = newCelRuleEvaluator(r, c.celEngine) + } + return result +} + +// ruleMatchesTags reports whether any of the rule's tags appear in tagSet. +func ruleMatchesTags(r armotypes.RuntimeRule, tagSet map[string]struct{}) bool { + for _, t := range r.Tags { + if _, ok := tagSet[t]; ok { + return true + } + } + return false +} diff --git a/admission/rules/cel/creator_test.go b/admission/rules/cel/creator_test.go new file mode 100644 index 00000000..ae900473 --- /dev/null +++ b/admission/rules/cel/creator_test.go @@ -0,0 +1,320 @@ +package cel + +import ( + "testing" + + armotypes "github.com/armosec/armoapi-go/armotypes" + admissioncel "github.com/kubescape/operator/admission/cel" +) + +// newTestCelEngine creates a real AdmissionCEL engine for tests. +func newTestCelEngine(t *testing.T) *admissioncel.AdmissionCEL { + t.Helper() + engine, err := admissioncel.NewAdmissionCEL() + if err != nil { + t.Fatalf("NewAdmissionCEL: %v", err) + } + return engine +} + +// testRules is a fixed set of RuntimeRules used across creator tests. +var testRules = []armotypes.RuntimeRule{ + { + ID: "R3000", + Name: "Exec to pod", + Description: "Detects exec to pod", + Tags: []string{"exec", "pod"}, + Severity: armotypes.RuleSeverityHigh, + Expressions: armotypes.RuleExpressions{ + Message: `"Exec detected on pod: " + event.Name`, + UniqueID: `event.Namespace + "/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodExecOptions"`}, + }, + }, + }, + { + ID: "R3001", + Name: "Port forward", + Description: "Detects port-forward to pod", + Tags: []string{"network", "pod"}, + Severity: armotypes.RuleSeverityMed, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodPortForwardOptions"`}, + }, + }, + }, + { + ID: "R3002", + Name: "Privileged pod", + Description: "Detects creation of a privileged pod", + Tags: []string{"security"}, + Severity: armotypes.RuleSeverityCritical, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "Pod"`}, + }, + }, + }, +} + +func TestSyncAndCreateByID(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + ev := creator.CreateRuleByID("R3000") + if ev == nil { + t.Fatal("expected non-nil evaluator for R3000") + } + if ev.ID() != "R3000" { + t.Errorf("ID = %q, want R3000", ev.ID()) + } + if ev.Name() != "Exec to pod" { + t.Errorf("Name = %q, want 'Exec to pod'", ev.Name()) + } +} + +func TestCreateByID_NotFound(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + ev := creator.CreateRuleByID("NONEXISTENT") + if ev != nil { + t.Errorf("expected nil for non-existent ID, got %v", ev) + } +} + +func TestCreateByName(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + ev := creator.CreateRuleByName("Port forward") + if ev == nil { + t.Fatal("expected non-nil evaluator for 'Port forward'") + } + if ev.ID() != "R3001" { + t.Errorf("ID = %q, want R3001", ev.ID()) + } +} + +func TestCreateByName_NotFound(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + ev := creator.CreateRuleByName("Does not exist") + if ev != nil { + t.Errorf("expected nil for non-existent name, got %v", ev) + } +} + +func TestCreateByTags_SingleMatch(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + evals := creator.CreateRulesByTags([]string{"security"}) + if len(evals) != 1 { + t.Fatalf("expected 1 evaluator for tag 'security', got %d", len(evals)) + } + if evals[0].ID() != "R3002" { + t.Errorf("ID = %q, want R3002", evals[0].ID()) + } +} + +func TestCreateByTags_MultipleMatches(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + // Both R3000 and R3001 have the "pod" tag. + evals := creator.CreateRulesByTags([]string{"pod"}) + if len(evals) != 2 { + t.Fatalf("expected 2 evaluators for tag 'pod', got %d", len(evals)) + } +} + +func TestCreateByTags_AnyMatch(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + // "exec" matches R3000, "security" matches R3002. + evals := creator.CreateRulesByTags([]string{"exec", "security"}) + if len(evals) != 2 { + t.Fatalf("expected 2 evaluators, got %d", len(evals)) + } +} + +func TestCreateByTags_NoMatch(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + evals := creator.CreateRulesByTags([]string{"nonexistent"}) + if len(evals) != 0 { + t.Errorf("expected empty slice for unknown tag, got %d evaluators", len(evals)) + } +} + +func TestCreateByTags_EmptyTags(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + evals := creator.CreateRulesByTags(nil) + if len(evals) != 0 { + t.Errorf("expected nil/empty for nil tags, got %d evaluators", len(evals)) + } +} + +func TestCreateAllRules(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + evals := creator.CreateAllRules() + if len(evals) != len(testRules) { + t.Fatalf("CreateAllRules: got %d evaluators, want %d", len(evals), len(testRules)) + } + // Verify IDs are preserved in order. + for i, ev := range evals { + if ev.ID() != testRules[i].ID { + t.Errorf("evals[%d].ID = %q, want %q", i, ev.ID(), testRules[i].ID) + } + } +} + +func TestCreateAllRules_EmptySet(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + // No SyncRules called — should return empty slice. + evals := creator.CreateAllRules() + if len(evals) != 0 { + t.Errorf("expected empty slice before SyncRules, got %d", len(evals)) + } +} + +func TestKindFilter_IsRebuiltOnSync(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + + // Before any SyncRules call, the filter is empty and accepts nothing. + // (No rules => no events to evaluate.) + if creator.KindFilter().Accepts("PodExecOptions") { + t.Error("empty creator should not accept any Kind") + } + + // Sync a single rule pinned to PodExecOptions. + creator.SyncRules([]armotypes.RuntimeRule{ + makeRule("R1", admExpr(`event.Kind == "PodExecOptions"`)), + }) + f := creator.KindFilter() + if !f.Accepts("PodExecOptions") { + t.Error("after sync, PodExecOptions should be accepted") + } + if f.Accepts("Secret") { + t.Error("after sync of single-kind rule, Secret must not be accepted") + } + + // Replace with a wildcard rule (no Kind constraint). + creator.SyncRules([]armotypes.RuntimeRule{ + makeRule("R2", admExpr(`event.Operation == "CREATE"`)), + }) + if !creator.KindFilter().IsWildcard() { + t.Error("after sync of wildcard rule, filter should be wildcard") + } +} + +// TestSyncRules_EvictsProgramsForRemovedRules ensures the CEL engine's program +// cache does not grow monotonically as rules come and go. After a SyncRules +// that drops a rule, expressions referenced only by the dropped rule must be +// evicted, while expressions still in use must survive. +func TestSyncRules_EvictsProgramsForRemovedRules(t *testing.T) { + engine := newTestCelEngine(t) + creator := NewCelRuleCreator(engine) + + // Two rules — each with its own expression, message, and uniqueID. + r1 := armotypes.RuntimeRule{ + ID: "R-A", + Expressions: armotypes.RuleExpressions{ + Message: `"A: " + event.Name`, + UniqueID: `event.Namespace + "/A/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodExecOptions"`}, + }, + }, + } + r2 := armotypes.RuntimeRule{ + ID: "R-B", + Expressions: armotypes.RuleExpressions{ + Message: `"B: " + event.Name`, + UniqueID: `event.Namespace + "/B/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "NetworkPolicy"`}, + }, + }, + } + creator.SyncRules([]armotypes.RuntimeRule{r1, r2}) + + // Force compilation of every expression by evaluating both rules. + evA := creator.CreateRuleByID("R-A") + evB := creator.CreateRuleByID("R-B") + attrsA := newEvalTestAttributes("PodExecOptions", "p", "ns", "CONNECT", "exec", + map[string]interface{}{"kind": "PodExecOptions"}) + attrsB := newEvalTestAttributes("NetworkPolicy", "np", "ns", "CREATE", "", + map[string]interface{}{"kind": "NetworkPolicy"}) + _ = evA.ProcessEvent(attrsA, nil) + _ = evB.ProcessEvent(attrsB, nil) + + // 6 entries seeded: 2 rules × (match + message + uniqueID). + if got := engine.ProgramCacheSize(); got != 6 { + t.Fatalf("after seeding both rules: ProgramCacheSize = %d, want 6", got) + } + + // Drop R-B; only R-A's three expressions should remain. + creator.SyncRules([]armotypes.RuntimeRule{r1}) + if got := engine.ProgramCacheSize(); got != 3 { + t.Errorf("after dropping R-B: ProgramCacheSize = %d, want 3", got) + } + + // Drop everything. + creator.SyncRules(nil) + if got := engine.ProgramCacheSize(); got != 0 { + t.Errorf("after dropping all rules: ProgramCacheSize = %d, want 0", got) + } +} + +func TestSyncRulesReplaces(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + creator.SyncRules(testRules) + + // Replace with a single-rule set. + replacement := []armotypes.RuntimeRule{ + {ID: "R9999", Name: "Replacement rule", Tags: []string{"new"}}, + } + creator.SyncRules(replacement) + + evals := creator.CreateAllRules() + if len(evals) != 1 { + t.Fatalf("after SyncRules replacement: got %d evaluators, want 1", len(evals)) + } + if evals[0].ID() != "R9999" { + t.Errorf("ID = %q, want R9999", evals[0].ID()) + } + + // Old rules must no longer be accessible. + if ev := creator.CreateRuleByID("R3000"); ev != nil { + t.Error("old rule R3000 still accessible after SyncRules replacement") + } +} + +func TestSyncRulesIsolation(t *testing.T) { + creator := NewCelRuleCreator(newTestCelEngine(t)) + + // Mutating the original slice after SyncRules must not affect the creator. + original := []armotypes.RuntimeRule{ + {ID: "R1", Name: "Rule One"}, + } + creator.SyncRules(original) + + // Mutate the original slice. + original[0].ID = "MUTATED" + + ev := creator.CreateRuleByID("R1") + if ev == nil { + t.Fatal("expected evaluator for R1 after mutation of original slice") + } +} diff --git a/admission/rules/cel/evaluator.go b/admission/rules/cel/evaluator.go new file mode 100644 index 00000000..80ae273e --- /dev/null +++ b/admission/rules/cel/evaluator.go @@ -0,0 +1,241 @@ +package cel + +import ( + "time" + + apitypes "github.com/armosec/armoapi-go/armotypes" + admissioncel "github.com/kubescape/operator/admission/cel" + "github.com/kubescape/operator/admission/rules" + rulesv1 "github.com/kubescape/operator/admission/rules/v1" + "github.com/kubescape/operator/objectcache" + logger "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" +) + +// Compile-time assertion that CelRuleEvaluator implements rules.RuleEvaluator. +var _ rules.RuleEvaluator = (*CelRuleEvaluator)(nil) + +// CelRuleEvaluator wraps a single armotypes.RuntimeRule and evaluates it against +// k8s admission events using the shared AdmissionCEL engine. +type CelRuleEvaluator struct { + rule apitypes.RuntimeRule + celEngine *admissioncel.AdmissionCEL + parameters map[string]interface{} +} + +// newCelRuleEvaluator is the package-internal constructor used by CelRuleCreator. +func newCelRuleEvaluator(rule apitypes.RuntimeRule, celEngine *admissioncel.AdmissionCEL) *CelRuleEvaluator { + return &CelRuleEvaluator{ + rule: rule, + celEngine: celEngine, + } +} + +// ID returns the rule's unique identifier. +func (e *CelRuleEvaluator) ID() string { + return e.rule.ID +} + +// Name returns the rule's human-readable name. +func (e *CelRuleEvaluator) Name() string { + return e.rule.Name +} + +// SetParameters stores per-binding parameter overrides. +func (e *CelRuleEvaluator) SetParameters(parameters map[string]interface{}) { + e.parameters = parameters +} + +// GetParameters returns the per-binding parameter overrides. +func (e *CelRuleEvaluator) GetParameters() map[string]interface{} { + return e.parameters +} + +// ProcessEvent evaluates the rule's CEL expressions against the admission event. +// Returns nil when the rule does not fire for this event. Returns a RuleFailure +// when the rule matches. +func (e *CelRuleEvaluator) ProcessEvent(attrs admission.Attributes, access objectcache.KubernetesCache) rules.RuleFailure { + if attrs == nil { + return nil + } + + // Build CEL event and evaluation context. + celEvent := admissioncel.NewAdmissionCelEvent(attrs) + evalCtx := e.celEngine.CreateEvalContext(celEvent) + + // Inject the active binding's parameter overrides under the "params" + // key so CEL expressions can reference params["threshold"], etc. + // CreateEvalContext seeded "params" to an empty map; replace it only + // when this evaluator has binding parameters so expressions referencing + // it remain evaluable in either case. + if e.parameters != nil { + evalCtx["params"] = e.parameters + } + + // Evaluate the rule's match expressions for the k8s-admission event type. + matched, err := e.celEngine.EvaluateRuleWithContext( + evalCtx, + apitypes.EventTypeK8sAdmission, + e.rule.Expressions.RuleExpression, + ) + if err != nil { + logger.L().Error("CelRuleEvaluator: failed to evaluate rule expressions", + helpers.String("ruleID", e.rule.ID), + helpers.Error(err)) + return nil + } + if !matched { + return nil + } + + // Evaluate the alert name from the Message expression; fall back to rule name. + alertName := e.rule.Name + if e.rule.Expressions.Message != "" { + msg, err := e.celEngine.EvaluateStringExpression(evalCtx, e.rule.Expressions.Message) + if err != nil { + logger.L().Warning("CelRuleEvaluator: failed to evaluate message expression", + helpers.String("ruleID", e.rule.ID), + helpers.Error(err)) + } else if msg != "" { + alertName = msg + } + } + + // Evaluate the unique ID expression. + uniqueID := "" + if e.rule.Expressions.UniqueID != "" { + uid, err := e.celEngine.EvaluateStringExpression(evalCtx, e.rule.Expressions.UniqueID) + if err != nil { + logger.L().Warning("CelRuleEvaluator: failed to evaluate uniqueID expression", + helpers.String("ruleID", e.rule.ID), + helpers.Error(err)) + } else { + uniqueID = uid + } + } + + failure := &rulesv1.GenericRuleFailure{ + BaseRuntimeAlert: apitypes.BaseRuntimeAlert{ + AlertName: alertName, + Severity: e.rule.Severity, + Timestamp: time.Now(), + UniqueID: uniqueID, + }, + RuleAlert: apitypes.RuleAlert{ + RuleDescription: e.rule.Description, + }, + AdmissionAlert: buildAdmissionAlert(attrs), + RuleID: e.rule.ID, + } + + // Enrich with K8s details when a cache is available. Skip for kinds + // where enrichment is not meaningful — enrichK8sDetails resolves a + // running Pod via clientset.CoreV1().Pods(ns).Get(...), which only + // makes sense for Pod CRUD or Pod subresource events. Other kinds + // (NetworkPolicy, RoleBinding, …) would either return a NotFound or + // fetch an unrelated Pod that happens to share the request name. + if access != nil && enrichmentApplicable(attrs) { + enrichK8sDetails(failure, attrs, access) + } + + return failure +} + +// enrichmentApplicable reports whether the K8s Pod enrichment pipeline makes +// sense for this admission event. True for Pod CRUD and Pod subresources +// (exec, portforward, attach) — all addressed by name in the pods collection. +// False for unrelated kinds whose name does not correspond to a Pod. +func enrichmentApplicable(attrs admission.Attributes) bool { + if attrs.GetResource().Resource == "pods" { + return true + } + switch attrs.GetKind().Kind { + case "PodExecOptions", "PodPortForwardOptions", "PodAttachOptions": + return true + } + return false +} + +// buildAdmissionAlert constructs an apitypes.AdmissionAlert from admission.Attributes. +func buildAdmissionAlert(attrs admission.Attributes) apitypes.AdmissionAlert { + alert := apitypes.AdmissionAlert{ + Kind: attrs.GetKind(), + RequestNamespace: attrs.GetNamespace(), + ObjectName: attrs.GetName(), + Resource: attrs.GetResource(), + Subresource: attrs.GetSubresource(), + Operation: attrs.GetOperation(), + DryRun: attrs.IsDryRun(), + } + + // Attach user info if available. + if ui := attrs.GetUserInfo(); ui != nil { + alert.UserInfo = &user.DefaultInfo{ + Name: ui.GetName(), + UID: ui.GetUID(), + Groups: ui.GetGroups(), + Extra: ui.GetExtra(), + } + } + + // Attach object if it is an *unstructured.Unstructured. + if obj := attrs.GetObject(); obj != nil { + if u, ok := obj.(*unstructured.Unstructured); ok { + alert.Object = u + } + } + + // Attach old object if it is an *unstructured.Unstructured. + if old := attrs.GetOldObject(); old != nil { + if u, ok := old.(*unstructured.Unstructured); ok { + alert.OldObject = u + } + } + + return alert +} + +// enrichK8sDetails populates RuntimeAlertK8sDetails on the failure using the +// Kubernetes API. Errors are logged and silently skipped — enrichment is +// best-effort and must never cause the rule to suppress a genuine match. +func enrichK8sDetails(failure *rulesv1.GenericRuleFailure, attrs admission.Attributes, access objectcache.KubernetesCache) { + clientset := access.GetClientset() + + pod, workloadKind, workloadName, workloadNamespace, workloadUID, nodeName, err := + rulesv1.GetControllerDetails(attrs, clientset) + if err != nil { + logger.L().Warning("CelRuleEvaluator: could not get controller details", + helpers.String("pod", attrs.GetName()), + helpers.Error(err)) + return + } + + k8sDetails := apitypes.RuntimeAlertK8sDetails{ + PodName: attrs.GetName(), + PodNamespace: attrs.GetNamespace(), + Namespace: attrs.GetNamespace(), + NodeName: nodeName, + WorkloadName: workloadName, + WorkloadNamespace: workloadNamespace, + WorkloadKind: workloadKind, + WorkloadUID: workloadUID, + } + + // Resolve container details for exec-to-pod events. + if attrs.GetKind().Kind == "PodExecOptions" { + containerName, err := rulesv1.GetContainerNameFromExecToPodEvent(attrs) + if err != nil { + logger.L().Warning("CelRuleEvaluator: could not get container name from exec event", + helpers.Error(err)) + } + k8sDetails.ContainerName = containerName + k8sDetails.ContainerID = rulesv1.GetContainerID(pod, containerName) + k8sDetails.Image = rulesv1.GetContainerImage(pod, containerName) + k8sDetails.ImageDigest = rulesv1.GetContainerImageDigest(pod, containerName) + } + + failure.RuntimeAlertK8sDetails = k8sDetails +} diff --git a/admission/rules/cel/evaluator_test.go b/admission/rules/cel/evaluator_test.go new file mode 100644 index 00000000..6818751f --- /dev/null +++ b/admission/rules/cel/evaluator_test.go @@ -0,0 +1,415 @@ +package cel + +import ( + "testing" + + armotypes "github.com/armosec/armoapi-go/armotypes" + "github.com/kubescape/operator/objectcache" + "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" +) + +// newEvalTestAttributes builds an admission.Attributes suitable for evaluator tests. +func newEvalTestAttributes(kind, name, namespace, operation, subresource string, obj map[string]interface{}) admission.Attributes { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: kind} + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + var u *unstructured.Unstructured + if obj != nil { + u = &unstructured.Unstructured{Object: obj} + } + userInfo := &user.DefaultInfo{ + Name: "test-user", + Groups: []string{"system:masters"}, + UID: "uid-123", + } + return admission.NewAttributesRecord(u, nil, gvk, namespace, name, gvr, subresource, + admission.Operation(operation), nil, false, userInfo) +} + +// newExecRule returns a RuntimeRule that matches PodExecOptions admission events. +func newExecRule() armotypes.RuntimeRule { + return armotypes.RuntimeRule{ + ID: "R3000", + Name: "Exec to pod", + Description: "Detects exec to pod", + Severity: armotypes.RuleSeverityHigh, + Tags: []string{"exec"}, + Expressions: armotypes.RuleExpressions{ + Message: `"Exec detected on pod: " + event.Name`, + UniqueID: `event.Namespace + "/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodExecOptions"`}, + }, + }, + } +} + +func TestIDAndName(t *testing.T) { + engine := newTestCelEngine(t) + rule := newExecRule() + ev := newCelRuleEvaluator(rule, engine) + + if ev.ID() != "R3000" { + t.Errorf("ID = %q, want R3000", ev.ID()) + } + if ev.Name() != "Exec to pod" { + t.Errorf("Name = %q, want 'Exec to pod'", ev.Name()) + } +} + +func TestParameters(t *testing.T) { + engine := newTestCelEngine(t) + ev := newCelRuleEvaluator(newExecRule(), engine) + + if p := ev.GetParameters(); p != nil { + t.Errorf("GetParameters before Set = %v, want nil", p) + } + + params := map[string]interface{}{"threshold": 5} + ev.SetParameters(params) + + got := ev.GetParameters() + if len(got) != 1 { + t.Fatalf("GetParameters len = %d, want 1", len(got)) + } + if got["threshold"] != 5 { + t.Errorf("GetParameters[threshold] = %v, want 5", got["threshold"]) + } +} + +func TestProcessEvent_NoMatch(t *testing.T) { + engine := newTestCelEngine(t) + ev := newCelRuleEvaluator(newExecRule(), engine) + + // Send a Pod event, rule only fires on PodExecOptions. + attrs := newEvalTestAttributes("Pod", "my-pod", "default", "CREATE", "", map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + }) + + result := ev.ProcessEvent(attrs, nil) + if result != nil { + t.Errorf("expected nil for non-matching event, got %v", result) + } +} + +func TestProcessEvent_Match(t *testing.T) { + engine := newTestCelEngine(t) + ev := newCelRuleEvaluator(newExecRule(), engine) + + attrs := newEvalTestAttributes("PodExecOptions", "my-pod", "default", "CONNECT", "exec", + map[string]interface{}{ + "command": []interface{}{"/bin/sh"}, + "container": "main", + }) + + result := ev.ProcessEvent(attrs, nil) + if result == nil { + t.Fatal("expected non-nil RuleFailure for matching event") + } + + // Rule ID + if result.GetRuleId() != "R3000" { + t.Errorf("GetRuleId = %q, want R3000", result.GetRuleId()) + } + + // BaseRuntimeAlert + base := result.GetBaseRuntimeAlert() + if base.AlertName != "Exec detected on pod: my-pod" { + t.Errorf("AlertName = %q, want 'Exec detected on pod: my-pod'", base.AlertName) + } + if base.Severity != armotypes.RuleSeverityHigh { + t.Errorf("Severity = %d, want %d", base.Severity, armotypes.RuleSeverityHigh) + } + if base.UniqueID != "default/my-pod" { + t.Errorf("UniqueID = %q, want 'default/my-pod'", base.UniqueID) + } + if base.Timestamp.IsZero() { + t.Error("Timestamp is zero, want non-zero") + } + + // RuleAlert + ruleAlert := result.GetRuleAlert() + if ruleAlert.RuleDescription != "Detects exec to pod" { + t.Errorf("RuleDescription = %q, want 'Detects exec to pod'", ruleAlert.RuleDescription) + } + + // AdmissionAlert + admAlert := result.GetAdmissionsAlert() + if admAlert.ObjectName != "my-pod" { + t.Errorf("AdmissionAlert.ObjectName = %q, want 'my-pod'", admAlert.ObjectName) + } + if admAlert.RequestNamespace != "default" { + t.Errorf("AdmissionAlert.RequestNamespace = %q, want 'default'", admAlert.RequestNamespace) + } + if admAlert.Subresource != "exec" { + t.Errorf("AdmissionAlert.Subresource = %q, want 'exec'", admAlert.Subresource) + } + if admAlert.Kind.Kind != "PodExecOptions" { + t.Errorf("AdmissionAlert.Kind.Kind = %q, want 'PodExecOptions'", admAlert.Kind.Kind) + } + if admAlert.UserInfo == nil { + t.Fatal("AdmissionAlert.UserInfo is nil") + } + if admAlert.UserInfo.Name != "test-user" { + t.Errorf("AdmissionAlert.UserInfo.Name = %q, want 'test-user'", admAlert.UserInfo.Name) + } + if admAlert.Object == nil { + t.Error("AdmissionAlert.Object is nil, want non-nil") + } +} + +func TestProcessEvent_NilAttrs(t *testing.T) { + engine := newTestCelEngine(t) + ev := newCelRuleEvaluator(newExecRule(), engine) + + result := ev.ProcessEvent(nil, nil) + if result != nil { + t.Errorf("expected nil for nil attrs, got %v", result) + } +} + +func TestProcessEvent_MessageFallback(t *testing.T) { + engine := newTestCelEngine(t) + + // Rule with no Message expression — AlertName should fall back to rule name. + rule := armotypes.RuntimeRule{ + ID: "R4000", + Name: "Fallback Rule", + Severity: armotypes.RuleSeverityLow, + Expressions: armotypes.RuleExpressions{ + // No Message field. + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "PodExecOptions"`}, + }, + }, + } + + ev := newCelRuleEvaluator(rule, engine) + attrs := newEvalTestAttributes("PodExecOptions", "my-pod", "default", "CONNECT", "exec", + map[string]interface{}{ + "kind": "PodExecOptions", + "apiVersion": "v1", + }) + + result := ev.ProcessEvent(attrs, nil) + if result == nil { + t.Fatal("expected non-nil RuleFailure") + } + if result.GetBaseRuntimeAlert().AlertName != "Fallback Rule" { + t.Errorf("AlertName = %q, want 'Fallback Rule'", result.GetBaseRuntimeAlert().AlertName) + } +} + +func TestProcessEvent_WithK8sEnrichment(t *testing.T) { + engine := newTestCelEngine(t) + ev := newCelRuleEvaluator(newExecRule(), engine) + + attrs := newEvalTestAttributes("PodExecOptions", "test-pod", "test-namespace", "CONNECT", "exec", + map[string]interface{}{ + "kind": "PodExecOptions", + "apiVersion": "v1", + "command": []interface{}{"bash"}, + "container": "test-container", + }) + + // KubernetesCacheMockImpl pre-populates a pod named "test-pod" in "test-namespace". + result := ev.ProcessEvent(attrs, objectcache.KubernetesCacheMockImpl{}) + if result == nil { + t.Fatal("expected non-nil RuleFailure") + } + + k8s := result.GetRuntimeAlertK8sDetails() + if k8s.PodName != "test-pod" { + t.Errorf("PodName = %q, want 'test-pod'", k8s.PodName) + } + if k8s.Namespace != "test-namespace" { + t.Errorf("Namespace = %q, want 'test-namespace'", k8s.Namespace) + } + if k8s.NodeName != "test-node" { + t.Errorf("NodeName = %q, want 'test-node'", k8s.NodeName) + } + if k8s.WorkloadName != "test-workload" { + t.Errorf("WorkloadName = %q, want 'test-workload'", k8s.WorkloadName) + } + if k8s.WorkloadKind != "ReplicaSet" { + t.Errorf("WorkloadKind = %q, want 'ReplicaSet'", k8s.WorkloadKind) + } + if k8s.ContainerName != "test-container" { + t.Errorf("ContainerName = %q, want 'test-container'", k8s.ContainerName) + } + if k8s.ContainerID != "containerd://abcdef1234567890" { + t.Errorf("ContainerID = %q, want 'containerd://abcdef1234567890'", k8s.ContainerID) + } +} + +// TestProcessEvent_UsesBindingParameters verifies that SetParameters values +// are reachable from CEL expressions via the "params" variable. Without this, +// per-binding overrides would be inert: parametrized rules would never see +// the parameter values supplied by their RuntimeAlertRuleBinding. +func TestProcessEvent_UsesBindingParameters(t *testing.T) { + engine := newTestCelEngine(t) + + // Rule fires only when params["allowExec"] is false. Tests that: + // (1) params is visible from CEL, (2) parameter overrides change behavior. + rule := armotypes.RuntimeRule{ + ID: "R7000", + Name: "Parameterized exec", + Severity: armotypes.RuleSeverityHigh, + Expressions: armotypes.RuleExpressions{ + Message: `"exec blocked: " + event.Name`, + UniqueID: `event.Namespace + "/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + { + EventType: armotypes.EventTypeK8sAdmission, + Expression: `event.Kind == "PodExecOptions" && !(has(params.allowExec) && params.allowExec)`, + }, + }, + }, + } + + attrs := newEvalTestAttributes("PodExecOptions", "test-pod", "default", "CONNECT", "exec", + map[string]interface{}{ + "kind": "PodExecOptions", + }) + + t.Run("no parameters set — rule fires", func(t *testing.T) { + ev := newCelRuleEvaluator(rule, engine) + result := ev.ProcessEvent(attrs, nil) + if result == nil { + t.Fatal("expected non-nil RuleFailure when no parameters override") + } + }) + + t.Run("allowExec=true via binding parameters — rule suppressed", func(t *testing.T) { + ev := newCelRuleEvaluator(rule, engine) + ev.SetParameters(map[string]interface{}{"allowExec": true}) + result := ev.ProcessEvent(attrs, nil) + if result != nil { + t.Errorf("expected nil RuleFailure when params.allowExec=true, got %+v", result) + } + }) + + t.Run("allowExec=false via binding parameters — rule fires", func(t *testing.T) { + ev := newCelRuleEvaluator(rule, engine) + ev.SetParameters(map[string]interface{}{"allowExec": false}) + result := ev.ProcessEvent(attrs, nil) + if result == nil { + t.Fatal("expected non-nil RuleFailure when params.allowExec=false") + } + }) +} + +func TestEnrichmentApplicable(t *testing.T) { + tests := []struct { + name string + kind string + resource string + want bool + }{ + {name: "Pod CRUD", kind: "Pod", resource: "pods", want: true}, + {name: "PodExecOptions subresource", kind: "PodExecOptions", resource: "pods", want: true}, + {name: "PodPortForwardOptions subresource", kind: "PodPortForwardOptions", resource: "pods", want: true}, + {name: "PodAttachOptions subresource", kind: "PodAttachOptions", resource: "pods", want: true}, + // Kind-only matches even if resource is wrong; this guards against + // API-server quirks where a subresource arrives addressed differently. + {name: "PodExec by kind alone", kind: "PodExecOptions", resource: "", want: true}, + {name: "NetworkPolicy CREATE", kind: "NetworkPolicy", resource: "networkpolicies", want: false}, + {name: "RoleBinding CREATE", kind: "RoleBinding", resource: "rolebindings", want: false}, + {name: "Secret CREATE", kind: "Secret", resource: "secrets", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: tt.kind} + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: tt.resource} + attrs := admission.NewAttributesRecord(nil, nil, gvk, "ns", "n", gvr, "", + admission.Create, nil, false, nil) + if got := enrichmentApplicable(attrs); got != tt.want { + t.Errorf("enrichmentApplicable(kind=%q, resource=%q) = %v, want %v", + tt.kind, tt.resource, got, tt.want) + } + }) + } +} + +// TestProcessEvent_SkipsEnrichmentForNonPodKind verifies that ProcessEvent does +// not populate RuntimeAlertK8sDetails when the admission event is not for a +// Pod or pod subresource. Calling enrichK8sDetails on, e.g., a NetworkPolicy +// would resolve a Pod by the NetworkPolicy's name — at best NotFound, at +// worst an unrelated Pod that shares the name. +func TestProcessEvent_SkipsEnrichmentForNonPodKind(t *testing.T) { + engine := newTestCelEngine(t) + rule := armotypes.RuntimeRule{ + ID: "R6000", + Name: "NetworkPolicy created", + Severity: armotypes.RuleSeverityMed, + Expressions: armotypes.RuleExpressions{ + Message: `"NetworkPolicy: " + event.Name`, + UniqueID: `event.Namespace + "/" + event.Name`, + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "NetworkPolicy"`}, + }, + }, + } + ev := newCelRuleEvaluator(rule, engine) + + gvk := schema.GroupVersionKind{Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"} + gvr := schema.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "networkpolicies"} + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + }} + userInfo := &user.DefaultInfo{Name: "kubernetes-admin"} + attrs := admission.NewAttributesRecord(obj, nil, gvk, "default", "test-netpol", gvr, "", + admission.Create, nil, false, userInfo) + + // Pass a Kubernetes cache: even though one is available, the validator + // must skip Pod-specific enrichment for a NetworkPolicy. + result := ev.ProcessEvent(attrs, objectcache.KubernetesCacheMockImpl{}) + if result == nil { + t.Fatal("expected non-nil RuleFailure") + } + + k8s := result.GetRuntimeAlertK8sDetails() + // Enrichment must NOT have populated Pod-derived fields. + if k8s.PodName != "" { + t.Errorf("PodName = %q, want empty (enrichment must be skipped for NetworkPolicy)", k8s.PodName) + } + if k8s.ContainerName != "" { + t.Errorf("ContainerName = %q, want empty", k8s.ContainerName) + } + if k8s.NodeName != "" { + t.Errorf("NodeName = %q, want empty", k8s.NodeName) + } +} + +func TestProcessEvent_WrongEventType(t *testing.T) { + engine := newTestCelEngine(t) + + // Rule with an exec event type expression (not k8s-admission). + rule := armotypes.RuntimeRule{ + ID: "R5000", + Name: "Wrong event type", + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeExec, Expression: `event.Kind == "PodExecOptions"`}, + }, + }, + } + + ev := newCelRuleEvaluator(rule, engine) + attrs := newEvalTestAttributes("PodExecOptions", "my-pod", "default", "CONNECT", "exec", + map[string]interface{}{ + "kind": "PodExecOptions", + "apiVersion": "v1", + }) + + // No k8s-admission expressions → should not match. + result := ev.ProcessEvent(attrs, nil) + if result != nil { + t.Errorf("expected nil when no k8s-admission expressions exist, got %v", result) + } +} diff --git a/admission/rules/cel/kindfilter.go b/admission/rules/cel/kindfilter.go new file mode 100644 index 00000000..fbdc388f --- /dev/null +++ b/admission/rules/cel/kindfilter.go @@ -0,0 +1,107 @@ +package cel + +import ( + "regexp" + + armotypes "github.com/armosec/armoapi-go/armotypes" +) + +// KindFilter is the set of admission Kinds that at least one currently-loaded +// rule could match. It is built from the loaded rule expressions at SyncRules +// time and used by the validator to skip CEL evaluation for events no rule +// targets. +// +// The filter is *conservative*: if an expression cannot be statically resolved +// to a finite set of Kinds (e.g. it uses ||, has no event.Kind == constraint, +// or uses set membership / regex), the filter falls back to wildcard mode and +// accepts every Kind. Correctness is preserved at the cost of skipping the +// optimization. +type KindFilter struct { + wildcard bool + kinds map[string]struct{} +} + +// Accepts reports whether at least one loaded rule could match an admission +// event of the given Kind. Wildcard filters accept every Kind. +func (f *KindFilter) Accepts(kind string) bool { + if f == nil || f.wildcard { + return true + } + _, ok := f.kinds[kind] + return ok +} + +// IsWildcard reports whether the filter accepts every Kind. +func (f *KindFilter) IsWildcard() bool { + return f == nil || f.wildcard +} + +// Kinds returns the set of admission Kinds this filter explicitly accepts. +// Returns nil for wildcard filters. +func (f *KindFilter) Kinds() []string { + if f == nil || f.wildcard { + return nil + } + out := make([]string, 0, len(f.kinds)) + for k := range f.kinds { + out = append(out, k) + } + return out +} + +// kindEqualsRHS matches: event.Kind == "X" or event.Kind == 'X'. +var kindEqualsRHS = regexp.MustCompile(`event\.Kind\s*==\s*["']([^"']+)["']`) + +// kindEqualsLHS matches: "X" == event.Kind or 'X' == event.Kind. +var kindEqualsLHS = regexp.MustCompile(`["']([^"']+)["']\s*==\s*event\.Kind`) + +// disjunctionToken matches the CEL OR operator. When present in an expression, +// we cannot safely narrow to a finite Kind set without parsing the AST, so the +// filter falls back to wildcard. +var disjunctionToken = regexp.MustCompile(`\|\|`) + +// buildKindFilter returns a KindFilter covering the admission expressions of +// the given rules. Only RuleExpression entries with EventType k8s-admission +// are considered (other event types are not evaluated by this operator). +func buildKindFilter(rs []armotypes.RuntimeRule) *KindFilter { + f := &KindFilter{kinds: map[string]struct{}{}} + + for _, r := range rs { + for _, expr := range r.Expressions.RuleExpression { + if expr.EventType != armotypes.EventTypeK8sAdmission { + continue + } + if f.absorbExpression(expr.Expression) { + // One unresolvable expression poisons the whole filter. + return &KindFilter{wildcard: true} + } + } + } + + // If no expression contributed any kind constraints, the filter would + // match nothing — which is wrong. Treat as wildcard. + if len(f.kinds) == 0 { + return &KindFilter{wildcard: true} + } + return f +} + +// absorbExpression adds Kind constraints from one CEL expression into the +// filter. Returns true if the expression is unresolvable and the caller should +// fall back to wildcard mode. +func (f *KindFilter) absorbExpression(expr string) (unresolvable bool) { + if disjunctionToken.MatchString(expr) { + return true + } + + var found bool + for _, m := range kindEqualsRHS.FindAllStringSubmatch(expr, -1) { + f.kinds[m[1]] = struct{}{} + found = true + } + for _, m := range kindEqualsLHS.FindAllStringSubmatch(expr, -1) { + f.kinds[m[1]] = struct{}{} + found = true + } + return !found +} diff --git a/admission/rules/cel/kindfilter_test.go b/admission/rules/cel/kindfilter_test.go new file mode 100644 index 00000000..fd8627d4 --- /dev/null +++ b/admission/rules/cel/kindfilter_test.go @@ -0,0 +1,224 @@ +package cel + +import ( + "sort" + "testing" + + armotypes "github.com/armosec/armoapi-go/armotypes" +) + +func makeRule(id string, exprs ...armotypes.RuleExpression) armotypes.RuntimeRule { + return armotypes.RuntimeRule{ + ID: id, + Expressions: armotypes.RuleExpressions{ + RuleExpression: exprs, + }, + } +} + +func admExpr(expr string) armotypes.RuleExpression { + return armotypes.RuleExpression{ + EventType: armotypes.EventTypeK8sAdmission, + Expression: expr, + } +} + +func TestKindFilter_Accepts(t *testing.T) { + tests := []struct { + name string + rules []armotypes.RuntimeRule + wildcard bool + wantKinds []string + // (kind, accept) probes + probes []struct { + kind string + accept bool + } + }{ + { + name: "single rule with single Kind on the RHS", + rules: []armotypes.RuntimeRule{ + makeRule("R1", admExpr(`event.Kind == "PodExecOptions"`)), + }, + wantKinds: []string{"PodExecOptions"}, + probes: []struct { + kind string + accept bool + }{ + {"PodExecOptions", true}, + {"Pod", false}, + {"NetworkPolicy", false}, + }, + }, + { + name: "Kind on the LHS", + rules: []armotypes.RuntimeRule{ + makeRule("R1", admExpr(`"PodExecOptions" == event.Kind`)), + }, + wantKinds: []string{"PodExecOptions"}, + probes: []struct { + kind string + accept bool + }{ + {"PodExecOptions", true}, + {"Pod", false}, + }, + }, + { + name: "single quotes", + rules: []armotypes.RuntimeRule{ + makeRule("R1", admExpr(`event.Kind == 'NetworkPolicy'`)), + }, + wantKinds: []string{"NetworkPolicy"}, + probes: []struct { + kind string + accept bool + }{ + {"NetworkPolicy", true}, + {"Pod", false}, + }, + }, + { + name: "conjunction with extra constraints (CREATE)", + rules: []armotypes.RuntimeRule{ + makeRule("R1", admExpr(`event.Kind == "NetworkPolicy" && event.Operation == "CREATE"`)), + }, + wantKinds: []string{"NetworkPolicy"}, + probes: []struct { + kind string + accept bool + }{ + {"NetworkPolicy", true}, + {"Pod", false}, + }, + }, + { + name: "two rules cover two different Kinds", + rules: []armotypes.RuntimeRule{ + makeRule("R1", admExpr(`event.Kind == "PodExecOptions"`)), + makeRule("R2", admExpr(`event.Kind == "PodPortForwardOptions"`)), + }, + wantKinds: []string{"PodExecOptions", "PodPortForwardOptions"}, + probes: []struct { + kind string + accept bool + }{ + {"PodExecOptions", true}, + {"PodPortForwardOptions", true}, + {"Pod", false}, + }, + }, + { + name: "rule with disjunction => wildcard (conservative)", + rules: []armotypes.RuntimeRule{ + makeRule("R1", admExpr(`event.Kind == "PodExecOptions" || event.Operation == "CREATE"`)), + }, + wildcard: true, + probes: []struct { + kind string + accept bool + }{ + {"PodExecOptions", true}, + {"AnythingElse", true}, + }, + }, + { + name: "rule with no Kind constraint => wildcard", + rules: []armotypes.RuntimeRule{ + makeRule("R1", admExpr(`event.Operation == "CREATE"`)), + }, + wildcard: true, + probes: []struct { + kind string + accept bool + }{ + {"Pod", true}, + {"Secret", true}, + }, + }, + { + name: "empty rule set => wildcard", + rules: nil, + wildcard: true, + probes: []struct { + kind string + accept bool + }{ + {"Pod", true}, + }, + }, + { + name: "non-admission expressions are ignored", + rules: []armotypes.RuntimeRule{ + { + ID: "R1", + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{ + {EventType: armotypes.EventTypeExec, Expression: `true`}, + {EventType: armotypes.EventTypeK8sAdmission, Expression: `event.Kind == "Pod"`}, + }, + }, + }, + }, + wantKinds: []string{"Pod"}, + probes: []struct { + kind string + accept bool + }{ + {"Pod", true}, + {"NetworkPolicy", false}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := buildKindFilter(tt.rules) + + if f.IsWildcard() != tt.wildcard { + t.Errorf("IsWildcard = %v, want %v", f.IsWildcard(), tt.wildcard) + } + + if !tt.wildcard { + got := f.Kinds() + sort.Strings(got) + want := append([]string(nil), tt.wantKinds...) + sort.Strings(want) + if !equalSlices(got, want) { + t.Errorf("Kinds = %v, want %v", got, want) + } + } + + for _, p := range tt.probes { + if f.Accepts(p.kind) != p.accept { + t.Errorf("Accepts(%q) = %v, want %v", p.kind, f.Accepts(p.kind), p.accept) + } + } + }) + } +} + +func TestKindFilter_NilSafety(t *testing.T) { + var f *KindFilter + if !f.Accepts("AnyKind") { + t.Error("nil KindFilter should accept all kinds") + } + if !f.IsWildcard() { + t.Error("nil KindFilter should report wildcard") + } + if f.Kinds() != nil { + t.Error("nil KindFilter Kinds() should return nil") + } +} + +func equalSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/admission/rules/v1/factory.go b/admission/rules/v1/factory.go deleted file mode 100644 index 9dcdf0d4..00000000 --- a/admission/rules/v1/factory.go +++ /dev/null @@ -1,60 +0,0 @@ -package rules - -import ( - "github.com/kubescape/operator/admission/rules" -) - -var _ rules.RuleCreator = (*RuleCreatorImpl)(nil) - -type RuleCreatorImpl struct { - ruleDescriptions []RuleDescriptor -} - -func NewRuleCreator() *RuleCreatorImpl { - return &RuleCreatorImpl{ - ruleDescriptions: []RuleDescriptor{ - R2000ExecToPodRuleDescriptor, - R2001PortForwardRuleDescriptor, - }, - } -} - -func (r *RuleCreatorImpl) CreateRulesByTags(tags []string) []rules.RuleEvaluator { - var rules []rules.RuleEvaluator - for _, rule := range r.ruleDescriptions { - if rule.HasTags(tags) { - rules = append(rules, rule.RuleCreationFunc()) - } - } - return rules -} - -func (r *RuleCreatorImpl) CreateRuleByID(id string) rules.RuleEvaluator { - for _, rule := range r.ruleDescriptions { - if rule.ID == id { - return rule.RuleCreationFunc() - } - } - return nil -} - -func (r *RuleCreatorImpl) CreateRuleByName(name string) rules.RuleEvaluator { - for _, rule := range r.ruleDescriptions { - if rule.Name == name { - return rule.RuleCreationFunc() - } - } - return nil -} - -func (r *RuleCreatorImpl) GetAllRuleDescriptors() []RuleDescriptor { - return r.ruleDescriptions -} - -func (r *RuleCreatorImpl) CreateAllRules() []rules.RuleEvaluator { - all := make([]rules.RuleEvaluator, 0, len(r.ruleDescriptions)) - for _, rd := range r.ruleDescriptions { - all = append(all, rd.RuleCreationFunc()) - } - return all -} diff --git a/admission/rules/v1/r2000_exec_to_pod.go b/admission/rules/v1/r2000_exec_to_pod.go deleted file mode 100644 index dd657392..00000000 --- a/admission/rules/v1/r2000_exec_to_pod.go +++ /dev/null @@ -1,198 +0,0 @@ -package rules - -import ( - "fmt" - "strings" - "time" - - apitypes "github.com/armosec/armoapi-go/armotypes" - "github.com/armosec/armoapi-go/armotypes/common" - "github.com/kubescape/go-logger" - "github.com/kubescape/go-logger/helpers" - "github.com/kubescape/operator/admission/rules" - "github.com/kubescape/operator/objectcache" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apiserver/pkg/admission" - "k8s.io/apiserver/pkg/authentication/user" -) - -const ( - R2000ID = "R2000" - R2000Name = "Exec to pod" -) - -var R2000ExecToPodRuleDescriptor = RuleDescriptor{ - ID: R2000ID, - Name: R2000Name, - Description: "Detecting exec to pod", - Tags: []string{"exec"}, - Priority: RulePriorityLow, - RuleCreationFunc: func() rules.RuleEvaluator { - return CreateRuleR2000ExecToPod() - }, -} - -type R2000ExecToPod struct { - BaseRule -} - -func CreateRuleR2000ExecToPod() *R2000ExecToPod { - return &R2000ExecToPod{} -} -func (rule *R2000ExecToPod) Name() string { - return R2000Name -} - -func (rule *R2000ExecToPod) ID() string { - return R2000ID -} - -func (rule *R2000ExecToPod) DeleteRule() { -} - -func (rule *R2000ExecToPod) ProcessEvent(event admission.Attributes, access objectcache.KubernetesCache) rules.RuleFailure { - if event == nil { - return nil - } - - if event.GetKind().Kind != "PodExecOptions" { - return nil - } - - var oldObject *unstructured.Unstructured - if event.GetOldObject() != nil { - oldObject = event.GetOldObject().(*unstructured.Unstructured) - } - - var options *unstructured.Unstructured - if event.GetOperationOptions() != nil { - options = event.GetOperationOptions().(*unstructured.Unstructured) - } - - client := access.GetClientset() - - pod, workloadKind, workloadName, workloadNamespace, workloadUID, nodeName, err := GetControllerDetails(event, client) - if err != nil { - logger.L().Error("Failed to get pod details", helpers.Error(err)) - return nil - } - - containerName, err := GetContainerNameFromExecToPodEvent(event) - if err != nil { - logger.L().Error("Failed to get container name from exec to pod event", helpers.Error(err)) - containerName = "" - } - - containerID := GetContainerID(pod, containerName) - - cmdline, err := getCommandLine(event.GetObject().(*unstructured.Unstructured)) - if err != nil { - logger.L().Error("Failed to get command line from exec to pod event", helpers.Error(err)) - cmdline = "" - } - - ruleFailure := GenericRuleFailure{ - BaseRuntimeAlert: apitypes.BaseRuntimeAlert{ - AlertName: rule.Name(), - FixSuggestions: "If this is a legitimate action, please consider removing this workload from the binding of this rule", - Severity: R2000ExecToPodRuleDescriptor.Priority, - Timestamp: time.Unix(0, time.Now().UnixNano()), - Identifiers: &common.Identifiers{ - Process: &common.ProcessEntity{ - Name: extractComm(cmdline), - CommandLine: cmdline, - }, - }, - UniqueID: fmt.Sprintf("%s%s%s", event.GetNamespace(), event.GetName(), containerName), - }, - AdmissionAlert: apitypes.AdmissionAlert{ - Kind: event.GetKind(), - ObjectName: event.GetName(), - RequestNamespace: event.GetNamespace(), - Resource: event.GetResource(), - Operation: event.GetOperation(), - Object: event.GetObject().(*unstructured.Unstructured), - Subresource: event.GetSubresource(), - UserInfo: &user.DefaultInfo{ - Name: event.GetUserInfo().GetName(), - UID: event.GetUserInfo().GetUID(), - Groups: event.GetUserInfo().GetGroups(), - Extra: event.GetUserInfo().GetExtra(), - }, - - DryRun: event.IsDryRun(), - Options: options, - OldObject: oldObject, - }, - RuleAlert: apitypes.RuleAlert{ - RuleDescription: fmt.Sprintf("Exec to pod detected on pod %s", event.GetName()), - }, - RuntimeAlertK8sDetails: apitypes.RuntimeAlertK8sDetails{ - PodName: event.GetName(), - PodNamespace: event.GetNamespace(), - Namespace: event.GetNamespace(), - PodUID: string(pod.UID), - WorkloadName: workloadName, - WorkloadNamespace: workloadNamespace, - WorkloadKind: workloadKind, - WorkloadUID: workloadUID, - NodeName: nodeName, - ContainerName: containerName, - ContainerID: containerID, - Image: GetContainerImage(pod, containerName), - ImageDigest: GetContainerImageDigest(pod, containerName), - }, - RuleID: R2000ID, - RuntimeProcessDetails: apitypes.ProcessTree{ - ProcessTree: apitypes.Process{ - Cmdline: cmdline, - Comm: extractComm(cmdline), - }, - }, - } - - return &ruleFailure -} - -func getCommandLine(object *unstructured.Unstructured) (string, error) { - commandField, ok := object.Object["command"] - if !ok { - return "", fmt.Errorf("alert is missing admission alert object command") - } - command, ok := interfaceToStringSlice(commandField) - if !ok { - return "", fmt.Errorf("alert cannot convert alert object command to string list") - } - - return strings.Join(command, " "), nil -} - -func extractComm(cmdline string) string { - comm := strings.Split(cmdline, " ") - if len(comm) == 0 { - return cmdline - } - - return comm[0] -} - -func interfaceToStringSlice(data interface{}) ([]string, bool) { - switch v := data.(type) { - case []string: - return v, true - case []interface{}: - result := make([]string, len(v)) - for i, item := range v { - str, ok := item.(string) - if !ok { - return nil, false - } - result[i] = str - } - return result, true - case string: - return []string{v}, true - default: - return nil, false - } -} diff --git a/admission/rules/v1/r2000_exec_to_pod_test.go b/admission/rules/v1/r2000_exec_to_pod_test.go deleted file mode 100644 index d54e7459..00000000 --- a/admission/rules/v1/r2000_exec_to_pod_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package rules - -import ( - "testing" - - "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" - "k8s.io/apiserver/pkg/authentication/user" -) - -func TestR2000(t *testing.T) { - event := admission.NewAttributesRecord( - &unstructured.Unstructured{ - Object: map[string]interface{}{ - "kind": "PodExecOptions", - "apiVersion": "v1", - "command": []interface{}{"bash"}, - "container": "test-container", - "stdin": true, - "stdout": true, - "stderr": true, - "tty": true, - }, - }, - 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 := CreateRuleR2000ExecToPod() - result := rule.ProcessEvent(event, objectcache.KubernetesCacheMockImpl{}) - - assert.NotNil(t, result) - assert.Equal(t, "test-container", result.GetRuntimeAlertK8sDetails().ContainerName) - assert.Equal(t, "test-workload", result.GetRuntimeAlertK8sDetails().WorkloadName) - assert.Equal(t, "test-namespace", result.GetRuntimeAlertK8sDetails().WorkloadNamespace) - assert.Equal(t, "ReplicaSet", result.GetRuntimeAlertK8sDetails().WorkloadKind) - assert.Equal(t, "test-replicaset-uid-12345", result.GetRuntimeAlertK8sDetails().WorkloadUID) - assert.Equal(t, "test-node", result.GetRuntimeAlertK8sDetails().NodeName) - assert.Equal(t, "Exec to pod detected on pod test-pod", result.GetRuleAlert().RuleDescription) - assert.Equal(t, "test-pod", result.GetRuntimeAlertK8sDetails().PodName) - assert.Equal(t, "test-pod-uid-12345", result.GetRuntimeAlertK8sDetails().PodUID) - 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) { - // Test that empty container name defaults to first container (Kubernetes behavior) - event := admission.NewAttributesRecord( - &unstructured.Unstructured{ - Object: map[string]interface{}{ - "kind": "PodExecOptions", - "apiVersion": "v1", - "command": []interface{}{"sh"}, - // No "container" field - should default to first container - "stdin": true, - "stdout": true, - "stderr": true, - "tty": true, - }, - }, - 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 := CreateRuleR2000ExecToPod() - result := rule.ProcessEvent(event, objectcache.KubernetesCacheMockImpl{}) - - assert.NotNil(t, result) - // Container name should be empty (not specified) - assert.Equal(t, "", result.GetRuntimeAlertK8sDetails().ContainerName) - // But ContainerID should be resolved to first container - assert.Equal(t, "containerd://abcdef1234567890", result.GetRuntimeAlertK8sDetails().ContainerID) - // WorkloadUID should be populated even though container name was empty - assert.Equal(t, "test-replicaset-uid-12345", result.GetRuntimeAlertK8sDetails().WorkloadUID) - assert.Equal(t, "test-pod-uid-12345", result.GetRuntimeAlertK8sDetails().PodUID) - 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 deleted file mode 100644 index e446c881..00000000 --- a/admission/rules/v1/r2001_portforward.go +++ /dev/null @@ -1,125 +0,0 @@ -package rules - -import ( - "fmt" - "time" - - apitypes "github.com/armosec/armoapi-go/armotypes" - "github.com/kubescape/go-logger" - "github.com/kubescape/go-logger/helpers" - "github.com/kubescape/operator/admission/rules" - "github.com/kubescape/operator/objectcache" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apiserver/pkg/admission" - "k8s.io/apiserver/pkg/authentication/user" -) - -const ( - R2001ID = "R2001" - R2001Name = "Port forward" -) - -var R2001PortForwardRuleDescriptor = RuleDescriptor{ - ID: R2001ID, - Name: R2001Name, - Description: "Detecting port forward", - Tags: []string{"portforward"}, - Priority: RulePriorityLow, - RuleCreationFunc: func() rules.RuleEvaluator { - return CreateRuleR2001PortForward() - }, -} - -type R2001PortForward struct { - BaseRule -} - -func CreateRuleR2001PortForward() *R2001PortForward { - return &R2001PortForward{} -} -func (rule *R2001PortForward) Name() string { - return R2001Name -} - -func (rule *R2001PortForward) ID() string { - return R2001ID -} - -func (rule *R2001PortForward) DeleteRule() { -} - -func (rule *R2001PortForward) ProcessEvent(event admission.Attributes, access objectcache.KubernetesCache) rules.RuleFailure { - if event == nil { - return nil - } - - if event.GetKind().Kind != "PodPortForwardOptions" { - return nil - } - - var oldObject *unstructured.Unstructured - if event.GetOldObject() != nil { - oldObject = event.GetOldObject().(*unstructured.Unstructured) - } - - var options *unstructured.Unstructured - if event.GetOperationOptions() != nil { - options = event.GetOperationOptions().(*unstructured.Unstructured) - } - - client := access.GetClientset() - - 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 - } - - ruleFailure := GenericRuleFailure{ - BaseRuntimeAlert: apitypes.BaseRuntimeAlert{ - AlertName: rule.Name(), - FixSuggestions: "If this is a legitimate action, please consider removing this workload from the binding of this rule", - Severity: R2001PortForwardRuleDescriptor.Priority, - Timestamp: time.Unix(0, time.Now().UnixNano()), - UniqueID: fmt.Sprintf("%s%s%s", event.GetNamespace(), event.GetName(), workloadName), - }, - AdmissionAlert: apitypes.AdmissionAlert{ - Kind: event.GetKind(), - ObjectName: event.GetName(), - RequestNamespace: event.GetNamespace(), - Resource: event.GetResource(), - Operation: event.GetOperation(), - Object: event.GetObject().(*unstructured.Unstructured), - Subresource: event.GetSubresource(), - UserInfo: &user.DefaultInfo{ - Name: event.GetUserInfo().GetName(), - UID: event.GetUserInfo().GetUID(), - Groups: event.GetUserInfo().GetGroups(), - Extra: event.GetUserInfo().GetExtra(), - }, - - DryRun: event.IsDryRun(), - Options: options, - OldObject: oldObject, - }, - RuleAlert: apitypes.RuleAlert{ - RuleDescription: fmt.Sprintf("Port forward detected on pod %s", event.GetName()), - }, - RuntimeAlertK8sDetails: apitypes.RuntimeAlertK8sDetails{ - PodName: event.GetName(), - Namespace: event.GetNamespace(), - PodNamespace: event.GetNamespace(), - PodUID: string(pod.UID), - WorkloadName: workloadName, - WorkloadNamespace: workloadNamespace, - WorkloadKind: workloadKind, - WorkloadUID: workloadUID, - NodeName: nodeName, - Image: GetContainerImage(pod, ""), - ImageDigest: GetContainerImageDigest(pod, ""), - }, - RuleID: R2001ID, - } - - return &ruleFailure -} diff --git a/admission/rules/v1/r2001_portforward_test.go b/admission/rules/v1/r2001_portforward_test.go deleted file mode 100644 index 78c7e30e..00000000 --- a/admission/rules/v1/r2001_portforward_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package rules - -import ( - "testing" - - "github.com/kubescape/operator/objectcache" - "github.com/zeebo/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 TestR2001(t *testing.T) { - event := admission.NewAttributesRecord( - &unstructured.Unstructured{ - Object: map[string]interface{}{ - "kind": "PodPortForwardOptions", - }, - }, - nil, - schema.GroupVersionKind{ - Kind: "PodPortForwardOptions", - }, - "test-namespace", - "test-pod", - schema.GroupVersionResource{ - Resource: "pods", - }, - "", - admission.Create, - nil, - false, - &user.DefaultInfo{ - Name: "test-user", - Groups: []string{"test-group"}, - }, - ) - - rule := CreateRuleR2001PortForward() - result := rule.ProcessEvent(event, objectcache.KubernetesCacheMockImpl{}) - - assert.NotNil(t, result) - assert.Equal(t, "test-workload", result.GetRuntimeAlertK8sDetails().WorkloadName) - assert.Equal(t, "test-namespace", result.GetRuntimeAlertK8sDetails().WorkloadNamespace) - assert.Equal(t, "ReplicaSet", result.GetRuntimeAlertK8sDetails().WorkloadKind) - assert.Equal(t, "test-replicaset-uid-12345", result.GetRuntimeAlertK8sDetails().WorkloadUID) - assert.Equal(t, "test-node", result.GetRuntimeAlertK8sDetails().NodeName) - 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-pod-uid-12345", result.GetRuntimeAlertK8sDetails().PodUID) - 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/admission/rules/v1/rule.go b/admission/rules/v1/rule.go deleted file mode 100644 index 9dcca22f..00000000 --- a/admission/rules/v1/rule.go +++ /dev/null @@ -1,66 +0,0 @@ -package rules - -import ( - "github.com/goradd/maps" - "github.com/kubescape/operator/admission/rules" -) - -const ( - RulePriorityNone = 0 - RulePriorityLow = 1 - RulePriorityMed = 5 - RulePriorityHigh = 8 - RulePriorityCritical = 10 - RulePrioritySystemIssue = 1000 -) - -type RuleDescriptor struct { - // Rule ID - ID string - // Rule Name - Name string - // Rule Description - Description string - // Priority - Priority int - // Tags - Tags []string - // Create a rule function - RuleCreationFunc func() rules.RuleEvaluator -} - -func (r *RuleDescriptor) HasTags(tags []string) bool { - for _, tag := range tags { - for _, ruleTag := range r.Tags { - if tag == ruleTag { - return true - } - } - } - return false -} - -type BaseRule struct { - // Mutex for protecting rule parameters. - parameters maps.SafeMap[string, interface{}] -} - -func (br *BaseRule) SetParameters(parameters map[string]interface{}) { - for k, v := range parameters { - br.parameters.Set(k, v) - } -} - -func (br *BaseRule) GetParameters() map[string]interface{} { - - // Create a copy to avoid returning a reference to the internal map - parametersCopy := make(map[string]interface{}, br.parameters.Len()) - - br.parameters.Range( - func(key string, value interface{}) bool { - parametersCopy[key] = value - return true - }, - ) - return parametersCopy -} diff --git a/admission/ruleswatcher/watcher.go b/admission/ruleswatcher/watcher.go new file mode 100644 index 00000000..43286e8e --- /dev/null +++ b/admission/ruleswatcher/watcher.go @@ -0,0 +1,158 @@ +package ruleswatcher + +import ( + "context" + "encoding/json" + + armotypes "github.com/armosec/armoapi-go/armotypes" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" + "github.com/kubescape/node-agent/pkg/k8sclient" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/watcher" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// RuleSyncer receives a filtered set of k8s-admission rules and applies them. +type RuleSyncer interface { + SyncRules(rules []armotypes.RuntimeRule) +} + +// RBCacheRefresher triggers a cache refresh after rules are synced. +type RBCacheRefresher interface { + RefreshRules() +} + +// RulesWatcher implements watcher.Adaptor, watching the Rules CRD and syncing +// any k8s-admission rules to the provided RuleSyncer. +type RulesWatcher struct { + k8sClient k8sclient.K8sClientInterface + ruleSyncer RuleSyncer + cacheRefresher RBCacheRefresher + watchResources []watcher.WatchResource +} + +var _ watcher.Adaptor = (*RulesWatcher)(nil) + +// NewRulesWatcher creates a RulesWatcher that watches the Rules CRD and +// delegates matching rules to ruleSyncer. If cacheRefresher is non-nil it is +// called after every sync. +func NewRulesWatcher(k8sClient k8sclient.K8sClientInterface, ruleSyncer RuleSyncer, cacheRefresher RBCacheRefresher) *RulesWatcher { + return &RulesWatcher{ + k8sClient: k8sClient, + ruleSyncer: ruleSyncer, + cacheRefresher: cacheRefresher, + watchResources: []watcher.WatchResource{ + watcher.NewWatchResource(typesv1.RuleGvr, metav1.ListOptions{}), + }, + } +} + +// WatchResources implements watcher.Adaptor. +func (w *RulesWatcher) WatchResources() []watcher.WatchResource { + return w.watchResources +} + +// AddHandler implements watcher.Adaptor. Any add event triggers a full sync. +func (w *RulesWatcher) AddHandler(ctx context.Context, _ runtime.Object) { + w.syncAllRules(ctx) +} + +// ModifyHandler implements watcher.Adaptor. Any modify event triggers a full sync. +func (w *RulesWatcher) ModifyHandler(ctx context.Context, _ runtime.Object) { + w.syncAllRules(ctx) +} + +// DeleteHandler implements watcher.Adaptor. Any delete event triggers a full sync. +func (w *RulesWatcher) DeleteHandler(ctx context.Context, _ runtime.Object) { + w.syncAllRules(ctx) +} + +// syncAllRules lists all Rules CRDs, extracts k8s-admission rules, and syncs +// them to the RuleSyncer. +func (w *RulesWatcher) syncAllRules(ctx context.Context) { + list, err := w.k8sClient.GetDynamicClient().Resource(typesv1.RuleGvr).List(ctx, metav1.ListOptions{}) + if err != nil { + logger.L().Error("failed to list Rules CRDs", helpers.Error(err)) + return + } + + var allRules []armotypes.RuntimeRule + for i := range list.Items { + rules, err := extractRulesFromCRD(list.Items[i].Object) + if err != nil { + logger.L().Warning("failed to extract rules from CRD", + helpers.String("name", list.Items[i].GetName()), + helpers.Error(err)) + continue + } + allRules = append(allRules, rules...) + } + + filtered := filterAdmissionRules(allRules) + w.ruleSyncer.SyncRules(filtered) + + if w.cacheRefresher != nil { + w.cacheRefresher.RefreshRules() + } +} + +// specWrapper is the top-level CRD spec shape expected from the Rules CRD. +type specWrapper struct { + Rules []armotypes.RuntimeRule `json:"rules"` +} + +// extractRulesFromCRD marshals the "spec" field of a CRD object map to JSON +// and unmarshals it into a slice of RuntimeRule values. +func extractRulesFromCRD(crd map[string]interface{}) ([]armotypes.RuntimeRule, error) { + specRaw, ok := crd["spec"] + if !ok { + return nil, nil + } + + data, err := json.Marshal(specRaw) + if err != nil { + return nil, err + } + + var wrapper specWrapper + if err := json.Unmarshal(data, &wrapper); err != nil { + return nil, err + } + + return wrapper.Rules, nil +} + +// filterAdmissionRules returns only rules that are enabled and have at least +// one expression with EventType == k8s-admission. +func filterAdmissionRules(rules []armotypes.RuntimeRule) []armotypes.RuntimeRule { + if len(rules) == 0 { + return []armotypes.RuntimeRule{} + } + + var result []armotypes.RuntimeRule + for _, r := range rules { + if !r.Enabled { + continue + } + if hasAdmissionExpression(r) { + result = append(result, r) + } + } + if result == nil { + return []armotypes.RuntimeRule{} + } + return result +} + +// hasAdmissionExpression reports whether the rule contains at least one +// expression with EventType == k8s-admission. +func hasAdmissionExpression(r armotypes.RuntimeRule) bool { + for _, expr := range r.Expressions.RuleExpression { + if expr.EventType == armotypes.EventTypeK8sAdmission { + return true + } + } + return false +} diff --git a/admission/ruleswatcher/watcher_test.go b/admission/ruleswatcher/watcher_test.go new file mode 100644 index 00000000..eeb862d5 --- /dev/null +++ b/admission/ruleswatcher/watcher_test.go @@ -0,0 +1,220 @@ +package ruleswatcher + +import ( + "testing" + + armotypes "github.com/armosec/armoapi-go/armotypes" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// filterAdmissionRules +// --------------------------------------------------------------------------- + +func TestFilterAdmissionRules(t *testing.T) { + admissionExpr := armotypes.RuleExpression{ + EventType: "k8s-admission", + Expression: `event.Operation == "CREATE"`, + } + execExpr := armotypes.RuleExpression{ + EventType: "exec", + Expression: `true`, + } + + tests := []struct { + name string + input []armotypes.RuntimeRule + wantIDs []string + }{ + { + name: "keeps enabled rule with admission expression", + input: []armotypes.RuntimeRule{ + { + ID: "rule-enabled-admission", + Enabled: true, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{admissionExpr}, + }, + }, + }, + wantIDs: []string{"rule-enabled-admission"}, + }, + { + name: "drops disabled rule even if it has admission expression", + input: []armotypes.RuntimeRule{ + { + ID: "rule-disabled-admission", + Enabled: false, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{admissionExpr}, + }, + }, + }, + wantIDs: []string{}, + }, + { + name: "drops enabled rule with non-admission expression only", + input: []armotypes.RuntimeRule{ + { + ID: "rule-enabled-exec", + Enabled: true, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{execExpr}, + }, + }, + }, + wantIDs: []string{}, + }, + { + name: "keeps rule that has both admission and exec expressions", + input: []armotypes.RuntimeRule{ + { + ID: "rule-mixed", + Enabled: true, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{execExpr, admissionExpr}, + }, + }, + }, + wantIDs: []string{"rule-mixed"}, + }, + { + name: "filters correctly from a mixed set", + input: []armotypes.RuntimeRule{ + { + ID: "keep-1", + Enabled: true, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{admissionExpr}, + }, + }, + { + ID: "drop-disabled", + Enabled: false, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{admissionExpr}, + }, + }, + { + ID: "drop-exec-only", + Enabled: true, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{execExpr}, + }, + }, + { + ID: "keep-2", + Enabled: true, + Expressions: armotypes.RuleExpressions{ + RuleExpression: []armotypes.RuleExpression{admissionExpr}, + }, + }, + }, + wantIDs: []string{"keep-1", "keep-2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterAdmissionRules(tt.input) + gotIDs := make([]string, len(got)) + for i, r := range got { + gotIDs[i] = r.ID + } + assert.Equal(t, tt.wantIDs, gotIDs) + }) + } +} + +func TestFilterAdmissionRules_Empty(t *testing.T) { + t.Run("nil input returns empty slice", func(t *testing.T) { + got := filterAdmissionRules(nil) + assert.NotNil(t, got) + assert.Empty(t, got) + }) + + t.Run("empty slice returns empty slice", func(t *testing.T) { + got := filterAdmissionRules([]armotypes.RuntimeRule{}) + assert.NotNil(t, got) + assert.Empty(t, got) + }) +} + +// --------------------------------------------------------------------------- +// extractRulesFromCRD +// --------------------------------------------------------------------------- + +func TestExtractRulesFromCRD(t *testing.T) { + t.Run("extracts rules from valid CRD map", func(t *testing.T) { + crd := map[string]interface{}{ + "apiVersion": "kubescape.io/v1", + "kind": "Rule", + "metadata": map[string]interface{}{ + "name": "test-rule", + }, + "spec": map[string]interface{}{ + "rules": []interface{}{ + map[string]interface{}{ + "id": "rule-1", + "name": "Test Rule 1", + "enabled": true, + "expressions": map[string]interface{}{ + "ruleExpression": []interface{}{ + map[string]interface{}{ + "eventType": "k8s-admission", + "expression": `event.Operation == "CREATE"`, + }, + }, + }, + }, + map[string]interface{}{ + "id": "rule-2", + "name": "Test Rule 2", + "enabled": false, + "expressions": map[string]interface{}{ + "ruleExpression": []interface{}{ + map[string]interface{}{ + "eventType": "exec", + "expression": `true`, + }, + }, + }, + }, + }, + }, + } + + rules, err := extractRulesFromCRD(crd) + require.NoError(t, err) + require.Len(t, rules, 2) + + assert.Equal(t, "rule-1", rules[0].ID) + assert.Equal(t, "Test Rule 1", rules[0].Name) + assert.True(t, rules[0].Enabled) + require.Len(t, rules[0].Expressions.RuleExpression, 1) + assert.Equal(t, armotypes.EventType("k8s-admission"), rules[0].Expressions.RuleExpression[0].EventType) + + assert.Equal(t, "rule-2", rules[1].ID) + assert.False(t, rules[1].Enabled) + }) + + t.Run("returns nil for CRD without spec", func(t *testing.T) { + crd := map[string]interface{}{ + "apiVersion": "kubescape.io/v1", + "kind": "Rule", + } + rules, err := extractRulesFromCRD(crd) + require.NoError(t, err) + assert.Nil(t, rules) + }) + + t.Run("returns empty for spec with no rules", func(t *testing.T) { + crd := map[string]interface{}{ + "spec": map[string]interface{}{}, + } + rules, err := extractRulesFromCRD(crd) + require.NoError(t, err) + assert.Empty(t, rules) + }) +} diff --git a/admission/webhook/selfidentity.go b/admission/webhook/selfidentity.go new file mode 100644 index 00000000..0a4121b3 --- /dev/null +++ b/admission/webhook/selfidentity.go @@ -0,0 +1,55 @@ +package webhook + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" +) + +// defaultServiceAccountTokenPath is the path at which the projected service +// account token is mounted inside the pod. +const defaultServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + +// readSelfSubject returns the operator's own admission subject (the value that +// will appear as UserInfo.Name on admission requests originating from this +// pod) by parsing the unverified `sub` claim out of the projected service +// account token at tokenPath. +// +// Returns ("", err) if the token cannot be read or parsed. Callers should +// treat an error as "self-detection unavailable" and continue without the +// short-circuit rather than failing startup. +func readSelfSubject(tokenPath string) (string, error) { + data, err := os.ReadFile(tokenPath) + if err != nil { + return "", fmt.Errorf("read service account token: %w", err) + } + token := strings.TrimSpace(string(data)) + return subjectFromJWT(token) +} + +// subjectFromJWT extracts the `sub` claim from a JWT without verifying the +// signature. The token is only used to identify our own pod, so the signature +// is irrelevant — if an attacker can replace the projected token volume they +// already have full control of the operator's identity. +func subjectFromJWT(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", fmt.Errorf("malformed JWT: expected 3 segments, got %d", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("decode JWT payload: %w", err) + } + var claims struct { + Sub string `json:"sub"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("parse JWT claims: %w", err) + } + if claims.Sub == "" { + return "", fmt.Errorf("JWT has empty sub claim") + } + return claims.Sub, nil +} diff --git a/admission/webhook/selfidentity_test.go b/admission/webhook/selfidentity_test.go new file mode 100644 index 00000000..df33158c --- /dev/null +++ b/admission/webhook/selfidentity_test.go @@ -0,0 +1,123 @@ +package webhook + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +// makeJWT builds a JWT-shaped string with the given JSON payload. The header +// and signature segments are placeholders — subjectFromJWT does not verify. +func makeJWT(payload string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + body := base64.RawURLEncoding.EncodeToString([]byte(payload)) + sig := base64.RawURLEncoding.EncodeToString([]byte("placeholder")) + return strings.Join([]string{header, body, sig}, ".") +} + +func TestSubjectFromJWT(t *testing.T) { + tests := []struct { + name string + token string + want string + wantErr bool + }{ + { + name: "valid token with sub claim", + token: makeJWT(`{"sub":"system:serviceaccount:kubescape:operator","iss":"kubernetes/serviceaccount"}`), + want: "system:serviceaccount:kubescape:operator", + }, + { + name: "valid token with extra claims", + token: makeJWT(`{"sub":"system:serviceaccount:ns:sa","aud":["api"],"exp":1234567890,"kubernetes.io":{"namespace":"ns","serviceaccount":{"name":"sa","uid":"abc"}}}`), + want: "system:serviceaccount:ns:sa", + }, + { + name: "missing sub claim", + token: makeJWT(`{"iss":"kubernetes/serviceaccount"}`), + wantErr: true, + }, + { + name: "empty sub claim", + token: makeJWT(`{"sub":""}`), + wantErr: true, + }, + { + name: "not a JWT", + token: "not.a.jwt.token.extra", + wantErr: true, + }, + { + name: "too few segments", + token: "header.payload", + wantErr: true, + }, + { + name: "invalid base64 payload", + token: "header.!!!notbase64!!!.sig", + wantErr: true, + }, + { + name: "valid base64 but not JSON", + token: "header." + base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".sig", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := subjectFromJWT(tt.token) + if (err != nil) != tt.wantErr { + t.Fatalf("subjectFromJWT() err = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("subjectFromJWT() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestReadSelfSubject(t *testing.T) { + t.Run("reads valid token from file", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token") + token := makeJWT(`{"sub":"system:serviceaccount:kubescape:operator"}`) + if err := os.WriteFile(path, []byte(token), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + + got, err := readSelfSubject(path) + if err != nil { + t.Fatalf("readSelfSubject() error: %v", err) + } + if got != "system:serviceaccount:kubescape:operator" { + t.Errorf("got %q, want %q", got, "system:serviceaccount:kubescape:operator") + } + }) + + t.Run("trims surrounding whitespace", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token") + token := makeJWT(`{"sub":"system:serviceaccount:ns:sa"}`) + if err := os.WriteFile(path, []byte("\n "+token+"\n"), 0o600); err != nil { + t.Fatalf("write token: %v", err) + } + + got, err := readSelfSubject(path) + if err != nil { + t.Fatalf("readSelfSubject() error: %v", err) + } + if got != "system:serviceaccount:ns:sa" { + t.Errorf("got %q, want %q", got, "system:serviceaccount:ns:sa") + } + }) + + t.Run("missing file returns error", func(t *testing.T) { + _, err := readSelfSubject("/no/such/path/token") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + }) +} diff --git a/admission/webhook/validator.go b/admission/webhook/validator.go index dcc20ab7..b298694a 100644 --- a/admission/webhook/validator.go +++ b/admission/webhook/validator.go @@ -3,6 +3,9 @@ package webhook import ( "context" "fmt" + "sync" + "sync/atomic" + "time" "github.com/kubescape/go-logger" "github.com/kubescape/go-logger/helpers" @@ -17,52 +20,276 @@ import ( "k8s.io/client-go/kubernetes" ) +const ( + // defaultWorkerPoolSize is the number of goroutines draining the + // admission evaluation queue. The pool is bounded; under burst load + // excess events are dropped, never queued unboundedly. + defaultWorkerPoolSize = 10 + // defaultQueueSize bounds the in-flight evaluation backlog. Values that + // can't be enqueued because the queue is full are dropped with a warning. + defaultQueueSize = 1000 + // dropLogInterval throttles drop warnings to every N drops so a burst + // can't flood the operator log. + dropLogInterval = 100 + // shutdownDrainTimeout bounds how long each worker spends draining queued + // jobs after its context is canceled. Long enough to flush a full queue + // against a healthy backend; short enough that pod termination is not + // noticeably delayed. + shutdownDrainTimeout = 10 * time.Second +) + +// evalJob is the unit of work handed off to the validator's worker pool. +// Holding admission.Attributes directly is safe: the framework does not +// pool or recycle these records, so workers can read the same fields the +// validator handler saw. +type evalJob struct { + attrs admission.Attributes +} + +// KindAcceptor decides whether an admission Kind should be evaluated by the +// rule pipeline. Implementations should return true for any Kind that at least +// one currently-loaded rule could match (and true for all Kinds when no +// static set can be determined). The validator uses this to skip work for +// events no rule targets. +type KindAcceptor interface { + Accepts(kind string) bool +} + type AdmissionValidator struct { kubernetesClient *k8sinterface.KubernetesApi objectCache objectcache.ObjectCache exporter exporters.Exporter ruleBindingCache rulebinding.RuleBindingCache + + // selfSubject is the operator's own admission subject + // (system:serviceaccount::). When set, requests with a matching + // UserInfo.Name are dropped before rule evaluation to prevent positive + // feedback loops from the operator's own API writes. Empty when the + // service account token cannot be parsed at startup. + selfSubject string + + // kindAcceptor pre-filters admission events by Kind before they enter + // the evaluation pipeline. nil means accept all Kinds. + kindAcceptor KindAcceptor + + // Async evaluation pipeline. Validate() snapshots requests onto jobs + // and returns nil immediately; worker goroutines drain the channel. + jobs chan evalJob + workerCount int + dropCount atomic.Int64 + started atomic.Bool + wg sync.WaitGroup } func NewAdmissionValidator(kubernetesClient *k8sinterface.KubernetesApi, objectCache objectcache.ObjectCache, exporter exporters.Exporter, ruleBindingCache rulebinding.RuleBindingCache) *AdmissionValidator { - return &AdmissionValidator{ + av := &AdmissionValidator{ kubernetesClient: kubernetesClient, objectCache: objectCache, exporter: exporter, ruleBindingCache: ruleBindingCache, + jobs: make(chan evalJob, defaultQueueSize), + workerCount: defaultWorkerPoolSize, + } + + subject, err := readSelfSubject(defaultServiceAccountTokenPath) + if err != nil { + logger.L().Warning("self-pod short-circuit disabled: could not read service account token", + helpers.Error(err)) + } else { + av.selfSubject = subject + logger.L().Info("self-pod short-circuit enabled", + helpers.String("selfSubject", subject)) + } + + return av +} + +// SetSelfSubject overrides the operator's self subject. Exposed for tests. +func (av *AdmissionValidator) SetSelfSubject(subject string) { + av.selfSubject = subject +} + +// SetKindAcceptor installs the Kind pre-filter used to skip evaluation for +// admission events no loaded rule could match. Passing nil disables the +// pre-filter and accepts every Kind. +func (av *AdmissionValidator) SetKindAcceptor(a KindAcceptor) { + av.kindAcceptor = a +} + +// SetWorkerPool reconfigures the async evaluation pipeline. Must be called +// before Start. Exposed for tests; in production, defaults are used. +func (av *AdmissionValidator) SetWorkerPool(workers, queueSize int) { + if av.started.Load() { + return + } + if workers > 0 { + av.workerCount = workers } + if queueSize > 0 { + av.jobs = make(chan evalJob, queueSize) + } +} + +// Start spawns the worker pool that drains the evaluation queue. Idempotent +// — subsequent calls are no-ops. Workers exit when ctx is canceled. +func (av *AdmissionValidator) Start(ctx context.Context) { + if !av.started.CompareAndSwap(false, true) { + return + } + logger.L().Info("admission validator workers starting", + helpers.Int("workers", av.workerCount), + helpers.Int("queueSize", cap(av.jobs))) + for i := 0; i < av.workerCount; i++ { + av.wg.Add(1) + go av.runWorker(ctx) + } +} + +// Wait blocks until all worker goroutines have exited. Exposed for tests and +// graceful shutdown. +func (av *AdmissionValidator) Wait() { + av.wg.Wait() +} + +// DropCount returns the cumulative number of admission events the validator +// has dropped because the evaluation queue was full. Suitable for export as +// a Prometheus counter. +func (av *AdmissionValidator) DropCount() int64 { + return av.dropCount.Load() } func (av *AdmissionValidator) GetClientset() kubernetes.Interface { return av.objectCache.GetKubernetesCache().GetClientset() } -// We are implementing the Validate method from the ValidationInterface interface. -func (av *AdmissionValidator) Validate(ctx context.Context, attrs admission.Attributes, o admission.ObjectInterfaces) (err error) { - if attrs.GetObject() != nil { - var object *unstructured.Unstructured - // Fetch the resource if it is a pod and the object is not a pod. - if attrs.GetResource().Resource == "pods" && attrs.GetKind().Kind != "Pod" { - object, err = av.fetchResource(ctx, attrs) - if err != nil { - return admission.NewForbidden(attrs, fmt.Errorf("failed to fetch resource: %w", err)) - } - } else { - object = attrs.GetObject().(*unstructured.Unstructured) +// isSelfRequest reports whether the admission request originated from the +// operator's own ServiceAccount. Used to short-circuit feedback loops. +func (av *AdmissionValidator) isSelfRequest(attrs admission.Attributes) bool { + if av.selfSubject == "" { + return false + } + ui := attrs.GetUserInfo() + if ui == nil { + return false + } + return ui.GetName() == av.selfSubject +} + +// Validate implements admission.ValidationInterface. The API server is +// synchronous from the framework's perspective, but our rule evaluation is +// not: matching requests are enqueued onto a bounded worker pool and the +// validator returns nil immediately. The API server never waits on CEL. +// +// Requests dropped here include: +// - Requests from the operator's own ServiceAccount (feedback-loop guard). +// - Requests whose Kind no loaded rule could match (Kind pre-filter). +// - Requests that cannot be enqueued because the queue is full (drop with +// warning + counter, never block the API server). +func (av *AdmissionValidator) Validate(_ context.Context, attrs admission.Attributes, _ admission.ObjectInterfaces) error { + if av.isSelfRequest(attrs) { + return nil + } + if av.kindAcceptor != nil && !av.kindAcceptor.Accepts(attrs.GetKind().Kind) { + return nil + } + if attrs.GetObject() == nil { + return nil + } + + select { + case av.jobs <- evalJob{attrs: attrs}: + default: + n := av.dropCount.Add(1) + if n == 1 || n%dropLogInterval == 0 { + logger.L().Warning("admission queue full, dropping event", + helpers.String("kind", attrs.GetKind().Kind), + helpers.String("namespace", attrs.GetNamespace()), + helpers.String("name", attrs.GetName()), + helpers.Int("totalDropped", int(n))) } + } - rules := av.ruleBindingCache.ListRulesForObject(ctx, object) - for _, rule := range rules { - failure := rule.ProcessEvent(attrs, av) - if failure != nil { - logger.L().Info("Rule failed", helpers.Interface("failure", failure)) - av.exporter.SendAdmissionAlert(failure) - return admission.NewForbidden(attrs, nil) + return nil +} + +// runWorker drains the evaluation queue. While ctx is live the worker blocks +// on av.jobs. Once ctx is canceled the worker switches to a non-blocking +// drain pass so jobs enqueued just before cancellation are still processed, +// then exits. Drain uses a fresh context with a bounded timeout — the worker +// ctx is already canceled and would short-circuit downstream API calls. +func (av *AdmissionValidator) runWorker(ctx context.Context) { + defer av.wg.Done() + for { + select { + case <-ctx.Done(): + av.drainJobs() + return + case job := <-av.jobs: + av.evaluate(ctx, job.attrs) + } + } +} + +// drainJobs processes every job currently in the queue, then returns. Multiple +// workers may call this concurrently; the channel reads are safe and they +// race to empty the queue cooperatively. +func (av *AdmissionValidator) drainJobs() { + drainCtx, cancel := context.WithTimeout(context.Background(), shutdownDrainTimeout) + defer cancel() + for { + select { + case <-drainCtx.Done(): + remaining := len(av.jobs) + if remaining > 0 { + logger.L().Warning("admission validator drain timed out; abandoning queued jobs", + helpers.Int("remaining", remaining)) } + return + case job := <-av.jobs: + av.evaluate(drainCtx, job.attrs) + default: + return } } +} - return nil +// evaluate runs the full match + alert pipeline for a single admission event. +// Errors are logged and swallowed — alert export is best-effort. +func (av *AdmissionValidator) evaluate(ctx context.Context, attrs admission.Attributes) { + var ( + object *unstructured.Unstructured + err error + ) + if attrs.GetResource().Resource == "pods" && attrs.GetKind().Kind != "Pod" { + object, err = av.fetchResource(ctx, attrs) + if err != nil { + logger.L().Warning("admission worker: failed to fetch resource", + helpers.String("kind", attrs.GetKind().Kind), + helpers.String("name", attrs.GetName()), + helpers.Error(err)) + return + } + } else { + un, ok := attrs.GetObject().(*unstructured.Unstructured) + if !ok { + logger.L().Warning("admission worker: object is not unstructured", + helpers.String("kind", attrs.GetKind().Kind)) + return + } + object = un + } + + matchedRules := av.ruleBindingCache.ListRulesForObject(ctx, object) + for _, rule := range matchedRules { + failure := rule.ProcessEvent(attrs, av) + if failure == nil { + continue + } + logger.L().Info("Rule matched", + helpers.String("ruleID", failure.GetRuleId()), + helpers.Interface("failure", failure)) + av.exporter.SendAdmissionAlert(failure) + } } // Fetch resource/objects from the Kubernetes API based on the given attributes. diff --git a/admission/webhook/validator_test.go b/admission/webhook/validator_test.go new file mode 100644 index 00000000..7131ccd4 --- /dev/null +++ b/admission/webhook/validator_test.go @@ -0,0 +1,409 @@ +package webhook + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/kubescape/operator/admission/rulebinding" + "github.com/kubescape/operator/admission/rules" + "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" +) + +// countingRuleBindingCache records how many times ListRulesForObject is called. +// Used by worker-pool tests to confirm enqueued jobs eventually reach evaluation. +type countingRuleBindingCache struct { + calls atomic.Int64 +} + +func (c *countingRuleBindingCache) ListRulesForObject(_ context.Context, _ *unstructured.Unstructured) []rules.RuleEvaluator { + c.calls.Add(1) + return nil +} + +// newTestValidator builds an AdmissionValidator with a small jobs channel so +// queue-depth assertions are easy to make. Workers are not started — pre-filter +// tests inspect the channel directly. +func newTestValidator(cache rulebinding.RuleBindingCache) *AdmissionValidator { + return &AdmissionValidator{ + ruleBindingCache: cache, + jobs: make(chan evalJob, 8), + workerCount: 1, + } +} + +func newSelfTestAttributes(username string) admission.Attributes { + // Use NetworkPolicy CREATE — this avoids the special pods/exec fetchResource + // branch in evaluate which would require a mocked dynamic client. + gvk := schema.GroupVersionKind{Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"} + gvr := schema.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "networkpolicies"} + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + }} + userInfo := &user.DefaultInfo{Name: username} + return admission.NewAttributesRecord(obj, nil, gvk, "default", "test-netpol", gvr, "", + admission.Create, nil, false, userInfo) +} + +func TestValidator_SelfPodShortCircuit(t *testing.T) { + const selfSubject = "system:serviceaccount:kubescape:operator" + + tests := []struct { + name string + configuredSubj string + requestUsername string + wantEnqueued bool + }{ + { + name: "request from operator SA is short-circuited", + configuredSubj: selfSubject, + requestUsername: selfSubject, + wantEnqueued: false, + }, + { + name: "request from kubernetes-admin enqueues", + configuredSubj: selfSubject, + requestUsername: "kubernetes-admin", + wantEnqueued: true, + }, + { + name: "request from a different SA enqueues", + configuredSubj: selfSubject, + requestUsername: "system:serviceaccount:default:builder", + wantEnqueued: true, + }, + { + name: "empty self subject disables the short-circuit", + configuredSubj: "", + requestUsername: selfSubject, + wantEnqueued: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + av := newTestValidator(&countingRuleBindingCache{}) + av.SetSelfSubject(tt.configuredSubj) + + attrs := newSelfTestAttributes(tt.requestUsername) + if err := av.Validate(context.Background(), attrs, nil); err != nil { + t.Fatalf("Validate returned error: %v", err) + } + + gotEnqueued := len(av.jobs) == 1 + if gotEnqueued != tt.wantEnqueued { + t.Errorf("enqueued=%v, want %v (queue len=%d)", + gotEnqueued, tt.wantEnqueued, len(av.jobs)) + } + }) + } +} + +// stubKindAcceptor accepts only the kinds in the set. +type stubKindAcceptor struct { + accepted map[string]struct{} +} + +func (s stubKindAcceptor) Accepts(kind string) bool { + _, ok := s.accepted[kind] + return ok +} + +func TestValidator_KindAcceptorPreFilter(t *testing.T) { + tests := []struct { + name string + acceptor KindAcceptor + wantEnqueued bool + }{ + { + name: "nil acceptor — every Kind enqueues", + acceptor: nil, + wantEnqueued: true, + }, + { + name: "acceptor includes NetworkPolicy — enqueues", + acceptor: stubKindAcceptor{accepted: map[string]struct{}{"NetworkPolicy": {}}}, + wantEnqueued: true, + }, + { + name: "acceptor excludes NetworkPolicy — short-circuited", + acceptor: stubKindAcceptor{accepted: map[string]struct{}{"Pod": {}}}, + wantEnqueued: false, + }, + { + name: "empty acceptor — short-circuited", + acceptor: stubKindAcceptor{accepted: map[string]struct{}{}}, + wantEnqueued: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + av := newTestValidator(&countingRuleBindingCache{}) + av.SetKindAcceptor(tt.acceptor) + + attrs := newSelfTestAttributes("kubernetes-admin") + if err := av.Validate(context.Background(), attrs, nil); err != nil { + t.Fatalf("Validate returned error: %v", err) + } + + gotEnqueued := len(av.jobs) == 1 + if gotEnqueued != tt.wantEnqueued { + t.Errorf("enqueued=%v, want %v", gotEnqueued, tt.wantEnqueued) + } + }) + } +} + +func TestValidator_SelfPodShortCircuit_NilUserInfo(t *testing.T) { + av := newTestValidator(&countingRuleBindingCache{}) + av.SetSelfSubject("system:serviceaccount:kubescape:operator") + + gvk := schema.GroupVersionKind{Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"} + gvr := schema.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "networkpolicies"} + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + }} + // Pass nil userInfo — should not short-circuit, should enqueue. + attrs := admission.NewAttributesRecord(obj, nil, gvk, "default", "test-netpol", gvr, "", + admission.Create, nil, false, nil) + + if err := av.Validate(context.Background(), attrs, nil); err != nil { + t.Fatalf("Validate returned error: %v", err) + } + + if len(av.jobs) != 1 { + t.Errorf("request with nil UserInfo was short-circuited; expected to enqueue (queue len=%d)", len(av.jobs)) + } +} + +// signalingRuleBindingCache notifies a channel each time ListRulesForObject is +// called. Lets worker-pool tests block until evaluation has actually happened. +type signalingRuleBindingCache struct { + calls atomic.Int64 + done chan struct{} +} + +func (c *signalingRuleBindingCache) ListRulesForObject(_ context.Context, _ *unstructured.Unstructured) []rules.RuleEvaluator { + c.calls.Add(1) + select { + case c.done <- struct{}{}: + default: + } + return nil +} + +func TestValidator_WorkerPool_ProcessesEnqueuedJobs(t *testing.T) { + cache := &signalingRuleBindingCache{done: make(chan struct{}, 4)} + av := newTestValidator(cache) + av.SetWorkerPool(2, 16) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + av.Start(ctx) + + for i := 0; i < 3; i++ { + if err := av.Validate(ctx, newSelfTestAttributes("kubernetes-admin"), nil); err != nil { + t.Fatalf("Validate returned error: %v", err) + } + } + + deadline := time.After(2 * time.Second) + for i := 0; i < 3; i++ { + select { + case <-cache.done: + case <-deadline: + t.Fatalf("timed out waiting for evaluation %d; cache calls=%d", i+1, cache.calls.Load()) + } + } + + if got := cache.calls.Load(); got != 3 { + t.Errorf("cache calls = %d, want 3", got) + } + if got := av.DropCount(); got != 0 { + t.Errorf("DropCount = %d, want 0", got) + } +} + +// blockingRuleBindingCache holds workers inside ListRulesForObject until release. +// Used to force the queue into a "full" state. +type blockingRuleBindingCache struct { + release chan struct{} + entered chan struct{} + count atomic.Int64 +} + +func (c *blockingRuleBindingCache) ListRulesForObject(_ context.Context, _ *unstructured.Unstructured) []rules.RuleEvaluator { + c.count.Add(1) + select { + case c.entered <- struct{}{}: + default: + } + <-c.release + return nil +} + +func TestValidator_WorkerPool_DropsWhenQueueFull(t *testing.T) { + cache := &blockingRuleBindingCache{ + release: make(chan struct{}), + entered: make(chan struct{}, 1), + } + defer close(cache.release) + + const workers = 1 + const queueSize = 2 + + av := newTestValidator(cache) + av.SetWorkerPool(workers, queueSize) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + av.Start(ctx) + + // Step 1: submit one job, wait until the worker has entered the cache + // call. After this barrier the worker is parked and the channel is empty, + // so subsequent submissions either fill the channel or drop. + if err := av.Validate(ctx, newSelfTestAttributes("kubernetes-admin"), nil); err != nil { + t.Fatalf("Validate returned error on first submission: %v", err) + } + select { + case <-cache.entered: + case <-time.After(2 * time.Second): + t.Fatal("worker never entered the blocking cache call") + } + + // Step 2: submit queueSize jobs to fill the channel. + for i := 0; i < queueSize; i++ { + if err := av.Validate(ctx, newSelfTestAttributes("kubernetes-admin"), nil); err != nil { + t.Fatalf("Validate returned error on queue-fill submission %d: %v", i, err) + } + } + if got := av.DropCount(); got != 0 { + t.Fatalf("DropCount = %d after queue fill, want 0", got) + } + + // Step 3: submit additional jobs — every one must drop. + const expectedDrops = 5 + for i := 0; i < expectedDrops; i++ { + if err := av.Validate(ctx, newSelfTestAttributes("kubernetes-admin"), nil); err != nil { + t.Fatalf("Validate returned error on drop submission %d: %v", i, err) + } + } + + if got := av.DropCount(); got != expectedDrops { + t.Errorf("DropCount = %d, want %d", got, expectedDrops) + } +} + +// TestValidator_WorkerPool_DrainsQueuedJobsOnShutdown verifies that jobs +// enqueued before context cancellation are processed by the drain path on +// worker exit rather than abandoned. +func TestValidator_WorkerPool_DrainsQueuedJobsOnShutdown(t *testing.T) { + cache := &blockingRuleBindingCache{ + release: make(chan struct{}), + entered: make(chan struct{}, 1), + } + + const workers = 1 + const queueSize = 4 + + av := newTestValidator(cache) + av.SetWorkerPool(workers, queueSize) + + ctx, cancel := context.WithCancel(context.Background()) + av.Start(ctx) + + // Step 1: submit one job and wait until the worker has actually entered + // the blocked cache call. After this barrier the channel is empty and + // the worker is parked. + if err := av.Validate(ctx, newSelfTestAttributes("kubernetes-admin"), nil); err != nil { + t.Fatalf("Validate returned error on first submission: %v", err) + } + select { + case <-cache.entered: + case <-time.After(2 * time.Second): + t.Fatal("worker never entered the blocking cache call") + } + + // Step 2: fill the queue with queueSize additional jobs — these must + // survive the cancel-and-drain handoff. + for i := 0; i < queueSize; i++ { + if err := av.Validate(ctx, newSelfTestAttributes("kubernetes-admin"), nil); err != nil { + t.Fatalf("Validate returned error on queue-fill submission %d: %v", i, err) + } + } + if got := av.DropCount(); got != 0 { + t.Fatalf("DropCount = %d after queue fill, want 0", got) + } + const total = 1 + queueSize + + // Cancel mid-flight. The worker, currently parked in the cache, won't + // see the cancel until ListRulesForObject returns. Release the cache so + // the worker continues; it must then enter the drain path and process + // every queued job before exiting. + cancel() + close(cache.release) + + done := make(chan struct{}) + go func() { + av.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("workers did not exit after cancel + drain") + } + + if got := cache.count.Load(); got != int64(total) { + t.Errorf("evaluated %d jobs, want %d (queued jobs were not drained)", got, total) + } + if got := av.DropCount(); got != 0 { + t.Errorf("DropCount = %d during drain test, want 0", got) + } +} + +func TestValidator_WorkerPool_StopsOnContextCancel(t *testing.T) { + av := newTestValidator(&countingRuleBindingCache{}) + av.SetWorkerPool(3, 4) + + ctx, cancel := context.WithCancel(context.Background()) + av.Start(ctx) + + cancel() + + done := make(chan struct{}) + go func() { + av.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("workers did not exit after context cancel") + } +} + +func TestValidator_NilObjectIsDropped(t *testing.T) { + av := newTestValidator(&countingRuleBindingCache{}) + + gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} + gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"} + // nil object — Validate should drop it without enqueuing. + attrs := admission.NewAttributesRecord(nil, nil, gvk, "default", "p", gvr, "", + admission.Create, nil, false, nil) + + if err := av.Validate(context.Background(), attrs, nil); err != nil { + t.Fatalf("Validate returned error: %v", err) + } + if len(av.jobs) != 0 { + t.Errorf("expected nil-object request to be dropped; queue len=%d", len(av.jobs)) + } +} diff --git a/docs/features/cel-admission-rules.md b/docs/features/cel-admission-rules.md new file mode 100644 index 00000000..be71727a --- /dev/null +++ b/docs/features/cel-admission-rules.md @@ -0,0 +1,106 @@ +# CEL-Driven Admission Rules + +## Overview + +The CEL admission rules feature replaces hardcoded admission webhook rules with +CRD-driven CEL (Common Expression Language) evaluation. Rules are stored in +`kubescape.io/v1` `Rules` CRDs and evaluated at admission time using a +configurable CEL engine. + +## Architecture + +``` +Rules CRD ──watch──▶ RulesWatcher ──sync──▶ CelRuleCreator + │ + ▼ +Admission Webhook ──▶ RBCache.ListRulesForObject ──▶ CelRuleEvaluator + │ + ▼ + AdmissionCEL.EvaluateRuleWithContext +``` + +## Components + +### AdmissionCEL (`admission/cel/cel.go`) + +CEL environment configured for evaluating expressions against admission events. +Compiled programs are cached for performance. + +Variables available in expressions: + +| Variable | Type | Description | +|-------------|-----------------------------|------------------------------------| +| `event` | `cel.AdmissionCelEvent` | The full admission event | +| `eventType` | `string` | Always `"k8s-admission"` | +| `object` | `map[string]any` | Incoming object (new state) | +| `oldObject` | `map[string]any` | Previous object (for UPDATE) | +| `options` | `map[string]any` | Admission options | + +### CelRuleCreator (`admission/rules/cel/creator.go`) + +Holds the active set of `armotypes.RuntimeRule` values and creates +`CelRuleEvaluator` instances on demand. The rule set is replaced atomically +via `SyncRules(rules []armotypes.RuntimeRule)`. + +### CelRuleEvaluator (`admission/rules/cel/evaluator.go`) + +Wraps a single `RuntimeRule` and implements `rules.RuleEvaluator`. Delegates +expression evaluation to `AdmissionCEL`. + +### RulesWatcher (`admission/ruleswatcher/watcher.go`) + +Implements `watcher.Adaptor`. Watches the `kubescape.io/v1/rules` CRD resource. +On any Add / Modify / Delete event it: + +1. Lists all `Rules` CRDs. +2. Extracts `RuntimeRule` values from each CRD's `spec.rules` field. +3. Filters to rules that are `enabled` and have at least one expression with + `eventType: k8s-admission`. +4. Calls `CelRuleCreator.SyncRules` with the filtered set. +5. Optionally calls `RBCacheRefresher.RefreshRules` to flush derived caches. + +## Rule CRD Format + +```yaml +apiVersion: kubescape.io/v1 +kind: Rule +metadata: + name: my-admission-rules +spec: + rules: + - id: "deny-privileged-containers" + name: "Deny Privileged Containers" + enabled: true + expressions: + ruleExpression: + - eventType: "k8s-admission" + expression: | + object.spec.containers.exists(c, + c.?securityContext.?privileged.orValue(false)) + severity: 8 + tags: ["security", "admission"] +``` + +## Event Type Constant + +`admission/cel/cel.go` defines: + +```go +const EventTypeK8sAdmission armotypes.EventType = "k8s-admission" +``` + +This is defined locally because the pinned `armoapi-go` version does not yet +export this constant. + +## Testing + +```bash +# Unit tests for CEL engine +go test ./admission/cel/... -v + +# Unit tests for CelRuleCreator and CelRuleEvaluator +go test ./admission/rules/cel/... -v + +# Unit tests for RulesWatcher +go test ./admission/ruleswatcher/... -v +``` diff --git a/go.mod b/go.mod index 82244f80..96874e92 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/kubescape/operator go 1.25.8 require ( - github.com/armosec/armoapi-go v0.0.673 + github.com/armosec/armoapi-go v0.0.714 github.com/armosec/registryx v0.0.35 github.com/armosec/utils-go v0.0.58 github.com/armosec/utils-k8s-go v0.0.35 @@ -18,6 +18,7 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/fsnotify/fsnotify v1.9.0 github.com/go-openapi/runtime v0.28.0 + github.com/google/cel-go v0.26.1 github.com/google/uuid v1.6.0 github.com/goradd/maps v1.3.0 github.com/gorilla/mux v1.8.1 @@ -32,7 +33,6 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go/modules/k3s v0.34.0 - github.com/zeebo/assert v1.3.1 go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.44.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 @@ -190,7 +190,6 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.26.1 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.21.2 // indirect diff --git a/go.sum b/go.sum index 1d04895c..708d29dd 100644 --- a/go.sum +++ b/go.sum @@ -145,8 +145,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armosec/armoapi-go v0.0.673 h1:Pwsf/K2Y1U8IExntU0iD+F0iAz1ys6BhiPyqEu2hw+Y= -github.com/armosec/armoapi-go v0.0.673/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= +github.com/armosec/armoapi-go v0.0.714 h1:gTgv20tUJo4EPWI3LrB56oxcNW7j3vvb5ZoK6kFgMYI= +github.com/armosec/armoapi-go v0.0.714/go.mod h1:9jAH0g8ZsryhiBDd/aNMX4+n10bGwTx/doWCyyjSxts= github.com/armosec/gojay v1.2.17 h1:VSkLBQzD1c2V+FMtlGFKqWXNsdNvIKygTKJI9ysY8eM= github.com/armosec/gojay v1.2.17/go.mod h1:vuvX3DlY0nbVrJ0qCklSS733AWMoQboq3cFyuQW9ybc= github.com/armosec/registryx v0.0.35 h1:fvP5/IZL0+jJUnNXvAj6ohJ6UVHaASVpR/cPLX0I4f0= @@ -1083,8 +1083,6 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A= -github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= diff --git a/main.go b/main.go index f058f5ea..fe2fe73b 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,10 @@ import ( "github.com/kubescape/node-agent/pkg/rulebindingmanager" "github.com/kubescape/node-agent/pkg/watcher/dynamicwatcher" exporters "github.com/kubescape/operator/admission/exporter" + admissioncel "github.com/kubescape/operator/admission/cel" + celrules "github.com/kubescape/operator/admission/rules/cel" rulebindingcachev1 "github.com/kubescape/operator/admission/rulebinding/cache" + "github.com/kubescape/operator/admission/ruleswatcher" "github.com/kubescape/operator/admission/rulesupdate" "github.com/kubescape/operator/admission/webhook" "github.com/kubescape/operator/config" @@ -193,29 +196,42 @@ func main() { addr := ":8443" + // CEL engine for admission rule evaluation + celEngine, err := admissioncel.NewAdmissionCEL() + if err != nil { + logger.L().Ctx(ctx).Fatal("failed to create admission CEL engine", helpers.Error(err)) + } + + // CRD-backed rule creator (replaces hardcoded R2000/R2001) + celRuleCreator := celrules.NewCelRuleCreator(celEngine) + // Create watchers dWatcher := dynamicwatcher.NewWatchHandler(k8sApi, ksStorageClient.SpdxV1beta1(), operatorConfig.SkipNamespace) - // create ruleBinding cache (when rules update is enabled we ignore bindings and run all rules) - ruleBindingCache := rulebindingcachev1.NewCache(k8sApi, operatorConfig.RulesUpdateEnabled()) + // create ruleBinding cache with injected rule creator + ruleBindingCache := rulebindingcachev1.NewCache(k8sApi, celRuleCreator, operatorConfig.RulesUpdateEnabled()) dWatcher.AddAdaptor(ruleBindingCache) + // Rules watcher — syncs k8s-admission rules from CRDs to the creator + rulesWatcher := ruleswatcher.NewRulesWatcher(k8sApi, celRuleCreator, ruleBindingCache) + dWatcher.AddAdaptor(rulesWatcher) + ruleBindingNotify := make(chan rulebindingmanager.RuleBindingNotify, 100) ruleBindingCache.AddNotifier(&ruleBindingNotify) - admissionController := webhook.New(addr, "/etc/certs/tls.crt", "/etc/certs/tls.key", runtime.NewScheme(), webhook.NewAdmissionValidator(k8sApi, objectCache, exporter, ruleBindingCache), ruleBindingCache) - // Start HTTP REST server for webhook + admissionValidator := webhook.NewAdmissionValidator(k8sApi, objectCache, exporter, ruleBindingCache) + admissionValidator.SetKindAcceptor(celRuleCreator) + admissionValidator.Start(serverContext) + + admissionController := webhook.New(addr, "/etc/certs/tls.crt", "/etc/certs/tls.key", runtime.NewScheme(), admissionValidator, ruleBindingCache) go func() { defer func() { - // Cancel the server context to stop other workers serverCancel() }() - err := admissionController.Run(serverContext) logger.L().Ctx(ctx).Fatal("server stopped", helpers.Error(err)) }() - // start watching dWatcher.Start(ctx) defer dWatcher.Stop(ctx) }