Is that pod actually running unprivileged? securityContext fields, Pod Security Standards, and what a manifest review can conclude

DedSec Application Security ·

The pull request adds a log shipper to a Deployment. The application container has securityContext: {runAsNonRoot: true, allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: {drop: [ALL]}}, and the reviewer has seen that block approved a dozen times. The new sidecar has no securityContext at all. The pod spec gains hostPID: true, so the shipper can attribute log lines to processes, and a hostPath volume for /var/log. Each line is small, and nothing in the diff says "privileged".

The pod now shares the node's process ID namespace, one of its containers runs as whatever user its image declares, and that container can read the node's log directory. The application container is still well configured. The pod is not isolated from the node in the way its best-configured container suggests.

That gap, between the settings a reviewer recognises and what the pod is actually allowed to do, is the subject of this post: what each field does in mechanism, what it does not prevent, how the Pod Security Standards group them, how Pod Security Admission applies those standards, and what a review of manifests in a repository can and cannot conclude.

Kubernetes behaviour below is taken from the Kubernetes documentation: the task page "Configure a Security Context for a Pod or Container", the PodSecurityContext and SecurityContext field descriptions in the API reference, the "Pod Security Standards" and "Pod Security Admission" concept pages, the seccomp tutorial, and the volumes page for hostPath and emptyDir. Capability semantics are from the Linux capabilities(7) man page. Where a statement is about container runtime behaviour, our own inference, or general practice rather than something the documentation states, the text says so.

Pod level, container level, and which one wins

securityContext appears in two places. spec.securityContext is a PodSecurityContext and applies to every container in the pod. spec.containers[*].securityContext is a SecurityContext and applies to one container. The task page states the rule: settings specified for a container apply only to that container, and they override settings made at the pod level when there is overlap.

The override is per field, not per block. The API reference repeats it on each overlapping field, for example runAsUser: if set in both SecurityContext and PodSecurityContext, the value in SecurityContext takes precedence for that container. So spec.securityContext: {runAsNonRoot: true} with a container that sets {runAsNonRoot: false} leaves that one container unchecked, and a pod-level runAsUser: 1000 with a container-level runAsUser: 0 runs that container as root.

The two objects do not carry the same fields, which decides where each has to be reviewed:

The container-only list is the one that matters most. There is no pod-wide way to set privileged: false, drop capabilities or disable privilege escalation. Each container, including every initContainer and any ephemeral container added for debugging, has to carry those settings itself, and a sidecar added in a later pull request starts with none of them.

runAsUser and runAsNonRoot are different controls

runAsUser sets the UID. The API reference describes it as the UID to run the entrypoint of the container process, defaulting to the user specified in image metadata if unspecified. runAsGroup does the same for the primary group, and the task page notes that if runAsGroup is omitted the primary group ID of the containers will be root (0). Our understanding is that some runtimes instead take the group from the image's passwd entry for that UID when one exists, so a manifest that sets runAsUser: 1000 and nothing else may run in group 0 or in whatever group the image assigns. Group 0 matters for any file that is group-writable by root; set runAsGroup explicitly rather than rely on either behaviour.

runAsNonRoot checks the UID; it does not set one. The API reference: if true, the kubelet will validate the image at runtime to ensure that it does not run as UID 0 and fail to start the container if it does. If unset or false, no such validation is performed. It changes nothing about which user runs. It decides whether the container is allowed to start.

Three consequences follow, and they are why a manifest with runAsNonRoot: true is not a statement about which user the process runs as:

privileged: true subsumes nearly everything else

The API reference's description of privileged is short: processes in privileged containers are essentially equivalent to root on the host. It defaults to false.

