diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/artifacthub-pkg.yml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/artifacthub-pkg.yml new file mode 100644 index 000000000..318f91b02 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/artifacthub-pkg.yml @@ -0,0 +1,22 @@ +version: 1.1.0 +name: k8spspseccomp +displayName: Seccomp +createdAt: "2024-06-03T13:44:11Z" +description: Controls the seccomp profile used by containers. Corresponds to the `seccomp.security.alpha.kubernetes.io/allowedProfileNames` annotation on a PodSecurityPolicy. For more information, see https://kubernetes.io/docs/concepts/policy/pod-security-policy/#seccomp +digest: 483941dab0df9cb51189b131e309bf927928b69b46ed51986d2f51e30fe758af +license: Apache-2.0 +homeURL: https://open-policy-agent.github.io/gatekeeper-library/website/seccomp +keywords: + - gatekeeper + - open-policy-agent + - policies +readme: |- + # Seccomp + Controls the seccomp profile used by containers. Corresponds to the `seccomp.security.alpha.kubernetes.io/allowedProfileNames` annotation on a PodSecurityPolicy. For more information, see https://kubernetes.io/docs/concepts/policy/pod-security-policy/#seccomp +install: |- + ### Usage + ```shell + kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/artifacthub/library/pod-security-policy/seccomp/1.1.0/template.yaml + ``` +provider: + name: Gatekeeper Library diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/kustomization.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/kustomization.yaml new file mode 100644 index 000000000..7d70d11b7 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - template.yaml diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/constraint.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/constraint.yaml new file mode 100644 index 000000000..0d28be8d9 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/constraint.yaml @@ -0,0 +1,15 @@ +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sPSPSeccomp +metadata: + name: psp-seccomp +spec: + match: + kinds: + - apiGroups: [""] + kinds: ["Pod"] + parameters: + exemptImages: + - nginx-exempt + allowedProfiles: + - runtime/default + - localhost/profile.json \ No newline at end of file diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/disallowed_ephemeral.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/disallowed_ephemeral.yaml new file mode 100644 index 000000000..1555d700e --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/disallowed_ephemeral.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + annotations: + container.seccomp.security.alpha.kubernetes.io/nginx: unconfined + labels: + app: nginx-seccomp +spec: + ephemeralContainers: + - name: nginx + image: nginx diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed.yaml new file mode 100644 index 000000000..2ff43d307 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed + annotations: + container.seccomp.security.alpha.kubernetes.io/nginx: runtime/default + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed2.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed2.yaml new file mode 100644 index 000000000..f8766e774 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed2.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed2 + annotations: + seccomp.security.alpha.kubernetes.io/pod: runtime/default + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed_exempt_image.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed_exempt_image.yaml new file mode 100644 index 000000000..d5f42987d --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed_exempt_image.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx-exempt + securityContext: + seccompProfile: + type: Unconfined diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed_localhost.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed_localhost.yaml new file mode 100644 index 000000000..856f3217b --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_allowed_localhost.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.json diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed.yaml new file mode 100644 index 000000000..8e94ca7e6 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + annotations: + container.seccomp.security.alpha.kubernetes.io/nginx: unconfined + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed2.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed2.yaml new file mode 100644 index 000000000..6008d8f72 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed2.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed2 + annotations: + seccomp.security.alpha.kubernetes.io/pod: unconfined + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed_localhost.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed_localhost.yaml new file mode 100644 index 000000000..c566f7ca4 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/samples/psp-seccomp/example_disallowed_localhost.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.log diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/suite.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/suite.yaml new file mode 100644 index 000000000..0347d33fb --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/suite.yaml @@ -0,0 +1,45 @@ +kind: Suite +apiVersion: test.gatekeeper.sh/v1alpha1 +metadata: + name: seccomp +tests: +- name: default-seccomp-required + template: template.yaml + constraint: samples/psp-seccomp/constraint.yaml + cases: + - name: example-disallowed-global + object: samples/psp-seccomp/example_disallowed2.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'unconfined' is not allowed for container 'nginx'. Found at: annotation seccomp.security.alpha.kubernetes.io/pod" + - name: example-disallowed-container + object: samples/psp-seccomp/example_disallowed.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'unconfined' is not allowed for container 'nginx'. Found at: annotation container.seccomp.security.alpha.kubernetes.io/nginx" + - name: example-allowed-container + object: samples/psp-seccomp/example_allowed.yaml + assertions: + - violations: no + - name: example-allowed-global + object: samples/psp-seccomp/example_allowed2.yaml + assertions: + - violations: no + - name: disallowed-ephemeral + object: samples/psp-seccomp/disallowed_ephemeral.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'unconfined' is not allowed for container 'nginx'. Found at: annotation container.seccomp.security.alpha.kubernetes.io/nginx" + - name: example-allowed-container-exempt-image + object: samples/psp-seccomp/example_allowed_exempt_image.yaml + assertions: + - violations: no + - name: example-allowed-container-localhost-profile + object: samples/psp-seccomp/example_allowed_localhost.yaml + assertions: + - violations: no + - name: example-disallowed-container-localhost-profile + object: samples/psp-seccomp/example_disallowed_localhost.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'localhost/profile.log' is not allowed for container 'nginx'. Found at: container securityContext." \ No newline at end of file diff --git a/artifacthub/library/pod-security-policy/seccomp/1.1.0/template.yaml b/artifacthub/library/pod-security-policy/seccomp/1.1.0/template.yaml new file mode 100644 index 000000000..d1b676d97 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccomp/1.1.0/template.yaml @@ -0,0 +1,398 @@ +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8spspseccomp + annotations: + metadata.gatekeeper.sh/title: "Seccomp" + metadata.gatekeeper.sh/version: 1.1.0 + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `seccomp.security.alpha.kubernetes.io/allowedProfileNames` annotation on + a PodSecurityPolicy. For more information, see + https://kubernetes.io/docs/concepts/policy/pod-security-policy/#seccomp +spec: + crd: + spec: + names: + kind: K8sPSPSeccomp + validation: + # Schema for the `parameters` field + openAPIV3Schema: + type: object + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `seccomp.security.alpha.kubernetes.io/allowedProfileNames` annotation on + a PodSecurityPolicy. For more information, see + https://kubernetes.io/docs/concepts/policy/pod-security-policy/#seccomp + properties: + exemptImages: + description: >- + Any container that uses an image that matches an entry in this list will be excluded + from enforcement. Prefix-matching can be signified with `*`. For example: `my-image-*`. + + It is recommended that users use the fully-qualified Docker image name (e.g. start with a domain name) + in order to avoid unexpectedly exempting images from an untrusted repository. + type: array + items: + type: string + allowedProfiles: + type: array + description: >- + An array of allowed profile values for seccomp on Pods/Containers. + + Can use the annotation naming scheme: `runtime/default`, `docker/default`, `unconfined` and/or + `localhost/some-profile.json`. The item `localhost/*` will allow any localhost based profile. + + Can also use the securityContext naming scheme: `RuntimeDefault`, `Unconfined` + and/or `Localhost`. For securityContext `Localhost`, use the parameter `allowedLocalhostProfiles` + to list the allowed profile JSON files. + + The policy code will translate between the two schemes so it is not necessary to use both. + + Putting a `*` in this array allows all Profiles to be used. + + This field is required since with an empty list this policy will block all workloads. + items: + type: string + allowedLocalhostFiles: + type: array + description: >- + When using securityContext naming scheme for seccomp and including `Localhost` this array holds + the allowed profile JSON files. + + Putting a `*` in this array will allows all JSON files to be used. + + This field is required to allow `Localhost` in securityContext as with an empty list it will block. + items: + type: string + targets: + - target: admission.k8s.gatekeeper.sh + code: + - engine: K8sNativeValidation + source: + variables: + - name: containers + expression: 'has(variables.anyObject.spec.containers) ? variables.anyObject.spec.containers : []' + - name: initContainers + expression: 'has(variables.anyObject.spec.initContainers) ? variables.anyObject.spec.initContainers : []' + - name: ephemeralContainers + expression: 'has(variables.anyObject.spec.ephemeralContainers) ? variables.anyObject.spec.ephemeralContainers : []' + - name: allowAllProfiles + expression: | + has(variables.params.allowedProfiles) && variables.params.allowedProfiles.exists(profile, profile == "*") + - name: exemptImagePrefixes + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, image.endsWith("*")).map(image, string(image).replace("*", "")) + - name: exemptImageExplicit + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, !image.endsWith("*")) + - name: exemptImages + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + container.image in variables.exemptImageExplicit || + variables.exemptImagePrefixes.exists(exemption, string(container.image).startsWith(exemption))).map(container, container.image) + - name: unverifiedContainers + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + !variables.allowAllProfiles && + !(container.image in variables.exemptImages)) + - name: inputAllowedProfiles + expression: | + !has(variables.params.allowedProfiles) ? [] : variables.params.allowedProfiles + - name: allowedLocalhostFiles + expression: | + has(variables.params.allowedLocalhostFiles) ? variables.params.allowedLocalhostFiles : [] + - name: allowedProfilesTranslation + expression: | + (variables.inputAllowedProfiles.filter(profile, + profile != "Localhost").map(profile, profile == "Unconfined" ? "unconfined" : profile)) + + (variables.inputAllowedProfiles.exists(profile, profile == "RuntimeDefault") ? ["runtime/default", "docker/default"] : []) + - name: allowSecurityContextLocalhost + expression: | + variables.inputAllowedProfiles.exists(profile, profile == "Localhost") + - name: derivedAllowedLocalhostFiles + expression: | + variables.allowSecurityContextLocalhost ? variables.params.allowedLocalhostFiles.map(file, "localhost/" + file) : [] + - name: localhostWildcardAllowed + expression: | + variables.inputAllowedProfiles.exists(profile, profile == "localhost/*") || variables.derivedAllowedLocalhostFiles.exists(profile, profile == "localhost/*") + - name: allowedProfiles + expression: | + (variables.allowedProfilesTranslation + variables.derivedAllowedLocalhostFiles) + - name: hasPodSeccomp + expression: | + has(variables.anyObject.spec.securityContext) && has(variables.anyObject.spec.securityContext.seccompProfile) + - name: hasPodAnnotations + expression: | + has(variables.anyObject.metadata.annotations) && ("seccomp.security.alpha.kubernetes.io/pod" in variables.anyObject.metadata.annotations) + - name: podAnnotationsProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !(has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations)) && + !variables.hasPodSeccomp && + variables.hasPodAnnotations + ).map(container, { + "container" : container.name, + "profile" : variables.anyObject.metadata.annotations["seccomp.security.alpha.kubernetes.io/pod"], + "file" : dyn(""), + "location" : dyn("annotation seccomp.security.alpha.kubernetes.io/pod"), + }) + - name: containerAnnotationsProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !variables.hasPodSeccomp && + has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations) + ).map(container, { + "container" : container.name, + "profile" : variables.anyObject.metadata.annotations["container.seccomp.security.alpha.kubernetes.io/" + container.name], + "file" : dyn(""), + "location" : dyn("annotation container.seccomp.security.alpha.kubernetes.io/" + container.name), + }) + - name: podLocalHostProfile + expression: | + variables.hasPodSeccomp && has(variables.anyObject.spec.securityContext.seccompProfile.localhostProfile) ? variables.anyObject.spec.securityContext.seccompProfile.localhostProfile : "" + - name: canonicalPodSecurityContextProfile + expression: | + has(variables.hasPodSeccomp) && has(variables.anyObject.spec.securityContext.seccompProfile.type) ? + (variables.anyObject.spec.securityContext.seccompProfile.type == "RuntimeDefault" ? ( + variables.allowedProfiles.exists(profile, profile == "runtime/default") ? "runtime/default" : variables.allowedProfiles.exists(profile, profile == "docker/default") ? "docker/default" : "runtime/default") : + variables.anyObject.spec.securityContext.seccompProfile.type == "Unconfined" ? "unconfined" : variables.anyObject.spec.securityContext.seccompProfile.type == "Localhost" ? "localhost/" + variables.podLocalHostProfile : "") + : "" + - name: podSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn(variables.canonicalPodSecurityContextProfile), + "file" : variables.podLocalHostProfile, + "location" : dyn("pod securityContext"), + }) + - name: containerSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + has(container.securityContext) && has(container.securityContext.seccompProfile) + ).map(container, { + "container" : container.name, + "profile" : dyn(has(container.securityContext.seccompProfile.type) ? (container.securityContext.seccompProfile.type == "RuntimeDefault" ? ( + variables.allowedProfiles.exists(profile, profile == "runtime/default") ? "runtime/default" : variables.allowedProfiles.exists(profile, profile == "docker/default") ? "docker/default" : "runtime/default") : + container.securityContext.seccompProfile.type == "Unconfined" ? "unconfined" : container.securityContext.seccompProfile.type == "Localhost" ? "localhost/" + container.securityContext.seccompProfile.localhostProfile : "") + : ""), + "file" : has(container.securityContext.seccompProfile.localhostProfile) ? container.securityContext.seccompProfile.localhostProfile : dyn(""), + "location" : dyn("container securityContext"), + }) + - name: containerProfilesMissing + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !(has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations)) && + !variables.hasPodSeccomp && + !variables.hasPodAnnotations + ).map(container, { + "container" : container.name, + "profile" : dyn("not configured"), + "file" : dyn(""), + "location" : dyn("no explicit profile found"), + }) + - name: allContainerProfiles + expression: | + variables.podAnnotationsProfiles + variables.containerAnnotationsProfiles + variables.podSecurityContextProfiles + variables.containerSecurityContextProfiles + variables.containerProfilesMissing + - name: badContainerProfiles + expression: | + variables.allContainerProfiles.filter(badContainerProfile, + !((badContainerProfile.profile in variables.allowedProfiles) || (badContainerProfile.profile.startsWith("localhost/") && variables.localhostWildcardAllowed)) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.allowedProfiles.join(", ")) + validations: + - expression: 'size(variables.badContainerProfiles) == 0' + messageExpression: | + variables.badContainerProfiles.join(", ") + - engine: Rego + source: + rego: | + package k8spspseccomp + + import data.lib.exempt_container.is_exempt + + container_annotation_key_prefix = "container.seccomp.security.alpha.kubernetes.io/" + + pod_annotation_key = "seccomp.security.alpha.kubernetes.io/pod" + + violation[{"msg": msg}] { + not input_wildcard_allowed_profiles + allowed_profiles := get_allowed_profiles + container := input_containers[name] + not is_exempt(container) + result := get_profile(container) + not allowed_profile(result.profile, result.file, allowed_profiles) + msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) + } + + get_message(profile, _, name, location, allowed_profiles) = message { + message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) + } + + input_wildcard_allowed_profiles { + input.parameters.allowedProfiles[_] == "*" + } + + input_wildcard_allowed_files { + input.parameters.allowedLocalhostFiles[_] == "*" + } + + input_wildcard_allowed_files { + "localhost/*" == input.parameters.allowedProfiles[_] + } + + # Simple allowed Profiles + allowed_profile(profile, _, allowed) { + not startswith(profile, "localhost/") + profile == allowed[_] + } + + # annotation localhost with wildcard + allowed_profile(profile, _, allowed) { + "localhost/*" == allowed[_] + startswith(profile, "localhost/") + } + + # annotation localhost without wildcard + allowed_profile(profile, _, allowed) { + startswith(profile, "localhost/") + profile == allowed[_] + } + + # The profiles explicitly in the list + get_allowed_profiles[allowed] { + allowed := input.parameters.allowedProfiles[_] + } + + # Seccomp Localhost to annotation translation + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + not contains(profile, "/") + file := object.get(input.parameters, "allowedLocalhostFiles", [])[_] + allowed := canonicalize_seccomp_profile({"type": profile, "localhostProfile": file}, "")[_] + } + + # Container profile as defined in pod annotation + get_profile(container) = {"profile": profile, "file": "", "location": location} { + not has_securitycontext_container(container) + not has_annotation(get_container_annotation_key(container.name)) + not has_securitycontext_pod + profile := input.review.object.metadata.annotations[pod_annotation_key] + location := sprintf("annotation %v", [pod_annotation_key]) + } + + # Container profile as defined in container annotation + get_profile(container) = {"profile": profile, "file": "", "location": location} { + not has_securitycontext_container(container) + not has_securitycontext_pod + container_annotation := get_container_annotation_key(container.name) + has_annotation(container_annotation) + profile := input.review.object.metadata.annotations[container_annotation] + location := sprintf("annotation %v", [container_annotation]) + } + + # Container profile as defined in pods securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + not has_securitycontext_container(container) + profile := canonicalize_seccomp_profile(input.review.object.spec.securityContext.seccompProfile, canonicalize_runtime_default_profile)[_] + file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") + location := "pod securityContext" + } + + # Container profile as defined in containers securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + has_securitycontext_container(container) + profile := canonicalize_seccomp_profile(container.securityContext.seccompProfile, canonicalize_runtime_default_profile)[_] + file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") + location := "container securityContext" + } + + # Container profile missing + get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { + not has_securitycontext_container(container) + not has_securitycontext_pod + not has_annotation(get_container_annotation_key(container.name)) + not has_annotation(pod_annotation_key) + } + + has_annotation(annotation) { + input.review.object.metadata.annotations[annotation] + } + + has_securitycontext_pod { + input.review.object.spec.securityContext.seccompProfile + } + + has_securitycontext_container(container) { + container.securityContext.seccompProfile + } + + get_container_annotation_key(name) = annotation { + annotation := concat("", [container_annotation_key_prefix, name]) + } + + input_containers[container.name] = container { + container := input.review.object.spec.containers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.initContainers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.ephemeralContainers[_] + } + + canonicalize_runtime_default_profile() = out { + "runtime/default" == input.parameters.allowedProfiles[_] + out := "runtime/default" + } else = out { + "docker/default" == input.parameters.allowedProfiles[_] + out := "docker/default" + } else = out { + out := "runtime/default" + } + + canonicalize_seccomp_profile(profile, def) = out { + profile.type == "RuntimeDefault" + def == "" + out := ["runtime/default", "docker/default"] + } else = out { + profile.type == "RuntimeDefault" + def != "" + out := [def] + } else = out { + profile.type == "Localhost" + out := [sprintf("localhost/%s", [profile.localhostProfile])] + } else = out { + profile.type == "Unconfined" + out := ["unconfined"] + } + libs: + - | + package lib.exempt_container + + is_exempt(container) { + exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) + img := container.image + exemption := exempt_images[_] + _matches_exemption(img, exemption) + } + + _matches_exemption(img, exemption) { + not endswith(exemption, "*") + exemption == img + } + + _matches_exemption(img, exemption) { + endswith(exemption, "*") + prefix := trim_suffix(exemption, "*") + startswith(img, prefix) + } diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/artifacthub-pkg.yml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/artifacthub-pkg.yml new file mode 100644 index 000000000..f573badaf --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/artifacthub-pkg.yml @@ -0,0 +1,22 @@ +version: 1.0.0 +name: k8spspseccompv2 +displayName: Seccomp V2 +createdAt: "2024-09-05T01:36:31Z" +description: Controls the seccomp profile used by containers. Corresponds to the `securityContext.seccompProfile` field. +digest: c6dbfe96ca7a4be156bee4bf42aef07a1424127fd9dc7d222b4e934c1919811f +license: Apache-2.0 +homeURL: https://open-policy-agent.github.io/gatekeeper-library/website/seccompv2 +keywords: + - gatekeeper + - open-policy-agent + - policies +readme: |- + # Seccomp V2 + Controls the seccomp profile used by containers. Corresponds to the `securityContext.seccompProfile` field. +install: |- + ### Usage + ```shell + kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/artifacthub/library/pod-security-policy/seccompv2/1.0.0/template.yaml + ``` +provider: + name: Gatekeeper Library diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/kustomization.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/kustomization.yaml new file mode 100644 index 000000000..7d70d11b7 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - template.yaml diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/constraint.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/constraint.yaml new file mode 100644 index 000000000..a85141492 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/constraint.yaml @@ -0,0 +1,17 @@ +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sPSPSeccompV2 +metadata: + name: psp-seccomp +spec: + match: + kinds: + - apiGroups: [""] + kinds: ["Pod"] + parameters: + exemptImages: + - nginx-exempt + allowedProfiles: + - RuntimeDefault + - Localhost + allowedLocalhostFiles: + - "*" diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/disallowed_ephemeral.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/disallowed_ephemeral.yaml new file mode 100644 index 000000000..4b32243a9 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/disallowed_ephemeral.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + ephemeralContainers: + - name: nginx + image: nginx diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed.yaml new file mode 100644 index 000000000..65dfc5be8 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: RuntimeDefault diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed_exempt_image.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed_exempt_image.yaml new file mode 100644 index 000000000..d5f42987d --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed_exempt_image.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx-exempt + securityContext: + seccompProfile: + type: Unconfined diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed_localhost.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed_localhost.yaml new file mode 100644 index 000000000..856f3217b --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_allowed_localhost.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.json diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_disallowed.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_disallowed.yaml new file mode 100644 index 000000000..40c115b86 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_disallowed.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Unconfined diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_disallowed2.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_disallowed2.yaml new file mode 100644 index 000000000..e08463f18 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/samples/psp-seccomp/example_disallowed2.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed2 + labels: + app: nginx-seccomp +spec: + securityContext: + seccompProfile: + type: Unconfined + containers: + - name: nginx + image: nginx diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/suite.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/suite.yaml new file mode 100644 index 000000000..f9daa264d --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/suite.yaml @@ -0,0 +1,36 @@ +kind: Suite +apiVersion: test.gatekeeper.sh/v1alpha1 +metadata: + name: seccomp +tests: +- name: default-seccomp-required + template: template.yaml + constraint: samples/psp-seccomp/constraint.yaml + cases: + - name: example-disallowed-global + object: samples/psp-seccomp/example_disallowed2.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'Unconfined' is not allowed for container 'nginx'. Found at: pod securityContext." + - name: example-disallowed-container + object: samples/psp-seccomp/example_disallowed.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'Unconfined' is not allowed for container 'nginx'. Found at: container securityContext." + - name: example-allowed-container + object: samples/psp-seccomp/example_allowed.yaml + assertions: + - violations: no + - name: example-allowed-container + object: samples/psp-seccomp/example_allowed_localhost.yaml + assertions: + - violations: no + - name: example-allowed-container-exempt-image + object: samples/psp-seccomp/example_allowed_exempt_image.yaml + assertions: + - violations: no + - name: disallowed-ephemeral + object: samples/psp-seccomp/disallowed_ephemeral.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'not configured' is not allowed for container 'nginx'. Found at: no explicit profile found" diff --git a/artifacthub/library/pod-security-policy/seccompv2/1.0.0/template.yaml b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/template.yaml new file mode 100644 index 000000000..ce0a53202 --- /dev/null +++ b/artifacthub/library/pod-security-policy/seccompv2/1.0.0/template.yaml @@ -0,0 +1,301 @@ +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8spspseccompv2 + annotations: + metadata.gatekeeper.sh/title: "Seccomp V2" + metadata.gatekeeper.sh/version: 1.0.0 + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. +spec: + crd: + spec: + names: + kind: K8sPSPSeccompV2 + validation: + # Schema for the `parameters` field + openAPIV3Schema: + type: object + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. + properties: + exemptImages: + description: >- + Any container that uses an image that matches an entry in this list will be excluded + from enforcement. Prefix-matching can be signified with `*`. For example: `my-image-*`. + + It is recommended that users use the fully-qualified Docker image name (e.g. start with a domain name) + in order to avoid unexpectedly exempting images from an untrusted repository. + type: array + items: + type: string + allowedProfiles: + type: array + description: >- + An array of allowed profile values for seccomp on Pods/Containers. + + Can use the securityContext naming scheme: `RuntimeDefault`, `Unconfined` + and/or `Localhost`. For securityContext `Localhost`, use the parameter `allowedLocalhostFiles` + to list the allowed profile JSON files. + + The policy code will translate between the two schemes so it is not necessary to use both. + + Putting a `*` in this array allows all Profiles to be used. + + This field is required since with an empty list this policy will block all workloads. + items: + type: string + allowedLocalhostFiles: + type: array + description: >- + When using securityContext naming scheme for seccomp and including `Localhost` this array holds + the allowed profile JSON files. + + Putting a `*` in this array will allows all JSON files to be used. + + This field is required to allow `Localhost` in securityContext as with an empty list it will block. + items: + type: string + targets: + - target: admission.k8s.gatekeeper.sh + code: + - engine: K8sNativeValidation + source: + variables: + - name: containers + expression: 'has(variables.anyObject.spec.containers) ? variables.anyObject.spec.containers : []' + - name: initContainers + expression: 'has(variables.anyObject.spec.initContainers) ? variables.anyObject.spec.initContainers : []' + - name: ephemeralContainers + expression: 'has(variables.anyObject.spec.ephemeralContainers) ? variables.anyObject.spec.ephemeralContainers : []' + - name: allowAllProfiles + expression: | + has(variables.params.allowedProfiles) && variables.params.allowedProfiles.exists(profile, profile == "*") + - name: exemptImagePrefixes + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, image.endsWith("*")).map(image, string(image).replace("*", "")) + - name: exemptImageExplicit + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, !image.endsWith("*")) + - name: exemptImages + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + container.image in variables.exemptImageExplicit || + variables.exemptImagePrefixes.exists(exemption, string(container.image).startsWith(exemption))).map(container, container.image) + - name: unverifiedContainers + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + !variables.allowAllProfiles && + !(container.image in variables.exemptImages)) + - name: inputNonLocalHostProfiles + expression: | + variables.params.allowedProfiles.filter(profile, profile != "Localhost").map(profile, {"type": profile}) + - name: inputLocalHostProfiles + expression: | + variables.params.allowedProfiles.exists(profile, profile == "Localhost") ? variables.params.allowedLocalhostFiles.map(file, {"type": "Localhost", "localHostProfile": string(file)}) : [] + - name: inputAllowedProfiles + expression: | + variables.inputNonLocalHostProfiles + variables.inputLocalHostProfiles + - name: hasPodSeccomp + expression: | + has(variables.anyObject.spec.securityContext) && has(variables.anyObject.spec.securityContext.seccompProfile) + - name: podLocalHostProfile + expression: | + variables.hasPodSeccomp && has(variables.anyObject.spec.securityContext.seccompProfile.localhostProfile) ? variables.anyObject.spec.securityContext.seccompProfile.localhostProfile : "" + - name: podSecurityContextProfileType + expression: | + has(variables.hasPodSeccomp) && has(variables.anyObject.spec.securityContext.seccompProfile.type) ? variables.anyObject.spec.securityContext.seccompProfile.type + : "" + - name: podSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn(variables.podSecurityContextProfileType), + "file" : variables.podLocalHostProfile, + "location" : dyn("pod securityContext"), + }) + - name: containerSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + has(container.securityContext) && has(container.securityContext.seccompProfile) + ).map(container, { + "container" : container.name, + "profile" : dyn(container.securityContext.seccompProfile.type), + "file" : has(container.securityContext.seccompProfile.localhostProfile) ? container.securityContext.seccompProfile.localhostProfile : dyn(""), + "location" : dyn("container securityContext"), + }) + - name: containerProfilesMissing + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn("not configured"), + "file" : dyn(""), + "location" : dyn("no explicit profile found"), + }) + - name: allContainerProfiles + expression: | + variables.podSecurityContextProfiles + variables.containerSecurityContextProfiles + variables.containerProfilesMissing + - name: badContainerProfilesWithoutFiles + expression: | + variables.allContainerProfiles.filter(container, + container.profile != "Localhost" && + !variables.inputAllowedProfiles.exists(profile, profile.type == container.profile) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.inputAllowedProfiles.map(profile, "{\"type\": \"" + profile.type + "\"" + (has(profile.localHostProfile) ? ", \"localHostProfile\": \"" + profile.localHostProfile + "\"}" : "}")).join(", ")) + - name: badContainerProfilesWithFiles + expression: | + variables.allContainerProfiles.filter(container, + container.profile == "Localhost" && + !variables.inputAllowedProfiles.exists(profile, profile.type == "Localhost" && (has(profile.localHostProfile) && (profile.localHostProfile == container.file || profile.localHostProfile == "*"))) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' With file '" + badProfile.file + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.inputAllowedProfiles.map(profile, "{\"type\": \"" + profile.type + "\"" + (has(profile.localHostProfile) ? ", \"localHostProfile\": \"" + profile.localHostProfile + "\"}" : "}")).join(", ")) + validations: + - expression: 'size(variables.badContainerProfilesWithoutFiles) == 0' + messageExpression: | + variables.badContainerProfilesWithoutFiles.join(", ") + - expression: 'size(variables.badContainerProfilesWithFiles) == 0' + messageExpression: | + variables.badContainerProfilesWithFiles.join(", ") + - engine: Rego + source: + rego: | + package k8spspseccomp + + import data.lib.exempt_container.is_exempt + + violation[{"msg": msg}] { + not input_wildcard_allowed_profiles + allowed_profiles := get_allowed_profiles + container := input_containers[name] + not is_exempt(container) + result := get_profile(container) + not allowed_profile(result.profile, result.file, allowed_profiles) + msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) + } + + get_message(profile, _, name, location, allowed_profiles) = message { + profile != "Localhost" + message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) + } + + get_message(profile, file, name, location, allowed_profiles) = message { + profile == "Localhost" + message := sprintf("Seccomp profile '%v' with file '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, file, name, location, allowed_profiles]) + } + + input_wildcard_allowed_profiles { + input.parameters.allowedProfiles[_] == "*" + } + + input_wildcard_allowed_files { + input.parameters.allowedLocalhostFiles[_] == "*" + } + + allowed_profile(_, _, _) { + input_wildcard_allowed_profiles + } + + allowed_profile(profile, _, _) { + profile == "Localhost" + input_wildcard_allowed_files + } + + # Simple allowed Profiles + allowed_profile(profile, _, allowed) { + profile != "Localhost" + allow_profile = allowed[_] + profile == allow_profile.type + } + + # annotation localhost without wildcard + allowed_profile(profile, file, allowed) { + profile == "Localhost" + allow_profile = allowed[_] + allow_profile.type == "Localhost" + file == allow_profile.localHostProfile + } + + # The profiles explicitly in the list + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + profile != "Localhost" + allowed := {"type": profile} + } + + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + profile == "Localhost" + file := object.get(input.parameters, "allowedLocalhostFiles", [""])[_] + allowed := {"type": "Localhost", "localHostProfile": file} + } + + # Container profile as defined in containers securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + has_securitycontext_container(container) + profile := container.securityContext.seccompProfile.type + file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") + location := "container securityContext" + } + + # Container profile as defined in pods securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + not has_securitycontext_container(container) + profile := input.review.object.spec.securityContext.seccompProfile.type + file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") + location := "pod securityContext" + } + + # Container profile missing + get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { + not has_securitycontext_container(container) + not has_securitycontext_pod + } + + has_securitycontext_pod { + input.review.object.spec.securityContext.seccompProfile + } + + has_securitycontext_container(container) { + container.securityContext.seccompProfile + } + + input_containers[container.name] = container { + container := input.review.object.spec.containers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.initContainers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.ephemeralContainers[_] + } + libs: + - | + package lib.exempt_container + + is_exempt(container) { + exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) + img := container.image + exemption := exempt_images[_] + _matches_exemption(img, exemption) + } + + _matches_exemption(img, exemption) { + not endswith(exemption, "*") + exemption == img + } + + _matches_exemption(img, exemption) { + endswith(exemption, "*") + prefix := trim_suffix(exemption, "*") + startswith(img, prefix) + } diff --git a/library/pod-security-policy/kustomization.yaml b/library/pod-security-policy/kustomization.yaml index 63d1d6d44..75e0bd1cf 100644 --- a/library/pod-security-policy/kustomization.yaml +++ b/library/pod-security-policy/kustomization.yaml @@ -17,3 +17,4 @@ resources: - selinux - users - volumes +- seccompv2 diff --git a/library/pod-security-policy/seccomp/samples/psp-seccomp/constraint.yaml b/library/pod-security-policy/seccomp/samples/psp-seccomp/constraint.yaml index d26af154e..0d28be8d9 100644 --- a/library/pod-security-policy/seccomp/samples/psp-seccomp/constraint.yaml +++ b/library/pod-security-policy/seccomp/samples/psp-seccomp/constraint.yaml @@ -8,6 +8,8 @@ spec: - apiGroups: [""] kinds: ["Pod"] parameters: + exemptImages: + - nginx-exempt allowedProfiles: - runtime/default - - docker/default + - localhost/profile.json \ No newline at end of file diff --git a/library/pod-security-policy/seccomp/samples/psp-seccomp/example_allowed_exempt_image.yaml b/library/pod-security-policy/seccomp/samples/psp-seccomp/example_allowed_exempt_image.yaml new file mode 100644 index 000000000..d5f42987d --- /dev/null +++ b/library/pod-security-policy/seccomp/samples/psp-seccomp/example_allowed_exempt_image.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx-exempt + securityContext: + seccompProfile: + type: Unconfined diff --git a/library/pod-security-policy/seccomp/samples/psp-seccomp/example_allowed_localhost.yaml b/library/pod-security-policy/seccomp/samples/psp-seccomp/example_allowed_localhost.yaml new file mode 100644 index 000000000..856f3217b --- /dev/null +++ b/library/pod-security-policy/seccomp/samples/psp-seccomp/example_allowed_localhost.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.json diff --git a/library/pod-security-policy/seccomp/samples/psp-seccomp/example_disallowed_localhost.yaml b/library/pod-security-policy/seccomp/samples/psp-seccomp/example_disallowed_localhost.yaml new file mode 100644 index 000000000..c566f7ca4 --- /dev/null +++ b/library/pod-security-policy/seccomp/samples/psp-seccomp/example_disallowed_localhost.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.log diff --git a/library/pod-security-policy/seccomp/suite.yaml b/library/pod-security-policy/seccomp/suite.yaml index 62336b26e..0347d33fb 100644 --- a/library/pod-security-policy/seccomp/suite.yaml +++ b/library/pod-security-policy/seccomp/suite.yaml @@ -30,3 +30,16 @@ tests: assertions: - violations: 1 message: "Seccomp profile 'unconfined' is not allowed for container 'nginx'. Found at: annotation container.seccomp.security.alpha.kubernetes.io/nginx" + - name: example-allowed-container-exempt-image + object: samples/psp-seccomp/example_allowed_exempt_image.yaml + assertions: + - violations: no + - name: example-allowed-container-localhost-profile + object: samples/psp-seccomp/example_allowed_localhost.yaml + assertions: + - violations: no + - name: example-disallowed-container-localhost-profile + object: samples/psp-seccomp/example_disallowed_localhost.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'localhost/profile.log' is not allowed for container 'nginx'. Found at: container securityContext." \ No newline at end of file diff --git a/library/pod-security-policy/seccomp/template.yaml b/library/pod-security-policy/seccomp/template.yaml index d252a1ace..d1b676d97 100644 --- a/library/pod-security-policy/seccomp/template.yaml +++ b/library/pod-security-policy/seccomp/template.yaml @@ -4,7 +4,7 @@ metadata: name: k8spspseccomp annotations: metadata.gatekeeper.sh/title: "Seccomp" - metadata.gatekeeper.sh/version: 1.0.1 + metadata.gatekeeper.sh/version: 1.1.0 description: >- Controls the seccomp profile used by containers. Corresponds to the `seccomp.security.alpha.kubernetes.io/allowedProfileNames` annotation on @@ -67,215 +67,332 @@ spec: type: string targets: - target: admission.k8s.gatekeeper.sh - rego: | - package k8spspseccomp - - import data.lib.exempt_container.is_exempt - - container_annotation_key_prefix = "container.seccomp.security.alpha.kubernetes.io/" - - pod_annotation_key = "seccomp.security.alpha.kubernetes.io/pod" - - naming_translation = { - # securityContext -> annotation - "RuntimeDefault": ["runtime/default", "docker/default"], - "Unconfined": ["unconfined"], - "Localhost": ["localhost"], - # annotation -> securityContext - "runtime/default": ["RuntimeDefault"], - "docker/default": ["RuntimeDefault"], - "unconfined": ["Unconfined"], - "localhost": ["Localhost"], - } - - violation[{"msg": msg}] { - not input_wildcard_allowed_profiles - allowed_profiles := get_allowed_profiles - container := input_containers[name] - not is_exempt(container) - result := get_profile(container) - not allowed_profile(result.profile, result.file, allowed_profiles) - msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) - } - - get_message(profile, _, name, location, allowed_profiles) = message { - not profile == "Localhost" - message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) - } - - get_message(profile, file, name, location, allowed_profiles) = message { - profile == "Localhost" - message := sprintf("Seccomp profile '%v' with file '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, file, name, location, allowed_profiles]) - } - - input_wildcard_allowed_profiles { - input.parameters.allowedProfiles[_] == "*" - } - - input_wildcard_allowed_files { - input.parameters.allowedLocalhostFiles[_] == "*" - } - - input_wildcard_allowed_files { - "localhost/*" == input.parameters.allowedProfiles[_] - } - - # Simple allowed Profiles - allowed_profile(profile, _, allowed) { - not startswith(lower(profile), "localhost") - profile == allowed[_] - } - - # seccomp Localhost without wildcard - allowed_profile(profile, file, allowed) { - profile == "Localhost" - not input_wildcard_allowed_files - profile == allowed[_] - allowed_files := {x | x := object.get(input.parameters, "allowedLocalhostFiles", [])[_]} | get_annotation_localhost_files - file == allowed_files[_] - } - - # seccomp Localhost with wildcard - allowed_profile(profile, _, allowed) { - profile == "Localhost" - input_wildcard_allowed_files - profile == allowed[_] - } - - # annotation localhost with wildcard - allowed_profile(profile, _, allowed) { - "localhost/*" == allowed[_] - startswith(profile, "localhost/") - } - - # annotation localhost without wildcard - allowed_profile(profile, _, allowed) { - startswith(profile, "localhost/") - profile == allowed[_] - } - - # Localhost files from annotation scheme - get_annotation_localhost_files[file] { - profile := input.parameters.allowedProfiles[_] - startswith(profile, "localhost/") - file := replace(profile, "localhost/", "") - } - - # The profiles explicitly in the list - get_allowed_profiles[allowed] { - allowed := input.parameters.allowedProfiles[_] - } - - # The simply translated profiles - get_allowed_profiles[allowed] { - profile := input.parameters.allowedProfiles[_] - not startswith(lower(profile), "localhost") - allowed := naming_translation[profile][_] - } - - # Seccomp Localhost to annotation translation - get_allowed_profiles[allowed] { - profile := input.parameters.allowedProfiles[_] - profile == "Localhost" - file := object.get(input.parameters, "allowedLocalhostFiles", [])[_] - allowed := sprintf("%v/%v", [naming_translation[profile][_], file]) - } - - # Annotation localhost to Seccomp translation - get_allowed_profiles[allowed] { - profile := input.parameters.allowedProfiles[_] - startswith(profile, "localhost") - allowed := naming_translation.localhost[_] - } - - # Container profile as defined in pod annotation - get_profile(container) = {"profile": profile, "file": "", "location": location} { - not has_securitycontext_container(container) - not has_annotation(get_container_annotation_key(container.name)) - not has_securitycontext_pod - profile := input.review.object.metadata.annotations[pod_annotation_key] - location := sprintf("annotation %v", [pod_annotation_key]) - } - - # Container profile as defined in container annotation - get_profile(container) = {"profile": profile, "file": "", "location": location} { - not has_securitycontext_container(container) - not has_securitycontext_pod - container_annotation := get_container_annotation_key(container.name) - has_annotation(container_annotation) - profile := input.review.object.metadata.annotations[container_annotation] - location := sprintf("annotation %v", [container_annotation]) - } - - # Container profile as defined in pods securityContext - get_profile(container) = {"profile": profile, "file": file, "location": location} { - not has_securitycontext_container(container) - profile := input.review.object.spec.securityContext.seccompProfile.type - file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") - location := "pod securityContext" - } - - # Container profile as defined in containers securityContext - get_profile(container) = {"profile": profile, "file": file, "location": location} { - has_securitycontext_container(container) - profile := container.securityContext.seccompProfile.type - file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") - location := "container securityContext" - } - - # Container profile missing - get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { - not has_annotation(get_container_annotation_key(container.name)) - not has_annotation(pod_annotation_key) - not has_securitycontext_pod - not has_securitycontext_container(container) - } - - has_annotation(annotation) { - input.review.object.metadata.annotations[annotation] - } - - has_securitycontext_pod { - input.review.object.spec.securityContext.seccompProfile - } - - has_securitycontext_container(container) { - container.securityContext.seccompProfile - } - - get_container_annotation_key(name) = annotation { - annotation := concat("", [container_annotation_key_prefix, name]) - } - - input_containers[container.name] = container { - container := input.review.object.spec.containers[_] - } - - input_containers[container.name] = container { - container := input.review.object.spec.initContainers[_] - } - - input_containers[container.name] = container { - container := input.review.object.spec.ephemeralContainers[_] - } - libs: - - | - package lib.exempt_container - - is_exempt(container) { - exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) - img := container.image - exemption := exempt_images[_] - _matches_exemption(img, exemption) - } - - _matches_exemption(img, exemption) { - not endswith(exemption, "*") - exemption == img - } - - _matches_exemption(img, exemption) { - endswith(exemption, "*") - prefix := trim_suffix(exemption, "*") - startswith(img, prefix) - } + code: + - engine: K8sNativeValidation + source: + variables: + - name: containers + expression: 'has(variables.anyObject.spec.containers) ? variables.anyObject.spec.containers : []' + - name: initContainers + expression: 'has(variables.anyObject.spec.initContainers) ? variables.anyObject.spec.initContainers : []' + - name: ephemeralContainers + expression: 'has(variables.anyObject.spec.ephemeralContainers) ? variables.anyObject.spec.ephemeralContainers : []' + - name: allowAllProfiles + expression: | + has(variables.params.allowedProfiles) && variables.params.allowedProfiles.exists(profile, profile == "*") + - name: exemptImagePrefixes + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, image.endsWith("*")).map(image, string(image).replace("*", "")) + - name: exemptImageExplicit + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, !image.endsWith("*")) + - name: exemptImages + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + container.image in variables.exemptImageExplicit || + variables.exemptImagePrefixes.exists(exemption, string(container.image).startsWith(exemption))).map(container, container.image) + - name: unverifiedContainers + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + !variables.allowAllProfiles && + !(container.image in variables.exemptImages)) + - name: inputAllowedProfiles + expression: | + !has(variables.params.allowedProfiles) ? [] : variables.params.allowedProfiles + - name: allowedLocalhostFiles + expression: | + has(variables.params.allowedLocalhostFiles) ? variables.params.allowedLocalhostFiles : [] + - name: allowedProfilesTranslation + expression: | + (variables.inputAllowedProfiles.filter(profile, + profile != "Localhost").map(profile, profile == "Unconfined" ? "unconfined" : profile)) + + (variables.inputAllowedProfiles.exists(profile, profile == "RuntimeDefault") ? ["runtime/default", "docker/default"] : []) + - name: allowSecurityContextLocalhost + expression: | + variables.inputAllowedProfiles.exists(profile, profile == "Localhost") + - name: derivedAllowedLocalhostFiles + expression: | + variables.allowSecurityContextLocalhost ? variables.params.allowedLocalhostFiles.map(file, "localhost/" + file) : [] + - name: localhostWildcardAllowed + expression: | + variables.inputAllowedProfiles.exists(profile, profile == "localhost/*") || variables.derivedAllowedLocalhostFiles.exists(profile, profile == "localhost/*") + - name: allowedProfiles + expression: | + (variables.allowedProfilesTranslation + variables.derivedAllowedLocalhostFiles) + - name: hasPodSeccomp + expression: | + has(variables.anyObject.spec.securityContext) && has(variables.anyObject.spec.securityContext.seccompProfile) + - name: hasPodAnnotations + expression: | + has(variables.anyObject.metadata.annotations) && ("seccomp.security.alpha.kubernetes.io/pod" in variables.anyObject.metadata.annotations) + - name: podAnnotationsProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !(has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations)) && + !variables.hasPodSeccomp && + variables.hasPodAnnotations + ).map(container, { + "container" : container.name, + "profile" : variables.anyObject.metadata.annotations["seccomp.security.alpha.kubernetes.io/pod"], + "file" : dyn(""), + "location" : dyn("annotation seccomp.security.alpha.kubernetes.io/pod"), + }) + - name: containerAnnotationsProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !variables.hasPodSeccomp && + has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations) + ).map(container, { + "container" : container.name, + "profile" : variables.anyObject.metadata.annotations["container.seccomp.security.alpha.kubernetes.io/" + container.name], + "file" : dyn(""), + "location" : dyn("annotation container.seccomp.security.alpha.kubernetes.io/" + container.name), + }) + - name: podLocalHostProfile + expression: | + variables.hasPodSeccomp && has(variables.anyObject.spec.securityContext.seccompProfile.localhostProfile) ? variables.anyObject.spec.securityContext.seccompProfile.localhostProfile : "" + - name: canonicalPodSecurityContextProfile + expression: | + has(variables.hasPodSeccomp) && has(variables.anyObject.spec.securityContext.seccompProfile.type) ? + (variables.anyObject.spec.securityContext.seccompProfile.type == "RuntimeDefault" ? ( + variables.allowedProfiles.exists(profile, profile == "runtime/default") ? "runtime/default" : variables.allowedProfiles.exists(profile, profile == "docker/default") ? "docker/default" : "runtime/default") : + variables.anyObject.spec.securityContext.seccompProfile.type == "Unconfined" ? "unconfined" : variables.anyObject.spec.securityContext.seccompProfile.type == "Localhost" ? "localhost/" + variables.podLocalHostProfile : "") + : "" + - name: podSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn(variables.canonicalPodSecurityContextProfile), + "file" : variables.podLocalHostProfile, + "location" : dyn("pod securityContext"), + }) + - name: containerSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + has(container.securityContext) && has(container.securityContext.seccompProfile) + ).map(container, { + "container" : container.name, + "profile" : dyn(has(container.securityContext.seccompProfile.type) ? (container.securityContext.seccompProfile.type == "RuntimeDefault" ? ( + variables.allowedProfiles.exists(profile, profile == "runtime/default") ? "runtime/default" : variables.allowedProfiles.exists(profile, profile == "docker/default") ? "docker/default" : "runtime/default") : + container.securityContext.seccompProfile.type == "Unconfined" ? "unconfined" : container.securityContext.seccompProfile.type == "Localhost" ? "localhost/" + container.securityContext.seccompProfile.localhostProfile : "") + : ""), + "file" : has(container.securityContext.seccompProfile.localhostProfile) ? container.securityContext.seccompProfile.localhostProfile : dyn(""), + "location" : dyn("container securityContext"), + }) + - name: containerProfilesMissing + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !(has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations)) && + !variables.hasPodSeccomp && + !variables.hasPodAnnotations + ).map(container, { + "container" : container.name, + "profile" : dyn("not configured"), + "file" : dyn(""), + "location" : dyn("no explicit profile found"), + }) + - name: allContainerProfiles + expression: | + variables.podAnnotationsProfiles + variables.containerAnnotationsProfiles + variables.podSecurityContextProfiles + variables.containerSecurityContextProfiles + variables.containerProfilesMissing + - name: badContainerProfiles + expression: | + variables.allContainerProfiles.filter(badContainerProfile, + !((badContainerProfile.profile in variables.allowedProfiles) || (badContainerProfile.profile.startsWith("localhost/") && variables.localhostWildcardAllowed)) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.allowedProfiles.join(", ")) + validations: + - expression: 'size(variables.badContainerProfiles) == 0' + messageExpression: | + variables.badContainerProfiles.join(", ") + - engine: Rego + source: + rego: | + package k8spspseccomp + + import data.lib.exempt_container.is_exempt + + container_annotation_key_prefix = "container.seccomp.security.alpha.kubernetes.io/" + + pod_annotation_key = "seccomp.security.alpha.kubernetes.io/pod" + + violation[{"msg": msg}] { + not input_wildcard_allowed_profiles + allowed_profiles := get_allowed_profiles + container := input_containers[name] + not is_exempt(container) + result := get_profile(container) + not allowed_profile(result.profile, result.file, allowed_profiles) + msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) + } + + get_message(profile, _, name, location, allowed_profiles) = message { + message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) + } + + input_wildcard_allowed_profiles { + input.parameters.allowedProfiles[_] == "*" + } + + input_wildcard_allowed_files { + input.parameters.allowedLocalhostFiles[_] == "*" + } + + input_wildcard_allowed_files { + "localhost/*" == input.parameters.allowedProfiles[_] + } + + # Simple allowed Profiles + allowed_profile(profile, _, allowed) { + not startswith(profile, "localhost/") + profile == allowed[_] + } + + # annotation localhost with wildcard + allowed_profile(profile, _, allowed) { + "localhost/*" == allowed[_] + startswith(profile, "localhost/") + } + + # annotation localhost without wildcard + allowed_profile(profile, _, allowed) { + startswith(profile, "localhost/") + profile == allowed[_] + } + + # The profiles explicitly in the list + get_allowed_profiles[allowed] { + allowed := input.parameters.allowedProfiles[_] + } + + # Seccomp Localhost to annotation translation + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + not contains(profile, "/") + file := object.get(input.parameters, "allowedLocalhostFiles", [])[_] + allowed := canonicalize_seccomp_profile({"type": profile, "localhostProfile": file}, "")[_] + } + + # Container profile as defined in pod annotation + get_profile(container) = {"profile": profile, "file": "", "location": location} { + not has_securitycontext_container(container) + not has_annotation(get_container_annotation_key(container.name)) + not has_securitycontext_pod + profile := input.review.object.metadata.annotations[pod_annotation_key] + location := sprintf("annotation %v", [pod_annotation_key]) + } + + # Container profile as defined in container annotation + get_profile(container) = {"profile": profile, "file": "", "location": location} { + not has_securitycontext_container(container) + not has_securitycontext_pod + container_annotation := get_container_annotation_key(container.name) + has_annotation(container_annotation) + profile := input.review.object.metadata.annotations[container_annotation] + location := sprintf("annotation %v", [container_annotation]) + } + + # Container profile as defined in pods securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + not has_securitycontext_container(container) + profile := canonicalize_seccomp_profile(input.review.object.spec.securityContext.seccompProfile, canonicalize_runtime_default_profile)[_] + file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") + location := "pod securityContext" + } + + # Container profile as defined in containers securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + has_securitycontext_container(container) + profile := canonicalize_seccomp_profile(container.securityContext.seccompProfile, canonicalize_runtime_default_profile)[_] + file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") + location := "container securityContext" + } + + # Container profile missing + get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { + not has_securitycontext_container(container) + not has_securitycontext_pod + not has_annotation(get_container_annotation_key(container.name)) + not has_annotation(pod_annotation_key) + } + + has_annotation(annotation) { + input.review.object.metadata.annotations[annotation] + } + + has_securitycontext_pod { + input.review.object.spec.securityContext.seccompProfile + } + + has_securitycontext_container(container) { + container.securityContext.seccompProfile + } + + get_container_annotation_key(name) = annotation { + annotation := concat("", [container_annotation_key_prefix, name]) + } + + input_containers[container.name] = container { + container := input.review.object.spec.containers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.initContainers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.ephemeralContainers[_] + } + + canonicalize_runtime_default_profile() = out { + "runtime/default" == input.parameters.allowedProfiles[_] + out := "runtime/default" + } else = out { + "docker/default" == input.parameters.allowedProfiles[_] + out := "docker/default" + } else = out { + out := "runtime/default" + } + + canonicalize_seccomp_profile(profile, def) = out { + profile.type == "RuntimeDefault" + def == "" + out := ["runtime/default", "docker/default"] + } else = out { + profile.type == "RuntimeDefault" + def != "" + out := [def] + } else = out { + profile.type == "Localhost" + out := [sprintf("localhost/%s", [profile.localhostProfile])] + } else = out { + profile.type == "Unconfined" + out := ["unconfined"] + } + libs: + - | + package lib.exempt_container + + is_exempt(container) { + exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) + img := container.image + exemption := exempt_images[_] + _matches_exemption(img, exemption) + } + + _matches_exemption(img, exemption) { + not endswith(exemption, "*") + exemption == img + } + + _matches_exemption(img, exemption) { + endswith(exemption, "*") + prefix := trim_suffix(exemption, "*") + startswith(img, prefix) + } diff --git a/library/pod-security-policy/seccompv2/kustomization.yaml b/library/pod-security-policy/seccompv2/kustomization.yaml new file mode 100644 index 000000000..7d70d11b7 --- /dev/null +++ b/library/pod-security-policy/seccompv2/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - template.yaml diff --git a/library/pod-security-policy/seccompv2/samples/psp-seccomp/constraint.yaml b/library/pod-security-policy/seccompv2/samples/psp-seccomp/constraint.yaml new file mode 100644 index 000000000..a85141492 --- /dev/null +++ b/library/pod-security-policy/seccompv2/samples/psp-seccomp/constraint.yaml @@ -0,0 +1,17 @@ +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sPSPSeccompV2 +metadata: + name: psp-seccomp +spec: + match: + kinds: + - apiGroups: [""] + kinds: ["Pod"] + parameters: + exemptImages: + - nginx-exempt + allowedProfiles: + - RuntimeDefault + - Localhost + allowedLocalhostFiles: + - "*" diff --git a/library/pod-security-policy/seccompv2/samples/psp-seccomp/disallowed_ephemeral.yaml b/library/pod-security-policy/seccompv2/samples/psp-seccomp/disallowed_ephemeral.yaml new file mode 100644 index 000000000..4b32243a9 --- /dev/null +++ b/library/pod-security-policy/seccompv2/samples/psp-seccomp/disallowed_ephemeral.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + ephemeralContainers: + - name: nginx + image: nginx diff --git a/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed.yaml b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed.yaml new file mode 100644 index 000000000..65dfc5be8 --- /dev/null +++ b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: RuntimeDefault diff --git a/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed_exempt_image.yaml b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed_exempt_image.yaml new file mode 100644 index 000000000..d5f42987d --- /dev/null +++ b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed_exempt_image.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx-exempt + securityContext: + seccompProfile: + type: Unconfined diff --git a/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed_localhost.yaml b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed_localhost.yaml new file mode 100644 index 000000000..856f3217b --- /dev/null +++ b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed_localhost.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.json diff --git a/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_disallowed.yaml b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_disallowed.yaml new file mode 100644 index 000000000..40c115b86 --- /dev/null +++ b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_disallowed.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Unconfined diff --git a/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_disallowed2.yaml b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_disallowed2.yaml new file mode 100644 index 000000000..e08463f18 --- /dev/null +++ b/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_disallowed2.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed2 + labels: + app: nginx-seccomp +spec: + securityContext: + seccompProfile: + type: Unconfined + containers: + - name: nginx + image: nginx diff --git a/library/pod-security-policy/seccompv2/suite.yaml b/library/pod-security-policy/seccompv2/suite.yaml new file mode 100644 index 000000000..f9daa264d --- /dev/null +++ b/library/pod-security-policy/seccompv2/suite.yaml @@ -0,0 +1,36 @@ +kind: Suite +apiVersion: test.gatekeeper.sh/v1alpha1 +metadata: + name: seccomp +tests: +- name: default-seccomp-required + template: template.yaml + constraint: samples/psp-seccomp/constraint.yaml + cases: + - name: example-disallowed-global + object: samples/psp-seccomp/example_disallowed2.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'Unconfined' is not allowed for container 'nginx'. Found at: pod securityContext." + - name: example-disallowed-container + object: samples/psp-seccomp/example_disallowed.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'Unconfined' is not allowed for container 'nginx'. Found at: container securityContext." + - name: example-allowed-container + object: samples/psp-seccomp/example_allowed.yaml + assertions: + - violations: no + - name: example-allowed-container + object: samples/psp-seccomp/example_allowed_localhost.yaml + assertions: + - violations: no + - name: example-allowed-container-exempt-image + object: samples/psp-seccomp/example_allowed_exempt_image.yaml + assertions: + - violations: no + - name: disallowed-ephemeral + object: samples/psp-seccomp/disallowed_ephemeral.yaml + assertions: + - violations: 1 + message: "Seccomp profile 'not configured' is not allowed for container 'nginx'. Found at: no explicit profile found" diff --git a/library/pod-security-policy/seccompv2/template.yaml b/library/pod-security-policy/seccompv2/template.yaml new file mode 100644 index 000000000..ce0a53202 --- /dev/null +++ b/library/pod-security-policy/seccompv2/template.yaml @@ -0,0 +1,301 @@ +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8spspseccompv2 + annotations: + metadata.gatekeeper.sh/title: "Seccomp V2" + metadata.gatekeeper.sh/version: 1.0.0 + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. +spec: + crd: + spec: + names: + kind: K8sPSPSeccompV2 + validation: + # Schema for the `parameters` field + openAPIV3Schema: + type: object + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. + properties: + exemptImages: + description: >- + Any container that uses an image that matches an entry in this list will be excluded + from enforcement. Prefix-matching can be signified with `*`. For example: `my-image-*`. + + It is recommended that users use the fully-qualified Docker image name (e.g. start with a domain name) + in order to avoid unexpectedly exempting images from an untrusted repository. + type: array + items: + type: string + allowedProfiles: + type: array + description: >- + An array of allowed profile values for seccomp on Pods/Containers. + + Can use the securityContext naming scheme: `RuntimeDefault`, `Unconfined` + and/or `Localhost`. For securityContext `Localhost`, use the parameter `allowedLocalhostFiles` + to list the allowed profile JSON files. + + The policy code will translate between the two schemes so it is not necessary to use both. + + Putting a `*` in this array allows all Profiles to be used. + + This field is required since with an empty list this policy will block all workloads. + items: + type: string + allowedLocalhostFiles: + type: array + description: >- + When using securityContext naming scheme for seccomp and including `Localhost` this array holds + the allowed profile JSON files. + + Putting a `*` in this array will allows all JSON files to be used. + + This field is required to allow `Localhost` in securityContext as with an empty list it will block. + items: + type: string + targets: + - target: admission.k8s.gatekeeper.sh + code: + - engine: K8sNativeValidation + source: + variables: + - name: containers + expression: 'has(variables.anyObject.spec.containers) ? variables.anyObject.spec.containers : []' + - name: initContainers + expression: 'has(variables.anyObject.spec.initContainers) ? variables.anyObject.spec.initContainers : []' + - name: ephemeralContainers + expression: 'has(variables.anyObject.spec.ephemeralContainers) ? variables.anyObject.spec.ephemeralContainers : []' + - name: allowAllProfiles + expression: | + has(variables.params.allowedProfiles) && variables.params.allowedProfiles.exists(profile, profile == "*") + - name: exemptImagePrefixes + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, image.endsWith("*")).map(image, string(image).replace("*", "")) + - name: exemptImageExplicit + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, !image.endsWith("*")) + - name: exemptImages + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + container.image in variables.exemptImageExplicit || + variables.exemptImagePrefixes.exists(exemption, string(container.image).startsWith(exemption))).map(container, container.image) + - name: unverifiedContainers + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + !variables.allowAllProfiles && + !(container.image in variables.exemptImages)) + - name: inputNonLocalHostProfiles + expression: | + variables.params.allowedProfiles.filter(profile, profile != "Localhost").map(profile, {"type": profile}) + - name: inputLocalHostProfiles + expression: | + variables.params.allowedProfiles.exists(profile, profile == "Localhost") ? variables.params.allowedLocalhostFiles.map(file, {"type": "Localhost", "localHostProfile": string(file)}) : [] + - name: inputAllowedProfiles + expression: | + variables.inputNonLocalHostProfiles + variables.inputLocalHostProfiles + - name: hasPodSeccomp + expression: | + has(variables.anyObject.spec.securityContext) && has(variables.anyObject.spec.securityContext.seccompProfile) + - name: podLocalHostProfile + expression: | + variables.hasPodSeccomp && has(variables.anyObject.spec.securityContext.seccompProfile.localhostProfile) ? variables.anyObject.spec.securityContext.seccompProfile.localhostProfile : "" + - name: podSecurityContextProfileType + expression: | + has(variables.hasPodSeccomp) && has(variables.anyObject.spec.securityContext.seccompProfile.type) ? variables.anyObject.spec.securityContext.seccompProfile.type + : "" + - name: podSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn(variables.podSecurityContextProfileType), + "file" : variables.podLocalHostProfile, + "location" : dyn("pod securityContext"), + }) + - name: containerSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + has(container.securityContext) && has(container.securityContext.seccompProfile) + ).map(container, { + "container" : container.name, + "profile" : dyn(container.securityContext.seccompProfile.type), + "file" : has(container.securityContext.seccompProfile.localhostProfile) ? container.securityContext.seccompProfile.localhostProfile : dyn(""), + "location" : dyn("container securityContext"), + }) + - name: containerProfilesMissing + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn("not configured"), + "file" : dyn(""), + "location" : dyn("no explicit profile found"), + }) + - name: allContainerProfiles + expression: | + variables.podSecurityContextProfiles + variables.containerSecurityContextProfiles + variables.containerProfilesMissing + - name: badContainerProfilesWithoutFiles + expression: | + variables.allContainerProfiles.filter(container, + container.profile != "Localhost" && + !variables.inputAllowedProfiles.exists(profile, profile.type == container.profile) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.inputAllowedProfiles.map(profile, "{\"type\": \"" + profile.type + "\"" + (has(profile.localHostProfile) ? ", \"localHostProfile\": \"" + profile.localHostProfile + "\"}" : "}")).join(", ")) + - name: badContainerProfilesWithFiles + expression: | + variables.allContainerProfiles.filter(container, + container.profile == "Localhost" && + !variables.inputAllowedProfiles.exists(profile, profile.type == "Localhost" && (has(profile.localHostProfile) && (profile.localHostProfile == container.file || profile.localHostProfile == "*"))) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' With file '" + badProfile.file + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.inputAllowedProfiles.map(profile, "{\"type\": \"" + profile.type + "\"" + (has(profile.localHostProfile) ? ", \"localHostProfile\": \"" + profile.localHostProfile + "\"}" : "}")).join(", ")) + validations: + - expression: 'size(variables.badContainerProfilesWithoutFiles) == 0' + messageExpression: | + variables.badContainerProfilesWithoutFiles.join(", ") + - expression: 'size(variables.badContainerProfilesWithFiles) == 0' + messageExpression: | + variables.badContainerProfilesWithFiles.join(", ") + - engine: Rego + source: + rego: | + package k8spspseccomp + + import data.lib.exempt_container.is_exempt + + violation[{"msg": msg}] { + not input_wildcard_allowed_profiles + allowed_profiles := get_allowed_profiles + container := input_containers[name] + not is_exempt(container) + result := get_profile(container) + not allowed_profile(result.profile, result.file, allowed_profiles) + msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) + } + + get_message(profile, _, name, location, allowed_profiles) = message { + profile != "Localhost" + message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) + } + + get_message(profile, file, name, location, allowed_profiles) = message { + profile == "Localhost" + message := sprintf("Seccomp profile '%v' with file '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, file, name, location, allowed_profiles]) + } + + input_wildcard_allowed_profiles { + input.parameters.allowedProfiles[_] == "*" + } + + input_wildcard_allowed_files { + input.parameters.allowedLocalhostFiles[_] == "*" + } + + allowed_profile(_, _, _) { + input_wildcard_allowed_profiles + } + + allowed_profile(profile, _, _) { + profile == "Localhost" + input_wildcard_allowed_files + } + + # Simple allowed Profiles + allowed_profile(profile, _, allowed) { + profile != "Localhost" + allow_profile = allowed[_] + profile == allow_profile.type + } + + # annotation localhost without wildcard + allowed_profile(profile, file, allowed) { + profile == "Localhost" + allow_profile = allowed[_] + allow_profile.type == "Localhost" + file == allow_profile.localHostProfile + } + + # The profiles explicitly in the list + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + profile != "Localhost" + allowed := {"type": profile} + } + + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + profile == "Localhost" + file := object.get(input.parameters, "allowedLocalhostFiles", [""])[_] + allowed := {"type": "Localhost", "localHostProfile": file} + } + + # Container profile as defined in containers securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + has_securitycontext_container(container) + profile := container.securityContext.seccompProfile.type + file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") + location := "container securityContext" + } + + # Container profile as defined in pods securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + not has_securitycontext_container(container) + profile := input.review.object.spec.securityContext.seccompProfile.type + file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") + location := "pod securityContext" + } + + # Container profile missing + get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { + not has_securitycontext_container(container) + not has_securitycontext_pod + } + + has_securitycontext_pod { + input.review.object.spec.securityContext.seccompProfile + } + + has_securitycontext_container(container) { + container.securityContext.seccompProfile + } + + input_containers[container.name] = container { + container := input.review.object.spec.containers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.initContainers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.ephemeralContainers[_] + } + libs: + - | + package lib.exempt_container + + is_exempt(container) { + exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) + img := container.image + exemption := exempt_images[_] + _matches_exemption(img, exemption) + } + + _matches_exemption(img, exemption) { + not endswith(exemption, "*") + exemption == img + } + + _matches_exemption(img, exemption) { + endswith(exemption, "*") + prefix := trim_suffix(exemption, "*") + startswith(img, prefix) + } diff --git a/src/pod-security-policy/seccomp/constraint.tmpl b/src/pod-security-policy/seccomp/constraint.tmpl index 7a5c95538..5fdd6d074 100644 --- a/src/pod-security-policy/seccomp/constraint.tmpl +++ b/src/pod-security-policy/seccomp/constraint.tmpl @@ -4,7 +4,7 @@ metadata: name: k8spspseccomp annotations: metadata.gatekeeper.sh/title: "Seccomp" - metadata.gatekeeper.sh/version: 1.0.1 + metadata.gatekeeper.sh/version: 1.1.0 description: >- Controls the seccomp profile used by containers. Corresponds to the `seccomp.security.alpha.kubernetes.io/allowedProfileNames` annotation on @@ -67,8 +67,14 @@ spec: type: string targets: - target: admission.k8s.gatekeeper.sh - rego: | -{{ file.Read "src/pod-security-policy/seccomp/src.rego" | strings.Indent 8 | strings.TrimSuffix "\n" }} - libs: - - | -{{ file.Read "src/pod-security-policy/seccomp/lib_exempt_container.rego" | strings.Indent 10 | strings.TrimSuffix "\n" }} + code: + - engine: K8sNativeValidation + source: +{{ file.Read "src/pod-security-policy/seccomp/src.cel" | strings.Indent 10 | strings.TrimSuffix "\n" }} + - engine: Rego + source: + rego: | +{{ file.Read "src/pod-security-policy/seccomp/src.rego" | strings.Indent 12 | strings.TrimSuffix "\n" }} + libs: + - | +{{ file.Read "src/pod-security-policy/seccomp/lib_exempt_container.rego" | strings.Indent 14 | strings.TrimSuffix "\n" }} diff --git a/src/pod-security-policy/seccomp/src.cel b/src/pod-security-policy/seccomp/src.cel new file mode 100644 index 000000000..bdb8b218b --- /dev/null +++ b/src/pod-security-policy/seccomp/src.cel @@ -0,0 +1,141 @@ +variables: +- name: containers + expression: 'has(variables.anyObject.spec.containers) ? variables.anyObject.spec.containers : []' +- name: initContainers + expression: 'has(variables.anyObject.spec.initContainers) ? variables.anyObject.spec.initContainers : []' +- name: ephemeralContainers + expression: 'has(variables.anyObject.spec.ephemeralContainers) ? variables.anyObject.spec.ephemeralContainers : []' +- name: allowAllProfiles + expression: | + has(variables.params.allowedProfiles) && variables.params.allowedProfiles.exists(profile, profile == "*") +- name: exemptImagePrefixes + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, image.endsWith("*")).map(image, string(image).replace("*", "")) +- name: exemptImageExplicit + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, !image.endsWith("*")) +- name: exemptImages + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + container.image in variables.exemptImageExplicit || + variables.exemptImagePrefixes.exists(exemption, string(container.image).startsWith(exemption))).map(container, container.image) +- name: unverifiedContainers + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + !variables.allowAllProfiles && + !(container.image in variables.exemptImages)) +- name: inputAllowedProfiles + expression: | + !has(variables.params.allowedProfiles) ? [] : variables.params.allowedProfiles +- name: allowedLocalhostFiles + expression: | + has(variables.params.allowedLocalhostFiles) ? variables.params.allowedLocalhostFiles : [] +- name: allowedProfilesTranslation + expression: | + (variables.inputAllowedProfiles.filter(profile, + profile != "Localhost").map(profile, profile == "Unconfined" ? "unconfined" : profile)) + + (variables.inputAllowedProfiles.exists(profile, profile == "RuntimeDefault") ? ["runtime/default", "docker/default"] : []) +- name: allowSecurityContextLocalhost + expression: | + variables.inputAllowedProfiles.exists(profile, profile == "Localhost") +- name: derivedAllowedLocalhostFiles + expression: | + variables.allowSecurityContextLocalhost ? variables.params.allowedLocalhostFiles.map(file, "localhost/" + file) : [] +- name: localhostWildcardAllowed + expression: | + variables.inputAllowedProfiles.exists(profile, profile == "localhost/*") || variables.derivedAllowedLocalhostFiles.exists(profile, profile == "localhost/*") +- name: allowedProfiles + expression: | + (variables.allowedProfilesTranslation + variables.derivedAllowedLocalhostFiles) +- name: hasPodSeccomp + expression: | + has(variables.anyObject.spec.securityContext) && has(variables.anyObject.spec.securityContext.seccompProfile) +- name: hasPodAnnotations + expression: | + has(variables.anyObject.metadata.annotations) && ("seccomp.security.alpha.kubernetes.io/pod" in variables.anyObject.metadata.annotations) +- name: podAnnotationsProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !(has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations)) && + !variables.hasPodSeccomp && + variables.hasPodAnnotations + ).map(container, { + "container" : container.name, + "profile" : variables.anyObject.metadata.annotations["seccomp.security.alpha.kubernetes.io/pod"], + "file" : dyn(""), + "location" : dyn("annotation seccomp.security.alpha.kubernetes.io/pod"), + }) +- name: containerAnnotationsProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !variables.hasPodSeccomp && + has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations) + ).map(container, { + "container" : container.name, + "profile" : variables.anyObject.metadata.annotations["container.seccomp.security.alpha.kubernetes.io/" + container.name], + "file" : dyn(""), + "location" : dyn("annotation container.seccomp.security.alpha.kubernetes.io/" + container.name), + }) +- name: podLocalHostProfile + expression: | + variables.hasPodSeccomp && has(variables.anyObject.spec.securityContext.seccompProfile.localhostProfile) ? variables.anyObject.spec.securityContext.seccompProfile.localhostProfile : "" +- name: canonicalPodSecurityContextProfile + expression: | + has(variables.hasPodSeccomp) && has(variables.anyObject.spec.securityContext.seccompProfile.type) ? + (variables.anyObject.spec.securityContext.seccompProfile.type == "RuntimeDefault" ? ( + variables.allowedProfiles.exists(profile, profile == "runtime/default") ? "runtime/default" : variables.allowedProfiles.exists(profile, profile == "docker/default") ? "docker/default" : "runtime/default") : + variables.anyObject.spec.securityContext.seccompProfile.type == "Unconfined" ? "unconfined" : variables.anyObject.spec.securityContext.seccompProfile.type == "Localhost" ? "localhost/" + variables.podLocalHostProfile : "") + : "" +- name: podSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn(variables.canonicalPodSecurityContextProfile), + "file" : variables.podLocalHostProfile, + "location" : dyn("pod securityContext"), + }) +- name: containerSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + has(container.securityContext) && has(container.securityContext.seccompProfile) + ).map(container, { + "container" : container.name, + "profile" : dyn(has(container.securityContext.seccompProfile.type) ? (container.securityContext.seccompProfile.type == "RuntimeDefault" ? ( + variables.allowedProfiles.exists(profile, profile == "runtime/default") ? "runtime/default" : variables.allowedProfiles.exists(profile, profile == "docker/default") ? "docker/default" : "runtime/default") : + container.securityContext.seccompProfile.type == "Unconfined" ? "unconfined" : container.securityContext.seccompProfile.type == "Localhost" ? "localhost/" + container.securityContext.seccompProfile.localhostProfile : "") + : ""), + "file" : has(container.securityContext.seccompProfile.localhostProfile) ? container.securityContext.seccompProfile.localhostProfile : dyn(""), + "location" : dyn("container securityContext"), + }) +- name: containerProfilesMissing + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !(has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations)) && + !variables.hasPodSeccomp && + !variables.hasPodAnnotations + ).map(container, { + "container" : container.name, + "profile" : dyn("not configured"), + "file" : dyn(""), + "location" : dyn("no explicit profile found"), + }) +- name: allContainerProfiles + expression: | + variables.podAnnotationsProfiles + variables.containerAnnotationsProfiles + variables.podSecurityContextProfiles + variables.containerSecurityContextProfiles + variables.containerProfilesMissing +- name: badContainerProfiles + expression: | + variables.allContainerProfiles.filter(badContainerProfile, + !((badContainerProfile.profile in variables.allowedProfiles) || (badContainerProfile.profile.startsWith("localhost/") && variables.localhostWildcardAllowed)) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.allowedProfiles.join(", ")) +validations: +- expression: 'size(variables.badContainerProfiles) == 0' + messageExpression: | + variables.badContainerProfiles.join(", ") diff --git a/src/pod-security-policy/seccomp/src.rego b/src/pod-security-policy/seccomp/src.rego index 61185ddb9..d06a271d1 100644 --- a/src/pod-security-policy/seccomp/src.rego +++ b/src/pod-security-policy/seccomp/src.rego @@ -6,18 +6,6 @@ container_annotation_key_prefix = "container.seccomp.security.alpha.kubernetes.i pod_annotation_key = "seccomp.security.alpha.kubernetes.io/pod" -naming_translation = { - # securityContext -> annotation - "RuntimeDefault": ["runtime/default", "docker/default"], - "Unconfined": ["unconfined"], - "Localhost": ["localhost"], - # annotation -> securityContext - "runtime/default": ["RuntimeDefault"], - "docker/default": ["RuntimeDefault"], - "unconfined": ["Unconfined"], - "localhost": ["Localhost"], -} - violation[{"msg": msg}] { not input_wildcard_allowed_profiles allowed_profiles := get_allowed_profiles @@ -29,15 +17,9 @@ violation[{"msg": msg}] { } get_message(profile, _, name, location, allowed_profiles) = message { - not profile == "Localhost" message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) } -get_message(profile, file, name, location, allowed_profiles) = message { - profile == "Localhost" - message := sprintf("Seccomp profile '%v' with file '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, file, name, location, allowed_profiles]) -} - input_wildcard_allowed_profiles { input.parameters.allowedProfiles[_] == "*" } @@ -52,23 +34,7 @@ input_wildcard_allowed_files { # Simple allowed Profiles allowed_profile(profile, _, allowed) { - not startswith(lower(profile), "localhost") - profile == allowed[_] -} - -# seccomp Localhost without wildcard -allowed_profile(profile, file, allowed) { - profile == "Localhost" - not input_wildcard_allowed_files - profile == allowed[_] - allowed_files := {x | x := object.get(input.parameters, "allowedLocalhostFiles", [])[_]} | get_annotation_localhost_files - file == allowed_files[_] -} - -# seccomp Localhost with wildcard -allowed_profile(profile, _, allowed) { - profile == "Localhost" - input_wildcard_allowed_files + not startswith(profile, "localhost/") profile == allowed[_] } @@ -84,38 +50,17 @@ allowed_profile(profile, _, allowed) { profile == allowed[_] } -# Localhost files from annotation scheme -get_annotation_localhost_files[file] { - profile := input.parameters.allowedProfiles[_] - startswith(profile, "localhost/") - file := replace(profile, "localhost/", "") -} - # The profiles explicitly in the list get_allowed_profiles[allowed] { allowed := input.parameters.allowedProfiles[_] } -# The simply translated profiles -get_allowed_profiles[allowed] { - profile := input.parameters.allowedProfiles[_] - not startswith(lower(profile), "localhost") - allowed := naming_translation[profile][_] -} - # Seccomp Localhost to annotation translation get_allowed_profiles[allowed] { profile := input.parameters.allowedProfiles[_] - profile == "Localhost" + not contains(profile, "/") file := object.get(input.parameters, "allowedLocalhostFiles", [])[_] - allowed := sprintf("%v/%v", [naming_translation[profile][_], file]) -} - -# Annotation localhost to Seccomp translation -get_allowed_profiles[allowed] { - profile := input.parameters.allowedProfiles[_] - startswith(profile, "localhost") - allowed := naming_translation.localhost[_] + allowed := canonicalize_seccomp_profile({"type": profile, "localhostProfile": file}, "")[_] } # Container profile as defined in pod annotation @@ -140,7 +85,7 @@ get_profile(container) = {"profile": profile, "file": "", "location": location} # Container profile as defined in pods securityContext get_profile(container) = {"profile": profile, "file": file, "location": location} { not has_securitycontext_container(container) - profile := input.review.object.spec.securityContext.seccompProfile.type + profile := canonicalize_seccomp_profile(input.review.object.spec.securityContext.seccompProfile, canonicalize_runtime_default_profile)[_] file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") location := "pod securityContext" } @@ -148,17 +93,17 @@ get_profile(container) = {"profile": profile, "file": file, "location": location # Container profile as defined in containers securityContext get_profile(container) = {"profile": profile, "file": file, "location": location} { has_securitycontext_container(container) - profile := container.securityContext.seccompProfile.type + profile := canonicalize_seccomp_profile(container.securityContext.seccompProfile, canonicalize_runtime_default_profile)[_] file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") location := "container securityContext" } # Container profile missing get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { + not has_securitycontext_container(container) + not has_securitycontext_pod not has_annotation(get_container_annotation_key(container.name)) not has_annotation(pod_annotation_key) - not has_securitycontext_pod - not has_securitycontext_container(container) } has_annotation(annotation) { @@ -188,3 +133,29 @@ input_containers[container.name] = container { input_containers[container.name] = container { container := input.review.object.spec.ephemeralContainers[_] } + +canonicalize_runtime_default_profile() = out { + "runtime/default" == input.parameters.allowedProfiles[_] + out := "runtime/default" +} else = out { + "docker/default" == input.parameters.allowedProfiles[_] + out := "docker/default" +} else = out { + out := "runtime/default" +} + +canonicalize_seccomp_profile(profile, def) = out { + profile.type == "RuntimeDefault" + def == "" + out := ["runtime/default", "docker/default"] +} else = out { + profile.type == "RuntimeDefault" + def != "" + out := [def] +} else = out { + profile.type == "Localhost" + out := [sprintf("localhost/%s", [profile.localhostProfile])] +} else = out { + profile.type == "Unconfined" + out := ["unconfined"] +} diff --git a/src/pod-security-policy/seccomp/src_test.rego b/src/pod-security-policy/seccomp/src_test.rego index 161afa359..74bba46c9 100644 --- a/src/pod-security-policy/seccomp/src_test.rego +++ b/src/pod-security-policy/seccomp/src_test.rego @@ -417,7 +417,7 @@ test_input_both_seccomp_pod_context_container_annotation_multiple_mixed { test_translation_seccomp_allowed_annotation_all { inp := {"parameters": input_parameters_annotation} output := get_allowed_profiles with input as inp - output == allowed_full_translated + output == allowed_full_translated_annotation_style } test_translation_seccomp_allowed_context_all { @@ -645,3 +645,11 @@ allowed_full_translated = { "RuntimeDefault", "docker/default", "runtime/default", "Unconfined", "unconfined", } + +allowed_full_translated_annotation_style = { + "runtime/default", + "docker/default", + "localhost/profile1.json", + "localhost/profile2.json", + "unconfined", +} diff --git a/src/pod-security-policy/seccompv2/constraint.tmpl b/src/pod-security-policy/seccompv2/constraint.tmpl new file mode 100644 index 000000000..d33f99ea4 --- /dev/null +++ b/src/pod-security-policy/seccompv2/constraint.tmpl @@ -0,0 +1,73 @@ +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8spspseccompv2 + annotations: + metadata.gatekeeper.sh/title: "Seccomp V2" + metadata.gatekeeper.sh/version: 1.0.0 + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. +spec: + crd: + spec: + names: + kind: K8sPSPSeccompV2 + validation: + # Schema for the `parameters` field + openAPIV3Schema: + type: object + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. + properties: + exemptImages: + description: >- + Any container that uses an image that matches an entry in this list will be excluded + from enforcement. Prefix-matching can be signified with `*`. For example: `my-image-*`. + + It is recommended that users use the fully-qualified Docker image name (e.g. start with a domain name) + in order to avoid unexpectedly exempting images from an untrusted repository. + type: array + items: + type: string + allowedProfiles: + type: array + description: >- + An array of allowed profile values for seccomp on Pods/Containers. + + Can use the securityContext naming scheme: `RuntimeDefault`, `Unconfined` + and/or `Localhost`. For securityContext `Localhost`, use the parameter `allowedLocalhostFiles` + to list the allowed profile JSON files. + + The policy code will translate between the two schemes so it is not necessary to use both. + + Putting a `*` in this array allows all Profiles to be used. + + This field is required since with an empty list this policy will block all workloads. + items: + type: string + allowedLocalhostFiles: + type: array + description: >- + When using securityContext naming scheme for seccomp and including `Localhost` this array holds + the allowed profile JSON files. + + Putting a `*` in this array will allows all JSON files to be used. + + This field is required to allow `Localhost` in securityContext as with an empty list it will block. + items: + type: string + targets: + - target: admission.k8s.gatekeeper.sh + code: + - engine: K8sNativeValidation + source: +{{ file.Read "src/pod-security-policy/seccompv2/src.cel" | strings.Indent 10 | strings.TrimSuffix "\n" }} + - engine: Rego + source: + rego: | +{{ file.Read "src/pod-security-policy/seccompv2/src.rego" | strings.Indent 12 | strings.TrimSuffix "\n" }} + libs: + - | +{{ file.Read "src/pod-security-policy/seccompv2/lib_exempt_container.rego" | strings.Indent 14 | strings.TrimSuffix "\n" }} diff --git a/src/pod-security-policy/seccompv2/lib_exempt_container.rego b/src/pod-security-policy/seccompv2/lib_exempt_container.rego new file mode 100644 index 000000000..c483416be --- /dev/null +++ b/src/pod-security-policy/seccompv2/lib_exempt_container.rego @@ -0,0 +1,19 @@ +package lib.exempt_container + +is_exempt(container) { + exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) + img := container.image + exemption := exempt_images[_] + _matches_exemption(img, exemption) +} + +_matches_exemption(img, exemption) { + not endswith(exemption, "*") + exemption == img +} + +_matches_exemption(img, exemption) { + endswith(exemption, "*") + prefix := trim_suffix(exemption, "*") + startswith(img, prefix) +} diff --git a/src/pod-security-policy/seccompv2/src.cel b/src/pod-security-policy/seccompv2/src.cel new file mode 100644 index 000000000..c2847062a --- /dev/null +++ b/src/pod-security-policy/seccompv2/src.cel @@ -0,0 +1,101 @@ +variables: +- name: containers + expression: 'has(variables.anyObject.spec.containers) ? variables.anyObject.spec.containers : []' +- name: initContainers + expression: 'has(variables.anyObject.spec.initContainers) ? variables.anyObject.spec.initContainers : []' +- name: ephemeralContainers + expression: 'has(variables.anyObject.spec.ephemeralContainers) ? variables.anyObject.spec.ephemeralContainers : []' +- name: allowAllProfiles + expression: | + has(variables.params.allowedProfiles) && variables.params.allowedProfiles.exists(profile, profile == "*") +- name: exemptImagePrefixes + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, image.endsWith("*")).map(image, string(image).replace("*", "")) +- name: exemptImageExplicit + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, !image.endsWith("*")) +- name: exemptImages + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + container.image in variables.exemptImageExplicit || + variables.exemptImagePrefixes.exists(exemption, string(container.image).startsWith(exemption))).map(container, container.image) +- name: unverifiedContainers + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + !variables.allowAllProfiles && + !(container.image in variables.exemptImages)) +- name: inputNonLocalHostProfiles + expression: | + variables.params.allowedProfiles.filter(profile, profile != "Localhost").map(profile, {"type": profile}) +- name: inputLocalHostProfiles + expression: | + variables.params.allowedProfiles.exists(profile, profile == "Localhost") ? variables.params.allowedLocalhostFiles.map(file, {"type": "Localhost", "localHostProfile": string(file)}) : [] +- name: inputAllowedProfiles + expression: | + variables.inputNonLocalHostProfiles + variables.inputLocalHostProfiles +- name: hasPodSeccomp + expression: | + has(variables.anyObject.spec.securityContext) && has(variables.anyObject.spec.securityContext.seccompProfile) +- name: podLocalHostProfile + expression: | + variables.hasPodSeccomp && has(variables.anyObject.spec.securityContext.seccompProfile.localhostProfile) ? variables.anyObject.spec.securityContext.seccompProfile.localhostProfile : "" +- name: podSecurityContextProfileType + expression: | + has(variables.hasPodSeccomp) && has(variables.anyObject.spec.securityContext.seccompProfile.type) ? variables.anyObject.spec.securityContext.seccompProfile.type + : "" +- name: podSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn(variables.podSecurityContextProfileType), + "file" : variables.podLocalHostProfile, + "location" : dyn("pod securityContext"), + }) +- name: containerSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + has(container.securityContext) && has(container.securityContext.seccompProfile) + ).map(container, { + "container" : container.name, + "profile" : dyn(container.securityContext.seccompProfile.type), + "file" : has(container.securityContext.seccompProfile.localhostProfile) ? container.securityContext.seccompProfile.localhostProfile : dyn(""), + "location" : dyn("container securityContext"), + }) +- name: containerProfilesMissing + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn("not configured"), + "file" : dyn(""), + "location" : dyn("no explicit profile found"), + }) +- name: allContainerProfiles + expression: | + variables.podSecurityContextProfiles + variables.containerSecurityContextProfiles + variables.containerProfilesMissing +- name: badContainerProfilesWithoutFiles + expression: | + variables.allContainerProfiles.filter(container, + container.profile != "Localhost" && + !variables.inputAllowedProfiles.exists(profile, profile.type == container.profile) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.inputAllowedProfiles.map(profile, "{\"type\": \"" + profile.type + "\"" + (has(profile.localHostProfile) ? ", \"localHostProfile\": \"" + profile.localHostProfile + "\"}" : "}")).join(", ")) +- name: badContainerProfilesWithFiles + expression: | + variables.allContainerProfiles.filter(container, + container.profile == "Localhost" && + !variables.inputAllowedProfiles.exists(profile, profile.type == "Localhost" && (has(profile.localHostProfile) && (profile.localHostProfile == container.file || profile.localHostProfile == "*"))) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' With file '" + badProfile.file + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.inputAllowedProfiles.map(profile, "{\"type\": \"" + profile.type + "\"" + (has(profile.localHostProfile) ? ", \"localHostProfile\": \"" + profile.localHostProfile + "\"}" : "}")).join(", ")) +validations: +- expression: 'size(variables.badContainerProfilesWithoutFiles) == 0' + messageExpression: | + variables.badContainerProfilesWithoutFiles.join(", ") +- expression: 'size(variables.badContainerProfilesWithFiles) == 0' + messageExpression: | + variables.badContainerProfilesWithFiles.join(", ") diff --git a/src/pod-security-policy/seccompv2/src.rego b/src/pod-security-policy/seccompv2/src.rego new file mode 100644 index 000000000..eee2ee037 --- /dev/null +++ b/src/pod-security-policy/seccompv2/src.rego @@ -0,0 +1,111 @@ +package k8spspseccomp + +import data.lib.exempt_container.is_exempt + +violation[{"msg": msg}] { + not input_wildcard_allowed_profiles + allowed_profiles := get_allowed_profiles + container := input_containers[name] + not is_exempt(container) + result := get_profile(container) + not allowed_profile(result.profile, result.file, allowed_profiles) + msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) +} + +get_message(profile, _, name, location, allowed_profiles) = message { + profile != "Localhost" + message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) +} + +get_message(profile, file, name, location, allowed_profiles) = message { + profile == "Localhost" + message := sprintf("Seccomp profile '%v' with file '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, file, name, location, allowed_profiles]) +} + +input_wildcard_allowed_profiles { + input.parameters.allowedProfiles[_] == "*" +} + +input_wildcard_allowed_files { + input.parameters.allowedLocalhostFiles[_] == "*" +} + +allowed_profile(_, _, _) { + input_wildcard_allowed_profiles +} + +allowed_profile(profile, _, _) { + profile == "Localhost" + input_wildcard_allowed_files +} + +# Simple allowed Profiles +allowed_profile(profile, _, allowed) { + profile != "Localhost" + allow_profile = allowed[_] + profile == allow_profile.type +} + +# annotation localhost without wildcard +allowed_profile(profile, file, allowed) { + profile == "Localhost" + allow_profile = allowed[_] + allow_profile.type == "Localhost" + file == allow_profile.localHostProfile +} + +# The profiles explicitly in the list +get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + profile != "Localhost" + allowed := {"type": profile} +} + +get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + profile == "Localhost" + file := object.get(input.parameters, "allowedLocalhostFiles", [""])[_] + allowed := {"type": "Localhost", "localHostProfile": file} +} + +# Container profile as defined in containers securityContext +get_profile(container) = {"profile": profile, "file": file, "location": location} { + has_securitycontext_container(container) + profile := container.securityContext.seccompProfile.type + file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") + location := "container securityContext" +} + +# Container profile as defined in pods securityContext +get_profile(container) = {"profile": profile, "file": file, "location": location} { + not has_securitycontext_container(container) + profile := input.review.object.spec.securityContext.seccompProfile.type + file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") + location := "pod securityContext" +} + +# Container profile missing +get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { + not has_securitycontext_container(container) + not has_securitycontext_pod +} + +has_securitycontext_pod { + input.review.object.spec.securityContext.seccompProfile +} + +has_securitycontext_container(container) { + container.securityContext.seccompProfile +} + +input_containers[container.name] = container { + container := input.review.object.spec.containers[_] +} + +input_containers[container.name] = container { + container := input.review.object.spec.initContainers[_] +} + +input_containers[container.name] = container { + container := input.review.object.spec.ephemeralContainers[_] +} diff --git a/src/pod-security-policy/seccompv2/src_test.rego b/src/pod-security-policy/seccompv2/src_test.rego new file mode 100644 index 000000000..372815b36 --- /dev/null +++ b/src/pod-security-policy/seccompv2/src_test.rego @@ -0,0 +1,361 @@ +package k8spspseccomp + +# securityContext based seccomp with containers + +test_input_seccomp_allowed_in_list { + inp := {"review": get_object({}, context_runtimedefault, single_container, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_allowed_all { + inp := {"review": get_object({}, context_runtimedefault, single_container, {}), "parameters": input_parameters_wildcard} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_container_allowed_all { + inp := {"review": get_object({}, {}, single_container_sc, {}), "parameters": input_parameters_wildcard} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_container_allowed_in_list { + inp := {"review": get_object({}, {}, single_container_sc, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_containers_allowed_in_list { + inp := {"review": get_object({}, {}, multiple_containers_sc, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_containers_allowed_in_list_localhost { + inp := {"review": get_object({}, {}, single_container_sc_localhost, {}), "parameters": input_parameters_in_list_locahost_file} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_containers_allowed_in_list_multiple { + inp := {"review": get_object({}, {}, multiple_containers_sc_mixed, {}), "parameters": input_parameters_in_list} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_not_allowed_not_in_list { + inp := {"review": get_object({}, context_runtimedefault, single_container, {}), "parameters": input_parameters_not_in_list} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_empty_parameters { + inp := {"review": get_object({}, context_runtimedefault, single_container, {}), "parameters": input_parameters_empty} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_pod_localhost_allowed_wrong_file { + inp := {"review": get_object({}, context_localhost, single_container, {}), "parameters": input_parameters_sc} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_pod_localhost_allowed_no_specified_file { + inp := {"review": get_object({}, context_localhost, single_container, {}), "parameters": input_parameters_sc_localhost_no_file} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_containers_mixed { + inp := {"review": get_object({}, {}, multiple_containers_sc_mixed, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_containers_mixed_missing { + inp := {"review": get_object({}, {}, multiple_containers_sc_missing, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_container_not_allowed_not_in_list { + inp := {"review": get_object({}, {}, single_container_sc, {}), "parameters": input_parameters_not_in_list} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_containers_not_allowed_not_in_list { + inp := {"review": get_object({}, {}, multiple_containers_sc, {}), "parameters": input_parameters_not_in_list} + results := violation with input as inp + count(results) == 2 +} + +test_input_seccomp_not_allowed_multiple_not_configured { + inp := {"review": get_object({}, {}, multiple_containers, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 2 +} + +# securityContext based seccomp with pod + +test_input_seccomp_pod_multiple_allowed_all { + inp := {"review": get_object({}, context_runtimedefault, multiple_containers, {}), "parameters": input_parameters_wildcard} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_pod_localhost_allowed_both_wildcard_file { + inp := {"review": get_object({}, context_localhost, single_container, {}), "parameters": input_parameters_localhost_wildcard_both} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_pod_container { + inp := {"review": get_object({}, context_runtimedefault, multiple_containers_sc_missing, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_pod_container_both_allowed { + inp := {"review": get_object({}, context_runtimedefault, multiple_containers_sc_missing, {}), "parameters": input_parameters_in_list} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_pod_container_mixed_not_allowed_but_exempt { + inp := {"review": get_object({}, context_runtimedefault, single_container, {}), "parameters": input_parameters_exempt} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_pod_multiple_allowed_in_list { + inp := {"review": get_object({}, context_runtimedefault, multiple_containers, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_pod_localhost_allowed_wildcard_file { + inp := {"review": get_object({}, context_localhost, single_container, {}), "parameters": input_parameters_sc_localhost_wildcard_file} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_pod_multiple_empty_parameters { + inp := {"review": get_object({}, context_runtimedefault, multiple_containers, {}), "parameters": input_parameters_empty} + results := violation with input as inp + count(results) == 2 +} + +test_input_seccomp_pod_multiple_not_allowed_not_in_list { + inp := {"review": get_object({}, context_runtimedefault, multiple_containers, {}), "parameters": input_parameters_not_in_list} + results := violation with input as inp + count(results) == 2 +} + +test_input_seccomp_pod_container_not_allowed { + inp := {"review": get_object({}, context_runtimedefault, multiple_containers_sc_missing, {}), "parameters": input_parameters_not_in_list} + results := violation with input as inp + count(results) == 2 +} + +test_input_seccomp_pod_container_mixed_allowed { + inp := {"review": get_object({}, context_localhost, multiple_containers_sc_missing, {}), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_pod_container_mixed_not_allowed { + inp := {"review": get_object({}, context_localhost, multiple_containers_sc_missing, {}), "parameters": input_parameters_not_in_list} + results := violation with input as inp + count(results) == 2 +} + +# securityContext based seccomp with init containers +test_input_seccomp_pod_initcontainer_both_allowed { + inp := {"review": get_object({}, context_runtimedefault, {}, multiple_containers_sc_missing), "parameters": input_parameters_in_list} + results := violation with input as inp + count(results) == 0 +} + +test_input_seccomp_pod_initcontainer_mixed_allowed { + inp := {"review": get_object({}, context_localhost, {}, multiple_containers_sc_missing), "parameters": input_parameter_in_list} + results := violation with input as inp + count(results) == 1 +} + +test_input_seccomp_pod_initcontainer_mixed_not_allowed { + inp := {"review": get_object({}, context_localhost, {}, multiple_containers_sc_missing), "parameters": input_parameters_not_in_list} + results := violation with input as inp + count(results) == 2 +} + +# Localhost seccomp profile build + +test_translation_seccomp_allowed_context_localhost_wildcard_file { + inp := {"parameters": input_parameters_localhost_wildcard_both} + output := get_allowed_profiles with input as inp + output == {{"type": "Localhost", "localHostProfile": "*"}} +} + +test_translation_seccomp_allowed_context_localhost_no_file { + inp := {"parameters": input_parameters_sc_localhost_no_file} + output := get_allowed_profiles with input as inp + output == {{"localHostProfile": "", "type": "Localhost"}} +} + +test_translation_seccomp_allowed_context_localhost_with_file { + inp := {"parameters": input_parameters_sc_localhost_with_file} + output := get_allowed_profiles with input as inp + output == {{"type": "Localhost", "localHostProfile": "profile.json"}} +} + +test_translation_seccomp_allowed_context_mixed { + inp := {"parameters": input_parameters_in_list} + output := get_allowed_profiles with input as inp + output == {{"type": "Localhost", "localHostProfile": "profile.json"}, {"type": "RuntimeDefault"}} +} + +# Create Review Object +get_object(annotations, podcontext, containers, initcontainers) = {"object": { + "metadata": { + "name": "nginx", + "annotations": annotations, + }, + "spec": { + "containers": containers, + "initContainers": initcontainers, + "securityContext": podcontext, + }, +}} + +# Test Containers +single_container = [{ + "name": "nginx", + "image": "nginx", +}] + +multiple_containers = [ + { + "name": "nginx", + "image": "nginx", + }, + { + "name": "nginx2", + "image": "nginx", + }, +] + +single_container_sc = [{ + "name": "nginx", + "image": "nginx", + "securityContext": context_runtimedefault, +}] + +single_container_sc_localhost = [{ + "name": "nginx", + "image": "nginx", + "securityContext": context_localhost, +}] + +multiple_containers_sc = [ + { + "name": "nginx", + "image": "nginx", + "securityContext": context_runtimedefault, + }, + { + "name": "nginx2", + "image": "nginx", + "securityContext": context_runtimedefault, + }, +] + +multiple_containers_sc_mixed = [ + { + "name": "nginx", + "image": "nginx", + "securityContext": context_runtimedefault, + }, + { + "name": "nginx2", + "image": "nginx", + "securityContext": context_localhost, + }, +] + +multiple_containers_sc_missing = [ + { + "name": "nginx", + "image": "nginx", + "securityContext": context_runtimedefault, + }, + { + "name": "nginx2", + "image": "nginx", + }, +] + +# Test securityContexts +context_localhost = {"seccompProfile": {"type": "Localhost", "localhostProfile": "profile.json"}} + +context_runtimedefault = {"seccompProfile": {"type": "RuntimeDefault"}} + +# Test Parameters +input_parameters_empty = {"allowedProfiles": []} + +input_parameters_wildcard = {"allowedProfiles": ["*"]} + +input_parameter_in_list = {"allowedProfiles": [ + "RuntimeDefault", +]} + +input_parameters_in_list = { + "allowedProfiles": [ + "RuntimeDefault", + "Localhost", + ], + "allowedLocalhostFiles": ["profile.json"], +} + +input_parameters_in_list_locahost_file = { + "allowedProfiles": [ + "Localhost", + ], + "allowedLocalhostFiles": ["profile.json"], +} + +input_parameters_not_in_list = {"allowedProfiles": [ + "Unconfined", +]} + +input_parameters_exempt = { + "exemptImages": ["nginx"], + "allowedProfiles": ["Unconfined"], +} + +input_parameters_sc = { + "allowedProfiles": [ + "RuntimeDefault", + "Localhost", + "Unconfined", + ], + "allowedLocalhostFiles": [ + "profile1.json", + "profile2.json", + ], +} + +input_parameters_sc_localhost_no_file = { + "allowedProfiles": ["Localhost"], +} + +input_parameters_localhost_wildcard_both = {"allowedProfiles": ["Localhost"], "allowedLocalhostFiles": ["*"]} + +input_parameters_sc_localhost_wildcard_file = { + "allowedProfiles": ["Localhost"], + "allowedLocalhostFiles": ["*"], +} + +input_parameters_sc_localhost_with_file = {"allowedProfiles": ["Localhost"], "allowedLocalhostFiles": ["profile.json"]} \ No newline at end of file diff --git a/website/docs/validation/seccomp.md b/website/docs/validation/seccomp.md index d73218666..c56cc549e 100644 --- a/website/docs/validation/seccomp.md +++ b/website/docs/validation/seccomp.md @@ -16,7 +16,7 @@ metadata: name: k8spspseccomp annotations: metadata.gatekeeper.sh/title: "Seccomp" - metadata.gatekeeper.sh/version: 1.0.1 + metadata.gatekeeper.sh/version: 1.1.0 description: >- Controls the seccomp profile used by containers. Corresponds to the `seccomp.security.alpha.kubernetes.io/allowedProfileNames` annotation on @@ -79,218 +79,335 @@ spec: type: string targets: - target: admission.k8s.gatekeeper.sh - rego: | - package k8spspseccomp - - import data.lib.exempt_container.is_exempt - - container_annotation_key_prefix = "container.seccomp.security.alpha.kubernetes.io/" - - pod_annotation_key = "seccomp.security.alpha.kubernetes.io/pod" - - naming_translation = { - # securityContext -> annotation - "RuntimeDefault": ["runtime/default", "docker/default"], - "Unconfined": ["unconfined"], - "Localhost": ["localhost"], - # annotation -> securityContext - "runtime/default": ["RuntimeDefault"], - "docker/default": ["RuntimeDefault"], - "unconfined": ["Unconfined"], - "localhost": ["Localhost"], - } - - violation[{"msg": msg}] { - not input_wildcard_allowed_profiles - allowed_profiles := get_allowed_profiles - container := input_containers[name] - not is_exempt(container) - result := get_profile(container) - not allowed_profile(result.profile, result.file, allowed_profiles) - msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) - } - - get_message(profile, _, name, location, allowed_profiles) = message { - not profile == "Localhost" - message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) - } - - get_message(profile, file, name, location, allowed_profiles) = message { - profile == "Localhost" - message := sprintf("Seccomp profile '%v' with file '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, file, name, location, allowed_profiles]) - } - - input_wildcard_allowed_profiles { - input.parameters.allowedProfiles[_] == "*" - } - - input_wildcard_allowed_files { - input.parameters.allowedLocalhostFiles[_] == "*" - } - - input_wildcard_allowed_files { - "localhost/*" == input.parameters.allowedProfiles[_] - } - - # Simple allowed Profiles - allowed_profile(profile, _, allowed) { - not startswith(lower(profile), "localhost") - profile == allowed[_] - } - - # seccomp Localhost without wildcard - allowed_profile(profile, file, allowed) { - profile == "Localhost" - not input_wildcard_allowed_files - profile == allowed[_] - allowed_files := {x | x := object.get(input.parameters, "allowedLocalhostFiles", [])[_]} | get_annotation_localhost_files - file == allowed_files[_] - } - - # seccomp Localhost with wildcard - allowed_profile(profile, _, allowed) { - profile == "Localhost" - input_wildcard_allowed_files - profile == allowed[_] - } - - # annotation localhost with wildcard - allowed_profile(profile, _, allowed) { - "localhost/*" == allowed[_] - startswith(profile, "localhost/") - } - - # annotation localhost without wildcard - allowed_profile(profile, _, allowed) { - startswith(profile, "localhost/") - profile == allowed[_] - } - - # Localhost files from annotation scheme - get_annotation_localhost_files[file] { - profile := input.parameters.allowedProfiles[_] - startswith(profile, "localhost/") - file := replace(profile, "localhost/", "") - } - - # The profiles explicitly in the list - get_allowed_profiles[allowed] { - allowed := input.parameters.allowedProfiles[_] - } - - # The simply translated profiles - get_allowed_profiles[allowed] { - profile := input.parameters.allowedProfiles[_] - not startswith(lower(profile), "localhost") - allowed := naming_translation[profile][_] - } - - # Seccomp Localhost to annotation translation - get_allowed_profiles[allowed] { - profile := input.parameters.allowedProfiles[_] - profile == "Localhost" - file := object.get(input.parameters, "allowedLocalhostFiles", [])[_] - allowed := sprintf("%v/%v", [naming_translation[profile][_], file]) - } - - # Annotation localhost to Seccomp translation - get_allowed_profiles[allowed] { - profile := input.parameters.allowedProfiles[_] - startswith(profile, "localhost") - allowed := naming_translation.localhost[_] - } - - # Container profile as defined in pod annotation - get_profile(container) = {"profile": profile, "file": "", "location": location} { - not has_securitycontext_container(container) - not has_annotation(get_container_annotation_key(container.name)) - not has_securitycontext_pod - profile := input.review.object.metadata.annotations[pod_annotation_key] - location := sprintf("annotation %v", [pod_annotation_key]) - } - - # Container profile as defined in container annotation - get_profile(container) = {"profile": profile, "file": "", "location": location} { - not has_securitycontext_container(container) - not has_securitycontext_pod - container_annotation := get_container_annotation_key(container.name) - has_annotation(container_annotation) - profile := input.review.object.metadata.annotations[container_annotation] - location := sprintf("annotation %v", [container_annotation]) - } - - # Container profile as defined in pods securityContext - get_profile(container) = {"profile": profile, "file": file, "location": location} { - not has_securitycontext_container(container) - profile := input.review.object.spec.securityContext.seccompProfile.type - file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") - location := "pod securityContext" - } - - # Container profile as defined in containers securityContext - get_profile(container) = {"profile": profile, "file": file, "location": location} { - has_securitycontext_container(container) - profile := container.securityContext.seccompProfile.type - file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") - location := "container securityContext" - } - - # Container profile missing - get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { - not has_annotation(get_container_annotation_key(container.name)) - not has_annotation(pod_annotation_key) - not has_securitycontext_pod - not has_securitycontext_container(container) - } - - has_annotation(annotation) { - input.review.object.metadata.annotations[annotation] - } - - has_securitycontext_pod { - input.review.object.spec.securityContext.seccompProfile - } - - has_securitycontext_container(container) { - container.securityContext.seccompProfile - } - - get_container_annotation_key(name) = annotation { - annotation := concat("", [container_annotation_key_prefix, name]) - } - - input_containers[container.name] = container { - container := input.review.object.spec.containers[_] - } - - input_containers[container.name] = container { - container := input.review.object.spec.initContainers[_] - } - - input_containers[container.name] = container { - container := input.review.object.spec.ephemeralContainers[_] - } - libs: - - | - package lib.exempt_container - - is_exempt(container) { - exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) - img := container.image - exemption := exempt_images[_] - _matches_exemption(img, exemption) - } - - _matches_exemption(img, exemption) { - not endswith(exemption, "*") - exemption == img - } - - _matches_exemption(img, exemption) { - endswith(exemption, "*") - prefix := trim_suffix(exemption, "*") - startswith(img, prefix) - } + code: + - engine: K8sNativeValidation + source: + variables: + - name: containers + expression: 'has(variables.anyObject.spec.containers) ? variables.anyObject.spec.containers : []' + - name: initContainers + expression: 'has(variables.anyObject.spec.initContainers) ? variables.anyObject.spec.initContainers : []' + - name: ephemeralContainers + expression: 'has(variables.anyObject.spec.ephemeralContainers) ? variables.anyObject.spec.ephemeralContainers : []' + - name: allowAllProfiles + expression: | + has(variables.params.allowedProfiles) && variables.params.allowedProfiles.exists(profile, profile == "*") + - name: exemptImagePrefixes + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, image.endsWith("*")).map(image, string(image).replace("*", "")) + - name: exemptImageExplicit + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, !image.endsWith("*")) + - name: exemptImages + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + container.image in variables.exemptImageExplicit || + variables.exemptImagePrefixes.exists(exemption, string(container.image).startsWith(exemption))).map(container, container.image) + - name: unverifiedContainers + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + !variables.allowAllProfiles && + !(container.image in variables.exemptImages)) + - name: inputAllowedProfiles + expression: | + !has(variables.params.allowedProfiles) ? [] : variables.params.allowedProfiles + - name: allowedLocalhostFiles + expression: | + has(variables.params.allowedLocalhostFiles) ? variables.params.allowedLocalhostFiles : [] + - name: allowedProfilesTranslation + expression: | + (variables.inputAllowedProfiles.filter(profile, + profile != "Localhost").map(profile, profile == "Unconfined" ? "unconfined" : profile)) + + (variables.inputAllowedProfiles.exists(profile, profile == "RuntimeDefault") ? ["runtime/default", "docker/default"] : []) + - name: allowSecurityContextLocalhost + expression: | + variables.inputAllowedProfiles.exists(profile, profile == "Localhost") + - name: derivedAllowedLocalhostFiles + expression: | + variables.allowSecurityContextLocalhost ? variables.params.allowedLocalhostFiles.map(file, "localhost/" + file) : [] + - name: localhostWildcardAllowed + expression: | + variables.inputAllowedProfiles.exists(profile, profile == "localhost/*") || variables.derivedAllowedLocalhostFiles.exists(profile, profile == "localhost/*") + - name: allowedProfiles + expression: | + (variables.allowedProfilesTranslation + variables.derivedAllowedLocalhostFiles) + - name: hasPodSeccomp + expression: | + has(variables.anyObject.spec.securityContext) && has(variables.anyObject.spec.securityContext.seccompProfile) + - name: hasPodAnnotations + expression: | + has(variables.anyObject.metadata.annotations) && ("seccomp.security.alpha.kubernetes.io/pod" in variables.anyObject.metadata.annotations) + - name: podAnnotationsProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !(has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations)) && + !variables.hasPodSeccomp && + variables.hasPodAnnotations + ).map(container, { + "container" : container.name, + "profile" : variables.anyObject.metadata.annotations["seccomp.security.alpha.kubernetes.io/pod"], + "file" : dyn(""), + "location" : dyn("annotation seccomp.security.alpha.kubernetes.io/pod"), + }) + - name: containerAnnotationsProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !variables.hasPodSeccomp && + has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations) + ).map(container, { + "container" : container.name, + "profile" : variables.anyObject.metadata.annotations["container.seccomp.security.alpha.kubernetes.io/" + container.name], + "file" : dyn(""), + "location" : dyn("annotation container.seccomp.security.alpha.kubernetes.io/" + container.name), + }) + - name: podLocalHostProfile + expression: | + variables.hasPodSeccomp && has(variables.anyObject.spec.securityContext.seccompProfile.localhostProfile) ? variables.anyObject.spec.securityContext.seccompProfile.localhostProfile : "" + - name: canonicalPodSecurityContextProfile + expression: | + has(variables.hasPodSeccomp) && has(variables.anyObject.spec.securityContext.seccompProfile.type) ? + (variables.anyObject.spec.securityContext.seccompProfile.type == "RuntimeDefault" ? ( + variables.allowedProfiles.exists(profile, profile == "runtime/default") ? "runtime/default" : variables.allowedProfiles.exists(profile, profile == "docker/default") ? "docker/default" : "runtime/default") : + variables.anyObject.spec.securityContext.seccompProfile.type == "Unconfined" ? "unconfined" : variables.anyObject.spec.securityContext.seccompProfile.type == "Localhost" ? "localhost/" + variables.podLocalHostProfile : "") + : "" + - name: podSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn(variables.canonicalPodSecurityContextProfile), + "file" : variables.podLocalHostProfile, + "location" : dyn("pod securityContext"), + }) + - name: containerSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + has(container.securityContext) && has(container.securityContext.seccompProfile) + ).map(container, { + "container" : container.name, + "profile" : dyn(has(container.securityContext.seccompProfile.type) ? (container.securityContext.seccompProfile.type == "RuntimeDefault" ? ( + variables.allowedProfiles.exists(profile, profile == "runtime/default") ? "runtime/default" : variables.allowedProfiles.exists(profile, profile == "docker/default") ? "docker/default" : "runtime/default") : + container.securityContext.seccompProfile.type == "Unconfined" ? "unconfined" : container.securityContext.seccompProfile.type == "Localhost" ? "localhost/" + container.securityContext.seccompProfile.localhostProfile : "") + : ""), + "file" : has(container.securityContext.seccompProfile.localhostProfile) ? container.securityContext.seccompProfile.localhostProfile : dyn(""), + "location" : dyn("container securityContext"), + }) + - name: containerProfilesMissing + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !(has(variables.anyObject.metadata.annotations) && (("container.seccomp.security.alpha.kubernetes.io/" + container.name) in variables.anyObject.metadata.annotations)) && + !variables.hasPodSeccomp && + !variables.hasPodAnnotations + ).map(container, { + "container" : container.name, + "profile" : dyn("not configured"), + "file" : dyn(""), + "location" : dyn("no explicit profile found"), + }) + - name: allContainerProfiles + expression: | + variables.podAnnotationsProfiles + variables.containerAnnotationsProfiles + variables.podSecurityContextProfiles + variables.containerSecurityContextProfiles + variables.containerProfilesMissing + - name: badContainerProfiles + expression: | + variables.allContainerProfiles.filter(badContainerProfile, + !((badContainerProfile.profile in variables.allowedProfiles) || (badContainerProfile.profile.startsWith("localhost/") && variables.localhostWildcardAllowed)) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.allowedProfiles.join(", ")) + validations: + - expression: 'size(variables.badContainerProfiles) == 0' + messageExpression: | + variables.badContainerProfiles.join(", ") + - engine: Rego + source: + rego: | + package k8spspseccomp + + import data.lib.exempt_container.is_exempt + + container_annotation_key_prefix = "container.seccomp.security.alpha.kubernetes.io/" + + pod_annotation_key = "seccomp.security.alpha.kubernetes.io/pod" + + violation[{"msg": msg}] { + not input_wildcard_allowed_profiles + allowed_profiles := get_allowed_profiles + container := input_containers[name] + not is_exempt(container) + result := get_profile(container) + not allowed_profile(result.profile, result.file, allowed_profiles) + msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) + } + + get_message(profile, _, name, location, allowed_profiles) = message { + message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) + } + + input_wildcard_allowed_profiles { + input.parameters.allowedProfiles[_] == "*" + } + + input_wildcard_allowed_files { + input.parameters.allowedLocalhostFiles[_] == "*" + } + + input_wildcard_allowed_files { + "localhost/*" == input.parameters.allowedProfiles[_] + } + + # Simple allowed Profiles + allowed_profile(profile, _, allowed) { + not startswith(profile, "localhost/") + profile == allowed[_] + } + + # annotation localhost with wildcard + allowed_profile(profile, _, allowed) { + "localhost/*" == allowed[_] + startswith(profile, "localhost/") + } + + # annotation localhost without wildcard + allowed_profile(profile, _, allowed) { + startswith(profile, "localhost/") + profile == allowed[_] + } + + # The profiles explicitly in the list + get_allowed_profiles[allowed] { + allowed := input.parameters.allowedProfiles[_] + } + + # Seccomp Localhost to annotation translation + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + not contains(profile, "/") + file := object.get(input.parameters, "allowedLocalhostFiles", [])[_] + allowed := canonicalize_seccomp_profile({"type": profile, "localhostProfile": file}, "")[_] + } + + # Container profile as defined in pod annotation + get_profile(container) = {"profile": profile, "file": "", "location": location} { + not has_securitycontext_container(container) + not has_annotation(get_container_annotation_key(container.name)) + not has_securitycontext_pod + profile := input.review.object.metadata.annotations[pod_annotation_key] + location := sprintf("annotation %v", [pod_annotation_key]) + } + + # Container profile as defined in container annotation + get_profile(container) = {"profile": profile, "file": "", "location": location} { + not has_securitycontext_container(container) + not has_securitycontext_pod + container_annotation := get_container_annotation_key(container.name) + has_annotation(container_annotation) + profile := input.review.object.metadata.annotations[container_annotation] + location := sprintf("annotation %v", [container_annotation]) + } + + # Container profile as defined in pods securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + not has_securitycontext_container(container) + profile := canonicalize_seccomp_profile(input.review.object.spec.securityContext.seccompProfile, canonicalize_runtime_default_profile)[_] + file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") + location := "pod securityContext" + } + + # Container profile as defined in containers securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + has_securitycontext_container(container) + profile := canonicalize_seccomp_profile(container.securityContext.seccompProfile, canonicalize_runtime_default_profile)[_] + file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") + location := "container securityContext" + } + + # Container profile missing + get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { + not has_securitycontext_container(container) + not has_securitycontext_pod + not has_annotation(get_container_annotation_key(container.name)) + not has_annotation(pod_annotation_key) + } + + has_annotation(annotation) { + input.review.object.metadata.annotations[annotation] + } + + has_securitycontext_pod { + input.review.object.spec.securityContext.seccompProfile + } + + has_securitycontext_container(container) { + container.securityContext.seccompProfile + } + + get_container_annotation_key(name) = annotation { + annotation := concat("", [container_annotation_key_prefix, name]) + } + + input_containers[container.name] = container { + container := input.review.object.spec.containers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.initContainers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.ephemeralContainers[_] + } + + canonicalize_runtime_default_profile() = out { + "runtime/default" == input.parameters.allowedProfiles[_] + out := "runtime/default" + } else = out { + "docker/default" == input.parameters.allowedProfiles[_] + out := "docker/default" + } else = out { + out := "runtime/default" + } + + canonicalize_seccomp_profile(profile, def) = out { + profile.type == "RuntimeDefault" + def == "" + out := ["runtime/default", "docker/default"] + } else = out { + profile.type == "RuntimeDefault" + def != "" + out := [def] + } else = out { + profile.type == "Localhost" + out := [sprintf("localhost/%s", [profile.localhostProfile])] + } else = out { + profile.type == "Unconfined" + out := ["unconfined"] + } + libs: + - | + package lib.exempt_container + + is_exempt(container) { + exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) + img := container.image + exemption := exempt_images[_] + _matches_exemption(img, exemption) + } + + _matches_exemption(img, exemption) { + not endswith(exemption, "*") + exemption == img + } + + _matches_exemption(img, exemption) { + endswith(exemption, "*") + prefix := trim_suffix(exemption, "*") + startswith(img, prefix) + } ``` @@ -316,10 +433,11 @@ spec: - apiGroups: [""] kinds: ["Pod"] parameters: + exemptImages: + - nginx-exempt allowedProfiles: - runtime/default - - docker/default - + - localhost/profile.json ``` Usage @@ -459,6 +577,89 @@ Usage kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccomp/samples/psp-seccomp/disallowed_ephemeral.yaml ``` + +
+example-allowed-container-exempt-image + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx-exempt + securityContext: + seccompProfile: + type: Unconfined + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccomp/samples/psp-seccomp/example_allowed_exempt_image.yaml +``` + +
+
+example-allowed-container-localhost-profile + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.json + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccomp/samples/psp-seccomp/example_allowed_localhost.yaml +``` + +
+
+example-disallowed-container-localhost-profile + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.log + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccomp/samples/psp-seccomp/example_disallowed_localhost.yaml +``` +
diff --git a/website/docs/validation/seccompv2.md b/website/docs/validation/seccompv2.md new file mode 100644 index 000000000..f93abdb3b --- /dev/null +++ b/website/docs/validation/seccompv2.md @@ -0,0 +1,519 @@ +--- +id: seccompv2 +title: Seccomp V2 +--- + +# Seccomp V2 + +## Description +Controls the seccomp profile used by containers. Corresponds to the `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. + +## Template +```yaml +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8spspseccompv2 + annotations: + metadata.gatekeeper.sh/title: "Seccomp V2" + metadata.gatekeeper.sh/version: 1.0.0 + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. +spec: + crd: + spec: + names: + kind: K8sPSPSeccompV2 + validation: + # Schema for the `parameters` field + openAPIV3Schema: + type: object + description: >- + Controls the seccomp profile used by containers. Corresponds to the + `securityContext.seccompProfile` field. Security contexts from the annotation is not considered as Kubernetes no longer reads security contexts from the annotation. + properties: + exemptImages: + description: >- + Any container that uses an image that matches an entry in this list will be excluded + from enforcement. Prefix-matching can be signified with `*`. For example: `my-image-*`. + + It is recommended that users use the fully-qualified Docker image name (e.g. start with a domain name) + in order to avoid unexpectedly exempting images from an untrusted repository. + type: array + items: + type: string + allowedProfiles: + type: array + description: >- + An array of allowed profile values for seccomp on Pods/Containers. + + Can use the securityContext naming scheme: `RuntimeDefault`, `Unconfined` + and/or `Localhost`. For securityContext `Localhost`, use the parameter `allowedLocalhostFiles` + to list the allowed profile JSON files. + + The policy code will translate between the two schemes so it is not necessary to use both. + + Putting a `*` in this array allows all Profiles to be used. + + This field is required since with an empty list this policy will block all workloads. + items: + type: string + allowedLocalhostFiles: + type: array + description: >- + When using securityContext naming scheme for seccomp and including `Localhost` this array holds + the allowed profile JSON files. + + Putting a `*` in this array will allows all JSON files to be used. + + This field is required to allow `Localhost` in securityContext as with an empty list it will block. + items: + type: string + targets: + - target: admission.k8s.gatekeeper.sh + code: + - engine: K8sNativeValidation + source: + variables: + - name: containers + expression: 'has(variables.anyObject.spec.containers) ? variables.anyObject.spec.containers : []' + - name: initContainers + expression: 'has(variables.anyObject.spec.initContainers) ? variables.anyObject.spec.initContainers : []' + - name: ephemeralContainers + expression: 'has(variables.anyObject.spec.ephemeralContainers) ? variables.anyObject.spec.ephemeralContainers : []' + - name: allowAllProfiles + expression: | + has(variables.params.allowedProfiles) && variables.params.allowedProfiles.exists(profile, profile == "*") + - name: exemptImagePrefixes + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, image.endsWith("*")).map(image, string(image).replace("*", "")) + - name: exemptImageExplicit + expression: | + !has(variables.params.exemptImages) ? [] : + variables.params.exemptImages.filter(image, !image.endsWith("*")) + - name: exemptImages + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + container.image in variables.exemptImageExplicit || + variables.exemptImagePrefixes.exists(exemption, string(container.image).startsWith(exemption))).map(container, container.image) + - name: unverifiedContainers + expression: | + (variables.containers + variables.initContainers + variables.ephemeralContainers).filter(container, + !variables.allowAllProfiles && + !(container.image in variables.exemptImages)) + - name: inputNonLocalHostProfiles + expression: | + variables.params.allowedProfiles.filter(profile, profile != "Localhost").map(profile, {"type": profile}) + - name: inputLocalHostProfiles + expression: | + variables.params.allowedProfiles.exists(profile, profile == "Localhost") ? variables.params.allowedLocalhostFiles.map(file, {"type": "Localhost", "localHostProfile": string(file)}) : [] + - name: inputAllowedProfiles + expression: | + variables.inputNonLocalHostProfiles + variables.inputLocalHostProfiles + - name: hasPodSeccomp + expression: | + has(variables.anyObject.spec.securityContext) && has(variables.anyObject.spec.securityContext.seccompProfile) + - name: podLocalHostProfile + expression: | + variables.hasPodSeccomp && has(variables.anyObject.spec.securityContext.seccompProfile.localhostProfile) ? variables.anyObject.spec.securityContext.seccompProfile.localhostProfile : "" + - name: podSecurityContextProfileType + expression: | + has(variables.hasPodSeccomp) && has(variables.anyObject.spec.securityContext.seccompProfile.type) ? variables.anyObject.spec.securityContext.seccompProfile.type + : "" + - name: podSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn(variables.podSecurityContextProfileType), + "file" : variables.podLocalHostProfile, + "location" : dyn("pod securityContext"), + }) + - name: containerSecurityContextProfiles + expression: | + variables.unverifiedContainers.filter(container, + has(container.securityContext) && has(container.securityContext.seccompProfile) + ).map(container, { + "container" : container.name, + "profile" : dyn(container.securityContext.seccompProfile.type), + "file" : has(container.securityContext.seccompProfile.localhostProfile) ? container.securityContext.seccompProfile.localhostProfile : dyn(""), + "location" : dyn("container securityContext"), + }) + - name: containerProfilesMissing + expression: | + variables.unverifiedContainers.filter(container, + !(has(container.securityContext) && has(container.securityContext.seccompProfile)) && + !variables.hasPodSeccomp + ).map(container, { + "container" : container.name, + "profile" : dyn("not configured"), + "file" : dyn(""), + "location" : dyn("no explicit profile found"), + }) + - name: allContainerProfiles + expression: | + variables.podSecurityContextProfiles + variables.containerSecurityContextProfiles + variables.containerProfilesMissing + - name: badContainerProfilesWithoutFiles + expression: | + variables.allContainerProfiles.filter(container, + container.profile != "Localhost" && + !variables.inputAllowedProfiles.exists(profile, profile.type == container.profile) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.inputAllowedProfiles.map(profile, "{\"type\": \"" + profile.type + "\"" + (has(profile.localHostProfile) ? ", \"localHostProfile\": \"" + profile.localHostProfile + "\"}" : "}")).join(", ")) + - name: badContainerProfilesWithFiles + expression: | + variables.allContainerProfiles.filter(container, + container.profile == "Localhost" && + !variables.inputAllowedProfiles.exists(profile, profile.type == "Localhost" && (has(profile.localHostProfile) && (profile.localHostProfile == container.file || profile.localHostProfile == "*"))) + ).map(badProfile, "Seccomp profile '" + badProfile.profile + "' With file '" + badProfile.file + "' is not allowed for container '" + badProfile.container + "'. Found at: " + badProfile.location + ". Allowed profiles: " + variables.inputAllowedProfiles.map(profile, "{\"type\": \"" + profile.type + "\"" + (has(profile.localHostProfile) ? ", \"localHostProfile\": \"" + profile.localHostProfile + "\"}" : "}")).join(", ")) + validations: + - expression: 'size(variables.badContainerProfilesWithoutFiles) == 0' + messageExpression: | + variables.badContainerProfilesWithoutFiles.join(", ") + - expression: 'size(variables.badContainerProfilesWithFiles) == 0' + messageExpression: | + variables.badContainerProfilesWithFiles.join(", ") + - engine: Rego + source: + rego: | + package k8spspseccomp + + import data.lib.exempt_container.is_exempt + + violation[{"msg": msg}] { + not input_wildcard_allowed_profiles + allowed_profiles := get_allowed_profiles + container := input_containers[name] + not is_exempt(container) + result := get_profile(container) + not allowed_profile(result.profile, result.file, allowed_profiles) + msg := get_message(result.profile, result.file, name, result.location, allowed_profiles) + } + + get_message(profile, _, name, location, allowed_profiles) = message { + profile != "Localhost" + message := sprintf("Seccomp profile '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, name, location, allowed_profiles]) + } + + get_message(profile, file, name, location, allowed_profiles) = message { + profile == "Localhost" + message := sprintf("Seccomp profile '%v' with file '%v' is not allowed for container '%v'. Found at: %v. Allowed profiles: %v", [profile, file, name, location, allowed_profiles]) + } + + input_wildcard_allowed_profiles { + input.parameters.allowedProfiles[_] == "*" + } + + input_wildcard_allowed_files { + input.parameters.allowedLocalhostFiles[_] == "*" + } + + allowed_profile(_, _, _) { + input_wildcard_allowed_profiles + } + + allowed_profile(profile, _, _) { + profile == "Localhost" + input_wildcard_allowed_files + } + + # Simple allowed Profiles + allowed_profile(profile, _, allowed) { + profile != "Localhost" + allow_profile = allowed[_] + profile == allow_profile.type + } + + # annotation localhost without wildcard + allowed_profile(profile, file, allowed) { + profile == "Localhost" + allow_profile = allowed[_] + allow_profile.type == "Localhost" + file == allow_profile.localHostProfile + } + + # The profiles explicitly in the list + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + profile != "Localhost" + allowed := {"type": profile} + } + + get_allowed_profiles[allowed] { + profile := input.parameters.allowedProfiles[_] + profile == "Localhost" + file := object.get(input.parameters, "allowedLocalhostFiles", [""])[_] + allowed := {"type": "Localhost", "localHostProfile": file} + } + + # Container profile as defined in containers securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + has_securitycontext_container(container) + profile := container.securityContext.seccompProfile.type + file := object.get(container.securityContext.seccompProfile, "localhostProfile", "") + location := "container securityContext" + } + + # Container profile as defined in pods securityContext + get_profile(container) = {"profile": profile, "file": file, "location": location} { + not has_securitycontext_container(container) + profile := input.review.object.spec.securityContext.seccompProfile.type + file := object.get(input.review.object.spec.securityContext.seccompProfile, "localhostProfile", "") + location := "pod securityContext" + } + + # Container profile missing + get_profile(container) = {"profile": "not configured", "file": "", "location": "no explicit profile found"} { + not has_securitycontext_container(container) + not has_securitycontext_pod + } + + has_securitycontext_pod { + input.review.object.spec.securityContext.seccompProfile + } + + has_securitycontext_container(container) { + container.securityContext.seccompProfile + } + + input_containers[container.name] = container { + container := input.review.object.spec.containers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.initContainers[_] + } + + input_containers[container.name] = container { + container := input.review.object.spec.ephemeralContainers[_] + } + libs: + - | + package lib.exempt_container + + is_exempt(container) { + exempt_images := object.get(object.get(input, "parameters", {}), "exemptImages", []) + img := container.image + exemption := exempt_images[_] + _matches_exemption(img, exemption) + } + + _matches_exemption(img, exemption) { + not endswith(exemption, "*") + exemption == img + } + + _matches_exemption(img, exemption) { + endswith(exemption, "*") + prefix := trim_suffix(exemption, "*") + startswith(img, prefix) + } + +``` + +### Usage +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccompv2/template.yaml +``` +## Examples +
+default-seccomp-required + +
+constraint + +```yaml +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sPSPSeccompV2 +metadata: + name: psp-seccomp +spec: + match: + kinds: + - apiGroups: [""] + kinds: ["Pod"] + parameters: + exemptImages: + - nginx-exempt + allowedProfiles: + - RuntimeDefault + - Localhost + allowedLocalhostFiles: + - "*" + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccompv2/samples/psp-seccomp/constraint.yaml +``` + +
+ +
+example-disallowed-global + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed2 + labels: + app: nginx-seccomp +spec: + securityContext: + seccompProfile: + type: Unconfined + containers: + - name: nginx + image: nginx + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_disallowed2.yaml +``` + +
+
+example-disallowed-container + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Unconfined + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_disallowed.yaml +``` + +
+
+example-allowed-container + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: RuntimeDefault + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed.yaml +``` + +
+
+example-allowed-container + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-allowed-localhost + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx + securityContext: + seccompProfile: + type: Localhost + localhostProfile: profile.json + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed_localhost.yaml +``` + +
+
+example-allowed-container-exempt-image + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + containers: + - name: nginx + image: nginx-exempt + securityContext: + seccompProfile: + type: Unconfined + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccompv2/samples/psp-seccomp/example_allowed_exempt_image.yaml +``` + +
+
+disallowed-ephemeral + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx-seccomp-disallowed + labels: + app: nginx-seccomp +spec: + ephemeralContainers: + - name: nginx + image: nginx + +``` + +Usage + +```shell +kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper-library/master/library/pod-security-policy/seccompv2/samples/psp-seccomp/disallowed_ephemeral.yaml +``` + +
+ + +
\ No newline at end of file diff --git a/website/sidebars.js b/website/sidebars.js index a14f9496c..5710b4a6e 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -68,6 +68,7 @@ module.exports = { 'validation/proc-mount', 'validation/read-only-root-filesystem', 'validation/seccomp', + 'validation/seccompv2', 'validation/selinux', 'validation/users', 'validation/volumes',