Week 9: Container & Kubernetes Security

A container isn't a lightweight VM with its own kernel — it's a set of isolation boundaries (namespaces, cgroups) drawn around processes that still share the host kernel. That distinction matters enormously for security: the isolation is real, but it's thinner than a VM's, and it's exactly where this week's controls — minimal images, Kubernetes RBAC, network policies, and Pod Security Standards — earn their place.

Module 8 of 15 Week 9 of 16 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Scan a container image for known vulnerabilities and build a minimal, non-root image
  • Write Kubernetes RBAC rules and NetworkPolicies that enforce least privilege
  • Explain what a container escape is and how runtime isolation limits its impact

1. Image Scanning & Building Minimal, Non-Root Images

A container image bundles an application with every dependency it needs — including, often, far more than it actually needs. Every extra package, library, and shell binary in an image is Week 3's attack surface principle, applied to a container: more installed means more that could carry a known vulnerability or be abused if the container is compromised.

scanning an image for known CVEs
$ trivy image node:18

node:18 (debian 12.4)
==================
Total: 143 (HIGH: 12, CRITICAL: 3)

CVE-2024-XXXXX  libssl3  CRITICAL  Fixed in 3.0.13-1

# Every one of these is a known, disclosed vulnerability in something
# bundled into this image -- exactly Week 11's CVE/patch-management
# process, but applied to container base images instead of a full OS

Two changes shrink this dramatically: a minimal base image, and running as a non-root user inside the container.

a minimal, non-root Dockerfile
# BAD: a full OS image, running as root by default
FROM node:18
COPY . .
CMD ["node", "server.js"]

# GOOD: minimal base + a multi-stage build + explicit non-root user
FROM node:18 AS build
COPY . .
RUN npm ci && npm run build

FROM node:18-slim
RUN useradd --uid 1001 appuser
COPY --from=build /app/dist /app
USER appuser        # everything after this runs as appuser, not root
WORKDIR /app
CMD ["node", "server.js"]

node:18-slim (or a distroless image, which strips out even the shell) has a dramatically smaller attack surface than the full image — fewer packages means fewer possible CVEs, and no shell means a common post-exploitation step (getting an interactive shell inside a compromised container) simply isn't available at all.

USER matters even without a container escape

If an application inside a container has an unrelated vulnerability (an RCE from Week 6/7), running as root inside the container means that exploit gets root privileges within the container's namespace — able to modify anything the container can touch, install tools, and more easily attempt an escape. Running as a non-root user limits what a compromised process can do, even before any escape attempt.

2. Kubernetes RBAC

Week 5's RBAC concept, applied to a Kubernetes cluster: Roles define a set of permissions on resources within a namespace, and RoleBindings grant a role to a specific user or service account.

a scoped Role and RoleBinding
# A role that can only read pods in the "staging" namespace -- nothing else
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: staging
  name: pod-reader
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list", "watch"]     # read-only -- no create/update/delete

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: alice-can-read-pods
  namespace: staging
subjects:
- kind: User
  name: alice
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

The dangerous default to watch for is ClusterRole bound with a ClusterRoleBinding — that grants the permission cluster-wide, across every namespace, instead of scoped to one. The same "start narrow, widen with evidence" discipline from Week 8's IAM section applies directly here: a service account almost never needs cluster-wide access, even if it's the easiest thing to grant while getting something working.

Service accounts, not just human users, need RBAC scrutiny

Every Pod runs as a Kubernetes service account, and by default that's the namespace's default service account with whatever permissions it's been granted — often more than the pod's actual application needs. If a compromised application inside a pod can query the Kubernetes API with broad permissions, RBAC misconfiguration turns one compromised container into a much larger cluster-wide problem.

3. Network Policies in Kubernetes

By default, every Pod in a Kubernetes cluster can talk to every other Pod, across namespaces — a flat network, exactly the anti-pattern Week 2 warned about. NetworkPolicies are how you segment it.

a default-deny NetworkPolicy, then a scoped allow
# Default-deny all ingress traffic in the "backend" namespace --
# nothing gets in unless a later policy explicitly allows it
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: backend
spec:
  podSelector: {}
  policyTypes: ["Ingress"]

---
# Now explicitly allow the frontend to reach the backend's API pods, on port 8080 only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-to-api
  namespace: backend
spec:
  podSelector:
    matchLabels: { app: api }
  policyTypes: ["Ingress"]
  ingress:
  - from:
    - namespaceSelector:
        matchLabels: { name: frontend }
    ports:
    - port: 8080

This is Week 2's segmentation and Week 3's minimal-attack-surface thinking, expressed as Kubernetes-native configuration: exactly which pods can reach exactly which other pods, on exactly which ports — a compromised frontend pod shouldn't be able to reach the database directly, the same principle as the three-tier network from Week 2's exercise.

NetworkPolicies require a CNI plugin that enforces them

Writing a NetworkPolicy manifest does nothing on its own if the cluster's networking plugin (the CNI) doesn't actually implement policy enforcement — some do (Calico, Cilium), some historically didn't. It's worth confirming the cluster's networking layer genuinely enforces NetworkPolicies before trusting one as a real control, not just a document that looks like one.

4. Pod Security Standards

Kubernetes' Pod Security Standards define three progressively strict profiles for what a Pod is allowed to do, enforced cluster- or namespace-wide.

the three profiles
Privileged  # no restrictions -- effectively opts out of pod-level security controls
Baseline    # blocks known privilege escalations (no privileged containers, no host
             # namespaces, no dangerous Linux capabilities) while staying broadly compatible
