"""CKS tasks 103-110: what needs a machine of its own - Falco and AppArmor. 6.1 Behavioural 103 let Falco tell you which Pod 104 a rule of your own analytics 105 the output of a shipped rule 106 alerts as JSON, to a file 107 a list, and one Namespace only 3.4 Kernel hardening 108 load a profile, confine a Deployment 109 a Pod that waits for its profile 110 write a profile These tasks have "backend": "vm". Their host is not a container but an Ubuntu virtual machine (images/vm) with a single-node kubeadm cluster: Falco's eBPF probe and AppArmor both need a kernel that the host owns, which a container under rootless podman never does. The VM host is built with `./ksim build cks --vm`; until it is, these tasks stay out of every session. Falco is tested by doing the thing a rule watches for and reading the alert it writes; AppArmor by trying, inside the container, what the profile forbids. Every task has a scripted solution in solve.py. """ from cks.common import C, PY, NS, ns_pick, name_pick # Falco on a VM host: the systemd unit falco-modern-bpf, alerts in the journal. FALCO = r""" # falco_log [SINCE]: Falco's alerts since then (default: the last 5 minutes), one per line falco_log() { nx "$CP" "journalctl -u falco-modern-bpf --no-pager -o cat --since '${1:--5min}'" /dev/null | tr -d '\0\n'; } # says NS POD 'COMMAND' TEXT: the command's output (stdout and stderr) contains TEXT says() { local out; out=$($K -n "$1" exec "$2" -- sh -c "$3" 2>&1); echo "$out" | grep -q -- "$4"; } """ QUESTIONS = [ # ======================================================== 6.1 Behavioural analytics ==== { "id": 103, "domain": "RUNTIME", "backend": "vm", "title": "Let Falco tell you which Pod", "params": lambda r: {"ns": ns_pick(r, "shop"), "bad": r.choice(["frontend", "worker", "cache", "reports"])}, "task": """ Falco runs on this host as the systemd unit `falco-modern-bpf` (also known as `falco`) and writes its alerts to the journal. Something in Namespace `%{ns}` keeps reading `/etc/shadow`. 1. From Falco's alerts, find the Pod that does it and write `/` to `{WS}/q103/pod.txt`. 2. Save one of the alert lines to `{WS}/q103/alert.txt`. 3. Which Falco **rule** raised it? Write the rule's name to `{WS}/q103/rule.txt`. 4. Scale the Deployment that owns the Pod to 0 replicas. Leave the others running. """, "setup": NS + FALCO + r""" mkns %{ns}; rm -rf "$WS/q103"; mkdir -p "$WS/q103" for d in frontend worker cache reports; do cmd='exec sleep 2147483647'; [ $d = %{bad} ] && cmd='while true; do cat /etc/shadow >/dev/null 2>&1; sleep 6; done' $K -n %{ns} create deploy $d --image=busybox:1.36 -- sh -c "$cmd" >/dev/null done for d in frontend worker cache reports; do $K -n %{ns} rollout status deploy $d --timeout=240s >/dev/null || exit 1; done pod=$($K -n %{ns} get pod -l app=%{bad} -o jsonpath='{.items[0].metadata.name}'); echo "%{ns}/$pod" > "$PRIV/q103" falco_up && alerted "Sensitive file opened.*k8s_pod_name=$pod " 60 || { echo "Falco raised no alert for the Pod" >&2; exit 1; } """, "checks": [ C("pod.txt names the Pod that reads /etc/shadow", 3, r"""eq "$(tr -d ' \n\r' < "$WS/q103/pod.txt" 2>/dev/null)" "$(cat "$PRIV/q103")" """), C("alert.txt holds a Falco alert about it", 2, r""" p=$(cat "$PRIV/q103"); grep -q 'Sensitive file opened' "$WS/q103/alert.txt" 2>/dev/null && grep -q "k8s_pod_name=${p#*/}" "$WS/q103/alert.txt" """), C("rule.txt names the rule", 2, r"""eq "$(tr -s ' ' < "$WS/q103/rule.txt" 2>/dev/null | sed 's/^ *//; s/ *$//' | tr -d '\r\n')" "Read sensitive file untrusted" """), C("the Pod's Deployment is scaled to 0, the others still run", 3, r""" eq "$(j -n %{ns} deploy %{bad} '{.spec.replicas}')" 0 || exit 1 for d in frontend worker cache reports; do [ $d = %{bad} ] || [ "$(j -n %{ns} deploy $d '{.status.availableReplicas}')" = 1 ] || exit 1; done"""), ], "solution": """ sudo journalctl -u falco-modern-bpf --since '5 min ago' | grep -i shadow | tail -3 # ... Warning Sensitive file opened for reading by non-trusted program | file=/etc/shadow ... command=cat /etc/shadow # container_name=busybox ... k8s_pod_name=%{bad}-xxxx k8s_ns_name=%{ns} # -> one line to {WS}/q103/alert.txt, %{ns}/ to {WS}/q103/pod.txt sudo grep -n -B40 'Sensitive file opened for reading by non-trusted program' /etc/falco/falco_rules.yaml | grep -- '- rule:' | tail -1 # - rule: Read sensitive file untrusted -> {WS}/q103/rule.txt k -n %{ns} scale deploy %{bad} --replicas=0 # An alert prints the rule's `output`, not its name: search the rules file for the text to find the # rule (with json_output the name is a field of its own). The fields after the bar are the rule's # output fields; container and Pod names come from Falco's container plugin. # docs: falco.org/docs -> Concepts -> Rules. """, }, { "id": 104, "domain": "RUNTIME", "backend": "vm", "title": "A Falco rule of your own", "params": lambda r: {"ns": ns_pick(r, "apps"), "pod": name_pick(r, "billing")}, "task": """ Falco runs on this host (systemd unit `falco-modern-bpf`, alerts in the journal). Add a rule of your own, in `/etc/falco/falco_rules.local.yaml`: - name `Shell in container`, priority `WARNING`; - it fires whenever a shell — `sh` or `bash` — is started inside a container; - its output is exactly Shell in container: user=%user.name container=%container.name pod=%k8s.pod.name cmdline=%proc.cmdline Falco must be running with the rule. Then set it off — `kubectl -n %{ns} exec %{pod} -- sh -c 'echo hello'` — and save the alert line to `{WS}/q104/alert.txt`. """, "setup": NS + FALCO + r""" mkns %{ns}; rm -rf "$WS/q104"; mkdir -p "$WS/q104" $K -n %{ns} run %{pod} --image=busybox:1.36 --command -- sleep 86400 >/dev/null $K -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=240s >/dev/null && falco_up """, "checks": [ C("falco_rules.local.yaml has the rule Shell in container, and Falco accepts the file", 3, r""" f=/etc/falco/falco_rules.local.yaml eq "$(nx "$CP" "yq '.[] | select(.rule == \"Shell in container\") | .priority' $f" /dev/null 2>&1"""), C("Falco is running", 2, FALCO + r"""falco_up && nx "$CP" "grep -q 'Shell in container' /etc/falco/falco_rules.local.yaml" /dev/null 2>&1 alerted "Warning Shell in container: user=root container=[^ ]+ pod=%{pod} cmdline=sh -c echo $m" 18"""), C("alert.txt holds such an alert", 1, r"""grep -q 'Shell in container: user=.* container=.* pod=%{pod} cmdline=' "$WS/q104/alert.txt" 2>/dev/null"""), ], "solution": """ sudo -i cat >> /etc/falco/falco_rules.local.yaml <<'EOF' - rule: Shell in container desc: A shell was started inside a container condition: spawned_process and container and proc.name in (sh, bash) output: "Shell in container: user=%user.name container=%container.name pod=%k8s.pod.name cmdline=%proc.cmdline" priority: WARNING EOF falco -V /etc/falco/falco_rules.yaml -V /etc/falco/falco_rules.local.yaml # both: Ok systemctl restart falco-modern-bpf && systemctl is-active falco-modern-bpf # (Falco also reloads changed rules by itself) exit k -n %{ns} exec %{pod} -- sh -c 'echo hello' sudo journalctl -u falco-modern-bpf --since '1 min ago' | grep 'Shell in container' | tail -1 | tee {WS}/q104/alert.txt # `spawned_process` and `container` are macros from the shipped rules file (a successful execve; # any container id but the host's): the local file is loaded after it and may use them. # `falco --list` prints every field a condition or an output may use. A rule needs rule, desc, # condition, output and priority - leave one out and Falco refuses to start. """, }, { "id": 105, "domain": "RUNTIME", "backend": "vm", "title": "The output of a shipped rule", "params": lambda r: {"ns": ns_pick(r, "data"), "pod": name_pick(r, "exporter")}, "task": """ The alerts of Falco's rule `Read sensitive file untrusted` are to be read by a script. Change the rule's **output** to exactly %evt.time,%container.id,%container.name,%k8s.ns.name,%k8s.pod.name,%user.name,%fd.name without editing the shipped rules file `/etc/falco/falco_rules.yaml`: override it in `/etc/falco/falco_rules.local.yaml`. The rule's condition and priority stay as they are. Pod `%{pod}` in Namespace `%{ns}` reads `/etc/shadow` every few seconds. When Falco runs with your change, save at least two of its alerts in the new format to `{WS}/q105/alerts.txt`. """, "setup": NS + FALCO + r""" mkns %{ns}; rm -rf "$WS/q105"; mkdir -p "$WS/q105" $K -n %{ns} run %{pod} --image=busybox:1.36 --command -- sh -c 'while true; do cat /etc/shadow >/dev/null 2>&1; sleep 5; done' >/dev/null $K -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=240s >/dev/null || exit 1 nx "$CP" 'sha256sum /etc/falco/falco_rules.yaml' "$PRIV/q105.sha" falco_up && alerted "Sensitive file opened.*k8s_pod_name=%{pod} " 60 && [ -s "$PRIV/q105.sha" ] """, "checks": [ C("the shipped rules file is untouched; the local file overrides the rule's output", 3, r""" eq "$(nx "$CP" 'sha256sum /etc/falco/falco_rules.yaml' /dev/null)" -ge 2 ]"""), ], "solution": """ sudo -i cat >> /etc/falco/falco_rules.local.yaml <<'EOF' - rule: Read sensitive file untrusted output: "%evt.time,%container.id,%container.name,%k8s.ns.name,%k8s.pod.name,%user.name,%fd.name" override: output: replace EOF falco -V /etc/falco/falco_rules.yaml -V /etc/falco/falco_rules.local.yaml systemctl restart falco-modern-bpf sleep 15; journalctl -u falco-modern-bpf --since '30 sec ago' -o cat | grep ',%{pod},' | tee {WS}/q105/alerts.txt # A rule of the same name in a later file changes the earlier one: `override` says which keys and # how (replace, or append for conditions and lists). Without it Falco takes the entry for a whole # new rule and complains about the keys that are missing. The shipped file is replaced by every # package update - which is why changes go into falco_rules.local.yaml. # docs: falco.org/docs -> Concepts -> Rules -> Overriding rules. """, }, { "id": 106, "domain": "RUNTIME", "backend": "vm", "title": "Alerts as JSON, to a file", "params": lambda r: {"ns": ns_pick(r, "ops"), "pod": name_pick(r, "collector")}, "task": """ A log shipper is to pick up Falco's alerts on this host. Change Falco's **configuration** (not its rules) so that - alerts are written as **JSON**, and - besides the journal they also go to the file `/var/log/falco/events.json`. Falco must be running with the change. A file of your own in `/etc/falco/config.d/` is the clean way to do it; `/etc/falco/falco.yaml` documents every setting. Pod `%{pod}` in Namespace `%{ns}` produces an alert every few seconds. """, "setup": NS + FALCO + r""" mkns %{ns} $K -n %{ns} run %{pod} --image=busybox:1.36 --command -- sh -c 'while true; do cat /etc/shadow >/dev/null 2>&1; sleep 5; done' >/dev/null $K -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=240s >/dev/null || exit 1 nx "$CP" 'rm -rf /var/log/falco' /dev/null); [ -n "$a" ] || exit 1 for i in 1 2 3 4 5; do sleep 3; b=$(nx "$CP" 'wc -l < /var/log/falco/events.json' /dev/null); [ "${b:-0}" -gt "$a" ] && exit 0; done; exit 1"""), C("its lines are JSON alerts: the newest is the Pod reading /etc/shadow", 5, r""" nx "$CP" 'tail -n 1 /var/log/falco/events.json' /dev/null | python3 -c ' import json, sys e = json.loads(sys.stdin.read()) f = e.get("output_fields", {}) sys.exit(0 if e.get("rule") == "Read sensitive file untrusted" and e.get("priority") == "Warning" and f.get("k8s.pod.name") == "%{pod}" and f.get("fd.name") == "/etc/shadow" else 1)'"""), C("the journal still gets the alerts", 2, FALCO + r""" nx "$CP" 'test -s /var/log/falco/events.json' /etc/falco/config.d/90-json-file.yaml </dev/null; done for n in %{ns} %{ns}-open; do $K -n $n wait --for=condition=Ready pod/probe --timeout=240s >/dev/null || exit 1; done falco_up """, "checks": [ C("the local rules file has the list network_tools and the rule, and Falco accepts it", 2, r""" f=/etc/falco/falco_rules.local.yaml l=$(nx "$CP" "yq '.[] | select(.list == \"network_tools\") | .items | join(\",\")' $f" /dev/null 2>&1"""), C("a network tool started in %{ns} raises the alert, in the given format", 5, FALCO + r""" falco_up || exit 1; m=ksim$RANDOM$RANDOM $K -n %{ns} exec probe -- wget -T 1 -qO- "http://127.0.0.1:9/$m" >/dev/null 2>&1 alerted "Error Network tool: ns=%{ns} pod=probe tool=wget cmdline=wget .*$m" 18"""), C("the same tool in another Namespace does not, nor does another program in %{ns}", 3, FALCO + r""" falco_up || exit 1; m=ksim$RANDOM$RANDOM $K -n %{ns} exec probe -- nslookup "$m.invalid" >/dev/null 2>&1 $K -n %{ns}-open exec probe -- wget -T 1 -qO- "http://127.0.0.1:9/$m" >/dev/null 2>&1 $K -n %{ns} exec probe -- ls "/$m" >/dev/null 2>&1 alerted "Network tool: ns=%{ns} pod=probe tool=nslookup cmdline=nslookup $m" 18 || exit 1 ! falco_log -40s | grep "Network tool:" | grep "$m" | grep -q -e "ns=%{ns}-open " -e "tool=ls " """), ], "solution": """ sudo -i cat >> /etc/falco/falco_rules.local.yaml <<'EOF' - list: network_tools items: [nc, ncat, wget, curl, nslookup] - rule: Network tool in restricted namespace desc: A network tool was started in a container of a Namespace that should not need one condition: spawned_process and container and proc.name in (network_tools) and k8s.ns.name = "%{ns}" output: "Network tool: ns=%k8s.ns.name pod=%k8s.pod.name tool=%proc.name cmdline=%proc.cmdline" priority: ERROR EOF falco -V /etc/falco/falco_rules.yaml -V /etc/falco/falco_rules.local.yaml systemctl restart falco-modern-bpf exit k -n %{ns} exec probe -- wget -T 1 -qO- http://127.0.0.1:9/x; sudo journalctl -u falco-modern-bpf --since '30 sec ago' -o cat | grep 'Network tool' k -n %{ns}-open exec probe -- wget -T 1 -qO- http://127.0.0.1:9/x # no alert for this one # A list is a named set of values for `in (...)`; a macro is a named piece of condition. Both must # be defined before the rule that uses them. `=` compares exactly; `k8s.ns.name` comes from the # container plugin, like k8s.pod.name. In busybox every tool is the same binary - proc.name is the # name it was started as, which is what you want here. """, }, # =========================================================== 3.4 Kernel hardening ==== { "id": 108, "domain": "SYS", "backend": "vm", "title": "Load an AppArmor profile, confine a Deployment", "params": lambda r: {"ns": ns_pick(r, "render"), "dep": name_pick(r, "converter")}, "task": """ `{WS}/q108/k8s-deny-write` is an AppArmor profile that forbids every write to a file. 1. Load the profile on this host's node, in enforce mode. 2. Make the container of Deployment `%{dep}` in Namespace `%{ns}` run under it. 3. In the new Pod, run `touch /tmp/test` and save the error message to `{WS}/q108/denied.txt`. 4. Write the profile the container's first process runs under — the content of `/proc/1/attr/current` inside the container — to `{WS}/q108/current.txt`. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q108"; mkdir -p "$WS/q108" cat > "$WS/q108/k8s-deny-write" <<'EOF' #include profile k8s-deny-write flags=(attach_disconnected) { #include file, # Deny all file writes. deny /** w, } EOF $K -n %{ns} create deploy %{dep} --image=busybox:1.36 -- sleep 86400 >/dev/null $K -n %{ns} rollout status deploy %{dep} --timeout=240s >/dev/null """, "checks": [ C("the profile k8s-deny-write is loaded on the node, in enforce mode", 3, AA + r"""loaded k8s-deny-write"""), C("the Deployment's Pod template asks for it, as a Localhost profile", 3, PY + r""" py -n %{ns} deploy %{dep} ' t = o["spec"]["template"]["spec"] p = [t.get("securityContext", {}).get("appArmorProfile")] + [c.get("securityContext", {}).get("appArmorProfile") for c in t["containers"]] any(x and x.get("type") == "Localhost" and x.get("localhostProfile") == "k8s-deny-write" for x in p)'"""), C("in the running Pod the profile is in force: nothing can be written", 3, AA + r""" for i in $(seq 1 20); do [ "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}')" = 1 ] && [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done p=$($K -n %{ns} get pod -l app=%{dep} --field-selector status.phase=Running --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}'); [ -n "$p" ] || exit 1 eq "$(confined %{ns} "$p")" "k8s-deny-write (enforce)" && says %{ns} "$p" 'touch /tmp/ksim-probe' 'Permission denied' && says %{ns} "$p" 'cat /etc/hostname' "$p" """), C("denied.txt and current.txt say so", 1, r""" grep -qi 'permission denied' "$WS/q108/denied.txt" 2>/dev/null && grep -q 'k8s-deny-write (enforce)' "$WS/q108/current.txt" 2>/dev/null"""), ], "solution": """ sudo apparmor_parser -q {WS}/q108/k8s-deny-write # -q: load quietly; -r to replace one that is loaded sudo aa-status | grep k8s-deny-write # listed under "profiles are in enforce mode" k -n %{ns} patch deploy %{dep} -p '{"spec":{"template":{"spec":{"containers":[{"name":"busybox","securityContext":{"appArmorProfile":{"type":"Localhost","localhostProfile":"k8s-deny-write"}}}]}}}}' k -n %{ns} rollout status deploy %{dep} k -n %{ns} exec deploy/%{dep} -- touch /tmp/test 2>&1 | tee {WS}/q108/denied.txt # Permission denied k -n %{ns} exec deploy/%{dep} -- cat /proc/1/attr/current | tee {WS}/q108/current.txt # k8s-deny-write (enforce) # localhostProfile is the NAME of the profile (the word after `profile` in the file), not the file # name - here they happen to be the same. The profile must be loaded on every node the Pod can run # on, before the Pod starts. apparmor_parser loads it until the next reboot; a file in # /etc/apparmor.d/ is loaded at every boot. docs: Tutorials -> "Restrict a Container's Access to # Resources with AppArmor". """, }, { "id": 109, "domain": "SYS", "backend": "vm", "title": "A Pod that waits for its profile", "params": lambda r: {"ns": ns_pick(r, "audit"), "pod": name_pick(r, "agent")}, "task": """ Pod `%{pod}` in Namespace `%{ns}` does not start. It is meant to run under the AppArmor profile that the security team wrote and put in `/etc/apparmor.d/k8s-audit-agent` on the node. Get the Pod running under that profile. Keep its image and command, and keep the profile's content as it is. """, "setup": NS + r""" mkns %{ns} nx "$CP" 'cat > /etc/apparmor.d/k8s-audit-agent' <<'EOF' || exit 1 abi , #include profile audit-agent-restricted flags=(attach_disconnected,mediate_deleted) { #include file, capability, # an audit agent reads files; it has no business on the network deny network inet, deny network inet6, } EOF nx "$CP" 'apparmor_parser -R /etc/apparmor.d/k8s-audit-agent 2>/dev/null; sha256sum /etc/apparmor.d/k8s-audit-agent' "$PRIV/q109.sha" $K apply -f - >/dev/null < /tmp/pod.yaml vi /tmp/pod.yaml # localhostProfile: audit-agent-restricted k replace --force -f /tmp/pod.yaml k -n %{ns} exec %{pod} -- cat /proc/1/attr/current # audit-agent-restricted (enforce) k -n %{ns} exec %{pod} -- wget -T 2 -qO- http://10.96.0.1:443/ # Permission denied: no sockets for this container # Two mistakes in one, and the usual two: the profile was on disk but never loaded, and the Pod # named the file instead of the profile. The kubelet refuses to start a container whose profile # the node does not have (CreateContainerError) rather than run it unconfined. # The `abi ,` line matters on Ubuntu 24.04: without it the profile is compiled for an older # kernel interface in which `network` rules are not enforced - and `deny network` does nothing. """, }, { "id": 110, "domain": "SYS", "backend": "vm", "title": "Write an AppArmor profile", "difficulty": "hard", "params": lambda r: {"ns": ns_pick(r, "ledger"), "pod": name_pick(r, "report"), "name": r.choice(["k8s-no-shadow", "k8s-app-guard", "k8s-reader"])}, "task": """ Write an AppArmor profile named `%{name}` for an ordinary application container. It allows what such a container normally does — file access, network, capabilities — with two exceptions: - `/etc/shadow` cannot be read; - nothing under `/usr` can be written. 1. Save the profile as `/etc/apparmor.d/%{name}` on this host's node, so that it is loaded at every boot, and load it now. 2. Create Pod `%{pod}` in Namespace `%{ns}` (image `busybox:1.36`, command `sleep 86400`) whose container runs under it. """, "setup": NS + r""" mkns %{ns} nx "$CP" 'apparmor_parser -R /etc/apparmor.d/%{name} 2>/dev/null; rm -f /etc/apparmor.d/%{name}' /dev/null 2>&1 \ && nx "$CP" "grep -Eq '^[[:space:]]*profile[[:space:]]+%{name}[[:space:]]' /etc/apparmor.d/%{name}" /etc/apparmor.d/%{name} <<'EOF' #include profile %{name} flags=(attach_disconnected,mediate_deleted) { #include file, network, capability, deny /etc/shadow r, deny /usr/** w, } EOF apparmor_parser -q /etc/apparmor.d/%{name} && aa-status | grep %{name} exit cat <` logs what would have been denied (dmesg, journalctl -k) instead # of denying it. """, }, ]