diff --git a/docs/generated/templates.md b/docs/generated/templates.md index 2c431017e..b9b24e4fd 100644 --- a/docs/generated/templates.md +++ b/docs/generated/templates.md @@ -63,6 +63,28 @@ KubeLinter supports the following templates: type: string ``` +## CEL + +**Key**: `cel-expression` + +**Description**: Flag objects with CEL expression + +**Supported Objects**: Any + + +**Parameters**: + +```yaml +- description: 'Check contains a CEL expression for validation logic. Two predefined + variables are available: ''object'' (the current Kubernetes object being processed) + and ''objects'' (all objects being linted).' + name: check + negationAllowed: true + regexAllowed: false + required: true + type: string +``` + ## cluster-admin Role Binding **Key**: `cluster-admin-role-binding` diff --git a/e2etests/bats-tests.sh b/e2etests/bats-tests.sh index 043107b16..a403a79ed 100755 --- a/e2etests/bats-tests.sh +++ b/e2etests/bats-tests.sh @@ -16,6 +16,35 @@ get_value_from() { echo "${value}" } +@test "template-cel" { + tmp="tests/checks/cel.yml" + cmd="${KUBE_LINTER_BIN} lint --config e2etests/testdata/cel-config.yaml --do-not-auto-add-defaults --format json ${tmp}" + run ${cmd} + + print_info "${status}" "${output}" "${cmd}" "${tmp}" + [ "$status" -eq 1 ] + + message1=$(get_value_from "${lines[0]}" '.Reports[0].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[0].Diagnostic.Message') + message2=$(get_value_from "${lines[0]}" '.Reports[1].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[1].Diagnostic.Message') + message3=$(get_value_from "${lines[0]}" '.Reports[2].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[2].Diagnostic.Message') + message4=$(get_value_from "${lines[0]}" '.Reports[3].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[3].Diagnostic.Message') + message5=$(get_value_from "${lines[0]}" '.Reports[4].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[4].Diagnostic.Message') + message6=$(get_value_from "${lines[0]}" '.Reports[5].Object.K8sObject.GroupVersionKind.Kind + ": " + .Reports[5].Diagnostic.Message') + + count=$(get_value_from "${lines[0]}" '.Reports | length') + + echo $message2 + + [[ "${message1}" == "Deployment: CEL check expression returned: Object has reloader annotation" ]] + [[ "${message2}" == "ServiceAccount: CEL check expression returned: Invalid EKS IAM role ARN format" ]] + [[ "${message3}" == "ServiceMonitor: CEL check expression returned: no services found matching the service monitor's label selector and namespace selector" ]] + [[ "${message4}" == "ServiceMonitor: CEL check expression returned: no services found matching the service monitor's label selector and namespace selector" ]] + [[ "${message5}" == "ServiceMonitor: CEL check expression returned: no services found matching the service monitor's label selector and namespace selector" ]] + [[ "${message6}" == "ServiceMonitor: CEL check expression returned: no services found matching the service monitor's label selector and namespace selector" ]] + + [[ "${count}" == "6" ]] +} + @test "template-check-installed-bash-version" { run "bash --version" [[ "${BASH_VERSION:0:1}" -ge '4' ]] || false diff --git a/e2etests/testdata/cel-config.yaml b/e2etests/testdata/cel-config.yaml new file mode 100644 index 000000000..5e0b9c4a5 --- /dev/null +++ b/e2etests/testdata/cel-config.yaml @@ -0,0 +1,59 @@ +checks: + addAllBuiltIn: false +customChecks: + - name: "cel-forbidden-annotation" + description: "cel espression cehck" + remediation: "Remove foo.bar/baz annotation" + scope: + objectKinds: + - DeploymentLike + template: "cel-expression" + params: + check: | + has(object.metadata.annotations) && object.metadata.annotations["foo.bar/baz"] == "true" ? "Object has reloader annotation" : "" + + - name: invalid-irsa-role + description: "IRSA annotations must have a valid IAM Role ARN value" + remediation: "Validate the format of the annotation's value to ensure it is a valid IAM Role ARN" + scope: + objectKinds: + - ServiceAccount + template: "cel-expression" + params: + check: | + object.metadata.annotations["eks.amazonaws.com/role-arn"].matches("^arn:aws:iam::\\d{12}:role/[\\w+=,.@-]{1,64}$") ? "" : "Invalid EKS IAM role ARN format" + + - name: "cel-dangling-servicemonitor" + description: "Flag service monitors which do not match any service" + remediation: "Ensure the ServiceMonitor's selector matches at least one Service" + scope: + objectKinds: + - ServiceMonitor + template: "cel-expression" + params: + check: | + // Check if ServiceMonitor has selectors + (!has(object.spec.selector.matchLabels) || size(object.spec.selector.matchLabels) == 0) && + (!has(object.spec.namespaceSelector.matchNames) || size(object.spec.namespaceSelector.matchNames) == 0) && + (!has(object.spec.namespaceSelector.any) || !object.spec.namespaceSelector.any) ? + "service monitor has no selector specified" : + + // Check if any services match the ServiceMonitor's selectors + !objects.exists(obj, + obj.kind == "Service" && + ( + // Check namespace selector + (has(object.spec.namespaceSelector.any) && object.spec.namespaceSelector.any) || + (has(object.spec.namespaceSelector.matchNames) && + object.spec.namespaceSelector.matchNames.exists(ns, ns == (has(obj.metadata.namespace) ? obj.metadata.namespace : ""))) || + (!has(object.spec.namespaceSelector.matchNames) && !has(object.spec.namespaceSelector.any)) + ) && + ( + // Check label selector + (!has(object.spec.selector.matchLabels) || size(object.spec.selector.matchLabels) == 0) || + (has(obj.metadata.labels) && + object.spec.selector.matchLabels.all(key, key in obj.metadata.labels && + obj.metadata.labels[key] == object.spec.selector.matchLabels[key])) + ) + ) ? + "no services found matching the service monitor's label selector and namespace selector" : "" \ No newline at end of file diff --git a/go.mod b/go.mod index d033850b4..4e31fc791 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/cert-manager/cert-manager v1.18.2 github.com/fatih/color v1.18.0 github.com/go-viper/mapstructure/v2 v2.4.0 + github.com/google/cel-go v0.26.0 github.com/kedacore/keda/v2 v2.17.2 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.5.0 @@ -27,12 +28,14 @@ require ( ) require ( + cel.dev/expr v0.24.0 // indirect dario.cat/mergo v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -103,6 +106,7 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect @@ -119,6 +123,7 @@ require ( golang.org/x/text v0.27.0 // indirect golang.org/x/time v0.11.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect google.golang.org/grpc v1.71.1 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index 287712b1b..307ace262 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= @@ -14,6 +16,8 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+ github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= @@ -117,6 +121,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -283,12 +289,19 @@ github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -419,7 +432,6 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= diff --git a/pkg/templates/all/all.go b/pkg/templates/all/all.go index b904f06aa..7c281ba90 100644 --- a/pkg/templates/all/all.go +++ b/pkg/templates/all/all.go @@ -4,6 +4,7 @@ import ( // Import all check templates. _ "golang.stackrox.io/kube-linter/pkg/templates/accesstoresources" _ "golang.stackrox.io/kube-linter/pkg/templates/antiaffinity" + _ "golang.stackrox.io/kube-linter/pkg/templates/cel" _ "golang.stackrox.io/kube-linter/pkg/templates/clusteradminrolebinding" _ "golang.stackrox.io/kube-linter/pkg/templates/containercapabilities" _ "golang.stackrox.io/kube-linter/pkg/templates/cpurequirements" diff --git a/pkg/templates/cel/internal/params/gen-params.go b/pkg/templates/cel/internal/params/gen-params.go new file mode 100644 index 000000000..14cb3ff77 --- /dev/null +++ b/pkg/templates/cel/internal/params/gen-params.go @@ -0,0 +1,71 @@ +// Code generated by kube-linter template codegen. DO NOT EDIT. +//go:build !templatecodegen +// +build !templatecodegen + +package params + +import ( + "fmt" + "strings" + + "golang.stackrox.io/kube-linter/pkg/check" + "golang.stackrox.io/kube-linter/pkg/templates/util" +) + +var ( + // Use some imports in case they don't get used otherwise. + _ = util.MustParseParameterDesc + + checkParamDesc = util.MustParseParameterDesc(`{ + "Name": "check", + "Type": "string", + "Description": "Check contains a CEL expression for validation logic. Two predefined variables are available: 'object' (the current Kubernetes object being processed) and 'objects' (all objects being linted).", + "Examples": null, + "Enum": null, + "SubParameters": null, + "ArrayElemType": "", + "Required": true, + "NoRegex": true, + "NotNegatable": false, + "XXXStructFieldName": "Check", + "XXXIsPointer": false +} +`) + + ParamDescs = []check.ParameterDesc{ + checkParamDesc, + } +) + +func (p *Params) Validate() error { + var validationErrors []string + if p.Check == "" { + validationErrors = append(validationErrors, "required param check not found") + } + if len(validationErrors) > 0 { + return fmt.Errorf("invalid parameters: %s", strings.Join(validationErrors, ", ")) + } + return nil +} + +// ParseAndValidate instantiates a Params object out of the passed map[string]interface{}, +// validates it, and returns it. +// The return type is interface{} to satisfy the type in the Template struct. +func ParseAndValidate(m map[string]interface{}) (interface{}, error) { + var p Params + if err := util.DecodeMapStructure(m, &p); err != nil { + return nil, err + } + if err := p.Validate(); err != nil { + return nil, err + } + return p, nil +} + +// WrapInstantiateFunc is a convenience wrapper that wraps an untyped instantiate function +// into a typed one. +func WrapInstantiateFunc(f func(p Params) (check.Func, error)) func(interface{}) (check.Func, error) { + return func(paramsInt interface{}) (check.Func, error) { + return f(paramsInt.(Params)) + } +} diff --git a/pkg/templates/cel/internal/params/params.go b/pkg/templates/cel/internal/params/params.go new file mode 100644 index 000000000..4417824b9 --- /dev/null +++ b/pkg/templates/cel/internal/params/params.go @@ -0,0 +1,9 @@ +package params + +// Params defines the configuration parameters for this template. +type Params struct { + // Check contains a CEL expression for validation logic. Two predefined variables are available: 'object' (the current Kubernetes object being processed) and 'objects' (all objects being linted). + // +required + // +noregex + Check string +} diff --git a/pkg/templates/cel/template.go b/pkg/templates/cel/template.go new file mode 100644 index 000000000..9af669c55 --- /dev/null +++ b/pkg/templates/cel/template.go @@ -0,0 +1,108 @@ +package cel + +import ( + "encoding/json" + "fmt" + + "github.com/google/cel-go/cel" + "golang.stackrox.io/kube-linter/pkg/check" + "golang.stackrox.io/kube-linter/pkg/config" + "golang.stackrox.io/kube-linter/pkg/diagnostic" + "golang.stackrox.io/kube-linter/pkg/lintcontext" + "golang.stackrox.io/kube-linter/pkg/objectkinds" + "golang.stackrox.io/kube-linter/pkg/templates" + "golang.stackrox.io/kube-linter/pkg/templates/cel/internal/params" +) + +const ( + templateKey = "cel-expression" +) + +func init() { + templates.Register(check.Template{ + HumanName: "CEL", + Key: templateKey, + Description: "Flag objects with CEL expression", + SupportedObjectKinds: config.ObjectKindsDesc{ + ObjectKinds: []string{objectkinds.Any}, + }, + Parameters: params.ParamDescs, + ParseAndValidateParams: params.ParseAndValidate, + Instantiate: params.WrapInstantiateFunc(func(p params.Params) (check.Func, error) { + return func(ctx lintcontext.LintContext, object lintcontext.Object) []diagnostic.Diagnostic { + msg, err := evaluate(p.Check, object, ctx.Objects()) + if err != nil { + return []diagnostic.Diagnostic{ + {Message: fmt.Sprintf("error evaluating CEL check expression: %v", err)}, + } + } + if msg != "" { + return []diagnostic.Diagnostic{ + {Message: fmt.Sprintf("CEL check expression returned: %v", msg)}, + } + } + return nil + }, nil + }), + }) +} + +func evaluate(check string, object lintcontext.Object, objects []lintcontext.Object) (string, error) { + // Convert object to map via JSON marshaling/unmarshaling for CEL compatibility + // We need to marshal the underlying K8sObject, not the lintcontext.Object + objectMap, err := toMap(object.K8sObject) + if err != nil { + return "", fmt.Errorf("failed to convert object to map: %w", err) + } + + // Convert objects to maps via JSON marshaling/unmarshaling + objectsMaps := make([]map[string]any, len(objects)) + for i, obj := range objects { + objMap, err := toMap(obj.K8sObject) + if err != nil { + return "", fmt.Errorf("failed to convert object %s to map: %w", obj.GetK8sObjectName().String(), err) + } + objectsMaps[i] = objMap + } + + e, err := cel.NewEnv( + cel.Variable("object", cel.MapType(cel.StringType, cel.AnyType)), + cel.Variable("objects", cel.ListType(cel.MapType(cel.StringType, cel.AnyType))), + ) + if err != nil { + return "", fmt.Errorf("failed to create CEL environment: %w", err) + } + ast, iss := e.Compile(check) + if iss.Err() != nil { + return "", fmt.Errorf("failed to compile CEL expression: %w", iss.Err()) + } + prg, err := e.Program(ast) + if err != nil { + return "", fmt.Errorf("failed to create CEL program: %w", err) + } + out, _, err := prg.Eval(map[string]any{ + "object": objectMap, + "objects": objectsMaps, + }) + if err != nil { + return "", fmt.Errorf("failed to evaluate CEL expression: %w", err) + } + + o, ok := out.Value().(string) + if !ok { + return "", fmt.Errorf("expected string, got %v", out.Value()) + } + return o, nil +} + +func toMap(obj any) (map[string]any, error) { + bytes, err := json.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("failed to marshal object: %w", err) + } + var output map[string]any + if err := json.Unmarshal(bytes, &output); err != nil { + return nil, fmt.Errorf("failed to unmarshal object: %w", err) + } + return output, nil +} diff --git a/pkg/templates/cel/template_test.go b/pkg/templates/cel/template_test.go new file mode 100644 index 000000000..f390308ca --- /dev/null +++ b/pkg/templates/cel/template_test.go @@ -0,0 +1,158 @@ +package cel + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "golang.stackrox.io/kube-linter/pkg/lintcontext" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestEvaluate(t *testing.T) { + tests := []struct { + name string + check string + object lintcontext.Object + objects []lintcontext.Object + expectedMsg string + expectError bool + }{ + { + name: "simple string return", + check: `"test message"`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + }, + }, + objects: []lintcontext.Object{}, + expectedMsg: "test message", + expectError: false, + }, + { + name: "conditional message based on object", + check: `object.metadata.name == "test-pod" ? "pod found" : "pod not found"`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + }, + }, + objects: []lintcontext.Object{}, + expectedMsg: "pod found", + expectError: false, + }, + { + name: "check objects list length", + check: `size(objects) > 0 ? "objects found" : "no objects"`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + }, + }, + objects: []lintcontext.Object{ + { + K8sObject: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "test-service"}, + }, + }, + }, + expectedMsg: "objects found", + expectError: false, + }, + { + name: "complex expression with object properties", + check: `object.metadata.namespace == "default" && object.metadata.name.startsWith("test") ? "valid test object" : "invalid object"`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + }, + }, + objects: []lintcontext.Object{}, + expectedMsg: "valid test object", + expectError: false, + }, + { + name: "empty string return", + check: `""`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + }, + }, + objects: []lintcontext.Object{}, + expectedMsg: "", + expectError: false, + }, + { + name: "invalid CEL expression", + check: `invalid syntax here`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + }, + }, + objects: []lintcontext.Object{}, + expectedMsg: "", + expectError: true, + }, + { + name: "non-string return type", + check: `123`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + }, + }, + objects: []lintcontext.Object{}, + expectedMsg: "", + expectError: true, + }, + { + name: "boolean return type", + check: `true`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, + }, + }, + objects: []lintcontext.Object{}, + expectedMsg: "", + expectError: true, + }, + { + name: "accessing nested properties", + check: `object.metadata.labels.app == "web" ? "web app detected" : "not a web app"`, + object: lintcontext.Object{ + K8sObject: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Labels: map[string]string{ + "app": "web", + }, + }, + }, + }, + objects: []lintcontext.Object{}, + expectedMsg: "web app detected", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, err := evaluate(tt.check, tt.object, tt.objects) + + if tt.expectError { + assert.Error(t, err) + assert.Empty(t, msg) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedMsg, msg) + } + }) + } +} diff --git a/tests/checks/cel.yml b/tests/checks/cel.yml new file mode 100644 index 000000000..35e24cb2b --- /dev/null +++ b/tests/checks/cel.yml @@ -0,0 +1,137 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fire + annotations: + foo.bar/baz: "true" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dont-fire + annotations: + foo.bar/baz: "false" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dont-fire + annotations: + foo.bar/baz: T +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: bad-irsa-role + annotations: + eks.amazonaws.com/role-arn: this-is-not-a-valid-iam-role-arn +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: good-irsa-role + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::121212121212:role/role-name-goes-here +apiVersion: v1 +kind: Service +metadata: + name: dont-fire + namespace: dontfire + labels: + app.kubernetes.io/name: dontfire +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: dont-fire +spec: + selector: + matchLabels: + app.kubernetes.io/name: dontfire +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: dont-fire2 +spec: + namespaceSelector: + matchNames: + - dontfire +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: dont-fire +spec: + selector: + matchLabels: + app.kubernetes.io/name: dontfire + namespaceSelector: + matchNames: + - dontfire +--- +apiVersion: v1 +kind: Service +metadata: + name: app1 + labels: + app.kubernetes.io/name: app1 +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: app1 +spec: + selector: + matchLabels: + app.kubernetes.io/name: app +--- +apiVersion: v1 +kind: Service +metadata: + name: app2 + labels: + app.kubernetes.io/name: app2 +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: app2 +spec: + selector: + matchLabels: + app.kubernetes.io/name: app +--- +apiVersion: v1 +kind: Service +metadata: + name: app1 + namespace: test1 +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: app1 +spec: + namespaceSelector: + matchNames: + - test2 +--- +apiVersion: v1 +kind: Service +metadata: + name: app1 + namespace: test1 +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: app1 +spec: + selector: + matchLabels: + app.kubernetes.io/name: app1 + namespaceSelector: + matchNames: + - test2 \ No newline at end of file