diff --git a/staging/src/k8s.io/pod-security-admission/admission/admission.go b/staging/src/k8s.io/pod-security-admission/admission/admission.go new file mode 100644 index 00000000000..7eb07abf8b0 --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/admission/admission.go @@ -0,0 +1,549 @@ +/* +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 admission + +import ( + "context" + "fmt" + "net/http" + "reflect" + "time" + + "k8s.io/klog/v2" + + admissionv1 "k8s.io/api/admission/v1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + admissionapi "k8s.io/pod-security-admission/admission/api" + "k8s.io/pod-security-admission/admission/api/validation" + "k8s.io/pod-security-admission/api" + "k8s.io/pod-security-admission/metrics" + "k8s.io/pod-security-admission/policy" +) + +const ( + namespaceMaxPodsToCheck = 3000 + namespacePodCheckTimeout = 1 * time.Second +) + +// Admission implements the core admission logic for the Pod Security Admission controller. +// The admission logic can be +type Admission struct { + Configuration *admissionapi.PodSecurityConfiguration + + // Getting policy checks per level/version + Evaluator policy.Evaluator + + // Metrics + Metrics metrics.EvaluationRecorder + + // Arbitrary object --> PodSpec + PodSpecExtractor PodSpecExtractor + + // API connections + NamespaceGetter NamespaceGetter + PodLister PodLister + + defaultPolicy api.Policy +} + +type NamespaceGetter interface { + GetNamespace(ctx context.Context, name string) (*corev1.Namespace, error) +} + +type PodLister interface { + ListPods(ctx context.Context, namespace string) ([]*corev1.Pod, error) +} + +// PodSpecExtractor extracts a PodSpec from pod-controller resources that embed a PodSpec. +// This interface can be extended to enforce policy on CRDs for custom pod-controllers. +type PodSpecExtractor interface { + // HasPodSpec returns true if the given resource type MAY contain an extractable PodSpec. + HasPodSpec(schema.GroupResource) bool + // ExtractPodSpec returns a pod spec and metadata to evaluate from the object. + // An error returned here does not block admission of the pod-spec-containing object and is not returned to the user. + // If the object has no pod spec, return `nil, nil, nil`. + ExtractPodSpec(runtime.Object) (*metav1.ObjectMeta, *corev1.PodSpec, error) +} + +var defaultPodSpecResources = map[schema.GroupResource]bool{ + corev1.Resource("pods"): true, + corev1.Resource("replicationcontrollers"): true, + corev1.Resource("podtemplates"): true, + appsv1.Resource("replicasets"): true, + appsv1.Resource("deployments"): true, + appsv1.Resource("statefulsets"): true, + appsv1.Resource("daemonsets"): true, + batchv1.Resource("jobs"): true, + batchv1.Resource("cronjobs"): true, +} + +type DefaultPodSpecExtractor struct{} + +func (DefaultPodSpecExtractor) HasPodSpec(gr schema.GroupResource) bool { + return defaultPodSpecResources[gr] +} + +func (DefaultPodSpecExtractor) ExtractPodSpec(obj runtime.Object) (*metav1.ObjectMeta, *corev1.PodSpec, error) { + switch o := obj.(type) { + case *corev1.Pod: + return &o.ObjectMeta, &o.Spec, nil + case *corev1.PodTemplate: + return extractPodSpecFromTemplate(&o.Template) + case *corev1.ReplicationController: + return extractPodSpecFromTemplate(o.Spec.Template) + case *appsv1.ReplicaSet: + return extractPodSpecFromTemplate(&o.Spec.Template) + case *appsv1.Deployment: + return extractPodSpecFromTemplate(&o.Spec.Template) + case *appsv1.DaemonSet: + return extractPodSpecFromTemplate(&o.Spec.Template) + case *appsv1.StatefulSet: + return extractPodSpecFromTemplate(&o.Spec.Template) + case *batchv1.Job: + return extractPodSpecFromTemplate(&o.Spec.Template) + case *batchv1.CronJob: + return extractPodSpecFromTemplate(&o.Spec.JobTemplate.Spec.Template) + default: + return nil, nil, fmt.Errorf("unexpected object type: %s", obj.GetObjectKind().GroupVersionKind().String()) + } +} + +func (DefaultPodSpecExtractor) PodSpecResources() []schema.GroupResource { + retval := make([]schema.GroupResource, 0, len(defaultPodSpecResources)) + for r := range defaultPodSpecResources { + retval = append(retval, r) + } + return retval +} + +func extractPodSpecFromTemplate(template *corev1.PodTemplateSpec) (*metav1.ObjectMeta, *corev1.PodSpec, error) { + if template == nil { + return nil, nil, nil + } + return &template.ObjectMeta, &template.Spec, nil +} + +// CompleteConfiguration() sets up default or derived configuration. +func (a *Admission) CompleteConfiguration() error { + if a.Configuration != nil { + if p, err := admissionapi.ToPolicy(a.Configuration.Defaults); err != nil { + return err + } else { + a.defaultPolicy = p + } + } + + if a.PodSpecExtractor == nil { + a.PodSpecExtractor = &DefaultPodSpecExtractor{} + } + + return nil +} + +// ValidateConfiguration() ensures all required fields are set with valid values. +func (a *Admission) ValidateConfiguration() error { + if a.Configuration == nil { + return fmt.Errorf("configuration required") + } else if errs := validation.ValidatePodSecurityConfiguration(a.Configuration); len(errs) > 0 { + return errs.ToAggregate() + } else { + if p, err := admissionapi.ToPolicy(a.Configuration.Defaults); err != nil { + return err + } else if !reflect.DeepEqual(p, a.defaultPolicy) { + return fmt.Errorf("default policy does not match; CompleteConfiguration() was not called before ValidateConfiguration()") + } + } + // TODO: check metrics is non-nil? + if a.PodSpecExtractor == nil { + return fmt.Errorf("PodSpecExtractor required") + } + if a.Evaluator == nil { + return fmt.Errorf("Evaluator required") + } + if a.NamespaceGetter == nil { + return fmt.Errorf("NamespaceGetter required") + } + if a.PodLister == nil { + return fmt.Errorf("PodLister required") + } + return nil +} + +// Validate admits an API request. +// The objects in admission attributes are expected to be external v1 objects that we care about. +func (a *Admission) Validate(ctx context.Context, attrs admission.Attributes) admissionv1.AdmissionResponse { + var response admissionv1.AdmissionResponse + switch attrs.GetResource().GroupResource() { + case corev1.Resource("namespaces"): + response = a.ValidateNamespace(ctx, attrs) + case corev1.Resource("pods"): + response = a.ValidatePod(ctx, attrs) + default: + response = a.ValidatePodController(ctx, attrs) + } + + // TODO: record metrics. + + return response +} + +func (a *Admission) ValidateNamespace(ctx context.Context, attrs admission.Attributes) admissionv1.AdmissionResponse { + // short-circuit on subresources + if attrs.GetSubresource() != "" { + return allowedResponse() + } + namespace, ok := attrs.GetObject().(*corev1.Namespace) + if !ok { + klog.InfoS("failed to assert namespace type", "type", reflect.TypeOf(attrs.GetObject())) + return internalErrorResponse("failed to decode namespace") + } + + newPolicy, newErr := a.PolicyToEvaluate(namespace.Labels) + + switch attrs.GetOperation() { + case admission.Create: + // require valid labels on create + if newErr != nil { + return invalidResponse(newErr.Error()) + } + return allowedResponse() + + case admission.Update: + // if update, check if policy labels changed + oldNamespace, ok := attrs.GetOldObject().(*corev1.Namespace) + if !ok { + klog.InfoS("failed to assert old namespace type", "type", reflect.TypeOf(attrs.GetOldObject())) + return internalErrorResponse("failed to decode old namespace") + } + oldPolicy, oldErr := a.PolicyToEvaluate(oldNamespace.Labels) + + // require valid labels on update if they have changed + if newErr != nil && (oldErr == nil || newErr.Error() != oldErr.Error()) { + return invalidResponse(newErr.Error()) + } + + // Skip dry-running pods: + // * if the enforce policy is unchanged + // * if the new enforce policy is privileged + // * if the new enforce is the same version and level was relaxed + // * for exempt namespaces + if newPolicy.Enforce == oldPolicy.Enforce { + return allowedResponse() + } + if newPolicy.Enforce.Level == api.LevelPrivileged { + return allowedResponse() + } + if newPolicy.Enforce.Version == oldPolicy.Enforce.Version && + api.CompareLevels(newPolicy.Enforce.Level, oldPolicy.Enforce.Level) < 1 { + return allowedResponse() + } + if a.exemptNamespace(attrs.GetNamespace()) { + return allowedResponse() + } + response := allowedResponse() + response.Warnings = a.EvaluatePodsInNamespace(ctx, namespace.Name, newPolicy.Enforce) + return response + + default: + return allowedResponse() + } +} + +// ignoredPodSubresources is a set of ignored Pod subresources. +// Any other subresource is expected to be a *v1.Pod type and is evaluated. +// This ensures a version skewed webhook fails safe and denies an unknown pod subresource that allows modifying the pod spec. +var ignoredPodSubresources = map[string]bool{ + "exec": true, + "attach": true, + "binding": true, + "eviction": true, + "log": true, + "portforward": true, + "proxy": true, + "status": true, +} + +func (a *Admission) ValidatePod(ctx context.Context, attrs admission.Attributes) admissionv1.AdmissionResponse { + // short-circuit on ignored subresources + if ignoredPodSubresources[attrs.GetSubresource()] { + return allowedResponse() + } + // short-circuit on exempt namespaces and users + if a.exemptNamespace(attrs.GetNamespace()) || a.exemptUser(attrs.GetUserInfo().GetName()) { + return allowedResponse() + } + + pod, ok := attrs.GetObject().(*corev1.Pod) + if !ok { + klog.InfoS("failed to assert pod type", "type", reflect.TypeOf(attrs.GetObject())) + return internalErrorResponse("failed to decode pod") + } + enforce := true + if attrs.GetOperation() == admission.Update { + oldPod, ok := attrs.GetOldObject().(*corev1.Pod) + if !ok { + klog.InfoS("failed to assert old pod type", "type", reflect.TypeOf(attrs.GetOldObject())) + return internalErrorResponse("failed to decode old pod") + } + if !isSignificantPodUpdate(pod, oldPod) { + // Nothing we care about changed, so always allow the update. + return allowedResponse() + } + } + return a.EvaluatePod(ctx, attrs.GetNamespace(), &pod.ObjectMeta, &pod.Spec, enforce) +} + +func (a *Admission) ValidatePodController(ctx context.Context, attrs admission.Attributes) admissionv1.AdmissionResponse { + // short-circuit on subresources + if attrs.GetSubresource() != "" { + return allowedResponse() + } + // short-circuit on exempt namespaces and users + if a.exemptNamespace(attrs.GetNamespace()) || a.exemptUser(attrs.GetUserInfo().GetName()) { + return allowedResponse() + } + + podMetadata, podSpec, err := a.PodSpecExtractor.ExtractPodSpec(attrs.GetObject()) + if err != nil { + klog.ErrorS(err, "failed to extract pod spec") + return internalErrorResponse("failed to extract pod template") + } + if podMetadata == nil && podSpec == nil { + // if a controller with an optional pod spec does not contain a pod spec, skip validation + return allowedResponse() + } + return a.EvaluatePod(ctx, attrs.GetNamespace(), podMetadata, podSpec, false) +} + +// EvaluatePod looks up the policy for the pods namespace, and checks it against the given pod(-like) object. +// The enforce policy is only checked if enforce=true. +func (a *Admission) EvaluatePod(ctx context.Context, namespaceName string, podMetadata *metav1.ObjectMeta, podSpec *corev1.PodSpec, enforce bool) admissionv1.AdmissionResponse { + // short-circuit on exempt runtimeclass + if a.exemptRuntimeClass(podSpec.RuntimeClassName) { + return allowedResponse() + } + + namespace, err := a.NamespaceGetter.GetNamespace(ctx, namespaceName) + if err != nil { + klog.ErrorS(err, "failed to fetch pod namespace", "namespace", namespaceName) + return internalErrorResponse(fmt.Sprintf("failed to lookup namespace %s", namespaceName)) + } + + auditAnnotations := map[string]string{} + nsPolicy, err := a.PolicyToEvaluate(namespace.Labels) + if err != nil { + klog.V(2).InfoS("failed to parse PodSecurity namespace labels", "err", err) + auditAnnotations["error"] = fmt.Sprintf("Failed to parse policy: %v", err) + } + + response := allowedResponse() + if enforce { + if result := policy.AggregateCheckResults(a.Evaluator.EvaluatePod(nsPolicy.Enforce, podMetadata, podSpec)); !result.Allowed { + response = forbiddenResponse(result.ForbiddenDetail()) + } + } + + // TODO: reuse previous evaluation if audit level+version is the same as enforce level+version + if result := policy.AggregateCheckResults(a.Evaluator.EvaluatePod(nsPolicy.Audit, podMetadata, podSpec)); !result.Allowed { + auditAnnotations["audit"] = result.ForbiddenDetail() + } + + // TODO: reuse previous evaluation if warn level+version is the same as audit or enforce level+version + if result := policy.AggregateCheckResults(a.Evaluator.EvaluatePod(nsPolicy.Warn, podMetadata, podSpec)); !result.Allowed { + // TODO: Craft a better user-facing warning message + response.Warnings = append(response.Warnings, fmt.Sprintf("Pod violates PodSecurity profile %s: %s", nsPolicy.Warn.String(), result.ForbiddenDetail())) + } + + response.AuditAnnotations = auditAnnotations + return response +} + +func (a *Admission) EvaluatePodsInNamespace(ctx context.Context, namespace string, enforce api.LevelVersion) []string { + timeout := namespacePodCheckTimeout + if deadline, ok := ctx.Deadline(); ok { + timeRemaining := time.Duration(0.9 * float64(time.Until(deadline))) // Leave a little time to respond. + if timeout > timeRemaining { + timeout = timeRemaining + } + } + deadline := time.Now().Add(timeout) + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + + pods, err := a.PodLister.ListPods(ctx, namespace) + if err != nil { + klog.ErrorS(err, "Failed to list pods", "namespace", namespace) + return []string{"Failed to list pods"} + } + + var warnings []string + if len(pods) > namespaceMaxPodsToCheck { + warnings = append(warnings, fmt.Sprintf("Large namespace: only checking the first %d of %d pods", namespaceMaxPodsToCheck, len(pods))) + pods = pods[0:namespaceMaxPodsToCheck] + } + + for i, pod := range pods { + // short-circuit on exempt runtimeclass + if a.exemptRuntimeClass(pod.Spec.RuntimeClassName) { + continue + } + r := policy.AggregateCheckResults(a.Evaluator.EvaluatePod(enforce, &pod.ObjectMeta, &pod.Spec)) + if !r.Allowed { + // TODO: consider aggregating results (e.g. multiple pods failed for the same reasons) + warnings = append(warnings, fmt.Sprintf("%s: %s", pod.Name, r.ForbiddenReason())) + } + if time.Now().After(deadline) { + return append(warnings, fmt.Sprintf("Timeout reached after checking %d pods", i+1)) + } + } + + return warnings +} + +func (a *Admission) PolicyToEvaluate(labels map[string]string) (api.Policy, error) { + return api.PolicyToEvaluate(labels, a.defaultPolicy) +} + +// allowResponse is the response used when the admission decision is allow. +func allowedResponse() admissionv1.AdmissionResponse { + return admissionv1.AdmissionResponse{Allowed: true} +} + +// forbiddenResponse is the response used when the admission decision is deny for policy violations. +func forbiddenResponse(msg string) admissionv1.AdmissionResponse { + return admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, + Reason: metav1.StatusReasonForbidden, + Message: msg, + Code: http.StatusForbidden, + }, + } +} + +// invalidResponse is the response used for namespace requests when namespace labels are invalid. +func invalidResponse(msg string) admissionv1.AdmissionResponse { + return admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, + Reason: metav1.StatusReasonInvalid, + Message: msg, + Code: 422, + }, + } +} + +// internalErrorResponse is the response used for unexpected errors +func internalErrorResponse(msg string) admissionv1.AdmissionResponse { + return admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, + Reason: metav1.StatusReasonInternalError, + Message: msg, + Code: http.StatusInternalServerError, + }, + } +} + +// isSignificantPodUpdate determines whether a pod update should trigger a policy evaluation. +// Relevant mutable pod fields as of 1.21 are image and seccomp annotations: +// * https://github.com/kubernetes/kubernetes/blob/release-1.21/pkg/apis/core/validation/validation.go#L3947-L3949 +func isSignificantPodUpdate(pod, oldPod *corev1.Pod) bool { + if pod.Annotations[corev1.SeccompPodAnnotationKey] != oldPod.Annotations[corev1.SeccompPodAnnotationKey] { + return true + } + if len(pod.Spec.Containers) != len(oldPod.Spec.Containers) { + return true + } + if len(pod.Spec.InitContainers) != len(oldPod.Spec.InitContainers) { + return true + } + for i := 0; i < len(pod.Spec.Containers); i++ { + if isSignificantContainerUpdate(&pod.Spec.Containers[i], &oldPod.Spec.Containers[i], pod.Annotations, oldPod.Annotations) { + return true + } + } + for i := 0; i < len(pod.Spec.InitContainers); i++ { + if isSignificantContainerUpdate(&pod.Spec.InitContainers[i], &oldPod.Spec.InitContainers[i], pod.Annotations, oldPod.Annotations) { + return true + } + } + for _, c := range pod.Spec.EphemeralContainers { + var oldC *corev1.Container + for i, oc := range oldPod.Spec.EphemeralContainers { + if oc.Name == c.Name { + oldC = (*corev1.Container)(&oldPod.Spec.EphemeralContainers[i].EphemeralContainerCommon) + break + } + } + if oldC == nil { + return true // EphemeralContainer added + } + if isSignificantContainerUpdate((*corev1.Container)(&c.EphemeralContainerCommon), oldC, pod.Annotations, oldPod.Annotations) { + return true + } + } + return false +} + +// isSignificantContainerUpdate determines whether a container update should trigger a policy evaluation. +func isSignificantContainerUpdate(container, oldContainer *corev1.Container, annotations, oldAnnotations map[string]string) bool { + if container.Image != oldContainer.Image { + return true + } + seccompKey := corev1.SeccompContainerAnnotationKeyPrefix + container.Name + return annotations[seccompKey] != oldAnnotations[seccompKey] +} + +func (a *Admission) exemptNamespace(namespace string) bool { + if len(namespace) == 0 { + return false + } + // TODO: consider optimizing to O(1) lookup + return containsString(namespace, a.Configuration.Exemptions.Namespaces) +} +func (a *Admission) exemptUser(username string) bool { + if len(username) == 0 { + return false + } + // TODO: consider optimizing to O(1) lookup + return containsString(username, a.Configuration.Exemptions.Usernames) +} +func (a *Admission) exemptRuntimeClass(runtimeClass *string) bool { + if runtimeClass == nil || len(*runtimeClass) == 0 { + return false + } + // TODO: consider optimizing to O(1) lookup + return containsString(*runtimeClass, a.Configuration.Exemptions.RuntimeClasses) +} +func containsString(needle string, haystack []string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} diff --git a/staging/src/k8s.io/pod-security-admission/admission/admission_test.go b/staging/src/k8s.io/pod-security-admission/admission/admission_test.go new file mode 100644 index 00000000000..47a3d406640 --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/admission/admission_test.go @@ -0,0 +1,465 @@ +/* +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 admission + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + admissionv1 "k8s.io/api/admission/v1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + admissionapi "k8s.io/pod-security-admission/admission/api" + "k8s.io/pod-security-admission/api" + "k8s.io/pod-security-admission/policy" + "k8s.io/utils/pointer" +) + +func TestDefaultExtractPodSpec(t *testing.T) { + metadata := metav1.ObjectMeta{ + Name: "foo-pod", + } + spec := corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "foo-container", + }}, + } + objects := []runtime.Object{ + &corev1.Pod{ + ObjectMeta: metadata, + Spec: spec, + }, + &corev1.PodTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "foo-template"}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metadata, + Spec: spec, + }, + }, + &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{Name: "foo-rc"}, + Spec: corev1.ReplicationControllerSpec{ + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metadata, + Spec: spec, + }, + }, + }, + &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "foo-rs"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metadata, + Spec: spec, + }, + }, + }, + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "foo-deployment"}, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metadata, + Spec: spec, + }, + }, + }, + &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "foo-ss"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metadata, + Spec: spec, + }, + }, + }, + &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{Name: "foo-ds"}, + Spec: appsv1.DaemonSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metadata, + Spec: spec, + }, + }, + }, + &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{Name: "foo-job"}, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metadata, + Spec: spec, + }, + }, + }, + &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "foo-cronjob"}, + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metadata, + Spec: spec, + }, + }, + }, + }, + }, + } + extractor := &DefaultPodSpecExtractor{} + for _, obj := range objects { + name := obj.(metav1.Object).GetName() + actualMetadata, actualSpec, err := extractor.ExtractPodSpec(obj) + assert.NoError(t, err, name) + assert.Equal(t, &metadata, actualMetadata, "%s: Metadata mismatch", name) + assert.Equal(t, &spec, actualSpec, "%s: PodSpec mismatch", name) + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-svc", + }, + } + _, _, err := extractor.ExtractPodSpec(service) + assert.Error(t, err, "service should not have an extractable pod spec") +} + +func TestDefaultHasPodSpec(t *testing.T) { + podLikeResources := []schema.GroupResource{ + corev1.Resource("pods"), + corev1.Resource("replicationcontrollers"), + corev1.Resource("podtemplates"), + appsv1.Resource("replicasets"), + appsv1.Resource("deployments"), + appsv1.Resource("statefulsets"), + appsv1.Resource("daemonsets"), + batchv1.Resource("jobs"), + batchv1.Resource("cronjobs"), + } + extractor := &DefaultPodSpecExtractor{} + for _, gr := range podLikeResources { + assert.True(t, extractor.HasPodSpec(gr), gr.String()) + } + + nonPodResources := []schema.GroupResource{ + corev1.Resource("services"), + admissionv1.Resource("admissionreviews"), + appsv1.Resource("foobars"), + } + for _, gr := range nonPodResources { + assert.False(t, extractor.HasPodSpec(gr), gr.String()) + } +} + +type testEvaluator struct { + lv api.LevelVersion +} + +func (t *testEvaluator) EvaluatePod(lv api.LevelVersion, meta *metav1.ObjectMeta, spec *corev1.PodSpec) []policy.CheckResult { + t.lv = lv + if meta.Annotations["error"] != "" { + return []policy.CheckResult{{Allowed: false, ForbiddenReason: meta.Annotations["error"]}} + } else { + return []policy.CheckResult{{Allowed: true}} + } +} + +type testPodLister struct { + called bool + pods []*corev1.Pod +} + +func (t *testPodLister) ListPods(ctx context.Context, namespace string) ([]*corev1.Pod, error) { + t.called = true + return t.pods, nil +} + +func TestValidateNamespace(t *testing.T) { + testcases := []struct { + name string + exemptNamespaces []string + exemptRuntimeClasses []string + // override default policy + defaultPolicy *api.Policy + // request subresource + subresource string + // labels for the new namespace + newLabels map[string]string + // labels for the old namespace (only used if update=true) + oldLabels map[string]string + // list of pods to return + pods []*corev1.Pod + + expectAllowed bool + expectError string + expectListPods bool + expectEvaluate api.LevelVersion + expectWarnings []string + }{ + // creation tests, just validate labels + { + name: "create privileged", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelPrivileged), api.EnforceVersionLabel: "v1.0"}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "create baseline", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline)}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "create restricted", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline)}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "create malformed level", + newLabels: map[string]string{api.EnforceLevelLabel: "unknown"}, + expectAllowed: false, + expectError: `must be one of privileged, baseline, restricted`, + expectListPods: false, + }, + { + name: "create malformed version", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelPrivileged), api.EnforceVersionLabel: "unknown"}, + expectAllowed: false, + expectError: `must be "latest" or "v1.x"`, + expectListPods: false, + }, + + // update tests that don't tighten effective policy, no pod list/evaluate + { + name: "update no-op", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "update no-op malformed level", + newLabels: map[string]string{api.EnforceLevelLabel: "unknown"}, + oldLabels: map[string]string{api.EnforceLevelLabel: "unknown"}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "update no-op malformed version", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "unknown"}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "unknown"}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "update relax level identical version", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "v1.0"}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "update relax level explicit latest", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "latest"}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "latest"}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "update relax level implicit latest", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline)}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted)}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "update to explicit privileged", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelPrivileged)}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "update to implicit privileged", + newLabels: map[string]string{}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"}, + expectAllowed: true, + expectListPods: false, + }, + { + name: "update exempt to restricted", + exemptNamespaces: []string{"test"}, + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"}, + oldLabels: map[string]string{}, + expectAllowed: true, + expectListPods: false, + }, + + // update tests that introduce labels errors + { + name: "update malformed level", + newLabels: map[string]string{api.EnforceLevelLabel: "unknown"}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"}, + expectAllowed: false, + expectError: `must be one of privileged, baseline, restricted`, + expectListPods: false, + }, + { + name: "update malformed version", + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelPrivileged), api.EnforceVersionLabel: "unknown"}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted), api.EnforceVersionLabel: "v1.0"}, + expectAllowed: false, + expectError: `must be "latest" or "v1.x"`, + expectListPods: false, + }, + + // update tests that tighten effective policy + { + name: "update to implicit restricted", + newLabels: map[string]string{}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline), api.EnforceVersionLabel: "v1.0"}, + defaultPolicy: &api.Policy{Enforce: api.LevelVersion{Level: api.LevelRestricted, Version: api.LatestVersion()}}, + expectAllowed: true, + expectListPods: true, + expectEvaluate: api.LevelVersion{Level: api.LevelRestricted, Version: api.LatestVersion()}, + expectWarnings: []string{"noruntimeclasspod: message", "runtimeclass1pod: message", "runtimeclass2pod: message"}, + }, + { + name: "update with runtimeclass exempt pods", + exemptRuntimeClasses: []string{"runtimeclass1"}, + newLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelRestricted)}, + oldLabels: map[string]string{api.EnforceLevelLabel: string(api.LevelBaseline)}, + expectAllowed: true, + expectListPods: true, + expectEvaluate: api.LevelVersion{Level: api.LevelRestricted, Version: api.LatestVersion()}, + expectWarnings: []string{"noruntimeclasspod: message", "runtimeclass2pod: message"}, + }, + + // TODO: test for aggregating pods with identical warnings + // TODO: test for bounding evalution time with a warning + // TODO: test for bounding pod count with a warning + // TODO: test for prioritizing evaluating pods from unique controllers + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + newObject := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Labels: tc.newLabels, + }, + } + var operation = admission.Create + var oldObject runtime.Object + if tc.oldLabels != nil { + operation = admission.Update + oldObject = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Labels: tc.oldLabels, + }, + } + } + + attrs := admission.NewAttributesRecord( + newObject, + oldObject, + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"}, + newObject.Name, + newObject.Name, + schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}, + tc.subresource, + operation, + nil, + false, + nil, + ) + + defaultPolicy := api.Policy{ + Enforce: api.LevelVersion{ + Level: api.LevelPrivileged, + Version: api.LatestVersion(), + }, + } + if tc.defaultPolicy != nil { + defaultPolicy = *tc.defaultPolicy + } + + pods := tc.pods + if pods == nil { + pods = []*corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "noruntimeclasspod", Annotations: map[string]string{"error": "message"}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "runtimeclass1pod", Annotations: map[string]string{"error": "message"}}, + Spec: corev1.PodSpec{RuntimeClassName: pointer.String("runtimeclass1")}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "runtimeclass2pod", Annotations: map[string]string{"error": "message"}}, + Spec: corev1.PodSpec{RuntimeClassName: pointer.String("runtimeclass2")}, + }, + } + } + podLister := &testPodLister{pods: pods} + evaluator := &testEvaluator{} + a := &Admission{ + PodLister: podLister, + Evaluator: evaluator, + Configuration: &admissionapi.PodSecurityConfiguration{ + Exemptions: admissionapi.PodSecurityExemptions{ + Namespaces: tc.exemptNamespaces, + RuntimeClasses: tc.exemptRuntimeClasses, + }, + }, + defaultPolicy: defaultPolicy, + } + result := a.ValidateNamespace(context.TODO(), attrs) + if result.Allowed != tc.expectAllowed { + t.Errorf("expected allowed=%v, got %v", tc.expectAllowed, result.Allowed) + } + + resultError := "" + if result.Result != nil { + resultError = result.Result.Message + } + if (len(resultError) > 0) != (len(tc.expectError) > 0) { + t.Errorf("expected error=%v, got %v", tc.expectError, resultError) + } + if len(tc.expectError) > 0 && !strings.Contains(resultError, tc.expectError) { + t.Errorf("expected error containing '%s', got %s", tc.expectError, resultError) + } + if podLister.called != tc.expectListPods { + t.Errorf("expected getPods=%v, got %v", tc.expectListPods, podLister.called) + } + if evaluator.lv != tc.expectEvaluate { + t.Errorf("expected to evaluate %v, got %v", tc.expectEvaluate, evaluator.lv) + } + if !reflect.DeepEqual(result.Warnings, tc.expectWarnings) { + t.Errorf("expected warnings:\n%v\ngot\n%v", tc.expectWarnings, result.Warnings) + } + }) + } +} diff --git a/staging/src/k8s.io/pod-security-admission/admission/doc.go b/staging/src/k8s.io/pod-security-admission/admission/doc.go new file mode 100644 index 00000000000..883a66469ea --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/admission/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 admission contains PodSecurity admission logic +package admission diff --git a/staging/src/k8s.io/pod-security-admission/admission/namespace.go b/staging/src/k8s.io/pod-security-admission/admission/namespace.go new file mode 100644 index 00000000000..59236ea0be2 --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/admission/namespace.go @@ -0,0 +1,50 @@ +/* +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 admission + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + corev1listers "k8s.io/client-go/listers/core/v1" +) + +func NamespaceGetterFromClient(client kubernetes.Interface) NamespaceGetter { + return &namespaceGetter{client: client} +} + +func NamespaceGetterFromListerAndClient(lister corev1listers.NamespaceLister, client kubernetes.Interface) NamespaceGetter { + return &namespaceGetter{lister: lister, client: client} +} + +type namespaceGetter struct { + lister corev1listers.NamespaceLister + client kubernetes.Interface +} + +func (n *namespaceGetter) GetNamespace(ctx context.Context, name string) (namespace *corev1.Namespace, err error) { + if n.lister != nil { + namespace, err := n.lister.Get(name) + if err == nil || !apierrors.IsNotFound(err) { + return namespace, err + } + } + return n.client.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{}) +} diff --git a/staging/src/k8s.io/pod-security-admission/admission/pods.go b/staging/src/k8s.io/pod-security-admission/admission/pods.go new file mode 100644 index 00000000000..19d783b9ee9 --- /dev/null +++ b/staging/src/k8s.io/pod-security-admission/admission/pods.go @@ -0,0 +1,61 @@ +/* +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 admission + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes" + corev1listers "k8s.io/client-go/listers/core/v1" +) + +// PodListerFromClient returns a PodLister that does live lists using the provided client. +func PodListerFromClient(client kubernetes.Interface) PodLister { + return &clientPodLister{client} +} + +type clientPodLister struct { + client kubernetes.Interface +} + +func (p *clientPodLister) ListPods(ctx context.Context, namespace string) ([]*corev1.Pod, error) { + list, err := p.client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + pods := make([]*corev1.Pod, len(list.Items)) + for i := range list.Items { + pods[i] = &list.Items[i] + } + return pods, nil +} + +// PodListerFromInformer returns a PodLister that does cached lists using the provided lister. +func PodListerFromInformer(lister corev1listers.PodLister) PodLister { + return &informerPodLister{lister} +} + +type informerPodLister struct { + lister corev1listers.PodLister +} + +func (p *informerPodLister) ListPods(ctx context.Context, namespace string) ([]*corev1.Pod, error) { + return p.lister.Pods(namespace).List(labels.Everything()) +}