Pod Security Standards: What Your kubeadm Cluster Does Not Do For You

admineci

admineci

Auteur

1891 mots

A fresh kubeadm cluster enforces nothing: containers run as root and nothing warns you. How to measure the gap and close it without breaking production.

Take an image whose Dockerfile has no USER instruction. Deploy it to a fresh kubeadm cluster with kubectl run. It starts, it reports Running, and its process is UID 0 with the full default capability set.

Nothing warns you. There is no admission error, no event, no annotation. If you came from OpenShift, where Security Context Constraints assign a per-namespace non-root UID and quietly rewrite what your container runs as, this is the moment a protection you never configured stops existing — and the only visible difference is that the deployment worked on the first try.

Kubernetes ships the mechanism to fix this. It has been generally available since v1.25 and it is off by default, because turning it on without preparation breaks running workloads. This guide covers what the default actually is, how to measure the impact before enforcing anything, and the rollout order that makes the change safe.

Scope

  • Self-managed clusters: kubeadm, and by extension anything you install yourself. Managed offerings often set some of this for you — check before assuming.
  • Pod Security Admission has been GA since Kubernetes v1.25. Examples were run on 1.36; the supported releases at the time of writing are 1.34, 1.35 and 1.36, with 1.37 due at the end of August 2026.
  • You need cluster-admin to label namespaces, and access to the API server configuration only for the exemptions section.
  • Every command here is reversible. The one that changes cluster behaviour is clearly marked.

What your cluster does by default: nothing

Pod Security Admission is a built-in admission controller. It evaluates each pod against a policy level assigned to its namespace. Assign no level, and no policy applies.

Without a level, the security context of a container comes from exactly two places: the USER instruction baked into the image, and any securityContext the author wrote in the manifest. If neither exists, the container runs as root, can escalate privileges, and holds the runtime's default capabilities.

This is a deliberate design decision, not an oversight. Kubernetes replaced the older PodSecurityPolicy mechanism, removed in v1.25, with something simpler and, importantly, non-mutating. PodSecurityPolicy could rewrite a pod spec to make it compliant. Pod Security Admission never modifies anything: it accepts a pod or it rejects it. That is easier to reason about and it means the burden of compliance moves to the manifest, where it is visible in version control.

It also means that migrating from a mutating system — OpenShift's SCCs behave this way for UID assignment — surfaces every image that was silently being corrected.

Three levels

privileged applies no restriction. It is the correct level for trusted infrastructure: CNI plugins, storage drivers, node agents, anything that legitimately needs host access. It is the wrong level for anything else.

baseline blocks the well-known privilege escalation paths while allowing a pod spec that has had no security thought put into it. No host namespaces, no privileged containers, no dangerous capabilities beyond a small allowed set, no unconfined seccomp. Most existing workloads pass baseline unchanged, which is what makes it the realistic first target.

restricted is the hardening profile. It requires the pod to explicitly declare that it does not need what it is being denied, which in practice means four things in nearly every manifest: run as non-root, disallow privilege escalation, drop all capabilities, and set the seccomp profile to the runtime default.

Three modes, and one asymmetry that catches everyone

Each level can be applied in three modes, independently, on the same namespace.

warn returns a message to whoever submitted the resource. audit writes an annotation into the audit log and nothing else. enforce rejects the pod.

Here is the asymmetry, and it is the single most useful thing to know before you start: enforce applies only to Pod objects. warn and audit also apply to workload resources — Deployments, StatefulSets, DaemonSets, Jobs and the rest.

Follow that through. You label a namespace with enforce: restricted. A developer applies a Deployment whose pod template violates the policy. The Deployment is accepted, because enforce does not look at Deployments. The ReplicaSet controller then creates the pod, and that is rejected — by a controller, not by the user's client. The developer sees a successful apply and no running pods.

The failure is visible, but not where anyone looks first:

kubectl get deploy my-app -o yaml

The rejection message sits in .status.conditions. The lesson is to always set warn alongside enforce, at the same level, so that the person applying the Deployment is told immediately rather than discovering it through an empty pod list.

Measure before you enforce

The temptation is to label a namespace and see what breaks. There is a better way, and it is built in.

Adding or changing an enforce label makes the admission plugin evaluate every existing pod in the namespace and return the violations as warnings. Combine that with a server-side dry run and you get a full impact report without changing anything:

kubectl label --dry-run=server --overwrite ns --all \
  pod-security.kubernetes.io/enforce=baseline

Run it again with restricted to see the gap between where you are and where you want to end up. The output names each namespace and each non-compliant pod, with the specific rule it fails. That report is your work plan, and producing it costs nothing and changes nothing.

Do this before any planning meeting about the migration. In most estates it turns a vague concern into a list of perhaps a dozen manifests, and it usually shows that baseline is nearly free while restricted is a real project.

The rollout order

The sequence that works has three stages, and the discipline is to not skip the middle one.

