04 · Security Hardening (Pod Security Standards)¶
Not run against a live cluster
Manifests and admission behavior below follow the documented Pod Security Admission spec; not executed against a live cluster in this session.
Pod Security Standards: three named levels¶
Kubernetes defines three built-in policy levels (replacing the deprecated PodSecurityPolicy), enforced by the in-tree Pod Security Admission controller — no separate installation required since 1.25:
- Privileged — unrestricted; effectively opts out of enforcement.
- Baseline — blocks known privilege escalations while staying broadly compatible (no privileged containers, no host namespaces, no arbitrary host path mounts).
- Restricted — the hardened, least-privilege tier: requires running as non-root, drops all Linux capabilities by default, forbids privilege escalation, requires a seccomp profile.
Enforcing a standard via namespace labels¶
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.29
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
kubectl apply -f payments-namespace.yaml
kubectl run test-priv --image=nginx --privileged -n payments
# Error from server (Forbidden): pods "test-priv" is forbidden:
# violates PodSecurity "restricted:v1.29": privileged (container "test-priv"
# must not set securityContext.privileged=true), ...
enforce rejects non-compliant Pods at admission time; warn and audit
let you roll a stricter policy out gradually — apply warn/audit first,
watch audit logs / kubectl warnings for a rollout period, then flip
enforce once nothing new is violating it.
A Restricted-compliant Pod spec¶
apiVersion: v1
kind: Pod
metadata:
name: api
namespace: payments
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: registry.example.com/api:v1.4.2
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
Every field here maps to a specific Restricted requirement:
runAsNonRoot/runAsUser block UID 0, allowPrivilegeEscalation: false
blocks setuid-style escalation inside the container,
readOnlyRootFilesystem plus an emptyDir for /tmp lets a process still
write scratch files without a writable root filesystem, and capabilities.drop:
["ALL"] removes every Linux capability (including ones a root-run process
would otherwise retain) unless explicitly added back.
Least-privilege RBAC alongside Pod-level hardening¶
Pod Security Standards constrain what a Pod's runtime can do; RBAC constrains what callers can do to the API. Both matter — a Restricted-compliant Pod running under a ServiceAccount with cluster-admin RBAC is still a serious blast-radius problem if compromised.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader
namespace: payments
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: api-pod-reader
namespace: payments
subjects:
- kind: ServiceAccount
name: api
namespace: payments
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
Worked example: rolling out Restricted without breaking things¶
# 1. Warn/audit only, on the existing namespace, no enforcement yet:
kubectl label namespace payments \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted --overwrite
# 2. Deploy normally; check kubectl output and audit logs for violations:
kubectl apply -f deployment.yaml -n payments
# Warning: would violate PodSecurity "restricted:latest": ... (allowPrivilegeEscalation != false)
# 3. Fix the flagged manifests, redeploy, confirm zero warnings, then enforce:
kubectl label namespace payments pod-security.kubernetes.io/enforce=restricted --overwrite
How It Actually Works¶
- Pod Security Admission is a built-in validating admission webhook-equivalent compiled into the API server, not a controller watching objects after the fact. It runs synchronously in the admission chain for every Pod create/update request, reading the effective policy from the target namespace's labels at request time — this is why enforcement is instantaneous and atomic (a violating Pod create is rejected outright with a 403, it never briefly exists) but also why it only ever evaluates Pod creation, not Pods already running when a namespace's label is tightened (existing Pods are grandfathered until they're next recreated).
- The three policy levels are implemented as static, versioned rule
sets baked into the API server binary, not CRDs or ConfigMaps. Each
Kubernetes minor version ships an updated definition of what
"restricted" means (new fields get added as the API evolves); the
enforce-versionlabel pins evaluation to a specific version's rule set so that a cluster upgrade doesn't retroactively start rejecting previously-compliant Pods just because the definition of "restricted" gained a new check. capabilities.drop: ["ALL"]interacts with the container runtime's default capability set, not with a Kubernetes-level default. The container runtime (containerd/CRI-O) grants a runtime-specific default Linux capability set (traditionally similar to Docker's list — things likeNET_BIND_SERVICE,CHOWN) unless told otherwise; Kubernetes'securityContext.capabilitiesis passed straight through to the runtime's OCI spec generation, sodrop: ["ALL"]really means "start from an empty capability set," and any capability actually required (e.g. binding to port 80 as non-root, needingNET_BIND_SERVICE) must be explicitly re-added viaadd.seccompProfile: RuntimeDefaultdelegates the actual syscall filtering to the container runtime's shipped seccomp profile via a BPF program loaded by the kernel, not by Kubernetes. Kubernetes only records the intent on the Pod spec; containerd/CRI-O translates that into a call that loads a seccomp BPF filter into the kernel for that process at container-start time, blocking a curated list of dangerous syscalls (likeptrace, unless explicitly needed) — the enforcement boundary is the kernel, which is why a wrong or missing seccomp profile can't be fixed by anything short of restarting the container.
Exercise¶
Create a namespace with pod-security.kubernetes.io/enforce: baseline,
then attempt to schedule a Pod with hostNetwork: true and confirm it's
rejected with a specific violation message. Fix the manifest, then bump
the namespace label to restricted and iterate on the same Pod spec
(adding runAsNonRoot, dropping capabilities, setting a seccomp profile)
until it's admitted cleanly.