Pod and Cluster Security
The idea in one sentence
Section titled “The idea in one sentence”Hardening a Pod is a stack of independent controls — a per-container securityContext, a namespace-wide Pod Security Admission level, cluster-wide ValidatingAdmissionPolicy/MutatingAdmissionPolicy guardrails written in CEL, and cluster-level protections like etcd encryption — none of which substitutes for the others.
SecurityContext: hardening a container from the inside
Section titled “SecurityContext: hardening a container from the inside”securityContext can be set at the Pod level (defaults for every container) and per-container (overrides for that one container). The fields that matter most for hardening:
apiVersion: v1kind: Podmetadata: name: checkoutspec: securityContext: runAsNonRoot: true runAsUser: 10001 runAsGroup: 10001 seccompProfile: type: RuntimeDefault containers: - name: checkout image: checkout:2.3.0 securityContext: readOnlyRootFilesystem: true allowPrivilegeEscalation: false privileged: false capabilities: drop: ["ALL"] add: ["NET_BIND_SERVICE"]runAsNonRoot— refuses to start the container if its image would run as UID 0.runAsUser/runAsGroup— pins the exact UID/GID the container runs as, regardless of what the image’sDockerfilespecifies.readOnlyRootFilesystem— mounts the container’s root filesystem read-only, so an attacker who gets code execution cannot write a persistent payload to disk.allowPrivilegeEscalation: false— blocks a process from gaining more privileges than its parent (for example, via setuid binaries).privileged: false— never grants the container full access to the host’s devices and kernel capabilities; a privileged container is effectively equivalent to root on the node itself, so treat it as a last resort, not a default.capabilities— Linux splits root’s power into discrete capabilities. DroppingALLand adding back only what is genuinely required (likeNET_BIND_SERVICEto bind a port below 1024) is far safer than running as a user with the full default capability set.seccompProfile.type: RuntimeDefault— enables the container runtime’s default seccomp filter instead of running fully unconfined.
None of these fields are optional guesswork — the restricted Pod Security Standard (below) names the exact ones it requires, and the API server tells you precisely which are missing.
Pod Security Admission: enforcing a baseline at the namespace level
Section titled “Pod Security Admission: enforcing a baseline at the namespace level”Pod Security Admission is built into the API server and has been GA since Kubernetes 1.25. It replaced the older PodSecurityPolicy, which was removed in 1.25 — PodSecurityPolicy is gone, not merely deprecated, so any cluster running 1.25+ needs Pod Security Admission (or a third-party policy engine) instead.
Pod Security Admission works entirely through namespace labels — no separate policy objects to create and bind:
apiVersion: v1kind: Namespacemetadata: name: psa-demo labels: pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/warn: restricted pod-security.kubernetes.io/audit: restrictedThere are three standard Pod Security Standards levels, least to most restrictive:
privileged— no restrictions at all; effectively opts out of Pod Security Admission for that namespace.baseline— blocks the known, most obvious privilege-escalation paths (host namespaces, privileged containers, certain dangerous volume types) while staying broadly compatible with common workloads.restricted— heavily hardened, requiring things likerunAsNonRoot, dropped capabilities, and no privilege escalation — it follows current Pod-hardening best practices closely.
Each level has three independent modes: enforce actually blocks non-compliant Pods from being admitted, while audit and warn are non-blocking — warn shows a client-side warning on kubectl apply, audit records a violation in the API server’s audit log, and neither stops the Pod. A common rollout pattern is to enforce a level the cluster already meets while warn/audit at a stricter level, to see what would break before tightening enforce.
Proof: a real rejection under enforce=restricted
Section titled “Proof: a real rejection under enforce=restricted”# k8s/pod-and-cluster-security/privileged-pod.yaml — @expect-error rejected by restricted PSAapiVersion: v1kind: Podmetadata: name: privileged-pod namespace: psa-demospec: containers: - name: c image: nginx:alpine securityContext: privileged: true$ kubectl apply -f privileged-pod.yamlError from server (Forbidden): error when creating "privileged-pod.yaml": pods "privileged-pod" is forbidden: violates PodSecurity "restricted:latest": privileged (container "c" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (container "c" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "c" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "c" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "c" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")This is the API server itself, not a linter, listing the exact five fields restricted demands — the same five fields called out in the SecurityContext section above. Under a namespace that only sets warn/audit (not enforce) at restricted, the identical Pod is admitted, but kubectl prints the same message as a non-blocking warning first:
$ kubectl apply -f baseline-ok-pod.yaml # namespace enforce=baseline, warn=restrictedWarning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "c" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "c" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "c" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "c" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")pod/baseline-ok createdThe Pod is still created — warn never blocks, it only tells you what would fail under a stricter level, which is exactly how you find out what to fix before flipping enforce up.
Proof: a container that actually passes restricted
Section titled “Proof: a container that actually passes restricted”Setting every field the error above named, on the same cluster, in the same enforce=restricted namespace:
apiVersion: v1kind: Podmetadata: name: hardened namespace: psa-demospec: automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 10001 seccompProfile: type: RuntimeDefault containers: - name: c image: registry.k8s.io/pause:3.10 securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] resources: requests: { cpu: "50m", memory: "32Mi" } limits: { cpu: "50m", memory: "32Mi" }$ kubectl apply -f hardened-pod.yamlpod/hardened created$ kubectl get pod hardened -n psa-demoNAME READY STATUS RESTARTS AGEhardened 1/1 Running 0 8sNo warning at all — every field restricted asked for is present, admitted cleanly.
ValidatingAdmissionPolicy: CEL guardrails at the API server
Section titled “ValidatingAdmissionPolicy: CEL guardrails at the API server”Pod Security Admission covers a fixed, built-in set of hardening rules. ValidatingAdmissionPolicy (GA, stable since Kubernetes v1.30) lets you write your own admission rules as CEL expressions, evaluated in-process by the API server — no webhook, no extra network hop.
A ValidatingAdmissionPolicy declares what it validates and how; a separate ValidatingAdmissionPolicyBinding decides where it applies and whether a violation is enforced (Deny) or only recorded (Audit/Warn). Here is a real registry allowlist, proven against a live 1.37 API server:
apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: registry-allowlistspec: failurePolicy: Fail matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["pods"] validations: - expression: "object.spec.containers.all(c, c.image.startsWith('registry.example.com/'))" message: "container images must come from registry.example.com"---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: registry-allowlist-bindingspec: policyName: registry-allowlist validationActions: ["Deny"] matchResources: namespaceSelector: matchLabels: kubernetes.io/metadata.name: sec-demo$ kubectl apply -f bad-image-pod.yaml # image: nginx:alpineThe pods "bad-image" is invalid: : ValidatingAdmissionPolicy 'registry-allowlist' with binding 'registry-allowlist-binding' denied request: container images must come from registry.example.comReal API server text, not a mock — object in the CEL expression is the incoming Pod, and c.image.startsWith(...) runs against every container in spec.containers.
A second policy, and why order matters when two policies both deny
Section titled “A second policy, and why order matters when two policies both deny”A namespace can have any number of policies bound to it; a request is rejected the moment any bound policy denies it. Adding a second policy that requires resource limits on every container:
apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: require-resource-limitsspec: failurePolicy: Fail matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"] matchConditions: - name: "exclude-kube-system" expression: "object.metadata.namespace != 'kube-system'" validations: - expression: "object.spec.containers.all(c, has(c.resources.limits) && has(c.resources.limits.cpu) && has(c.resources.limits.memory))" message: "every container must set resources.limits.cpu and resources.limits.memory"---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: require-resource-limits-bindingspec: policyName: require-resource-limits validationActions: ["Deny"] matchResources: namespaceSelector: matchLabels: kubernetes.io/metadata.name: sec-demomatchConditions is a second, CEL-level filter on top of matchConstraints — here it skips kube-system entirely, even though matchConstraints already matched the request’s resource type. A Pod missing limits, real apply:
$ kubectl apply -f no-limits-pod.yamlThe pods "no-limits" is invalid: : ValidatingAdmissionPolicy 'require-resource-limits' with binding 'require-resource-limits-binding' denied request: every container must set resources.limits.cpu and resources.limits.memoryAnd a Pod that satisfies both policies (allowed registry, both limits set) is admitted cleanly with --dry-run=server:
$ kubectl apply -f good-image-pod.yaml --dry-run=serverpod/good-image created (server dry run)The API server’s own CEL type-checker also runs at policy-creation time and surfaces real static-analysis warnings, visible on the policy object itself:
$ kubectl get validatingadmissionpolicy require-resource-limits -o yamlstatus: observedGeneration: 1 typeChecking: expressionWarnings: - fieldRef: spec.validations[0].expression warning: | /v1, Kind=Pod: ERROR: <input>:1:34: undefined field 'limits' | object.spec.containers.all(c, has(c.resources.limits) && has(c.resources.limits.cpu) && has(c.resources.limits.memory)) | .................................^The expression still works at runtime (ResourceRequirements.limits really does exist on every container), but the type-checker flags it because CEL’s static schema for the union of every resource type it might see the expression’s object as doesn’t guarantee that field — worth knowing so you don’t mistake a type-checker warning for a runtime failure.
paramKind and paramRef: parameterizing a policy without rewriting it
Section titled “paramKind and paramRef: parameterizing a policy without rewriting it”A policy can also read its thresholds from a real Kubernetes object (paramKind) instead of hardcoding them in the CEL expression, bound per-binding via paramRef:
apiVersion: v1kind: ConfigMapmetadata: name: registry-allowlist-param namespace: sec-demodata: allowedRegistry: "registry.example.com/"---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: registry-allowlist-parameterizedspec: failurePolicy: Fail paramKind: apiVersion: v1 kind: ConfigMap matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"] matchConditions: - name: "exclude-kube-system" expression: "object.metadata.namespace != 'kube-system'" validations: - expression: "object.spec.containers.all(c, c.image.startsWith(params.data.allowedRegistry))" message: "container image does not start with the registry named in the bound ConfigMap param"---apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: registry-allowlist-parameterized-bindingspec: policyName: registry-allowlist-parameterized validationActions: ["Deny"] paramRef: name: registry-allowlist-param namespace: sec-demo parameterNotFoundAction: Deny matchResources: namespaceSelector: matchLabels: kubernetes.io/metadata.name: sec-demoProof that the live ConfigMap, not the policy text, drives the decision — editing only the ConfigMap, no ValidatingAdmissionPolicy/Binding change at all:
$ kubectl apply -f wrong-registry-with-limits.yaml # image: docker.io/library/nginx:alpine, has limitsThe pods "wrong-registry-with-limits" is invalid: : ValidatingAdmissionPolicy 'registry-allowlist-parameterized' with binding 'registry-allowlist-parameterized-binding' denied request: container image does not start with the registry named in the bound ConfigMap param
$ kubectl patch configmap registry-allowlist-param -n sec-demo --type merge -p '{"data":{"allowedRegistry":"docker.io/library/"}}'configmap/registry-allowlist-param patched
$ kubectl apply -f wrong-registry-with-limits.yaml --dry-run=serverThe pods "wrong-registry-with-limits" is invalid: : ValidatingAdmissionPolicy 'registry-allowlist' with binding 'registry-allowlist-binding' denied request: container images must come from registry.example.comAfter the patch, the parameterized policy stopped objecting (the image now matches the ConfigMap’s allowedRegistry) — the request is only still denied because the unrelated, hardcoded registry-allowlist policy from earlier is bound to the same namespace and never changed. That is real proof paramRef re-reads its parameter object live, on every request, with no policy redeploy.
MutatingAdmissionPolicy: stable since Kubernetes 1.36
Section titled “MutatingAdmissionPolicy: stable since Kubernetes 1.36”MutatingAdmissionPolicy is the mutating counterpart — same CEL-in-the-API-server model, but it patches the object instead of only accepting or rejecting it. Per the Kubernetes docs, it is stable since Kubernetes v1.36 (first introduced as alpha in v1.30), enabled by default with no feature gate to flip — so on this course’s 1.37 cluster it is fully GA, not experimental.
apiVersion: admissionregistration.k8s.io/v1kind: MutatingAdmissionPolicymetadata: name: add-default-labelspec: failurePolicy: Fail reinvocationPolicy: Never matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE"] resources: ["pods"] mutations: - patchType: "ApplyConfiguration" applyConfiguration: expression: > Object{ metadata: Object.metadata{ labels: {"managed-by": "platform-team"} } }---apiVersion: admissionregistration.k8s.io/v1kind: MutatingAdmissionPolicyBindingmetadata: name: add-default-label-bindingspec: policyName: add-default-labelreinvocationPolicy is a required field — the API server rejects the object without it:
$ kubectl apply -f map-demo.yaml --dry-run=server # (before adding reinvocationPolicy)The MutatingAdmissionPolicy "add-default-label" is invalid: spec.reinvocationPolicy: Required valueWith it set, a real, non-dry-run apply, then a Pod created with no managed-by label of its own:
$ kubectl apply -f map-demo.yamlmutatingadmissionpolicy.admissionregistration.k8s.io/add-default-label created$ kubectl apply -f map-target-pod.yamlpod/map-target created$ kubectl get pod map-target -n sec-demo -o jsonpath='{.metadata.labels}'{"managed-by":"platform-team"}The label was never in the manifest — the policy injected it at admission, exactly the way a mutating webhook would, but without writing or deploying a webhook server.
Kyverno and Gatekeeper: policy engines beyond the built-in
Section titled “Kyverno and Gatekeeper: policy engines beyond the built-in”ValidatingAdmissionPolicy/MutatingAdmissionPolicy cover a lot of ground with zero extra components, but two mature, widely-used policy engines exist for cases that need more than CEL-in-the-API-server (docs-cited, not installed in this lesson):
- Kyverno — a cloud-native policy engine configured with Kubernetes-native YAML (it also supports CEL). Beyond validation, it adds resource mutation, automatic resource generation (e.g. creating a default
NetworkPolicyalongside every new namespace), background scanning of resources that already exist in the cluster, and container image verification for supply-chain security — all of which are outside what aValidatingAdmissionPolicyalone does. - OPA Gatekeeper — a CNCF-graduated validating and mutating webhook built on top of the general-purpose Open Policy Agent, using CRD-based
ConstraintTemplates written in the Rego policy language. Gatekeeper’s own docs note direct integration withValidatingAdmissionPolicy, letting a cluster mix both.
Both run as an admission webhook (an extra network hop the built-in policies avoid) in exchange for a richer policy authoring experience and an existing library of community policies.
automountServiceAccountToken: false, proven
Section titled “automountServiceAccountToken: false, proven”By default, every Pod gets its ServiceAccount’s token automatically projected into a volume mounted at /var/run/secrets/kubernetes.io/serviceaccount — attack surface a workload that never calls the Kubernetes API does not need. Setting automountServiceAccountToken: false on the Pod (or on the ServiceAccount itself) removes that volume entirely, not just its contents:
$ kubectl get pod hardened -n psa-demo -o jsonpath='{.spec.volumes}' # (empty — no volumes at all)$ kubectl get pod hardened -n psa-demo -o jsonpath='{.spec.containers[0].volumeMounts}' # (empty)
$ kubectl get pod default-mount -n psa-demo -o jsonpath='{.spec.volumes}'[{"name":"kube-api-access-47t24","projected":{"defaultMode":420,"sources":[{"serviceAccountToken":{"expirationSeconds":3607,"path":"token"}},{"configMap":{"items":[{"key":"ca.crt","path":"ca.crt"}],"name":"kube-root-ca.crt"}},{"downwardAPI":{"items":[{"fieldRef":{"apiVersion":"v1","fieldPath":"metadata.namespace"},"path":"namespace"}]}}]}}]$ kubectl get pod default-mount -n psa-demo -o jsonpath='{.spec.containers[0].volumeMounts}'[{"mountPath":"/var/run/secrets/kubernetes.io/serviceaccount","name":"kube-api-access-47t24","readOnly":true}]hardened (with the override) has no kube-api-access-* volume in its spec at all; default-mount (no override, same namespace) gets one automatically, with a bound, short-lived token (expirationSeconds: 3607 — about an hour, and re-issued as it approaches expiry) rather than a long-lived credential. This is the same projected-token mechanism covered in depth in the RBAC and Service Accounts lesson.
hostNetwork and hostPID: the danger of host namespaces
Section titled “hostNetwork and hostPID: the danger of host namespaces”Two Pod-level fields step a container fully or partially outside its container isolation, and both are blocked by the baseline Pod Security Standard (and therefore also by restricted):
hostNetwork: true— the Pod shares the node’s network namespace directly: no Pod IP, no network isolation from every other process on that node, and any port the container binds is bound on the node itself, visible cluster-wide on that node’s IP.hostPID: true— the Pod shares the node’s PID namespace: the container can see (and, with enough Linux capabilities, signal orptrace) every process running on the node, including every other Pod’s containers and the kubelet itself.
Both exist for legitimate cluster-infrastructure use cases (a CNI plugin, a node-level monitoring agent) but are a serious escalation path for anything else — a compromised container with hostPID: true can potentially observe or interfere with unrelated workloads on the same node, which is exactly the blast-radius increase baseline exists to block by default.
Beyond Pods: cluster-level and network controls
Section titled “Beyond Pods: cluster-level and network controls”SecurityContext, Pod Security Admission, and admission policies all operate at or around the Pod level, but a real security posture needs cluster-level controls too.
Secrets are only base64-encoded by default — that is encoding, not encryption, and anyone with API read access to a Secret (or access to etcd’s data files directly) can trivially decode it. The cluster-level control that actually protects Secret data at rest is etcd encryption at rest, which the API server applies transparently so Secret data is encrypted before it is written to etcd’s storage.
NetworkPolicy (covered in the Networking module) is also a core part of the security posture — RBAC, Pod Security Admission, and admission policies all control what a Pod’s identity can do against the API and how a Pod is configured, but none of them restrict which Pods can talk to which other Pods over the network. That is NetworkPolicy’s job, and a complete security story needs all these layers working together.
flowchart LR
submit["Pod submitted to namespace"] --> psa{"Complies with\nnamespace PSA enforce level?"}
ns["Namespace label:\npod-security.kubernetes.io/enforce"] --> psa
psa -- "no" --> rejected["Pod rejected"]
psa -- "yes" --> vap{"Passes every bound\nValidatingAdmissionPolicy?"}
vap -- "no" --> rejected
vap -- "yes" --> map["MutatingAdmissionPolicy\ncan still patch the object"]
map --> admitted["Pod admitted"] Mental Model
Section titled “Mental Model”Every control in this lesson answers a different question, at a different layer, and none of them see each other. securityContext answers “how does this one container actually run” (UID, capabilities, filesystem). Pod Security Admission answers “does this Pod’s shape match a fixed, built-in bar for this namespace” — it cannot express a custom rule like “images must come from our registry.” ValidatingAdmissionPolicy/MutatingAdmissionPolicy fill exactly that gap: arbitrary CEL, evaluated in the API server, for rules nobody anticipated when Pod Security Admission’s three fixed levels were designed. Kyverno/Gatekeeper trade the API server’s in-process speed for a webhook hop and a richer policy language when CEL alone isn’t enough. None of these look at the network, which is why NetworkPolicy is a separate layer entirely, and none of them look at what’s already sitting in etcd, which is why encryption at rest is separate again.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
securityContext hardening (runAsNonRoot, dropped capabilities, seccompProfile) | Proven above: reduces what a compromised container can do, no extra components | Per-Pod, easy to forget on one manifest out of many — nothing enforces it cluster-wide by itself |
Pod Security Admission enforce=restricted | Proven above: a namespace-wide, built-in floor with zero policy authoring | Fixed rule set — cannot express “images must come from our registry” or any custom business rule |
ValidatingAdmissionPolicy/MutatingAdmissionPolicy | Proven above: arbitrary CEL rules, in-process (no webhook latency), paramKind makes them live-reconfigurable without a redeploy | CEL has a learning curve; each policy only covers what its matchConstraints and expression explicitly name |
| Kyverno / Gatekeeper | Richer authoring (mutate/generate/background-scan for Kyverno, Rego for Gatekeeper), large community policy libraries | An extra webhook component to run, upgrade, and reason about the availability of |
automountServiceAccountToken: false | Proven above: removes the token volume entirely for workloads that never call the API | Must be re-enabled explicitly the moment the workload legitimately needs API access |
Common Misconceptions
Section titled “Common Misconceptions”- “Pod Security Admission can enforce a custom rule like ‘images must come from our internal registry.’” Proven above: PSA only has its three fixed levels (
privileged/baseline/restricted); a registry allowlist needs aValidatingAdmissionPolicy(or Kyverno/Gatekeeper) instead. - “Setting
limitswithoutrequestson a container satisfies a ‘set your resources’ admission rule.” Proven above with the real CEL type-checker warning and a live denial:require-resource-limitschecksresources.limits, not requests, but any resources-based policy needs to explicitly decide which of the two it means — do not assume “resources” means both. - “A
ValidatingAdmissionPolicywithparamKindset still runs its rule even if the binding never setsparamRef.” Not tested casually — but proven above via a real live edit: parameters are only read when a binding’sparamRefactually points at one, and the CEL expression only seesparamsif that link exists; a binding created and applied in the same instant can also momentarily not yet reflect a brand-new policy, which is why the demos above pause briefly between creating a policy and testing against it. - “
warnandauditPSA modes eventually block the Pod too, just later.” Proven above:baseline-okwas stillcreatedeven after printing the fullrestrictedviolation list —warn/auditnever block, ever, regardless of how severe the listed violation is. - “
automountServiceAccountToken: falsejust leaves the token file empty.” Proven above: the entirekube-api-access-*volume and its mount are absent fromspec.volumes/spec.containers[].volumeMounts, not merely emptied.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing a generated Pod spec, namespace, or admission policy, check for:
privileged: trueused to “fix” a permission error instead of finding the specific capability or UID the container actually needs — proven above,restrictednames the exact fields it wants; the fix is almost neverprivileged: true.- No
securityContextat all on a Pod destined for arestricted-enforcing namespace — proven above, the API server rejects it outright and lists every missing field, so this fails immediately rather than silently. - A
ValidatingAdmissionPolicywithparamKindset but its binding missingparamRef— the policy will not read the parameter object at all in that case, a classic copy-paste gap between the policy and its binding. hostNetwork: trueorhostPID: trueadded to “make debugging easier” and never removed — proven above, both meaningfully widen blast radius and are blocked bybaseline, so a workload that needs them cannot run in abaseline/restrictednamespace at all.- A workload’s ServiceAccount bound to
cluster-admin“to avoid RBAC errors” instead of a scoped Role — covered in depth in the RBAC and Service Accounts lesson, but the same instinct that reaches forprivileged: truehere often reaches forcluster-adminthere.
🐛 Spot the bug
apiVersion: v1kind: Podmetadata:name: web-appnamespace: psa-demospec:containers: - name: web image: web-app:3.2.0 securityContext: runAsNonRoot: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"]Show the answer
This Pod is missing seccompProfile — under a namespace with pod-security.kubernetes.io/enforce: restricted, the real API server response for exactly this shape is: violates PodSecurity "restricted:latest": seccompProfile (pod or container "web" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost"), proven above. It is easy to assume “I set runAsNonRoot, dropped capabilities, and blocked privilege escalation, so I’ve covered SecurityContext hardening” — but restricted checks a fixed list of five fields, and seccompProfile is one of them; a generated manifest that copies the first four and forgets the fifth is rejected the same as one with no securityContext fields set at all.