Security & Policy Labs
Security is the domain people read the most about and practise the least — which is exactly backwards for a performance-based exam that hands you a cluster and says “this pod shouldn’t be allowed to do that; make it so.” These twelve labs fix that. You’ll cut a namespace off from the network and re-open exactly one door, scope a ServiceAccount until the API server says no, watch Pod Security Admission bounce a privileged pod, roll a Kyverno policy from audit to enforce, write Rego for Gatekeeper, turn on strict mTLS and prove an unmeshed client can’t get in, scan and sign an image, block the unsigned one at admission, and finally watch Falco shout at you in real time. Every lab is 5–15 minutes on a throwaway local cluster, every lab ends with a command that proves it worked, and your progress saves in this browser.
Imagine a big building where, right now, every door is unlocked and anyone can walk into any room. That’s a fresh Kubernetes cluster. In these labs you lock everything first, then hand out exactly one key at a time — a key for the hallway, a key for the kitchen, and nothing else. Then you hire a guard who checks every parcel at the front desk before it comes in, and an alarm that beeps if someone starts opening drawers they shouldn’t. Twelve small jobs, and by the end the building is safe and people can still get to work.
Everything here is local and disposable. Never point these commands at a shared or production cluster: several of them deliberately deny traffic, reject pods, and install cluster-wide admission webhooks — a broken webhook can wedge a real cluster. You’ll want Docker (or Podman), kind or minikube, kubectl, helm, and (later) trivy, cosign and istioctl. Flags, chart values and API versions in this space drift fast — Kyverno moved validationFailureAction, Istio bumped its security API group, Falco changed drivers. Treat every command here as the shape of the answer and follow each project’s current quickstart for the exact version you install. When you’re done: kind delete cluster --name cnpe-sec and nothing lingers.
Before you start: a cluster that actually enforces policy
One setup detail decides whether Lab 1 teaches you anything or quietly does nothing: NetworkPolicy is only a request. The API server happily stores it, but nothing is enforced unless your CNI plugin implements it. Older kind clusters ship a CNI that ignores NetworkPolicy entirely, so your default-deny “works” and traffic keeps flowing — the single most common reason a candidate loses points on a network task. Start with a CNI you know enforces policy:
# Option A — minikube, one line, Calico enforces NetworkPolicy minikube start -p cnpe-sec --cni=calico --memory=6g --cpus=4 # Option B — kind with the default CNI switched off, then Cilium cat > kind-sec.yaml <<'EOF' kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 networking: # we bring our own, policy-enforcing CNI disableDefaultCNI: true nodes: - role: control-plane - role: worker EOF kind create cluster --name cnpe-sec --config kind-sec.yaml helm repo add cilium https://helm.cilium.io/ && helm repo update helm install cilium cilium/cilium -n kube-system --set ipam.mode=kubernetes kubectl -n kube-system rollout status ds/cilium --timeout=5m # either way, you are ready when every node is Ready kubectl get nodes
Work the labs in order — later ones reuse namespaces, workloads and tools from earlier ones. If a lab looks like it did nothing, resist the urge to move on: the debugging is the exam skill. Keep the troubleshooting playbook and the command reference open in another tab, and deep-dive any concept via Security & Policy Enforcement.
Cluster guardrails: network, identity, baseline
# Lab 1 — save each document below to its OWN file, in this order:
# 1-deny.yaml 2-dns.yaml 3-web-ingress.yaml 4-client-egress.yaml
# The point of the lab is applying them one at a time and watching the
# failure mode change, so do not paste all four into a single file.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: guarded
spec:
podSelector: {} # empty selector = every pod in the namespace
policyTypes: [Ingress, Egress] # no rules below = nothing in, nothing out
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: guarded
spec:
podSelector: {} # every pod may still resolve names
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
podSelector:
matchLabels: { k8s-app: kube-dns }
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-client-to-web
namespace: guarded
spec:
podSelector:
matchLabels: { app: web } # this policy protects the web pods
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: client }
ports:
- { protocol: TCP, port: 80 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-client-egress-to-web
namespace: guarded
spec:
podSelector:
matchLabels: { app: client } # ...and the caller needs egress too
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels: { app: web }
ports:
- { protocol: TCP, port: 80 }kubectl create ns guarded, then kubectl -n guarded create deployment web --image=nginx:1.27 --port=80, kubectl -n guarded expose deployment web --port=80 and wait with kubectl -n guarded rollout status deploy/web. Prove it’s wide open — kubectl -n guarded run client --rm -it --restart=Never --labels=app=client --image=curlimages/curl:8.10.1 -- curl -sS -m 5 http://web returns HTML. Now kubectl apply -f 1-deny.yaml and re-run that client: it fails, and the failure is DNS — a default-deny that includes Egress kills name resolution, the classic trap. Apply 2-dns.yaml and watch the error change from “could not resolve host” to a connection timeout: the name now resolves, the connection still does not. Then apply 3-web-ingress.yaml and 4-client-egress.yaml to open the single path client → web:80, remembering that NetworkPolicy is directional and both ends must agree: the destination needs Ingress and the source needs Egress.app=client returns nginx’s HTML, while kubectl -n guarded run snoop --rm -it --restart=Never --labels=app=other --image=curlimages/curl:8.10.1 -- curl -sS -m 5 http://web times out. Confirm the rule set with kubectl -n guarded get netpol and kubectl -n guarded describe netpol default-deny-all.kubectl create ns tenant-a and kubectl -n tenant-a create serviceaccount deployer. Grant it exactly enough to ship and nothing more — kubectl -n tenant-a create role app-deployer --verb=get,list,watch,create,update,patch,delete --resource=deployments,pods,services,configmaps — then bind it with kubectl -n tenant-a create rolebinding deployer-binds --role=app-deployer --serviceaccount=tenant-a:deployer. Now interrogate the API server as that identity with impersonation: kubectl auth can-i list pods --as=system:serviceaccount:tenant-a:deployer -n tenant-a (yes), kubectl auth can-i get secrets --as=system:serviceaccount:tenant-a:deployer -n tenant-a (no), kubectl auth can-i list pods --as=system:serviceaccount:tenant-a:deployer -n default (no — a Role stops at its namespace), and kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:tenant-a:deployer (no — that’s the privilege-escalation door). Finish with the whole picture: kubectl auth can-i --list --as=system:serviceaccount:tenant-a:deployer -n tenant-a.kubectl auth can-i get secrets --as=system:serviceaccount:tenant-a:deployer -n tenant-a prints no while the pods check prints yes — and you can say out loud why RBAC has no “deny” rule (it’s purely additive).# conforming.yaml — the shape a "restricted" namespace demands (Lab 3)
apiVersion: v1
kind: Pod
metadata:
name: polite
namespace: locked
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]kubectl create ns locked, then label the door: kubectl label ns locked pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/warn=restricted pod-security.kubernetes.io/audit=restricted. Try to smuggle something in — kubectl -n locked run rogue --image=busybox:1.36 --restart=Never --privileged -- sleep 3600 — and read the rejection carefully; it names every field that violated restricted. Try an ordinary pod too, kubectl -n locked run barebones --image=busybox:1.36 --restart=Never -- sleep 3600: also refused, because restricted demands runAsNonRoot, allowPrivilegeEscalation: false, capabilities.drop: ALL and a seccomp profile that nobody set. Now apply conforming.yaml above and watch the same namespace accept it. Finally, feel the difference between the levels: kubectl label ns locked pod-security.kubernetes.io/enforce=baseline --overwrite (the --overwrite is mandatory — re-labelling without it errors out), then re-run both pods. barebones is now admitted, because baseline only blocks the genuinely dangerous things; rogue is still refused, because privileged is dangerous at every level above privileged. Three levels (privileged, baseline, restricted), three modes (enforce, audit, warn).enforce=restricted the rogue pod is refused with a message containing violates PodSecurity "restricted:latest" and kubectl -n locked get pod polite shows Running; after switching to enforce=baseline, kubectl -n locked get pod barebones shows Running while rogue is still rejected. Verify which level is live with kubectl get ns locked -o jsonpath='{.metadata.labels}'. No webhook, no controller, no install — that’s the point.Policy as code: Kyverno and Gatekeeper
# kyverno-validate.yaml — Lab 4. Start in Audit, finish in Enforce.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-latest-tag
spec:
validationFailureAction: Audit # flip to Enforce in step 4
background: true # also report on already-running pods
rules:
- name: require-image-tag
match:
any:
- resources:
kinds: ["Pod"]
validate:
message: "An explicit image tag is required — ':latest' is not allowed."
pattern:
spec:
containers:
- image: "!*:latest"helm repo add kyverno https://kyverno.github.io/kyverno/, helm repo update, then helm install kyverno kyverno/kyverno -n kyverno --create-namespace and wait with kubectl -n kyverno rollout status deploy/kyverno-admission-controller. Apply kyverno-validate.yaml in Audit mode and deliberately break it: kubectl -n guarded run sloppy --image=nginx:latest --restart=Never. The pod is created — audit never blocks — but a report records the violation. Read it with kubectl get policyreport -A and kubectl describe polr -n guarded. That’s the safe way to switch a policy on across a live platform. Now fix the backlog (kubectl -n guarded delete pod sloppy), edit the policy to validationFailureAction: Enforce, re-apply, and try the bad pod again. Version drift note: Kyverno 1.13+ moves this setting to a per-rule validate.failureAction — if the API server rejects the field, check kubectl explain clusterpolicy.spec.rules.validate for your version.kubectl get polr -A with fail; after flipping to Enforce the same command is rejected at admission with your custom message. Confirm the policy is live with kubectl get cpol disallow-latest-tag showing READY: True.# kyverno-mutate.yaml — Lab 5. Mutating webhooks run BEFORE validating ones.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: add-secure-defaults
spec:
rules:
- name: harden-containers
match:
any:
- resources:
kinds: ["Pod"]
namespaces: ["guarded"]
mutate:
patchStrategicMerge:
metadata:
labels:
+(hardened-by): kyverno # "+" = add only if absent
spec:
automountServiceAccountToken: false
containers:
- (name): "*" # "()" = match every container
securityContext:
+(allowPrivilegeEscalation): false
+(runAsNonRoot): true
+(capabilities):
drop: ["ALL"]kyverno-mutate.yaml, then submit a deliberately plain pod that sets no securityContext at all: kubectl -n guarded run plain --image=nginx:1.27 --restart=Never. Inspect what actually landed in etcd with kubectl -n guarded get pod plain -o jsonpath='{.spec.containers[0].securityContext}' and kubectl -n guarded get pod plain --show-labels. Nothing in your command asked for any of it. Expect the pod to then sit in CreateContainerConfigError with “container has runAsNonRoot and image will run as root” — stock nginx wants UID 0, and your mutation just took it away. That is not a bug in the lab, it is the whole risk of mutate rules in one screenshot: silently rewriting other people’s pods breaks the ones that were relying on the old default, which is why you roll a mutate policy out namespace by namespace and watch. Now understand why the order matters: mutating webhooks run first, so the Lab 4 validating policy judges the post-mutation object — which is how a platform can make the safe thing automatic instead of merely mandatory.{"allowPrivilegeEscalation":false,...} for a pod you created with no securityContext, and --show-labels shows hardened-by=kyverno. Tidy up before moving on: kubectl -n guarded delete pod plain.# kyverno-generate.yaml — Lab 6. Every new namespace is born locked down.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kyverno:generate-netpol
labels: # the aggregation label is the gotcha:
rbac.kyverno.io/aggregate-to-background-controller: "true"
rules:
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["create", "update", "delete", "get", "list", "watch"]
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: seed-default-deny
spec:
rules:
- name: add-default-deny-to-new-namespaces
match:
any:
- resources:
kinds: ["Namespace"]
generate:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
name: default-deny-all
namespace: "{{request.object.metadata.name}}"
synchronize: true # re-create it if someone deletes it
data:
spec:
podSelector: {}
policyTypes: [Ingress, Egress]kyverno-generate.yaml — note the ClusterRole: Kyverno’s background controller has no permission to create NetworkPolicies until you aggregate one to it, and forgetting that label is the number-one reason a generate rule silently does nothing. Now onboard a tenant the way a real platform would: kubectl create ns tenant-b, then immediately kubectl -n tenant-b get netpol. A default-deny you never wrote is already there. Test synchronize by deleting it — kubectl -n tenant-b delete netpol default-deny-all — and watching it come back within seconds. If it doesn’t appear, check kubectl describe cpol seed-default-deny and the background-controller logs.kubectl -n tenant-b get netpol default-deny-all succeeds on a namespace you created after the policy, and the object is restored after you delete it.# gatekeeper.yaml — Lab 7. The logic (template) and the rule (constraint) are separate.
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8srequiredlabels
spec:
crd:
spec:
names: { kind: K8sRequiredLabels } # this creates a NEW CRD you can use
validation:
openAPIV3Schema:
type: object
properties:
labels: { type: array, items: { type: string } }
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8srequiredlabels
violation[{"msg": msg}] {
provided := {label | input.review.object.metadata.labels[label]}
required := {label | label := input.parameters.labels[_]}
missing := required - provided
count(missing) > 0
msg := sprintf("missing required labels: %v", [missing])
}
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels # the kind your template just defined
metadata:
name: ns-must-have-owner
spec:
enforcementAction: deny # "dryrun" audits only; "warn" nags
match:
kinds:
- apiGroups: [""]
kinds: ["Namespace"]
parameters:
labels: ["owner"]helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts, helm repo update, then helm install gatekeeper gatekeeper/gatekeeper -n gatekeeper-system --create-namespace, and wait for the admission webhook to actually be serving: kubectl -n gatekeeper-system rollout status deploy/gatekeeper-controller-manager. Apply the ConstraintTemplate from gatekeeper.yaml first and confirm it minted a new CRD: kubectl get crd k8srequiredlabels.constraints.gatekeeper.sh. Only then apply the Constraint — applying both at once is a race that often fails on the first try, which is itself worth seeing. Test the deny path with kubectl create ns orphan (rejected) and the allow path by applying a Namespace manifest carrying labels: { owner: payments }. Then read Gatekeeper’s audit results — it also scans what already exists — with kubectl get k8srequiredlabels ns-must-have-owner -o jsonpath='{.status.violations}' (audit runs on a timer, so give it about a minute). Finish by switching enforcementAction to dryrun and confirming orphan now creates while still being counted — and leave it on dryrun, because Labs 8, 11 and 12 create namespaces that carry no owner label.kubectl create ns orphan fails with missing required labels: {"owner"}, a labelled namespace applies cleanly, and the constraint’s status.violations lists your pre-existing unlabelled namespaces.Workload identity: strict mTLS and authorization
# peer-auth.yaml — Lab 8. STRICT means "show me a mesh certificate or go away".
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
# namespace-wide here; the same object in istio-system with no
# selector is how you make it mesh-wide
namespace: mesh
spec:
mtls:
mode: STRICT # STRICT | PERMISSIVE | DISABLE | UNSETbin on your path — curl -L https://istio.io/downloadIstio | sh -, then cd istio-* and export PATH=$PWD/bin:$PATH — and install: istioctl install --set profile=minimal -y (the minimal profile is kind-friendly; demo wants more RAM). That download also gives you the sample apps, which is why we fetch it rather than just the binary. Now kubectl create ns mesh, turn on injection with kubectl label ns mesh istio-injection=enabled, and deploy the two samples: kubectl -n mesh apply -f samples/httpbin/httpbin.yaml and kubectl -n mesh apply -f samples/sleep/sleep.yaml. Add an unmeshed client for contrast: kubectl create ns nomesh then kubectl -n nomesh apply -f samples/sleep/sleep.yaml — no label, no sidecar. Verify injection worked before you conclude anything: kubectl -n mesh get pods shows 2/2 (app + istio-proxy) while kubectl -n nomesh get pods shows 1/1. Both clients can reach the service right now — check with kubectl -n mesh exec deploy/sleep -c sleep -- curl -s -o /dev/null -w "%{http_code}\n" http://httpbin.mesh:8000/get and the identical command with -n nomesh. Now demand identity: kubectl apply -f peer-auth.yaml and re-run both. Prefer Linkerd? linkerd install --crds | kubectl apply -f - then linkerd install | kubectl apply -f - — the CRDs are a separate first step in current Linkerd and skipping it is the classic first-run failure — annotate the namespace with linkerd.io/inject: enabled, and prove it with a Server + AuthorizationPolicy requiring MeshTLSAuthentication — same lesson, different dialect.200 and the pod in nomesh prints 000 (connection reset — the sidecar hangs up on plaintext). Then prove the identity is cryptographic, not vibes: kubectl -n mesh exec deploy/sleep -c sleep -- curl -s http://httpbin.mesh:8000/headers | grep -i x-forwarded-client-cert prints a header containing URI=spiffe://cluster.local/ns/mesh/sa/sleep, and istioctl proxy-config secret deploy/httpbin -n mesh lists a default secret with VALID CERT: true.# Lab 9 — two files again: 9a-deny-all.yaml, then 9b-allow-sleep.yaml.
# Deny-all first, then one narrow ALLOW.
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-nothing
namespace: mesh
spec: {} # an empty spec denies ALL traffic in the ns
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-sleep-get
namespace: mesh
spec:
selector:
matchLabels: { app: httpbin }
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/mesh/sa/sleep"] # identity, not IP
to:
- operation:
methods: ["GET"]
paths: ["/get"]9a-deny-all.yaml and re-run the meshed curl from Lab 8 — you now get 403 with RBAC: access denied, because an empty spec: {} matches every workload in the namespace and allows nothing. Apply 9b-allow-sleep.yaml and re-run: GET /get returns 200. Now probe the edges, which is where the understanding lives. Wrong path: the same curl against /headers → 403. Wrong method: add -X POST to the /get call → 403. Wrong principal: stand up a second client under a different ServiceAccount with kubectl -n mesh create deployment sleep2 --image=curlimages/curl:8.10.1 -- sleep 3600 (it runs as default, not sleep), then kubectl -n mesh exec deploy/sleep2 -c curl -- curl -s -o /dev/null -w "%{http_code}\n" http://httpbin.mesh:8000/get → 403. Note that the principal is a cryptographic mesh identity issued in Lab 8, not an IP address anyone can spoof — this is the layer NetworkPolicy can’t reach.deploy/sleep in mesh, curl -s -o /dev/null -w "%{http_code}\n" http://httpbin.mesh:8000/get prints 200 while the same command against /headers prints 403. Keep both NetworkPolicy (L3/L4) and AuthorizationPolicy (L7) in your head — the exam tests both.Supply chain and runtime
trivy (Homebrew, apt, or just docker run aquasec/trivy). Look at a deliberately stale image: trivy image --severity HIGH,CRITICAL nginx:1.21. Now make it a gate instead of a report — trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed nginx:1.21; echo "exit=$?" — and compare with a current image such as nginx:1.27. That non-zero exit code is literally how a pipeline step fails; --ignore-unfixed is how you stay sane, because failing on CVEs with no available patch just teaches people to skip the gate. Then produce the ingredients list: trivy image --format cyclonedx --output sbom.json nginx:1.27, count the components with jq '.components | length' sbom.json, and re-scan the SBOM itself with trivy sbom sbom.json — that is how you answer “are we affected?” months later without rebuilding anything. Bonus: trivy fs . on a repo, and trivy config . on your manifests.trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed nginx:1.21; echo "exit=$?" prints exit=1, and jq '.components | length' sbom.json prints a number greater than zero. Then compare the two images with a number rather than a vibe: trivy image --quiet --severity CRITICAL --format json nginx:1.21 | jq '[.Results[]?.Vulnerabilities // []] | flatten | length' against the same command for nginx:1.27 — the old image should be dramatically worse. (If the current image also trips the gate on the day you run it, that is not a broken lab; that is the gate doing its job, and the reason --ignore-unfixed and a documented exception process exist.)# Lab 11 — two identical images, one signed. ttl.sh is anonymous and ephemeral, # so the only thing that differs is whether anyone vouched for the bytes. # The random suffix matters: ttl.sh has a flat, public namespace and a fixed # name like ttl.sh/cnpe-good is very likely to already belong to someone else. export SUFFIX=$(openssl rand -hex 4) export GOOD=ttl.sh/cnpe-good-$SUFFIX:1h export BAD=ttl.sh/cnpe-bad-$SUFFIX:1h docker pull nginx:1.27 docker tag nginx:1.27 $GOOD && docker push $GOOD docker tag nginx:1.27 $BAD && docker push $BAD # writes cosign.key (password-protected) and cosign.pub cosign generate-key-pair # -y accepts the transparency-log upload prompt non-interactively cosign sign -y --key cosign.key $GOOD cosign verify --key cosign.pub $GOOD # succeeds cosign verify --key cosign.pub $BAD # fails: no matching signatures
# verify-images.yaml — Lab 11. Paste YOUR cosign.pub between the BEGIN/END lines,
# keeping this indentation. Same validationFailureAction drift as Lab 4 applies.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-signed-images
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30 # signature checks hit the registry — give them room
rules:
- name: check-cosign-signature
match:
any:
- resources:
kinds: ["Pod"]
namespaces: ["signed"]
verifyImages:
- imageReferences: ["ttl.sh/*"]
attestors:
- count: 1
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
REPLACE-WITH-THE-CONTENTS-OF-cosign.pub
-----END PUBLIC KEY-----cosign verify on your laptop. Now close the loop at admission, which is the part that matters — paste the contents of cosign.pub (all of it, including the BEGIN/END lines, indented to match) into verify-images.yaml, kubectl apply -f verify-images.yaml, kubectl create ns signed, and try to run each image there. Keep the shell you exported $GOOD and $BAD in, or re-export them. If both pods are refused, your key block is almost certainly mis-indented — check kubectl describe cpol verify-signed-images before blaming cosign.kubectl -n signed run good --image=$GOOD --restart=Never is admitted and kubectl -n signed run bad --image=$BAD --restart=Never is rejected by Kyverno with a message about a failed image-signature check. Confirm Kyverno rewrote the admitted image to an immutable digest — kubectl -n signed get pod good -o jsonpath='{.spec.containers[0].image}' should now contain @sha256:, because verifying a tag and then running whatever the tag points to later would prove nothing. You have just made “unsigned code cannot run here” a property of the cluster, not a rule in a wiki.helm repo add falcosecurity https://falcosecurity.github.io/charts, helm repo update, then helm install falco falcosecurity/falco -n falco --create-namespace --set driver.kind=modern_ebpf --set tty=true, and confirm the DaemonSet is up: kubectl -n falco rollout status ds/falco --timeout=5m. Open a second terminal and tail the alerts before you cause any: kubectl -n falco logs -l app.kubernetes.io/name=falco -f. Now trigger two of the default rules on purpose. First, a shell where no shell belongs: kubectl -n guarded exec -it deploy/web -- bash fires Terminal shell in container. Second, inside that shell, cat /etc/shadow fires Read sensitive file untrusted. Then read one alert field by field — rule, priority, and the identifiers that tell you where — because a runtime alert you cannot trace to a workload is noise. Note that plain Falco names the container; the Kubernetes pod and namespace fields come from the chart’s Kubernetes collector (--set collectors.kubernetes.enabled=true on current charts), and wiring that up is the difference between an alert an on-call engineer can act on and one they mute. If the driver won’t load (common in nested virtualisation and on some Docker Desktop kernels), try --set driver.kind=ebpf or modern-bpf, and check the project’s current driver matrix rather than fighting it.kubectl -n falco logs -l app.kubernetes.io/name=falco | grep -c "Terminal shell in container" prints at least 1, and kubectl -n falco logs -l app.kubernetes.io/name=falco | grep -i "sensitive file" returns the /etc/shadow read — both naming the container you exec’d into. Two alerts you caused, on demand.“Here’s my test for whether you built guardrails or a wall: after all twelve labs, can I still deploy my app without reading any of them? Lab 5 and Lab 6 are the ones that pass — they make the safe thing happen to me. Lab 4 in Enforce mode only passes if the rejection message tells me exactly what to change. If your policy says ‘denied by policy require-non-root’ and nothing else, you’ve built a wall.”
What you’ll have built
☺ Like you’re 10: A building where every door starts locked, every parcel gets checked at the desk, and an alarm goes off if someone pokes around where they shouldn’t.
Finish all twelve and you will have hands-on reps with essentially the whole Security & Compliance domain, plus the parts of it that leak into every other domain. Concretely: a namespace isolated by default-deny NetworkPolicy with one audited path and working DNS; a ServiceAccount whose limits you proved with kubectl auth can-i --as; a namespace enforcing the restricted Pod Security Standard; a Kyverno policy rolled the professional way from audit to enforce, plus a mutate rule that hands out secure defaults and a generate rule that makes every new namespace arrive locked down; a Gatekeeper ConstraintTemplate you wrote in Rego; strict mTLS and an L7 AuthorizationPolicy keyed on cryptographic workload identity; a Trivy gate that fails a build and a CycloneDX SBOM you can re-scan later; cosign signatures verified at admission; and a live Falco alert you triggered yourself. Take these straight into the timed drills in practice tasks and security practice, and slot them into the bigger build in the hands-on lab track. Almost all of it double-counts: the same twelve reps cover most of KCSA and a solid chunk of CKS.
⚖ CNPA vs CNPE — This whole page is CNPE-specific — CNPA is a closed-book, multiple-choice exam with no lab, no cluster, and no hands-on component at all, so nothing here is rehearsed the same way for it. But the underlying concepts (default-deny networking, least-privilege RBAC, admission control, policy-as-code, mTLS/zero trust, supply-chain scanning and signing, runtime detection) are exactly what CNPA tests via closed-book recall, so this concept-level knowledge still matters for CNPA prep — just not as twelve labs to run.
Comfortable? Raise the bar. Put every policy in this page under GitOps so the guardrails themselves reconcile from Git — then try to hand-delete one and watch it come back. Replace the Lab 11 key pair with keyless signing (OIDC identity + the Rekor transparency log) and make Kyverno verify the issuer and subject instead of a public key. Add a Kyverno verifyImages rule that requires an SBOM attestation, not just a signature. Wire External Secrets so nothing in your repo is a plaintext credential — the secrets management lesson explains why sealed-in-Git is not the same as safe. Then be your own attacker: exec into a pod and see how many of the twelve controls actually notice.
Foxy: My default-deny is applied and the pod still reaches the internet. Is NetworkPolicy broken?
Timmy: The policy is stored, not enforced. Your CNI has to implement it. Rebuild the cluster with Calico or Cilium and try again — that gotcha is worth a whole exam task.
Gizmo: Or… hear me out… enforcementAction: dryrun on everything, forever. Green dashboards, zero tickets, and I get my Fridays back. 🤑
Pip: That’s a smoke detector with the battery taken out, Gizmo. Audit is a runway. Pick the date you flip to enforce, and put it in the ticket.
Dot: Honestly? Lab 5 is my favourite. My pod came out hardened and I didn’t have to learn what allowPrivilegeEscalation means.
Timmy: That’s the whole job, Dot. The secure path should also be the easy one — otherwise people route around us, and then we’ve secured nothing.
1. You apply a default-deny NetworkPolicy with policyTypes: [Ingress, Egress] and every pod in the namespace immediately fails with “could not resolve host.” What did you forget, and what exactly do you add? 2. Which single command proves a ServiceAccount cannot read Secrets, without creating a pod? 3. A Kyverno mutate rule and a Kyverno validate rule both match the same pod — which runs first, and why does that matter? 4. Your Kyverno generate policy is Ready but no NetworkPolicy appears in new namespaces. What’s the most likely cause? 5. NetworkPolicy already restricts who can talk to httpbin. What does an Istio AuthorizationPolicy add that NetworkPolicy structurally cannot?
Check your answers
- You forgot that a default-deny including Egress also blocks DNS. Add an egress policy allowing UDP and TCP port
53to the cluster DNS pods inkube-system(label selectork8s-app: kube-dns). It’s the single most common NetworkPolicy mistake. kubectl auth can-i get secrets --as=system:serviceaccount:tenant-a:deployer -n tenant-a— impersonation asks the API server’s own authorizer. Add--listto dump every permission the identity holds.- Mutating webhooks run first; validating webhooks run last and cannot modify the object. So validation judges the post-mutation object — a mutate rule that adds
runAsNonRoot: truecan make a pod pass a validate rule the author never satisfied by hand. - Missing RBAC for the background controller. Kyverno can only generate resource kinds it has been granted — add a
ClusterRolelabelledrbac.kyverno.io/aggregate-to-background-controller: "true"coveringnetworkpolicies. - Identity and L7 context. NetworkPolicy works at L3/L4 on pod/namespace selectors and IPs; an
AuthorizationPolicymatches the caller’s cryptographic mTLS principal (its SPIFFE identity) plus HTTP method and path. Run both — they guard different layers.