In mechanism, and this is runtime behaviour rather than Kubernetes documentation, a privileged container is started with the full capability set, access to the host's devices, and without the seccomp and AppArmor or SELinux confinement the runtime would otherwise apply. Many of the other fields in this post are restrictions the runtime places on the container. privileged: true tells the runtime to lift them. A privileged container with runAsNonRoot: true and readOnlyRootFilesystem: true is a privileged container. The Kubernetes documentation also notes, for allowPrivilegeEscalation, that it is always true when the container runs as privileged, which is the API saying the same thing about one field.

The review rule is blunt: privileged: true is a finding on its own, and its other settings do not mitigate it. Where a workload genuinely needs node access (some CNI, storage and monitoring agents do), it belongs in a namespace whose admission policy permits it explicitly, with as few other workloads beside it as possible.

allowPrivilegeEscalation and no_new_privs

The task page states that allowPrivilegeEscalation controls whether a process can gain more privileges than its parent process, and that it directly controls whether the no_new_privs flag is set on the container process. no_new_privs is a Linux process attribute: once set, execve will not grant privileges the caller did not already have, so setuid and setgid bits and file capabilities on an executed binary are not honoured.

That is the gap runAsNonRoot leaves. A container that starts as a non-root user, with a setuid-root binary in its image, can run that binary and become root unless no_new_privs is set. Setting allowPrivilegeEscalation: false closes that path for every process in the container.

The task page states that the field is always true when the container is run as privileged or has CAP_SYS_ADMIN. In our understanding the API server rejects an explicit allowPrivilegeEscalation: false alongside privileged: true outright, so that combination never applies; with SYS_ADMIN added, no_new_privs does little to constrain a process that already holds the capability. A manifest with {allowPrivilegeEscalation: false, capabilities: {add: [SYS_ADMIN]}} reads as hardened and is not. The API reference also notes that the field cannot be set when the pod's spec.os.name is windows; it is a Linux control.

Capabilities: the default set is not empty

Linux splits root's privileges into capabilities, and a container's processes are limited to the set the runtime grants. The capabilities field lets a manifest add to or drop from that set. Kubernetes examples, and the Pod Security Standards' lists, write names without the CAP_ prefix: SYS_ADMIN rather than CAP_SYS_ADMIN.

What a manifest that says nothing about capabilities gets is decided by the container runtime, not by Kubernetes, and it is not empty. This is runtime behaviour rather than Kubernetes documentation, so check your runtime's configuration, but containerd's default set, inherited from Docker, includes capabilities such as CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID, NET_BIND_SERVICE, KILL and NET_RAW. CRI-O's default set has been smaller in recent releases and, as we understand it, omits NET_RAW, which is one more reason to read your own runtime's configuration rather than assume. A container running as root with that default set can change ownership of any file it can reach, bypass file permission checks, and open raw sockets.

Two capabilities deserve their own review line:

The pattern to review for is capabilities: {drop: [ALL], add: [NET_BIND_SERVICE]}: drop the runtime default entirely, then add back each capability by name with a reason. Without drop: [ALL], an add list is additions to a default set the manifest never states. A subtlety, which is our understanding of Linux capability rules rather than Kubernetes documentation and worth testing on your runtime: for a container running as a non-root UID, a capability in the add list is in the bounding set but is not automatically in the process's effective set, so a non-root process may not be able to use it unless the binary carries a file capability. Test the workload, do not assume the add list worked.

readOnlyRootFilesystem and the writable paths you add back

The API reference: whether this container has a read-only root filesystem, defaulting to false. With it set, the container's image layers cannot be written to, so a process cannot drop a binary into /usr/local/bin, modify an installed package, or edit the application's own code on disk.

It does not make the container read-only. Every volume mounted into the container is writable unless the volumeMount sets readOnly: true. Applications that need scratch space get an emptyDir mounted at /tmp or similar, and that path is writable for whatever runs in the container, including an attacker's payload. The volumes page notes that an emptyDir with medium: Memory is a tmpfs whose files count against the container's memory limit, and that sizeLimit caps an emptyDir's size; both are worth setting, because an unbounded writable volume is also a way to exhaust the node's disk or the pod's memory.

