Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CEL variables support #37

Merged
merged 5 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/builtins/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ import (
)

//go:embed *
var EmbbedChecksFS embed.FS
var EmbedChecksFS embed.FS
4 changes: 2 additions & 2 deletions internal/builtins/embed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import (
"github.com/stretchr/testify/assert"
)

func TestEmbbedChecks(t *testing.T) {
entries, err := EmbbedChecksFS.ReadDir(".")
func TestEmbedChecks(t *testing.T) {
entries, err := EmbedChecksFS.ReadDir(".")
assert.NoError(t, err)
assert.Greater(t, len(entries), 0)
}
31 changes: 17 additions & 14 deletions internal/builtins/pss/restricted/M-113_run_as_non_root.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,23 @@ match:
- group: batch
version: v1
resource: jobs
validations:
# Pod or Containers must set `securityContext.runAsNonRoot`
- expression: >
(has(podSpec.securityContext) && has(podSpec.securityContext.runAsNonRoot)) ||
allContainers.all(container,
has(container.securityContext) && has(container.securityContext.runAsNonRoot)
variables:
- name: podRunAsNonRoot
expression: podSpec.?securityContext.?runAsNonRoot.orValue(false)

# containers that explicitly set runAsNonRoot=false
- name: explicitlyBadContainers
expression: >
allContainers.filter(c,
has(c.securityContext) && has(c.securityContext.runAsNonRoot) && c.securityContext.runAsNonRoot == false
)
message: "Container could be running as root user"

# Neither Pod nor Containers should set `securityContext.runAsNonRoot` to false
- expression: >
(!has(podSpec.securityContext) || !has(podSpec.securityContext.runAsNonRoot) || podSpec.securityContext.runAsNonRoot != false)
&&
allContainers.all(container,
!has(container.securityContext) || !has(container.securityContext.runAsNonRoot) || container.securityContext.runAsNonRoot != false
# containers that didn't set runAsNonRoot and aren't caught by a pod-level runAsNonRoot=true
- name: implicitlyBadContainers
expression: >
allContainers.filter(c,
!variables.podRunAsNonRoot && (!has(c.securityContext) || !has(c.securityContext.runAsNonRoot))
)
message: "Container could be running as root user"

validations:
- expression: variables.explicitlyBadContainers.size() == 0 && variables.implicitlyBadContainers.size() == 0
34 changes: 27 additions & 7 deletions internal/builtins/pss/restricted/M-113_run_as_non_root_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

- name: "securityContext not specified"
pass: false
message: "Container could be running as root user"
input: |
apiVersion: apps/v1
kind: Deployment
Expand All @@ -34,9 +33,31 @@
matchLabels:
app: nginx

- name: "Pod explicitly set runAsNonRoot to false and Container to true"
- name: "Pod set runAsNonRoot to false"
pass: false
message: "Container could be running as root user"
input: |
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
template:
metadata:
name: nginx
labels:
app: nginx
spec:
securityContext:
runAsNonRoot: false
containers:
- name: nginx
image: nginx
selector:
matchLabels:
app: nginx

- name: "Pod set runAsNonRoot to false and container to true"
pass: true
input: |
apiVersion: apps/v1
kind: Deployment
Expand All @@ -60,9 +81,8 @@
matchLabels:
app: nginx

- name: "Container explicitly set runAsNonRoot to false and Pod to true"
- name: "container set runAsNonRoot to false and Pod to true"
pass: false
message: "Container could be running as root user"
input: |
apiVersion: apps/v1
kind: Deployment
Expand All @@ -86,7 +106,7 @@
matchLabels:
app: nginx

- name: "Container set runAsNonRoot to true and Pod not specified"
- name: "container set runAsNonRoot to true and Pod not specified"
pass: true
input: |
apiVersion: apps/v1
Expand All @@ -109,7 +129,7 @@
matchLabels:
app: nginx

- name: "Pod set runAsNonRoot to true and Container not specified"
- name: "Pod set runAsNonRoot to true and container not specified"
pass: true
input: |
apiVersion: apps/v1
Expand Down
4 changes: 2 additions & 2 deletions pkg/loader/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import (
var Builtins []types.Check

func init() {
c, _, walkFn := walkDir(builtins.EmbbedChecksFS.ReadFile, true)
err := fs.WalkDir(builtins.EmbbedChecksFS, ".", walkFn)
c, _, walkFn := walkDir(builtins.EmbedChecksFS.ReadFile, true)
err := fs.WalkDir(builtins.EmbedChecksFS, ".", walkFn)
if err != nil {
log.Fatal(err)
}
Expand Down
6 changes: 6 additions & 0 deletions pkg/types/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Check struct {
ID string `json:"id"`
Match Match `json:"match"`
Validations []Validation `json:"validations"`
Variables []Variable `json:"variables"`
Params map[string]any `json:"params"`
Severity Severity `json:"severity"`
Message string `json:"message"`
Expand Down Expand Up @@ -51,6 +52,11 @@ type Validation struct {
Message string `json:"message,omitempty"`
}

type Variable struct {
Name string `json:"name"`
Expression string `json:"expression"`
}

type Test struct {
Name string `json:"name"`
Input string `json:"input"`
Expand Down
4 changes: 4 additions & 0 deletions pkg/validator/activation.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (
AllContainersVarName = "allContainers"
APIVersionsVarName = "apiVersions"
KubeVersionVarName = "kubeVersion"
VariableVarName = "variables"
)

// activation implements the interpreter.Activation
Expand All @@ -37,6 +38,7 @@ type activation struct {
params any
apiVersions []string
kubeVersion any
variables any
}

func (a *activation) ResolveName(name string) (any, bool) {
Expand All @@ -55,6 +57,8 @@ func (a *activation) ResolveName(name string) (any, bool) {
return a.apiVersions, true
case KubeVersionVarName:
return a.kubeVersion, true
case VariableVarName:
return a.variables, true
default:
return nil, false
}
Expand Down
93 changes: 71 additions & 22 deletions pkg/validator/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,53 +43,102 @@ var baseEnvOptions = []cel.EnvOption{
k8scellib.Quantity(),

cel.Variable(ObjectVarName, cel.DynType),
cel.Variable(ParamsVarName, cel.DynType),
cel.Variable(APIVersionsVarName, cel.ListType(cel.StringType)),
cel.Variable(KubeVersionVarName, cel.DynType),
}

var programOptions = []cel.ProgramOption{
cel.EvalOptions(cel.OptOptimize),
cel.CostLimit(1000000),
cel.InterruptCheckFrequency(100),
}

var podSpecEnvOptions = []cel.EnvOption{
cel.Variable(PodMetaVarName, cel.DynType),
cel.Variable(PodSpecVarName, cel.DynType),
cel.Variable(AllContainersVarName, cel.ListType(cel.DynType)),
}

// Compile compiles the expressions of the given check and returns a Validator
// Compile compiles variables and expressions of the given check and returns a Validator
func Compile(check types.Check, apiResources []*metav1.APIResourceList, kubeVersion *version.Info) (Validator, error) {
if len(check.Validations) == 0 {
return nil, errors.New("invalid check: a check must have at least 1 validation")
}
hasPodSpec := MatchesPodSpec(check.Match.Resources)
env, err := newEnv(hasPodSpec)
env, err := newEnv(check)
if err != nil {
return nil, fmt.Errorf("environment construction error %s", err.Error())
}
prgs := make([]cel.Program, 0, len(check.Validations))
for i, v := range check.Validations {
ast, issues := env.Compile(v.Expression)
if issues != nil && issues.Err() != nil {
return nil, fmt.Errorf("validation[%d].expression: type-check error: %s", i, issues.Err())
}
if ast.OutputType() != cel.BoolType {
return nil, fmt.Errorf("validation[%d].expression: cel expression must evaluate to a bool", i)
}
prg, err := env.Program(ast, cel.EvalOptions(cel.OptOptimize))
if err != nil {
return nil, fmt.Errorf("validation[%d].expression: program construction error: %s", i, err)
}
prgs = append(prgs, prg)
}

variables, err := compileVariables(env, check.Variables)

prgs, err := compileValidations(env, check.Validations)

apiVersions := make([]string, 0, len(apiResources))
for _, resource := range apiResources {
apiVersions = append(apiVersions, resource.GroupVersion)
}
return &CELValidator{check: check, programs: prgs, hasPodSpec: hasPodSpec, apiVersions: apiVersions, kubeVersion: kubeVersion}, nil
return &CELValidator{check: check, programs: prgs, apiVersions: apiVersions, kubeVersion: kubeVersion, variables: variables}, nil
}

func newEnv(podSpec bool) (*cel.Env, error) {
func newEnv(check types.Check) (*cel.Env, error) {
opts := baseEnvOptions
if podSpec {
if MatchesPodSpec(check.Match.Resources) {
opts = append(opts, podSpecEnvOptions...)
}
if len(check.Variables) > 0 {
opts = append(opts, cel.Variable(VariableVarName, cel.MapType(cel.StringType, cel.DynType)))
}
if len(check.Params) > 0 {
opts = append(opts, cel.Variable(ParamsVarName, cel.DynType))
}
return cel.NewEnv(opts...)
}

func compileVariables(env *cel.Env, vars []types.Variable) ([]compiledVariable, error) {
variables := make([]compiledVariable, 0, len(vars))
for _, v := range vars {
prg, err := compileExpression(env, v.Expression, cel.AnyType)
if err != nil {
return nil, fmt.Errorf("variables[%q].expression: %s", v.Name, err)
}
variables = append(variables, compiledVariable{name: v.Name, program: prg})
}
return variables, nil
}

func compileValidations(env *cel.Env, vals []types.Validation) ([]cel.Program, error) {
prgs := make([]cel.Program, 0, len(vals))
for i, v := range vals {
prg, err := compileExpression(env, v.Expression, cel.BoolType)
if err != nil {
return nil, fmt.Errorf("validations[%d].expression: %s", i, err)
}
prgs = append(prgs, prg)
}
return prgs, nil
}

func compileExpression(env *cel.Env, exp string, allowedTypes ...*cel.Type) (cel.Program, error) {
ast, issues := env.Compile(exp)
if issues != nil && issues.Err() != nil {
return nil, fmt.Errorf("type-check error: %s", issues.Err())
}
found := false
for _, t := range allowedTypes {
if ast.OutputType() == t || cel.AnyType == t {
found = true
break
}
}
if !found {
if len(allowedTypes) == 1 {
return nil, fmt.Errorf("must evaluate to %v", allowedTypes[0].String())
}
return nil, fmt.Errorf("must evaluate to one of %v", allowedTypes)
}
prg, err := env.Program(ast, programOptions...)
if err != nil {
return nil, fmt.Errorf("program construction error: %s", err)
}
return prg, nil
}
31 changes: 26 additions & 5 deletions pkg/validator/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,26 @@ import (

"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
marvin "github.com/undistro/marvin/pkg/types"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/version"

marvin "github.com/undistro/marvin/pkg/types"
"k8s.io/apiserver/pkg/cel/lazy"
)

// CELValidator is a Validator that performs CEL expressions
type CELValidator struct {
check marvin.Check
programs []cel.Program
hasPodSpec bool
apiVersions []string
kubeVersion *version.Info
variables []compiledVariable
}

type compiledVariable struct {
name string
program cel.Program
}

func (r *CELValidator) SetAPIVersions(apiVersions []string) {
Expand All @@ -47,10 +53,15 @@ func (r *CELValidator) Validate(obj unstructured.Unstructured, params any) (bool
if params == nil {
params = r.check.Params
}
input := &activation{object: obj.UnstructuredContent(), apiVersions: r.apiVersions, params: params}
input := &activation{object: obj.UnstructuredContent(), apiVersions: r.apiVersions, params: params, variables: make(map[string]any)}
if err := r.setPodSpecParams(obj, input); err != nil {
return false, "", err
}
lazyMap := lazy.NewMapValue(types.MapType)
for _, v := range r.variables {
lazyMap.Append(v.name, callback(v, input))
}
input.variables = lazyMap
for i, prg := range r.programs {
out, _, err := prg.Eval(input)
if err != nil {
Expand All @@ -63,8 +74,18 @@ func (r *CELValidator) Validate(obj unstructured.Unstructured, params any) (bool
return true, "", nil
}

func callback(v compiledVariable, activation any) lazy.GetFieldFunc {
return func(_ *lazy.MapValue) ref.Val {
val, _, err := v.program.Eval(activation)
if err != nil {
return types.NewErr("variable %q fails to evaluate: %v", v.name, err)
}
return val
}
}

func (r *CELValidator) setPodSpecParams(obj unstructured.Unstructured, input *activation) error {
if !r.hasPodSpec || !HasPodSpec(obj) {
if !HasPodSpec(obj) {
return nil
}
meta, spec, err := ExtractPodSpec(obj)
Expand Down