Restricted  # the strictest, hardening-focused profile: enforces non-root, drops all
             # Linux capabilities by default, requires a read-only root filesystem
enforcing "restricted" on a namespace
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/audit: restricted

With this label applied, the cluster rejects any Pod spec that doesn't comply — including a Pod requesting privileged: true (giving it near-host-level access) or running as root, both of which are exactly what an attacker who's already exploited an application inside a container would want in order to escalate further.

A "restricted" pod spec matches Section 1's non-root image

These controls reinforce each other: a non-root USER in the Dockerfile (Section 1) plus a "restricted" Pod Security Standard means the container can't run as root even if someone tries to override it at deploy time — the image-level and cluster-level defenses back each other up instead of relying on either one alone.

5. Container Escapes & Runtime Isolation

A container escape is when a process breaks out of its container's isolation boundaries and gains access to the host system, or to other containers on the same host — the worst-case outcome this entire week's controls exist to prevent or limit.

how escapes typically happen
1. A privileged container (--privileged, or CAP_SYS_ADMIN granted) with
   near-full access to the host's devices and kernel features
2. A mounted host path (e.g. mounting the Docker socket /var/run/docker.sock
   INTO a container) -- that container can now control the host's Docker
   daemon directly, which is effectively root on the host
3. A kernel vulnerability -- since containers share the host kernel,
   a kernel exploit can cross the container boundary entirely

The Docker-socket mount is worth calling out specifically because it's a genuinely common, well-intentioned mistake (often done to let a CI container build other container images) that is, in practical terms, equivalent to granting that container root on the host — Week 3's "the docker group is functionally root" callout, in container form.

Every control this week reduces the blast radius, even without stopping an escape outright

A minimal image (Section 1) means fewer tools available to an attacker mid-escape. Non-root (Section 1, 4) means a compromised process starts with fewer privileges to build on. RBAC (Section 2) and NetworkPolicies (Section 3) limit what a successfully-escaped process can reach next. No single layer is a complete guarantee — together, they're defense in depth applied specifically to the container boundary.

6. Hands-on Exercise

Hands-on

Harden a container image, then apply Kubernetes RBAC and network policies to a mini cluster

Take an intentionally loose Dockerfile through every hardening step from this week, then apply the same discipline to Kubernetes manifests.

Part 1 — Harden the image:

  1. Write a Dockerfile for a small app (any language) that uses a full base image and runs as root by default. Scan it with Trivy (or an equivalent free scanner) and record the total/critical vulnerability count.
  2. Rewrite it as a multi-stage build using a slim or distroless final base image, with an explicit non-root USER (Section 1's pattern). Re-scan and compare the vulnerability count against your first version.
  3. Confirm the non-root user actually took effect: run docker run --rm your-image whoami (or equivalent) and confirm it's not root.
Hint

If your app breaks after switching to a slim/distroless base, it's usually a missing shared library the full image had installed implicitly — check the scan/build error for exactly which one, and add only that specific dependency rather than reverting to the full image.

Part 2 — RBAC and network policies (a local cluster is fine):

  1. Set up a local Kubernetes cluster (minikube or kind) with two namespaces: frontend and backend.
  2. Write a Role scoped to backend that only allows reading pods and services (no create/update/delete), and a RoleBinding granting it to a specific service account — deploy a pod using that service account, and confirm (via kubectl auth can-i) it can list pods but cannot delete one.
  3. Apply a default-deny NetworkPolicy to backend, then a scoped allow permitting only pods in frontend to reach a specific labeled pod in backend on a specific port.
  4. Deploy a third, unrelated pod in a different namespace and confirm it cannot reach the backend pod — direct evidence your NetworkPolicy is actually enforced, not just written.
Hint

If your default-deny policy seems to have no effect at all (traffic still flows freely), check that your cluster's CNI plugin actually enforces NetworkPolicies — kind's default CNI historically didn't, and needs a policy-enforcing CNI (like Calico) installed explicitly for this exercise to demonstrate anything real.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does running a container's process as a non-root user matter, even if the application inside has no known vulnerability today?

It's a defense-in-depth measure for vulnerabilities discovered later, not just ones known today. If the application is ever exploited (a future RCE, a dependency vulnerability), running as non-root limits what that exploit's process can do inside the container — and makes any subsequent escape attempt start from a much weaker position than if it already had root.

Q2

What's the practical difference between a Kubernetes Role/RoleBinding and a ClusterRole/ClusterRoleBinding?

A Role/RoleBinding scopes permissions to a single namespace. A ClusterRole/ClusterRoleBinding grants permissions cluster-wide, across every namespace. Granting cluster-wide access to a service account that only ever needs to operate within one namespace is a common, unnecessary widening of privilege — the least-privilege default should almost always be namespace-scoped.

Q3

Why is mounting the Docker socket (/var/run/docker.sock) into a container roughly equivalent to giving that container root on the host?

The Docker socket lets whoever can talk to it fully control the host's Docker daemon — start new containers with arbitrary host mounts, run commands as root on the host through them, and more. A container with access to that socket can trivially launch a new, fully privileged container with the entire host filesystem mounted, which is functionally the same as having root on the host itself.

Q4

Why do multiple layers of container security (minimal image, non-root, RBAC, network policy) matter together, rather than picking the single "best" one?

No single layer stops every attack path — a minimal image reduces what's available post-compromise, non-root limits initial privilege, RBAC limits what a compromised identity can query, and network policies limit what a compromised pod can reach next. Each control addresses a different stage of a potential attack; together they reduce the overall blast radius even when any one control alone would have been insufficient.