L51 · Kubernetes security, part 2
Module 7: Cloud, Containers, and Kubernetes · ~20 min · Lesson 51 of 71
🎯 What you'll learn
- How to lay out NetworkPolicies for a whole namespace, what they can't do, and what enforces them on EKS
- The three Pod Security Standards, and how Pod Security Admission labels enforce them
- How admission control works: webhooks (Kyverno, Gatekeeper) and the built-in ValidatingAdmissionPolicy
- What runtime security sees that every other layer misses (Falco, GuardDuty Runtime Monitoring)
🔥 Why this matters
An attacker finds a deserialization bug (L36) in quotes-api and gets a shell inside its pod. L50
limited what the pod's ServiceAccount and IAM role can do through APIs. Now the questions change.
Is the process root, and can it reach the node? Can it connect to another team's MongoDB? Will
anyone notice the shell? Each answer depends on a layer you configure today.
1. Four layers, four questions
L50 secured the API server. This lesson secures the workloads behind it.
CI / kubectl apply
│ 1. "May this object exist?"
▼
┌────────────────────────┐ Pod Security Admission, Kyverno,
│ Admission control │ Gatekeeper, ValidatingAdmission-
└───────────┬────────────┘ Policy
│ 2. stored, scheduled, container starts
▼
┌────────────────────────┐ "What may it do to its node?"
│ Pod securityContext │ (Pod Security Standards)
└───────────┬────────────┘
│ 3. it opens connections
▼
┌────────────────────────┐ "Who may it talk to?"
│ NetworkPolicy (CNI) │
└───────────┬────────────┘
│ 4. it runs for weeks
▼
┌────────────────────────┐ "Is it doing something odd now?"
│ Runtime detection │ Falco, GuardDuty
└────────────────────────┘
The first three prevent; the last one detects what got past them.
2. NetworkPolicy, namespace by namespace
L12 introduced NetworkPolicy, pod-level firewall rules, and its traps: pods are open until a policy selects them, and nothing happens unless the network plugin (the CNI) enforces it.
The pattern that scales is the same in every namespace: deny everything, then allow each flow by name.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny-all, namespace: payments}
spec:
podSelector: {} # every pod here
policyTypes: ["Ingress", "Egress"] # no rules = none allowed
Then one small policy per arrow:
kong ns ──8080──▶ quotes-api ──27017──▶ 10.20.8.0/24 (Mongo)
│
├──53 UDP/TCP──▶ kube-system/kube-dns
└──443───────▶ VPC endpoints (STS, S3)
monitoring ns ──8081──▶ quotes-api (metrics only, L38)
anything else ──X──▶ dropped
Who enforces it on EKS? The Amazon VPC CNI does, once enableNetworkPolicy is set in the
add-on's configuration (L12 lists the version and node requirements). Calico or Cilium can enforce
instead. Without one of these, kubectl get networkpolicy shows your rules and packets ignore them.
Standard NetworkPolicy has limits: no deny rules or cluster-wide policy (a namespace owner can
always add an allow), no DNS names (api.partner.example must be a CIDR), no HTTP paths, and no
identity or encryption. Cilium (CiliumNetworkPolicy: HTTP rules, toFQDNs, deny) and Calico
(GlobalNetworkPolicy with ordered deny rules) fill the first three. An upstream cluster-wide admin
policy API is still alpha and changing. For identity, a service mesh such as Istio or Linkerd runs
mTLS between pods (L10's deep dive): NetworkPolicy says "this IP may reach that port", mTLS says
"this caller is provably quotes-api".
3. Pod Security Standards
Here is a pod spec that looks like a harmless debugging aid:
securityContext: {privileged: true}
volumes:
- name: host
hostPath: {path: /}
That's root on the node, and every other pod's secrets on it (deep dive 1). Kubernetes defines three Pod Security Standards, which are named levels of what a pod may ask for:
| Level | Meant for | Blocks |
|---|---|---|
| Privileged | Trusted system agents (CNI, log shippers) | Nothing |
| Baseline | Most apps, minimal changes | privileged, hostNetwork/hostPID/hostIPC, hostPath volumes, host ports, extra capabilities beyond a default list |
| Restricted | Hardened apps (your APIs) | Everything Baseline blocks, plus the rules below |
Restricted additionally requires:
runAsNonRoot: true(andrunAsUsernot 0)allowPrivilegeEscalation: falsecapabilities.drop: ["ALL"](onlyNET_BIND_SERVICEmay be added back)seccompProfile.type:RuntimeDefaultorLocalhost- volumes only of safe types:
configMap,secret,emptyDir,projected,persistentVolumeClaim,downwardAPI,csi,ephemeral
Restricted doesn't require readOnlyRootFilesystem; set it anyway where the app allows.
4. Pod Security Admission: labels on the namespace
Pod Security Admission (PSA) is the built-in admission controller that enforces these levels. It replaced PodSecurityPolicy, which was removed in Kubernetes 1.25. You turn it on per namespace with labels:
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.33
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
| Mode | On violation |
|---|---|
enforce |
The pod is rejected |
warn |
The client sees a warning; the object is accepted |
audit |
An annotation is added to the API server audit log (L52) |
One trap catches nearly everyone: enforce checks Pods, not Deployments. warn and audit
also check workload templates. So a non-compliant Deployment is created with a warning, and then its
ReplicaSet fails to create any pods (the worked example).
On EKS, PSA is on, but a namespace with no labels runs at privileged.
5. Admission control beyond PSA
Rules like "images only from our ECR registry" need a policy engine. All of them plug into the same pipeline:
request ─▶ authn ─▶ authz (RBAC, L50)
│
▼
mutating admission (may change it: add defaults, sidecars)
│
▼
schema validation
│
▼
validating admission (may only say yes/no: PSA, VAP,
│ webhooks)
▼
persisted to etcd ─▶ scheduler ─▶ kubelet
You have three common choices:
- Validating (or mutating) admission webhooks: the API server sends the object to your HTTPS service and waits for an answer. Kyverno (policies in YAML) and OPA Gatekeeper (policies in Rego, L27) both run this way.
- ValidatingAdmissionPolicy (VAP): built in, GA since Kubernetes 1.30. Rules are CEL (Common Expression Language) expressions evaluated inside the API server. There's no extra service to run or keep healthy.
- PSA, for the pod-security basics.
A VAP that only allows images from your registry (made-up account ID):
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: {name: ecr-images-only}
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
variables:
- name: reg
expression: "'111122223333.dkr.ecr.us-east-1.amazonaws.com/'"
validations:
- expression: >-
object.spec.containers.all(c,
c.image.startsWith(variables.reg)) &&
(!has(object.spec.initContainers) ||
object.spec.initContainers.all(c,
c.image.startsWith(variables.reg)))
message: "images must come from the team ECR registry"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: {name: ecr-images-only}
spec:
policyName: ecr-images-only
validationActions: ["Deny"]
matchResources:
namespaceSelector: {matchLabels: {team: payments}}
Note the initContainers clause. Forgetting it is the classic bypass for image rules.
6. Runtime security: watching what actually runs
Admission checks the manifest once. It can't see bash spawning in a running pod after an RCE.
Falco (a CNCF graduated project) watches system calls on each node through eBPF and matches them against rules. A custom rule:
- rule: Shell in payments container
desc: An interactive shell started in a payments pod
condition: >
spawned_process and container
and proc.name in (bash, sh, zsh)
and k8s.ns.name = "payments"
output: >
Shell in payments (user=%user.name
pod=%k8s.pod.name cmd=%proc.cmdline)
priority: WARNING
GuardDuty Runtime Monitoring is AWS's managed equivalent. A GuardDuty agent runs as an EKS
add-on (a DaemonSet) and reports findings such as Execution:Runtime/ReverseShell,
PrivilegeEscalation:Runtime/ContainerMountsHostDirectory and CryptoCurrency:Runtime/BitcoinTool.B.
It's separate from GuardDuty's EKS Protection, which reads the cluster's audit log (L52).
Detection only helps if the alert reaches an on-call with a runbook (L13's alert fatigue).
🧪 Worked example: from "created" to running restricted
The payments namespace has the labels from section 4. CI applies an old manifest:
$ kubectl apply -f quotes-api.yaml -n payments
Warning: would violate PodSecurity "restricted:v1.33":
allowPrivilegeEscalation != false (container "app" must
set securityContext.allowPrivilegeEscalation=false), ...
deployment.apps/quotes-api created
CI is green. But no pods appear:
$ kubectl get events -n payments \
--field-selector reason=FailedCreate
... replicaset/quotes-api-6f7c9d Error creating: pods
"quotes-api-6f7c9d-" is forbidden: violates PodSecurity
"restricted:v1.33": runAsNonRoot != true, seccompProfile ...
(Output wrapped and shortened.) Fixed pod template:
spec:
automountServiceAccountToken: false # no k8s API calls
securityContext: # pod level
runAsNonRoot: true
runAsUser: 10001
seccompProfile: {type: RuntimeDefault}
containers:
- name: app
image: 111122223333.dkr.ecr.us-east-1.amazonaws.com/q@sha256:...
securityContext: # container level
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
readOnlyRootFilesystem: true # not required; do it
volumeMounts:
- {name: tmp, mountPath: /tmp} # app still needs /tmp
volumes:
- {name: tmp, emptyDir: {}}
Two gotchas. If the image's USER is a name like app, the kubelet can't prove it's non-root
and fails with CreateContainerConfigError; set runAsUser or a numeric USER (L49). And skip
the token only if the pod never calls the Kubernetes API (IRSA uses its own projected token).
Make CI wait on kubectl rollout status so "created, no pods" fails the build.
🛠 Try it (optional, ~5 min)
Read-only, if you have access to a dev namespace on a cluster (EKS for the last command; replace
<ns> and <cluster>):
kubectl get ns <ns> --show-labels # any pod-security labels?
kubectl get networkpolicy -n <ns> # any default-deny?
kubectl get validatingadmissionpolicies # cluster-scoped
kubectl get validatingwebhookconfigurations
J='{range .items[*]}{.metadata.name}{"\t"}'
J+='{.spec.containers[*].securityContext}{"\n"}{end}'
kubectl get pods -n <ns> -o jsonpath="$J"
aws eks describe-addon --cluster-name <cluster> \
--addon-name vpc-cni --query addon.configurationValues
No enforce label means the namespace is privileged. An empty securityContext means defaults,
often root. Policies without enableNetworkPolicy in the add-on config (and no Calico or Cilium)
aren't enforced. If RBAC denies a command, just note which.
🧠 Key takeaways
- Default-deny ingress and egress per namespace, then one allow per flow; on EKS, confirm something enforces it.
- Baseline blocks host access; Restricted adds non-root, no privilege escalation, drop ALL and seccomp.
- PSA is per-namespace labels;
enforcechecks Pods, so non-compliant Deployments "succeed" and never start. - VAP (CEL, built in) or Kyverno/Gatekeeper (webhooks) enforce your own rules at admission.
- Runtime tools (Falco, GuardDuty Runtime Monitoring) catch what passed every check, if someone answers.
📖 Words to know
| Term | Meaning |
|---|---|
| NetworkPolicy | Kubernetes object that allows traffic to and from selected pods; enforced by the CNI |
| Pod Security Standards | Three levels (Privileged, Baseline, Restricted) of what a pod may request |
| Pod Security Admission | Built-in admission controller enforcing those levels via namespace labels |
| securityContext | Pod and container fields controlling user, capabilities, privilege and seccomp |
| seccomp | Linux filter that limits which system calls a process may make |
| Admission webhook | An HTTPS service the API server asks to approve (or change) an object |
| ValidatingAdmissionPolicy | Built-in admission rules written in CEL, evaluated in the API server |
| Falco | Open-source runtime detection that matches system calls against rules |
⏭ Next time
L52 moves up to the cloud account: CloudTrail, VPC Flow Logs and GuardDuty, and the first five things an attacker does after landing in AWS.
Container escapes: what privileged, hostPath and hostPID give away
The Baseline level can look like box-ticking. It isn't. Each field it blocks is a known way out of the container and onto the node, and the node is the prize.
Why the node matters
A node holds far more than one pod's secrets:
- every mounted Secret and ServiceAccount token of every pod scheduled there, under
/var/lib/kubelet/pods/ - the kubelet's own credentials, which can read objects for the pods on that node
- the container runtime socket (
containerd.sock), which can start any container - the node's IAM role, through the instance metadata service
1. RCE in quotes-api (L36)
│
▼
2. pod is privileged, or mounts hostPath /
│ mount or read the node's filesystem
▼
3. read tokens of every pod on the node
│ /var/lib/kubelet/pods/*/volumes/...
▼
4. one of them is a CI or operator ServiceAccount
│ with wide RBAC (L27's "secretly admin" verbs)
▼
5. cluster-wide access; node IAM role on the side
What each field gives away
| Field | What an attacker in that container gets |
|---|---|
privileged: true |
All capabilities and host devices: can mount the node's disk, load kernel modules |
hostPath: / (or /var/lib/kubelet, /etc) |
Read and write node files: other pods' tokens, cron or systemd for persistence |
hostPath to containerd.sock |
Ask the runtime to start a privileged container: same as root on the node |
hostPID: true |
See every process on the node; with enough privilege, read /proc/<pid>/environ of other containers, where env-var secrets live |
hostNetwork: true |
The node's network namespace: NetworkPolicy generally doesn't apply, and IMDSv2's hop limit of 1 no longer blocks the metadata service |
capabilities.add: [SYS_ADMIN] |
Enough for several known escapes, such as the cgroups release_agent trick (CVE-2022-0492) |
GuardDuty Runtime Monitoring has findings for several of these, for example
PrivilegeEscalation:Runtime/ContainerMountsHostDirectory,
PrivilegeEscalation:Runtime/DockerSocketAccessed and
PrivilegeEscalation:Runtime/CGroupsReleaseAgentModified. Detection is the backup; not granting
the field is the fix. (Don't try these on a shared cluster. Reading about them is enough.)
Worked example: reviewing a vendor's DaemonSet
A monitoring vendor's Helm chart renders this (trimmed):
kind: DaemonSet
spec:
template:
spec:
hostNetwork: true
hostPID: true
containers:
- name: agent
securityContext: {privileged: true}
volumeMounts:
- {name: root, mountPath: /host}
volumes:
- {name: root, hostPath: {path: /}}
Ask what it needs, not what it asks for. A log shipper needs to read /var/log/pods; it doesn't
need to write /. A reasonable negotiation:
asks for needs change
hostPath / read /var/log/pods hostPath /var/log/pods,
readOnly: true
privileged: true none (logs only) remove
hostPID, hostNetwork none for logs remove
Some agents genuinely need more (CNI plugins, Falco itself, GuardDuty's agent). Give them their own
namespace labelled enforce: privileged, with tight RBAC so app teams can't deploy there, and write
down why. Every app namespace stays at Restricted, and the exception list is short enough to audit.
🧠 Extra takeaways
- The node holds every pod's tokens, the runtime socket and an IAM role; escaping one pod reaches all of them.
privileged,hostPath,hostPID,hostNetworkandSYS_ADMINare each a known escape path.hostNetworkpods bypass NetworkPolicy and the IMDSv2 hop-limit protection.- Isolate genuinely privileged agents in their own namespace with a documented exception.
Rolling out guardrails without breaking prod
Switching a busy namespace straight to enforce: restricted can stop the next deployment cold.
Every admission tool has a "tell me, don't block me" mode. Use it first.
Step 1: measure with a dry run
Server-side dry run evaluates a label change against the pods already running, without saving it. It needs permission to patch namespaces, so a platform engineer may have to run it:
$ kubectl label --dry-run=server --overwrite ns payments \
pod-security.kubernetes.io/enforce=restricted
Warning: existing pods in namespace "payments" violate the
new PodSecurity enforce level "restricted:latest"
Warning: quotes-api-6f7c9d-x2k4p: allowPrivilegeEscalation
!= false, unrestricted capabilities, runAsNonRoot != true
namespace/payments labeled (server dry run)
(Output wrapped; exact wording varies by version.)
Step 2: warn and audit, then enforce
week 1 week 2-3 week 4
warn+audit ─▶ fix manifests, watch ─▶ enforce, pinned
(nothing warnings in CI logs enforce-version
blocked) and audit log (e.g. v1.33)
Pin enforce-version rather than latest, so a cluster upgrade can't tighten the rules under you
without a reviewed change. The same pattern exists everywhere:
| Tool | "Report only" setting | "Block" setting |
|---|---|---|
| PSA | warn / audit labels |
enforce label |
| VAP binding | validationActions: [Warn, Audit] |
[Deny] |
| Gatekeeper constraint | enforcementAction: dryrun or warn |
deny |
| Kyverno policy | Audit |
Enforce (the field's name and location have changed across Kyverno versions; check yours) |
Webhooks can take the cluster down
A webhook is a network call in the path of every matching request. Two settings decide what happens when it's slow or down:
failurePolicy:Failrejects the request if the webhook can't answer (fail closed);Ignorelets it through (fail open). The default in thev1API isFail.timeoutSeconds: default 10, maximum 30. A slow webhook makes everykubectl applyslow.
The classic outage: a policy engine's webhook matches every namespace with failurePolicy: Fail.
Its pods crash. Now nothing can be created, including the engine's own replacement pods. That's
L04's fail-closed trade-off at cluster scale. Fail closed is right for security rules, so make the
webhook hard to break instead:
kind: ValidatingWebhookConfiguration
webhooks:
- name: policy.example.com
failurePolicy: Fail
timeoutSeconds: 5
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: ["kube-system", "policy-engine"]
Exclude the system namespaces and the engine's own namespace, run several replicas with a PodDisruptionBudget, and alert when the webhook's error rate or latency climbs. Those excluded namespaces are now unguarded, so lock down who can deploy into them with RBAC (L50). For simple rules, a VAP avoids the problem: it runs inside the API server, so there's no extra service to fail.
🧠 Extra takeaways
- Dry-run a PSA label first; it lists the running pods that would violate it.
- Go warn/audit → fix → enforce, and pin
enforce-version. - Webhooks default to fail closed; exclude system namespaces and keep the engine highly available.
- Prefer VAP for simple rules: no webhook to go down.
The quiz for this lesson and the Tests section need JavaScript.