The trade-off is a narrowing, not an elimination. A read-only root with one emptyDir at /tmp leaves one writable directory, which is easier to watch and reason about than an entirely writable filesystem. As far as we know the pod spec has no field to mount an emptyDir with noexec, so do not assume the writable path cannot hold an executable; whether that is enforced is a runtime or node configuration question.

seccompProfile: RuntimeDefault, Unconfined, and what unset means

seccomp filters the system calls a process can make. seccompProfile.type takes RuntimeDefault (the container runtime's default profile), Localhost (a profile file on the node, named by localhostProfile) or Unconfined (no filtering). It can be set at pod level and overridden per container.

What happens when the field is unset depends on the kubelet. The seccomp tutorial describes the kubelet's seccomp-default option: when enabled, the kubelet uses the RuntimeDefault profile by default instead of the Unconfined (seccomp disabled) mode. With it not enabled, an unset seccompProfile means Unconfined. Whether the option is on is node configuration, not anything in the pod spec, so check your cluster rather than reading the answer from a manifest, and on managed platforms check how the provider configures the kubelet.

That makes an explicit seccompProfile: {type: RuntimeDefault} worth requiring in manifests even on clusters where it is the default, because the manifest then means the same thing on every cluster it is applied to. The restricted Pod Security Standard requires exactly that: the profile must be explicitly set to RuntimeDefault or Localhost. An explicit Unconfined is a finding; the baseline standard forbids it.

The fields outside securityContext that break isolation

A review that only reads securityContext blocks misses the fields with the largest effect, because they sit elsewhere in the pod spec. Each of these is restricted by the baseline Pod Security Standard.

Two more pod-level fields belong on the same list for a different reason. A pod's service account token is mounted by default unless automountServiceAccountToken is false, and what that token can do is decided by RBAC; the RBAC post covers grants such as create on pods that make a pod spec itself a privilege. And sysctls set at pod level reach into the kernel; the baseline standard allows only a listed set of namespaced sysctls.

Pod Security Standards: three levels, as data

The Pod Security Standards page defines three cumulative levels. Privileged is unrestricted. Baseline is described as minimally restrictive and preventing known privilege escalations. Restricted follows current pod hardening practice, at the cost of some compatibility. In summary of the page's tables:

The detail to read in the restricted table is where each control may be set. For runAsNonRoot, the container fields may be left undefined if the pod-level field is true. For allowPrivilegeEscalation and capabilities there is no pod-level field, so each container has to set them. The levels are a useful vocabulary for review even on a cluster that does not enforce them: "this chart meets baseline but not restricted, because the sidecar does not drop ALL" is a precise finding.

Pod Security Admission: what it checks and when

Pod Security Admission is the built-in admission controller that applies those standards. It is configured per namespace, with labels of the form pod-security.kubernetes.io/MODE: LEVEL, where MODE is enforce, audit or warn and LEVEL is privileged, baseline or restricted. The concept page defines the modes: enforce rejects a violating pod, audit allows it and records an annotation in the audit log, and warn allows it and returns a warning to the user. A namespace can set all three at different levels, for example enforce baseline and warn restricted.

It evaluates pods, and that changes where the error appears. The concept page explains that because pods are often created indirectly, by controllers from workload resources, audit and warn are applied to workload resources such as Deployments, but enforce is applied only to the resulting pod objects. In practice: kubectl apply of a Deployment whose template violates the enforced level succeeds, with a warning if warn is set. The Deployment controller creates a ReplicaSet, and the ReplicaSet controller's attempts to create pods are rejected. The error is in the ReplicaSet's events and status, the Deployment shows zero available replicas, and the pipeline that applied it may report success. Inspect the ReplicaSet when a rollout stalls on a namespace with enforce set.

Labels are permissions. Whoever can update a namespace can change its pod-security labels, including lowering enforce to privileged. The namespace label is the policy, so RBAC on namespaces is part of pod security.

Versions move unless pinned. Each mode has a companion label, pod-security.kubernetes.io/MODE-version, taking a Kubernetes minor version such as v1.30 or the value latest. latest evaluates against the standard as defined in the cluster's current version, so a cluster upgrade can change what a namespace enforces. Pinning holds the definition still, at the cost of not picking up new checks.

Exemptions live in cluster configuration, not in the namespace. The admission controller's configuration can exempt usernames, runtime classes and namespaces, and sets the defaults for namespaces with no labels. The concept page notes that exempting a user only exempts pods they create directly, not workload resources, since the pods are created by controllers. None of this is visible in the application's manifests.

Existing pods are not evicted. When an enforce label is added or tightened, the documentation says the controller checks existing pods and returns violations as warnings. The label does not evict them; our reading is that enforcement first applies when they are recreated, for example on the next rollout or node drain.

What a manifest review can conclude, and what it cannot

Render first. securityContext in a Helm chart can be values-driven, sometimes through a helper that merges defaults with per-workload overrides, and kustomize overlays can patch it per environment. values.yaml shows inputs, not results. Run helm template with each environment's values files, or kustomize build per overlay, and review that output. The Terraform post covers the same gap between configuration in a repository and configuration applied, for a different tool.

A review of rendered manifests can establish, for each pod template:

It cannot establish:

The honest output of a static review is "as written, with these assumptions", and the assumptions belong in the finding. The post on why "the scan passed" is not "the scan ran" covers the scanner-side version: a check that cannot see the image's user should say so rather than pass.

Where AppSecD fits

AppSecD's infrastructure and containers coverage includes Terraform, CloudFormation, Helm, Ansible and Pulumi rules; Dockerfile hardening with image CVE scanning; and Kubernetes RBAC, network policy and pod security context analysis. Results land in the same deduplicated finding model as the platform's other scanning, and policy-driven gating can block the pull request or commit that introduces a violation, while the author still has the context. The review method in this post (rendering first, resolving pod and container precedence per field, reading host fields outside securityContext, and separating what the manifest states from what the image, runtime and admission chain decide) is general practice, not a description of how AppSecD's pod security context checks work. Nothing here claims that AppSecD renders charts with your values, inspects image configuration for the runtime user, or observes what admission or the runtime applied.

A checklist for the next pod spec change

  1. Render it for every environment's values or overlays, and review the output.
  2. Resolve each container's effective settings field by field. A container value overrides the pod value for that field only.
  3. Check every container, including initContainers, sidecars added later and ephemeral containers, for the container-only fields.
  4. Reject privileged: true and SYS_ADMIN unless the workload is a node agent in a namespace that permits it explicitly.
  5. Require allowPrivilegeEscalation: false and remember it gives no meaningful protection alongside privileged or SYS_ADMIN.
  6. Require capabilities drop: [ALL], and a reason for every add, with NET_RAW justified individually.
  7. Set runAsNonRoot: true with a numeric runAsUser, or a numeric USER in an image pinned by digest, and set runAsGroup too.
  8. Set readOnlyRootFilesystem: true, with each writable path an emptyDir that has a sizeLimit.
  9. Set seccompProfile: {type: RuntimeDefault} explicitly, at pod level or on every container.
  10. Read the fields outside securityContext: hostNetwork, hostPID, hostIPC, hostPath, hostPort, sysctls and automountServiceAccountToken.
  11. Check the namespace's pod-security labels, pinned versions, and who can change them.
  12. Confirm on a running pod that what the cluster stored matches what you reviewed.

A securityContext block is a set of requests to the kubelet and the container runtime, per container, with defaults supplied by things the manifest does not mention. Whether a pod is running unprivileged depends on every container in it, the fields beside the block, the image it starts from, and what the admission chain did on the way in.

More about AppSecD · All posts