diff --git a/staging/src/k8s.io/pod-security-admission/policy/checks.go b/staging/src/k8s.io/pod-security-admission/policy/checks.go new file mode 100644 index 00000000000..f46b3b3e4ac --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/policy/checks.go @@ -0,0 +1,178 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policy + +import ( + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/pod-security-admission/api" +) + +type Check struct { + // ID is the unique ID of the check. + ID string + // Level is the policy level this check belongs to. + // Must be Baseline or Restricted. + // Baseline checks are evaluated for baseline and restricted namespaces. + // Restricted checks are only evaluated for restricted namespaces. + Level api.Level + // Versions contains one or more revisions of the check that apply to different versions. + // If the check is not yet assigned to a version, this must be a single-item list with a MinimumVersion of "". + // Otherwise, MinimumVersion of items must represent strictly increasing versions. + Versions []VersionedCheck +} + +type VersionedCheck struct { + // MinimumVersion is the first policy version this check applies to. + // If unset, this check is not yet assigned to a policy version. + // If set, must not be "latest". + MinimumVersion api.Version + // CheckPod determines if the pod is allowed. + CheckPod CheckPodFn +} + +type CheckPodFn func(*metav1.ObjectMeta, *corev1.PodSpec) CheckResult + +// CheckResult contains the result of checking a pod and indicates whether the pod is allowed, +// and if not, why it was forbidden. +// +// Example output for (false, "host ports", "8080, 9090"): +// When checking all pods in a namespace: +// disallowed by policy "baseline": host ports, privileged containers, non-default capabilities +// When checking an individual pod: +// disallowed by policy "baseline": host ports (8080, 9090), privileged containers, non-default capabilities (CAP_NET_RAW) +type CheckResult struct { + // Allowed indicates if the check allowed the pod. + Allowed bool + // ForbiddenReason must be set if Allowed is false. + // ForbiddenReason should be as succinct as possible and is always output. + // Examples: + // - "host ports" + // - "privileged containers" + // - "non-default capabilities" + ForbiddenReason string + // ForbiddenDetail should only be set if Allowed is false, and is optional. + // ForbiddenDetail can include specific values that were disallowed and is used when checking an individual object. + // Examples: + // - list specific invalid host ports: "8080, 9090" + // - list specific invalid containers: "container1, container2" + // - list specific non-default capabilities: "CAP_NET_RAW" + ForbiddenDetail string +} + +// AggergateCheckResult holds the aggregate result of running CheckPod across multiple checks. +type AggregateCheckResult struct { + // Allowed indicates if all checks allowed the pod. + Allowed bool + // ForbiddenReasons is a slice of the forbidden reasons from all the forbidden checks. It should not include empty strings. + // ForbiddenReasons and ForbiddenDetails must have the same number of elements, and the indexes are for the same check. + ForbiddenReasons []string + // ForbiddenDetails is a slice of the forbidden details from all the forbidden checks. It may include empty strings. + // ForbiddenReasons and ForbiddenDetails must have the same number of elements, and the indexes are for the same check. + ForbiddenDetails []string +} + +// ForbiddenReason returns a comma-separated string of of the forbidden reasons. +// Example: host ports, privileged containers, non-default capabilities +func (a *AggregateCheckResult) ForbiddenReason() string { + return strings.Join(a.ForbiddenReasons, ", ") +} + +// ForbiddenDetail returns a detailed forbidden message, with non-empty details formatted in +// parentheses with the associated reason. +// Example: host ports (8080, 9090), privileged containers, non-default capabilities (NET_RAW) +func (a *AggregateCheckResult) ForbiddenDetail() string { + var b strings.Builder + for i := 0; i < len(a.ForbiddenReasons); i++ { + b.WriteString(a.ForbiddenReasons[i]) + if a.ForbiddenDetails[i] != "" { + b.WriteString(" (") + b.WriteString(a.ForbiddenDetails[i]) + b.WriteString(")") + } + if i != len(a.ForbiddenReasons)-1 { + b.WriteString(", ") + } + } + return b.String() +} + +// UnknownForbiddenReason is used as the placeholder forbidden reason for checks that incorrectly disallow without providing a reason. +const UnknownForbiddenReason = "unknown forbidden reason" + +// AggregateCheckPod runs all the checks and aggregates the forbidden results into a single CheckResult. +// The aggregated reason is a comma-separated +func AggregateCheckResults(results []CheckResult) AggregateCheckResult { + var ( + reasons []string + details []string + ) + for _, result := range results { + if !result.Allowed { + if len(result.ForbiddenReason) == 0 { + reasons = append(reasons, UnknownForbiddenReason) + } else { + reasons = append(reasons, result.ForbiddenReason) + } + details = append(details, result.ForbiddenDetail) + } + } + return AggregateCheckResult{ + Allowed: len(reasons) == 0, + ForbiddenReasons: reasons, + ForbiddenDetails: details, + } +} + +var ( + defaultChecks []func() Check + experimentalChecks []func() Check +) + +func addCheck(f func() Check) { + // add to experimental or versioned list + c := f() + if len(c.Versions) == 1 && c.Versions[0].MinimumVersion == (api.Version{}) { + experimentalChecks = append(experimentalChecks, f) + } else { + defaultChecks = append(defaultChecks, f) + } +} + +// DefaultChecks returns checks that are expected to be enabled by default. +// The results are mutually exclusive with ExperimentalChecks. +// It returns a new copy of checks on each invocation and is expected to be called once at setup time. +func DefaultChecks() []Check { + retval := make([]Check, 0, len(defaultChecks)) + for _, f := range defaultChecks { + retval = append(retval, f()) + } + return retval +} + +// ExperimentalChecks returns checks that have not yet been assigned to policy versions. +// The results are mutually exclusive with DefaultChecks. +// It returns a new copy of checks on each invocation and is expected to be called once at setup time. +func ExperimentalChecks() []Check { + retval := make([]Check, 0, len(experimentalChecks)) + for _, f := range experimentalChecks { + retval = append(retval, f()) + } + return retval +} diff --git a/staging/src/k8s.io/pod-security-admission/policy/doc.go b/staging/src/k8s.io/pod-security-admission/policy/doc.go new file mode 100644 index 00000000000..a6211eb5dd6 --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/policy/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package policy contains implementations of Pod Security Standards checks +package policy // import "k8s.io/pod-security-admission/policy" diff --git a/staging/src/k8s.io/pod-security-admission/policy/registry.go b/staging/src/k8s.io/pod-security-admission/policy/registry.go new file mode 100644 index 00000000000..244bfe3787b --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/policy/registry.go @@ -0,0 +1,146 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policy + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/pod-security-admission/api" +) + +// Evaluator holds the Checks that are used to validate a policy. +type Evaluator interface { + // EvaluatePod evaluates the pod against the policy for the given level & version. + EvaluatePod(lv api.LevelVersion, podMetadata *metav1.ObjectMeta, podSpec *corev1.PodSpec) []CheckResult +} + +// checkRegistry provides a default implementation of an Evaluator. +type checkRegistry struct { + // The checks are a map of check_ID -> sorted slice of versioned checks, newest first + baselineChecks, restrictedChecks map[api.Version][]CheckPodFn + // maxVersion is the maximum version that is cached, guaranteed to be at least + // the max MinimumVersion of all registered checks. + maxVersion api.Version +} + +// NewEvaluator constructs a new Evaluator instance from the list of checks. If the provided checks are invalid, +// an error is returned. A valid list of checks must meet the following requirements: +// 1. Check.ID is unique in the list +// 2. Check.Level must be either Baseline or Restricted +// 3. Checks must have a non-empty set of versions, sorted in a strictly increasing order +// 4. Check.Versions cannot include 'latest' +func NewEvaluator(checks []Check) (Evaluator, error) { + if err := validateChecks(checks); err != nil { + return nil, err + } + r := &checkRegistry{ + baselineChecks: map[api.Version][]CheckPodFn{}, + restrictedChecks: map[api.Version][]CheckPodFn{}, + } + populate(r, checks) + return r, nil +} + +func (r *checkRegistry) EvaluatePod(lv api.LevelVersion, podMetadata *metav1.ObjectMeta, podSpec *corev1.PodSpec) []CheckResult { + if lv.Level == api.LevelPrivileged { + return nil + } + if r.maxVersion.Older(lv.Version) { + lv.Version = r.maxVersion + } + results := []CheckResult{} + for _, check := range r.baselineChecks[lv.Version] { + results = append(results, check(podMetadata, podSpec)) + } + if lv.Level == api.LevelBaseline { + return results + } + for _, check := range r.restrictedChecks[lv.Version] { + results = append(results, check(podMetadata, podSpec)) + } + return results +} + +func validateChecks(checks []Check) error { + ids := map[string]bool{} + for _, check := range checks { + if ids[check.ID] { + return fmt.Errorf("multiple checks registered for ID %s", check.ID) + } + ids[check.ID] = true + if check.Level != api.LevelBaseline && check.Level != api.LevelRestricted { + return fmt.Errorf("check %s: invalid level %s", check.ID, check.Level) + } + if len(check.Versions) == 0 { + return fmt.Errorf("check %s: empty", check.ID) + } + maxVersion := api.Version{} + for _, c := range check.Versions { + if c.MinimumVersion == (api.Version{}) { + return fmt.Errorf("check %s: undefined version found", check.ID) + } + if c.MinimumVersion.Latest() { + return fmt.Errorf("check %s: version cannot be 'latest'", check.ID) + } + if maxVersion == c.MinimumVersion { + return fmt.Errorf("check %s: duplicate version %s", check.ID, c.MinimumVersion) + } + if !maxVersion.Older(c.MinimumVersion) { + return fmt.Errorf("check %s: versions must be strictly increasing", check.ID) + } + maxVersion = c.MinimumVersion + } + } + return nil +} + +func populate(r *checkRegistry, validChecks []Check) { + // Find the max(MinimumVersion) across all checks. + for _, c := range validChecks { + lastVersion := c.Versions[len(c.Versions)-1].MinimumVersion + if r.maxVersion.Older(lastVersion) { + r.maxVersion = lastVersion + } + } + + for _, c := range validChecks { + if c.Level == api.LevelRestricted { + inflateVersions(c, r.restrictedChecks, r.maxVersion) + } else { + inflateVersions(c, r.baselineChecks, r.maxVersion) + } + } +} + +func inflateVersions(check Check, versions map[api.Version][]CheckPodFn, maxVersion api.Version) { + for i, c := range check.Versions { + var nextVersion api.Version + if i+1 < len(check.Versions) { + nextVersion = check.Versions[i+1].MinimumVersion + } else { + // Assumes only 1 Major version. + nextVersion = api.MajorMinorVersion(1, maxVersion.Minor()+1) + } + // Iterate over all versions from the minimum of the current check, to the minimum of the + // next check, or the maxVersion++. + for v := c.MinimumVersion; v.Older(nextVersion); v = api.MajorMinorVersion(1, v.Minor()+1) { + versions[v] = append(versions[v], check.Versions[i].CheckPod) + } + } +} diff --git a/staging/src/k8s.io/pod-security-admission/policy/registry_test.go b/staging/src/k8s.io/pod-security-admission/policy/registry_test.go new file mode 100644 index 00000000000..cfcb15246d8 --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/policy/registry_test.go @@ -0,0 +1,103 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policy + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/pod-security-admission/api" +) + +func TestCheckRegistry(t *testing.T) { + checks := []Check{ + generateCheck("a", api.LevelBaseline, []string{"v1.0"}), + generateCheck("b", api.LevelBaseline, []string{"v1.10"}), + generateCheck("c", api.LevelBaseline, []string{"v1.0", "v1.5", "v1.10"}), + generateCheck("d", api.LevelBaseline, []string{"v1.11", "v1.15", "v1.20"}), + generateCheck("e", api.LevelRestricted, []string{"v1.0"}), + generateCheck("f", api.LevelRestricted, []string{"v1.12", "v1.16", "v1.21"}), + } + + reg, err := NewEvaluator(checks) + require.NoError(t, err) + + levelCases := []struct { + level api.Level + version string + expectedReasons []string + }{ + {api.LevelPrivileged, "v1.0", nil}, + {api.LevelPrivileged, "latest", nil}, + {api.LevelBaseline, "v1.0", []string{"a:v1.0", "c:v1.0"}}, + {api.LevelBaseline, "v1.4", []string{"a:v1.0", "c:v1.0"}}, + {api.LevelBaseline, "v1.5", []string{"a:v1.0", "c:v1.5"}}, + {api.LevelBaseline, "v1.10", []string{"a:v1.0", "b:v1.10", "c:v1.10"}}, + {api.LevelBaseline, "v1.11", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.11"}}, + {api.LevelBaseline, "latest", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.20"}}, + {api.LevelRestricted, "v1.0", []string{"a:v1.0", "c:v1.0", "e:v1.0"}}, + {api.LevelRestricted, "v1.4", []string{"a:v1.0", "c:v1.0", "e:v1.0"}}, + {api.LevelRestricted, "v1.5", []string{"a:v1.0", "c:v1.5", "e:v1.0"}}, + {api.LevelRestricted, "v1.10", []string{"a:v1.0", "b:v1.10", "c:v1.10", "e:v1.0"}}, + {api.LevelRestricted, "v1.11", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.11", "e:v1.0"}}, + {api.LevelRestricted, "latest", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.20", "e:v1.0", "f:v1.21"}}, + {api.LevelRestricted, "v1.10000", []string{"a:v1.0", "b:v1.10", "c:v1.10", "d:v1.20", "e:v1.0", "f:v1.21"}}, + } + for _, test := range levelCases { + t.Run(fmt.Sprintf("%s:%s", test.level, test.version), func(t *testing.T) { + results := reg.EvaluatePod(api.LevelVersion{test.level, versionOrPanic(test.version)}, nil, nil) + + // Set extract the ForbiddenReasons from the results. + var actualReasons []string + for _, result := range results { + actualReasons = append(actualReasons, result.ForbiddenReason) + } + assert.ElementsMatch(t, test.expectedReasons, actualReasons) + }) + } +} + +func generateCheck(id string, level api.Level, versions []string) Check { + c := Check{ + ID: id, + Level: level, + } + for _, ver := range versions { + v := versionOrPanic(ver) // Copy ver so it can be used in the CheckPod closure. + c.Versions = append(c.Versions, VersionedCheck{ + MinimumVersion: v, + CheckPod: func(_ *metav1.ObjectMeta, _ *corev1.PodSpec) CheckResult { + return CheckResult{ + ForbiddenReason: fmt.Sprintf("%s:%s", id, v), + } + }, + }) + } + return c +} + +func versionOrPanic(v string) api.Version { + ver, err := api.ParseVersion(v) + if err != nil { + panic(err) + } + return ver +} diff --git a/staging/src/k8s.io/pod-security-admission/policy/visitor.go b/staging/src/k8s.io/pod-security-admission/policy/visitor.go new file mode 100644 index 00000000000..d8e3cbe0e9e --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/policy/visitor.go @@ -0,0 +1,42 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package policy + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// ContainerVisitorWithPath is called with each container and the field.Path to that container +type ContainerVisitorWithPath func(container *corev1.Container, path *field.Path) + +// visitContainersWithPath invokes the visitor function with a pointer to the spec +// of every container in the given pod spec and the field.Path to that container. +func visitContainersWithPath(podSpec *corev1.PodSpec, specPath *field.Path, visitor ContainerVisitorWithPath) { + fldPath := specPath.Child("initContainers") + for i := range podSpec.InitContainers { + visitor(&podSpec.InitContainers[i], fldPath.Index(i)) + } + fldPath = specPath.Child("containers") + for i := range podSpec.Containers { + visitor(&podSpec.Containers[i], fldPath.Index(i)) + } + fldPath = specPath.Child("ephemeralContainers") + for i := range podSpec.EphemeralContainers { + visitor((*corev1.Container)(&podSpec.EphemeralContainers[i].EphemeralContainerCommon), fldPath.Index(i)) + } +}