Lab 13 — Encode the Bouncer: Admission Policy as Code, Then a Camera for the Gap¶
Hands-on lab. Environment:
plaintext-labs/cloud/13-kubernetes-admission-runtime(runs on a localkindcluster — no cloud account). Objective: encode the "never admit" verdict as Kyverno policy that blocks a bad pod at the API server, then add one Falco rule for the behavior admission can't see — a preventive + detective pair. Target: ~2 hrs, one finish line.Variant D · breach-driven, build-first. ← Back to the module concept
✈ Flight card — the 6 things to hold¶
Glance here when you lose the thread. This replaces re-reading the module.
| # | Fact | Why it matters |
|---|---|---|
| 1 | Admission is the bouncer; runtime is the camera. You need both. | Each is blind to exactly what the other catches — the whole reason this is one module. |
| 2 | The bouncer reads the spec before any container starts. | A Kyverno ClusterPolicy denies at kube-apiserver — cheap prevention that runs once. |
| 3 | Four specs must never be admitted. | privileged, hostPath: /, hostNetwork/hostPID, and the silent default — runAsRoot. |
| 4 | The path is the policy. | A deny whose spec path is subtly wrong admits the bad pod while looking correct — prove every one. |
| 5 | Roll out Audit → understand → Enforce. |
Jumping straight to Enforce on a live cluster is how a CronJob can't start at 2 AM. |
| 6 | Falco fires on behavior the manifest never reveals. | exec into a pod, a proc from /tmp, a sensitive-file read — the gap the door can't close. |
↳ Go deeper — pull only when a step doesn't click: the module's model: the bouncer and the camera, and the Kyverno validate reference.
Warm-up — answer before you build (2 min)¶
Don't look below. Being forced to retrieve is what builds the memory.
- You need both admission control and runtime detection. Name in one line what each one is blind to that the other catches.
- Of the four "never admit" specs, which is the most common — and why is "nothing set at all" dangerous?
Setup¶
This is a reference lab — it ships a one-command environment in the companion
plaintext-labs repo. It runs on a local kind
cluster; Kyverno installs in Enforce mode, so a denied pod genuinely bounces at the API server.
Prerequisites: Docker (running), kind >= v0.23.0, kubectl, and helm.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/cloud/13-kubernetes-admission-runtime
make up # create kind cluster, install Kyverno + Falco (Helm), apply seed policies
make demo # non-compliant pod (denied) + compliant pod (admitted); trigger a Falco alert
make shell # ephemeral kubectl pod for cluster exploration
make logs-falco # tail Falco output (run in a SECOND terminal)
make down # delete the cluster when done · make reset rebuilds from scratch
What this lab is — and isn't. This one does enforce: a denied pod really bounces at the API
server — kubectl apply returns the error and the pod never starts. The seed gives you two policies
(disallow-privileged.yaml, require-non-root.yaml) and leaves you to write the rest; the lab is the
building, not a tour. Falco runs as a DaemonSet on the node and watches real syscalls.
▸ On track if:
make upends withCluster ready, andkubectl get clusterpolicieslistsdisallow-privileged-containersandrequire-run-as-non-root, both withACTION=Enforce.Authorization note. This lab runs on a local kind cluster you own. Only test clusters you own or have explicit written permission to access. The "attacks" here are
kubectl execinto your own pods.
Scenario¶
The target account is rolling out Kyverno to its EKS clusters after reading the Graboid write-up in a threat brief: a worm spread because exposed container endpoints would run anything handed to them — the same open door that let attackers cryptojack Tesla's own cluster. You're the security engineer who owns the initial policy set. Your job: encode the verdict "these pod specs should never be admitted" as Kyverno policy that holds for every future pod, prove it blocks the bad and admits the good, then add one Falco rule for the behavior a manifest can't reveal — because prevention without detection is blind to its own gaps. Each step runs the same rhythm: Read → Do → Prove → Record.
Build it — read a little, do a little¶
Step 1 — Read the door that exists, then watch it reject¶
Concept (30 sec): Flight-card #2 and #4. A ClusterPolicy is a spec pattern plus an action; the
exact spec path the deny inspects is the policy. Get the path wrong and it admits the bad pod
while looking right.
Do it: kubectl get clusterpolicies, then open manifests/policies/disallow-privileged.yaml and
require-non-root.yaml and find each deny condition and the spec path it checks (e.g.
request.object.spec.containers[].securityContext.privileged). Then make the bouncer reject:
kubectl apply -f manifests/pod-bad.yaml # pod: lab-noncompliant — expect an ERROR
kubectl apply -f manifests/pod-good.yaml # pod: lab-compliant — expect success
kubectl get pod lab-compliant
▸ On track if:
pod-bad.yamlis denied — the error namesdisallow-privileged-containers(and/orrequire-run-as-non-root) and quotes the failing field, and nolab-noncompliantpod exists.lab-compliantreachesRunning. You've now seen prevention happen before a container started — the moment Graboid and Tesla never met. Record the exact error text.
Step 2 — Encode the two verdicts you called (hostPath, host namespaces)¶
Concept (30 sec): Flight-card #3. The seed covers privileged and root; you predicted all four.
Write the other two — this is the judgment-as-code build.
Do it: author two new policies under manifests/policies/:
disallow-host-path.yaml— deny any pod with ahostPathvolume (hostPath: /is how you read/etc/shadowfrom a "contained" pod). Hint: adenyoverrequest.object.spec.volumes[]checking for thehostPathkey.disallow-host-namespaces.yaml— denyhostNetwork: true,hostPID: true, orhostIPC: trueat the pod level.
Apply them (kubectl apply -f manifests/policies/) and prove each with a violating pod and a compliant
one (a pod with only an emptyDir / no host namespaces).
▸ On track if: a pod mounting
hostPath: { path: "/" }is denied by yourdisallow-host-pathpolicy for the right field, and one with onlyemptyDiris admitted; a pod setting any of the three host namespaces is denied, and one setting none is admitted — verified, not assumed. Record which field each policy fired on.
Step 3 — Run the rollout the right way (Audit → Enforce)¶
Concept (30 sec): Flight-card #5. Audit logs violations without blocking; you watch the reports,
remediate, then flip to Enforce.
Do it: set one of your new policies to validationFailureAction: Audit, re-apply it, apply a
violating pod, and read kubectl get policyreport -A. Then flip it back to Enforce and re-apply the
violating pod.
▸ On track if: under
Auditthe violating pod runs but apolicyreportrecords the violation; underEnforcethe same pod is blocked. Record one sentence on why a real cluster starts every policy inAudit.
Step 4 — A camera for the gap: misbehave inside a compliant pod¶
Concept (30 sec): Flight-card #6. The bouncer read the spec; it cannot see what an admitted pod does. Falco can.
Do it: in a second terminal, make logs-falco. Re-admit the compliant pod
(kubectl apply -f manifests/pod-good.yaml), then do the thing the manifest never revealed:
▸ On track if: Falco emits the seed rule
Read Sensitive File in Kubernetes Podat priorityWARNING, withpod=lab-compliantin the output. Ask yourself: could any Kyverno policy have prevented this? (No — the spec was compliant; the behavior is the signal. That is the gap.)
Step 5 — Write your own runtime rule for the pivot¶
Concept (30 sec): Graboid's heirs land via a foothold and then run a dropped tool to pivot. Watch for a process executing from a world-writable dir.
Do it: in manifests/falco-runtime-rules.yaml there is a seed rule Execution from /tmp in
Container. Tune it (tighten the condition / clarify the output with %k8s.pod.name and a
priority), reload Falco (make reset, or re-run the Helm install), and trigger it — then confirm a
benign in-container process does not fire it:
▸ On track if: your rule fires at
CRITICALon the/tmpexecution with pod context, and a normal command (e.g.kubectl exec lab-compliant -- id) stays quiet. A rule that fires on everything is noise nobody reads.
Prove the control (your finish line)¶
One preventive + detective pair, re-checked against the honesty bar:
- Blocked at the door —
kubectl apply -f manifests/pod-bad.yamlis denied with a Kyverno error quoting the policy and field, and each of your two new policies denies a violating pod while admitting a compliant one. - Caught at runtime — a runtime event inside the compliant pod (
/etc/passwdread and your/tmp-exec) each fire a Falco rule withpod=lab-compliantcontext, and a benign process does not.
If a bad pod both can't start and the thing that slips past gets seen, you've built the pair. Score your four README "Call it" predictions against what actually blocked; note any you missed.
Recall check — close the doc, answer from memory (3 min)¶
- Why do you need both admission control and runtime detection — what is each blind to?
- Of the four "never admit" specs, which is most common, and why is "nothing set" dangerous?
- Why roll a Kyverno policy out in
AuditbeforeEnforce— and how do you prove adenyblocks the right field?
Deliverables¶
manifests/policies/disallow-host-path.yamlanddisallow-host-namespaces.yaml— your two new admission policies (the prevention-as-code).manifests/falco-runtime-rules.yaml— with your tuned/tmp-execution rule (the detection for the gap).policy-report.md— admit/deny results with the exact Kyverno errors, theAudit→Enforcenote, your Falco alert text, and the one-line answer to which Part-3/4 actions admission could have prevented, and why the answer is "none."
Commit these four. Cluster state, kubeconfigs, and secret values stay out of the commit.
Automate & own it¶
Required — judgment-as-code, not keystroke scripting. Your four "never admit" verdicts only hold if a
policy proves them on every change, not just when you remember to apply it. Write a CI gate
(validate-policies.yaml, a GitHub Actions workflow) that runs the kyverno CLI (kyverno apply)
to dry-run all your policies against the manifests in the repo — no live cluster — and fails the
build when pod-bad.yaml (or a host-path / host-namespace pod) is admitted, and passes when
pod-good.yaml is. Have a model draft the workflow; read every line and confirm three things: it runs
kyverno apply, not kubectl apply; it fails on a denied manifest for the right policy; and it
succeeds on the compliant one. This is the bouncer encoded so a bad pod can't merge to the cluster config
in the first place — your verdict, made un-recurrable, exactly the gate the capstone reuses.
Definition of done (kubernetes-admission-runtime ✅)¶
- [ ]
kubectl apply -f manifests/pod-bad.yamlis denied with a Kyverno error quoting the policy and field;pod-good.yamlreachesRunning. - [ ] Your
disallow-host-pathanddisallow-host-namespacespolicies each deny a violating pod and admit a compliant one — verified, not assumed. - [ ] You demonstrated the
Audit→Enforceflip on one policy and can say in one sentence why production starts inAudit. - [ ] Your tuned Falco rule fires on the
/tmpexecution with pod context and does not fire on a benign process. - [ ] You can answer in writing: which of the runtime actions could admission policy have prevented, and why is the answer "none"?
Connects forward¶
- The policies here are what module 14's
stratus-red-team/ Kubernetes attacks try to bypass — your door is the thing the purple-team probes. - Falco's structured JSON output becomes the detection signal that module 15 ingests into a SIEM and correlates; module 16 reconstructs an incident from it.
- The
kyverno applyCI gate is a direct sibling of the IaC gate from module 06 and the RBAC/NetworkPolicy-as-code from module 12 — all converge in the capstone, where a green pipeline rebuilds the hardened cluster and the gate fails the original permissive config.
Marketable proof¶
"I write Kubernetes admission policy as code — denying privileged, host-mount, host-namespace, and root pods at the API server before they start — roll it out
Audit→Enforcethe safe way, gate it in CI with the Kyverno CLI, and layer a tuned Falco rule for the runtime behavior admission can't see. I can explain why prevention without detection is blind to its own gaps."
Stretch¶
- Add a Kyverno mutate policy that auto-injects
runAsNonRoot: trueandallowPrivilegeEscalation: falseinto any pod missing them, and confirmkubectl describe podshows the mutated values — defense that doesn't depend on the developer. - Write a Kyverno generate policy that drops a default-deny NetworkPolicy into every new namespace (the module-12 control, applied automatically) — closing the lateral-movement path Graboid used to hop hosts.
- Wire Falco output to a webhook (a local HTTP listener or
ngrok) so the/tmp-exec alert lands somewhere a responder would actually see it.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).