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.
$ 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.
# 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 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.
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.
# 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.
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.
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
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.
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.
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.
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
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:
- 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.
- 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. - Confirm the non-root user actually took effect: run
docker run --rm your-image whoami(or equivalent) and confirm it's notroot.
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):
- Set up a local Kubernetes cluster (minikube or kind) with two namespaces:
frontendandbackend. - Write a Role scoped to
backendthat 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 (viakubectl auth can-i) it can list pods but cannot delete one. - Apply a default-deny NetworkPolicy to
backend, then a scoped allow permitting only pods infrontendto reach a specific labeled pod inbackendon a specific port. - 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.
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?
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?
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?
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?
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.