Stage one: observe. Set warn and audit to your target level, and leave enforce at privileged. Nothing is blocked. Developers see warnings when they apply, and the audit log accumulates violations. Let this run long enough to cover your deployment cycle — a week is usually enough, a full sprint is better.

kubectl label ns team-alpha \
  pod-security.kubernetes.io/enforce=privileged \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

Stage two: enforce the floor. Move enforce to baseline, keeping warn and audit at restricted. Most workloads are unaffected. You have now closed the privilege escalation paths that matter most, while continuing to surface the remaining gap.

Stage three: enforce the target. When the audit log has been quiet for a cycle, move enforce to restricted. By this point there should be no surprises, because you have been watching the same signal for weeks.

One property makes this safer than it sounds: enforcement applies at admission, so existing running pods are not evicted. A pod that would now be rejected keeps running until something recreates it. That is a grace period, not a reprieve — the next rollout, node drain or eviction will fail — but it means a mistake does not take production down instantly.

Pin the version, or your policy changes under you

The definition of each level evolves between Kubernetes releases. A pod that satisfies restricted on 1.34 may not satisfy restricted on 1.37, because the profile has been tightened.

Every mode label accepts a companion version label. Pinning it means a cluster upgrade changes the version of Kubernetes, not the rules your workloads are judged against:

apiVersion: v1
kind: Namespace
metadata:
  name: team-alpha
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: v1.36
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/audit-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/warn-version: latest

The combination above is worth reading closely. Enforcement is pinned, so upgrades cannot break admission. Audit and warn are set to latest, so you keep seeing what current best practice would require. You get stability where it matters and visibility where it is free.

Leaving every label at latest means a control plane upgrade can reject workloads that were compliant the day before, during a maintenance window when you are already busy.

What restricted actually asks for

In practice, bringing a manifest to restricted is a short and repetitive edit:

securityContext:
  runAsNonRoot: true
  seccompProfile:
    type: RuntimeDefault

containers:
- name: app
  securityContext:
    allowPrivilegeEscalation: false
    capabilities:
      drop: ["ALL"]

Two of these deserve comment.

runAsNonRoot: true is an assertion, not an instruction. It does not choose a UID; it tells the kubelet to refuse to start the container if the resolved user is root. If the image has no USER, the pod fails at startup rather than at admission, with a message about running as root. The fix belongs in the image, or in an explicit runAsUser.

drop: ["ALL"] is not always sufficient by itself. A web server binding port 80 needs NET_BIND_SERVICE, which restricted permits you to add back. The correct answer is usually to bind an unprivileged port instead and let the Service map it, which removes the capability requirement entirely.

Infrastructure namespaces are the exception, and they should be explicit

Your CNI plugin, your storage drivers and your node-level agents genuinely need host access. They belong at privileged, and saying so in a label is better than leaving the namespace unlabelled and hoping nobody tightens the default later.

Do not use this as a general escape hatch. A namespace at privileged because one DaemonSet needs it means every other workload in that namespace is unrestricted too. Separate infrastructure from applications by namespace before you separate them by policy.

Cluster-level exemptions exist as well, by username, by runtime class or by namespace, configured in the admission controller configuration rather than in labels. They are the right tool for a controller that legitimately creates privileged pods, and the wrong tool for a workload nobody wants to fix. Exemptions are cluster-wide and invisible from the namespace, which makes them easy to forget and hard to audit.

What Pod Security Admission is not

It evaluates the security context of a pod. That is all it does.

It does not check which registry an image comes from, whether the image has been scanned, whether resource limits are set, whether a required label is present, or anything else about the workload. It cannot express an exception for one Deployment inside an otherwise restricted namespace. It cannot mutate a manifest to make it compliant.

This is a deliberate floor rather than a policy engine. When you need custom rules, per-workload exceptions with an approval trail, or mutation, that is the job of a policy engine such as Kyverno or OPA Gatekeeper, layered on top. The two are complementary: the built-in mechanism is always present and cannot be uninstalled by mistake, which is exactly what you want from a floor.

What this guide does not cover

The exemption configuration file and how to deliver it to the API server, which differs by installation method. Migration from PodSecurityPolicy for clusters coming from before v1.25, which has its own ordering. Policy engines and how to scope them against the built-in levels. And network-level isolation, which is a separate axis entirely — a non-root pod with no capabilities can still reach every other pod in the cluster unless network policy says otherwise.

The one measurement worth taking today is the dry run. It takes a minute, changes nothing, and tells you exactly how far your cluster is from a level you can defend.

We design and operate Kubernetes and OpenShift platforms under cloud-native orchestration, and the identity, access and hardening work that surrounds them under cybersecurity and identity management. If you self-manage a cluster, assume nothing is enforced until you have run the dry run and read the output.

Partager cet article

Twitter LinkedIn

Vous avez un projet similaire ?

Nos experts sont là pour vous accompagner dans vos projets cloud et infrastructure.