Cloud Security · lesson 4 of 8
Intermediate
8- 1 Scanning Terraform for Misconfiguration with Checkov
- 2 Policy as Code: Testing Terraform Plans with OPA and Conftest
- 3 CIS Docker Benchmark Checks with docker-bench-security
- 4 Kubernetes Pod Security Admission: Enforcing the Restricted Profile
- 5 CIS Kubernetes Benchmark Checks with kube-bench in kind
- 6 Linting AWS IAM Policies Before Deploy with Parliament
- 7 Auditing the AWS IAM Credential Report with Python
- 8 Hunting in AWS CloudTrail Logs with Python
Also part of Cloud Security
11- + IAM Concepts That Transfer: AWS vs Azure vs GCP from Multi-Cloud
- + IAM: Users, Roles, and Policies from AWS
- + GCP IAM: Roles and Service Accounts from Google Cloud
- + Entra ID: Users, Roles, and RBAC from Azure
- + IAM and Least Privilege from Cloud Interviews
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + Docker Security from Docker
- + Security, RBAC, and Observability from Kubernetes
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Building an Incident Timeline from Auth, Web and IDS Logs from Defensive Security
- + Envelope Encryption and Key Rotation Without Re-encrypting Data from Cryptography
Also part of: Kubernetes
Kubernetes Pod Security Admission: Enforcing the Restricted Profile
Label a namespace restricted and watch Kubernetes reject a privileged pod with the real violates PodSecurity message, then admit the compliant spec.
By SkillByExample · Published
Kubernetes ships with its own guard rail for pods. Pod Security Admission checks every new pod against the Pod Security Standards, and you turn it on per namespace with a label. No add-on and no cloud account is needed, so a local minikube cluster shows exactly what a managed cluster does.
Tested with minikube 1.38.1 (Docker driver), Kubernetes 1.35.1, kubectl 1.35.2 and busybox:1.37.
A separate profile keeps the lab away from any other cluster:
$ minikube start -p psa-lab --driver=docker | tail -4
* Verifying Kubernetes components...
- Using image gcr.io/k8s-minikube/storage-provisioner:v5
* Enabled addons: storage-provisioner, default-storageclass
* Done! kubectl is now configured to use "psa-lab" cluster and "default" namespace by default
$ kubectl --context psa-lab version | tail -1
Server Version: v1.35.1
A restricted namespace rejects a privileged pod
The namespace carries the policy as labels. enforce-version pins the rules to a release, so a
cluster upgrade can’t silently tighten them:
# ns.yaml: a namespace that enforces the restricted Pod Security Standard
apiVersion: v1
kind: Namespace
metadata:
name: shop
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.35
# pod-bad.yaml: the "just make it work" pod
apiVersion: v1
kind: Pod
metadata:
name: debug
namespace: shop
spec:
containers:
- name: app
image: busybox:1.37
command: ["sleep", "3600"]
securityContext:
privileged: true
$ kubectl apply -f ns.yaml
namespace/shop created
$ kubectl apply -f pod-bad.yaml
Error from server (Forbidden): error when creating "pod-bad.yaml": pods "debug" is forbidden: violates PodSecurity "restricted:v1.35": privileged (container "app" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (container "app" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "app" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "app" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "app" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
The API server refused the pod before anything was scheduled, and the message is a complete to-do
list. Only the first item comes from the privileged: true line. The other four are things a pod
fails by saying nothing: unless the spec sets them, a container may escalate privileges, keeps
the default capabilities, may run as root and runs without the default seccomp filter. Restricted
requires all of them to be stated explicitly.
The compliant spec
Each item in the message maps to one field:
# pod-good.yaml: the same workload, inside the restricted profile
apiVersion: v1
kind: Pod
metadata:
name: debug
namespace: shop
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: busybox:1.37
command: ["sleep", "3600"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
$ kubectl apply -f pod-good.yaml
pod/debug created
$ kubectl -n shop wait --for=condition=Ready pod/debug --timeout=120s
pod/debug condition met
$ kubectl -n shop exec debug -- id
uid=65534(nobody) gid=65534(nobody) groups=65534(nobody)
Admitted and running as nobody. The pod-level securityContext sets the user and the seccomp
profile for every container. Escalation and capabilities are container-level fields. Dropping
privileged was a deletion. The rest were additions, which is why “restricted” feels strict: its
defaults are the opposite of Kubernetes’ defaults.
Rolling it out to a namespace that already has pods
Turning on enforce in a busy namespace is risky, so preview it first. This namespace already
runs a root pod:
# legacy.yaml: an existing namespace with a pod that runs as root
apiVersion: v1
kind: Namespace
metadata:
name: legacy
---
apiVersion: v1
kind: Pod
metadata:
name: worker
namespace: legacy
spec:
containers:
- name: app
image: busybox:1.37
command: ["sleep", "3600"]
# worker2.yaml: a new root pod in the legacy namespace
apiVersion: v1
kind: Pod
metadata:
name: worker2
namespace: legacy
spec:
containers:
- name: app
image: busybox:1.37
command: ["sleep", "3600"]
$ kubectl apply -f legacy.yaml
namespace/legacy created
pod/worker created
$ kubectl label --dry-run=server --overwrite ns legacy pod-security.kubernetes.io/enforce=restricted
Warning: existing pods in namespace "legacy" violate the new PodSecurity enforce level "restricted:latest"
Warning: worker: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/legacy labeled (server dry run)
$ kubectl label --overwrite ns legacy pod-security.kubernetes.io/enforce=baseline pod-security.kubernetes.io/warn=restricted
namespace/legacy labeled
$ kubectl apply -f worker2.yaml
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "app" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "app" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "app" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "app" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
pod/worker2 created
$ kubectl -n legacy get pods
NAME READY STATUS RESTARTS AGE
worker 1/1 Running 0 3s
worker2 1/1 Running 0 1s
--dry-run=server asks the API server what would happen without changing anything: worker
would be out of policy. So the rollout goes in two steps. enforce=baseline blocks the dangerous
things (privileged, host namespaces) that this workload doesn’t do, and warn=restricted lets
worker2 in while printing exactly what to fix. Every kubectl apply and CI deploy now shows the
gap until the manifests are fixed and enforce can move to restricted. With no version label,
the levels follow the cluster as restricted:latest.
Practice
- Set
enforce=restrictedonlegacyfor real. Areworkerandworker2evicted? Then applyworker3.yaml, a copy ofworker2.yamlnamedworker3.
Solution
$ kubectl label --overwrite ns legacy pod-security.kubernetes.io/enforce=restricted
Warning: existing pods in namespace "legacy" violate the new PodSecurity enforce level "restricted:latest"
Warning: worker (and 1 other pod): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/legacy labeled
$ kubectl -n legacy get pods
NAME READY STATUS RESTARTS AGE
worker 1/1 Running 0 12s
worker2 1/1 Running 0 10s
$ kubectl apply -f worker3.yaml
Error from server (Forbidden): error when creating "worker3.yaml": pods "worker3" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "app" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "app" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "app" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "app" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
Nothing is evicted. Admission only judges pods as they are created, so the old pods keep running until something recreates them, and then they are rejected too. A Deployment’s next rollout is where an unprepared namespace breaks.
- Copy
pod-good.yamltopod-nouser.yaml, rename the poddebug2and delete therunAsUser: 65534line. Is it admitted? Does it run?
Solution
$ kubectl apply -f pod-nouser.yaml
pod/debug2 created
$ sleep 15
$ kubectl -n shop get pod debug2
NAME READY STATUS RESTARTS AGE
debug2 0/1 CreateContainerConfigError 0 16s
$ kubectl -n shop describe pod debug2 | grep "Error:"
Warning Failed 2s (x3 over 15s) kubelet spec.containers{app}: Error: container has runAsNonRoot and image will run as root (pod: "debug2_shop(8f152931-7b05-4017-bdf2-2518c569c500)", container: app)
Admission passes, because runAsNonRoot: true is set. The kubelet then refuses to start the
container, because busybox’s default user is root. Two different checks at two different times:
admission reads the spec, and the kubelet checks what the image will actually do. Either set a UID
or use an image built to run as non-root.
- Create a namespace
baseline-demothat enforcesbaseline, and apply a privileged pod there. Does baseline stop it?
Solution
# priv.yaml: a privileged pod in a baseline namespace
apiVersion: v1
kind: Pod
metadata:
name: priv
namespace: baseline-demo
spec:
containers:
- name: app
image: busybox:1.37
command: ["sleep", "3600"]
securityContext:
privileged: true
$ kubectl create ns baseline-demo
namespace/baseline-demo created
$ kubectl label ns baseline-demo pod-security.kubernetes.io/enforce=baseline
namespace/baseline-demo labeled
$ kubectl apply -f priv.yaml
Error from server (Forbidden): error when creating "priv.yaml": pods "priv" is forbidden: violates PodSecurity "baseline:latest": privileged (container "app" must not set securityContext.privileged=true)
Yes. Baseline lists only the one violation that is a known escalation path. The root user and missing seccomp profile that restricted complained about are allowed under baseline, which is why baseline is a safe first step for existing workloads.
- Clean up the lab.
Solution
$ minikube delete -p psa-lab
* Deleting "psa-lab" in docker ...
* Deleting container "psa-lab" ...
* Removing C:\Users\abhis\.minikube\machines\psa-lab ...
* Removed all traces of the "psa-lab" cluster.
$ kubectl config current-context
error: current-context is not set
Deleting the profile removes the cluster with every namespace and pod in it. It also unsets
kubectl’s current context, so switch back to your usual cluster with
kubectl config use-context <name> if you have one.