feat(root): add Kubernetes security curriculum

Four new modules, moving ROOT 09 off PLANNED and building the
22-scenario CKS-style bank into a coherent course instead of
disconnected exam tasks — see docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md
for the CORE/GUIDED/INDEPENDENT/ADVANCED/ASSESSMENT-BANK classification
behind every placement.

root-09-m01-security-model-and-securitycontext — READY, full
gold-standard pattern: defense in depth, least privilege, hardening a
Pod with SecurityContext. Lesson, demonstration (a Pod before/after
hardening), knowledge check, guided exercise (a hardening checklist),
real Labs (CORE: non-root/drop-capabilities/read-only-fs/
pod-security-context; GUIDED: seccomp; ADVANCED: AppArmor; INDEPENDENT:
the generic pod-security-context duplicate), and a reflection.

root-09-m02-serviceaccounts-and-rbac — PARTIAL: workload identity and
least-privilege permissions. Lesson + knowledge check + real Labs.

root-09-m03-network-and-supplychain-security — PARTIAL: NetworkPolicy
as a security control (default-deny), image/registry trust, TLS
ingress. Lesson + knowledge check + real Labs.

root-09-m04-cluster-hardening-and-runtime — PARTIAL, deliberately
survey-depth: Pod Security Admission and a lighter treatment of
audit-policy/runtime-sandboxing topics that don't belong in the core
daily-driver path.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-26 09:07:07 -05:00
co-authored by Claude Sonnet 5
parent 73cea1ba1d
commit 960a638ec9
10 changed files with 633 additions and 0 deletions
@@ -0,0 +1,87 @@
# Defense in Depth & the Kubernetes Security Model
## Security is a posture, not a single setting
There is no single "make it secure" switch in Kubernetes. Real security comes from **defense in
depth** — multiple independent layers, each reducing what an attacker (or a bug) can do if an
earlier layer fails. If a container has a vulnerability that lets an attacker execute arbitrary
code, the question that actually matters is: what can that code DO once it's running? A
container running as root, with every Linux capability, on a writable filesystem, with no
restrictions at all, can do far more damage than one deliberately hardened to need almost
nothing.
## Least privilege: grant only what's actually needed
The organizing principle behind everything in this course is **least privilege**: a workload
should have exactly the permissions and capabilities it needs to do its job, and nothing more.
This sounds obvious stated plainly, but the DEFAULT behavior of a plain container is closer to
"maximum privilege unless told otherwise" — root user, all standard Linux capabilities, a
writable filesystem. Hardening a Pod means deliberately narrowing that down.
## SecurityContext: where you actually configure this
`securityContext` (settable at the Pod level and per-container) is where you declare these
restrictions:
```yaml
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: app
securityContext:
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
- **`runAsNonRoot` / `runAsUser`** — the container process runs as a specific non-root user
instead of root. If a container image was built to require root and you set this, it will
fail to start — which is a good, loud failure, not a silent security gap. Hardening
sometimes surfaces an image's OWN bad assumptions.
- **`capabilities: drop: ["ALL"]`** — Linux capabilities are fine-grained permissions
(`NET_BIND_SERVICE`, `SYS_ADMIN`, dozens more) a process can hold even without full root.
Dropping all of them, then adding back only the specific ones actually needed, is the
least-privilege pattern applied to capabilities specifically.
- **`readOnlyRootFilesystem: true`** — the container's own filesystem cannot be written to at
all (use an explicit `emptyDir` volume, ROOT 08, for anything that genuinely needs to write
temporary files). This closes off a whole category of attack that relies on writing a
malicious file to disk.
## Why this matters even when "nothing is currently wrong"
None of these settings fix a bug or a vulnerability — they limit the BLAST RADIUS if one exists
that you don't yet know about. A container hardened with non-root, dropped capabilities, and a
read-only filesystem gives a successful exploit dramatically less to work with than an
unhardened one, even against the exact same vulnerability. This is the entire point of defense
in depth: you don't rely on any single layer being perfect.
## Instructor Notes
**Approximate timing:** 15 minutes read; 10-12 minutes live with the demonstration.
**Demo plan:** Have the demonstration activity ready to run right after this lesson — seeing a
concrete before/after Pod definition lands the abstract "least privilege" principle far better
than the lesson text alone.
**Likely misconceptions to watch for:**
- Students sometimes think hardening settings are only relevant "if you expect to be attacked."
Reframe: defense in depth assumes SOMETHING will eventually go wrong somewhere (a
vulnerability, a bug, a misconfiguration) and prepares for that, rather than betting
everything on nothing ever going wrong.
- `runAsNonRoot: true` failing a Pod's startup is sometimes read as "the security setting is
broken." It's the opposite — it's correctly refusing to let a root-requiring image start,
which is valuable, actionable information about the image itself.
**Discussion question:** "If dropping all capabilities breaks a container that turns out to
genuinely need one specific capability, what's the right fix — removing the drop, or something
more targeted?" (Answer: add back only that ONE specific capability rather than removing the
blanket drop — least privilege means "exactly what's needed," not "give up and grant
everything again.")
**Optional extension:** ask students to identify one setting from this lesson that would have
made zero difference against a vulnerability that lets an attacker read environment variables
(a good check that they understand these are layered, not universal, protections — the answer
is that none of SecurityContext's settings here directly protect secrets stored in env vars;
that's a different concern, covered later in this course).
@@ -0,0 +1,87 @@
# Demonstration: A Pod Before and After Hardening
## Before: the default, unhardened Pod
```yaml
apiVersion: v1
kind: Pod
metadata:
name: app-unhardened
spec:
containers:
- name: app
image: myapp:latest
```
Checking what this actually runs as:
```
$ kubectl exec app-unhardened -- id
uid=0(root) gid=0(root) groups=0(root)
$ kubectl exec app-unhardened -- touch /etc/anything-i-want
$ kubectl exec app-unhardened -- ls -la /etc/anything-i-want
-rw-r--r-- 1 root root 0 Jan 1 00:00 /etc/anything-i-want
```
Running as root, with a fully writable filesystem, including system directories. This is not a
misconfiguration — it's simply what happens when nothing restricts it.
## After: the same application, hardened
```yaml
apiVersion: v1
kind: Pod
metadata:
name: app-hardened
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
containers:
- name: app
image: myapp:latest
securityContext:
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
```
```
$ kubectl exec app-hardened -- id
uid=1000 gid=0 groups=0
$ kubectl exec app-hardened -- touch /etc/anything-i-want
touch: /etc/anything-i-want: Read-only file system
```
Same application image, same functionality (assuming the app doesn't itself require root or
filesystem writes it wasn't told about) — but a compromise of this container now can't write
to the filesystem, can't use the elevated capabilities that were dropped, and never had root to
begin with.
## What this actually buys you
Nothing here fixed a bug in `myapp:latest` — the image is identical in both cases. What changed
is what an attacker (or a bug behaving unexpectedly) could DO if something inside that container
went wrong. The unhardened version had almost no limits; the hardened version has several
independent ones, any of which alone would have blocked the `touch` command above.
## Instructor Notes
**Demo script:** Run both Pods live if time allows — watching `touch` succeed on one and fail
on the other in real time is far more convincing than reading it.
**Approximate timing:** 8-10 minutes live; 5-7 minutes self-paced.
**Common misconception to preempt:** students sometimes read the hardened Pod's failed `touch`
as "hardening broke something." Reinforce: the FUNCTIONALITY didn't change (the application
itself never needed to write to `/etc`) — only an unnecessary, unused capability was removed.
If hardening breaks something the application genuinely needs, that's real information about
the application, not a reason to skip hardening.
**Optional extension:** ask what would happen if `myapp:latest` were built assuming it could
write a cache file to `/tmp` inside the container — a good bridge to the fact that
`readOnlyRootFilesystem` often needs an explicit `emptyDir` volume mounted at the specific path
an application legitimately needs to write to (ROOT 08), rather than being an all-or-nothing
choice.
@@ -0,0 +1,47 @@
# Knowledge Check: Security Model & SecurityContext
---
**1. A container is hardened with `capabilities: drop: ["ALL"]` and then fails because it
genuinely needs to bind to a low-numbered network port, which requires `NET_BIND_SERVICE`.
What's the least-privilege fix?**
*Answer:* Add back only `NET_BIND_SERVICE` specifically (`capabilities: add:
["NET_BIND_SERVICE"]` alongside the drop-all), not remove the blanket drop entirely.
Least privilege means granting exactly what's needed, not reverting to granting everything.
---
**2. True or false: setting `runAsNonRoot: true` fixes a vulnerability inside the application
itself.**
*Answer:* False. It doesn't fix anything about the application's code — it limits what a
compromise of that code could DO, by ensuring the process never has root privileges to begin
with. This is the defense-in-depth distinction: reducing blast radius, not eliminating bugs.
---
**3. Why might `runAsNonRoot: true` cause a Pod to fail to start entirely, and is that a
problem with the setting?**
*Answer:* If the container image was built assuming it runs as root (e.g. it tries to bind a
privileged port, or write to a root-owned directory), the Pod will fail to start under
`runAsNonRoot`. This is NOT a problem with the setting — it's valuable, actionable information
that the image itself makes an assumption that should probably be fixed.
---
**4. A container has `readOnlyRootFilesystem: true` and needs to write temporary cache files.
What's the correct way to allow that without abandoning the read-only filesystem entirely?**
*Answer:* Mount an explicit `emptyDir` volume (ROOT 08) at the specific path the application
needs to write to. This keeps the rest of the filesystem read-only while allowing the one
legitimately-needed writable location.
## Instructor Notes
**Facilitation:** Question 1 and question 4 both reinforce the same "narrow the exception, don't
remove the protection" pattern — worth calling out that they're the same principle applied
twice.
**Approximate timing:** 5-7 minutes.
@@ -0,0 +1,51 @@
# Guided Exercise: Planning a Pod Hardening Checklist
Paper-and-thinking exercise before the Labs.
## The scenario
You're handed a Pod spec for a small internal API with NO `securityContext` set at all — the
fully-default, unhardened case from the demonstration. Your team lead asks you to harden it
before it ships.
## Your task
Without writing the actual YAML yet, answer these:
1. List the settings you'd add, in the order you'd verify them (hint: there are at least three
distinct concerns from the lesson — user/root, filesystem, capabilities).
2. For each setting, name ONE way you'd notice if you'd broken the application by adding it
(what would the failure actually look like?).
3. If the application genuinely needs to write a cache file to disk, does that mean you should
skip `readOnlyRootFilesystem` entirely? What's the more precise fix?
4. Suppose after hardening, the container still runs fine but a security scan flags that it
still has the `NET_RAW` capability. What's the most likely reason, and what's the fix?
## A worked answer to check yourself against
1. In a reasonable order: (a) `runAsNonRoot`/`runAsUser` — a fundamental identity change,
surfaces image assumptions fastest; (b) `capabilities: drop: ["ALL"]` — narrow, usually safe
for a typical web API; (c) `readOnlyRootFilesystem: true` — most likely to need an
accompanying volume, so verify last once the others are confirmed working.
2. (a) Pod fails to start entirely (`CrashLoopBackOff` or an immediate error) if the image
assumed root. (b) The application errors on a specific operation that needed a capability
you dropped (e.g. failing to bind a privileged port). (c) A write operation fails at runtime
(a log line, an exception) the first time the app tries to write to disk.
3. No — the fix is mounting an explicit `emptyDir` volume at the specific path the cache needs,
keeping the rest of the filesystem read-only rather than abandoning the protection entirely.
4. Most likely: `capabilities: drop: ["ALL"]` was never actually applied (a YAML indentation
mistake, or it was added to the Pod's securityContext instead of the container's — capability
drops are container-level, not Pod-level). The fix is verifying the drop is actually present
on the CONTAINER's securityContext specifically.
## Instructor Notes
**Facilitation:** Question 4 is deliberately tricky — Pod-level vs. container-level
`securityContext` fields is a real, common source of "I set this and it didn't work" confusion
worth surfacing here before students hit it in a Lab.
**Approximate timing:** 15 minutes live with discussion; 12-15 minutes self-paced.
**Optional extension:** ask students to identify which of the settings in this exercise would
be MOST valuable to apply first if they could only add one to a large, existing fleet of
unhardened Pods under time pressure, and why.
@@ -0,0 +1,42 @@
# Reflection: Least Privilege Is a Default, Not an Afterthought
You just hardened real Pods across several Labs — non-root, dropped capabilities, a read-only
filesystem, and a full SecurityContext review. Take a few minutes to connect this to real
practice.
## Reflection questions
1. Before this module, would you have described "runs as root with a fully writable
filesystem" as insecure, or just as "the default, unremarkable configuration"? Has that
changed?
2. None of the settings you configured fixed a bug in any application. What did they actually
change, precisely?
3. If your team had to choose between hardening every new Pod by default (as a policy) versus
hardening Pods only after an incident reveals a gap, which produces less total risk over
time, and why?
## Real Ops takeaway
**Unhardened is not neutral — it's the maximum-privilege default.** A container with no
SecurityContext configured isn't "unopinionated"; it's root, every capability, fully writable —
the MOST privileged configuration possible, not a safe middle ground. Teams that treat hardening
as optional are choosing maximum blast radius by default, usually without realizing they're
choosing anything at all.
**Defense in depth means no single mistake should be catastrophic.** The value of stacking
non-root + dropped capabilities + read-only filesystem isn't that any ONE of them is perfect —
it's that a real compromise would have to defeat several independent restrictions simultaneously
instead of exploiting one gap that grants everything. This is exactly why security-minded teams
apply all of these by default rather than picking "the one that matters most."
This module is complete. Continue to the next module.
## Instructor Notes
**Discussion questions for a live classroom:** question 3 is worth pushing on directly — it's
the actual business case for security-by-default, not just a technical preference.
**Approximate timing:** 8-10 minutes live discussion; 5 minutes self-paced writing.
**Optional extension:** ask students to draft a one-paragraph "default Pod hardening policy"
their hypothetical team would adopt, based on what they've learned in this module.