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:
+87
@@ -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).
|
||||
+87
@@ -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.
|
||||
+47
@@ -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.
|
||||
+51
@@ -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.
|
||||
+42
@@ -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.
|
||||
Reference in New Issue
Block a user