
🌐 Auch auf: English · Français · Español
🇸🇪 Ich bin zurück aus Schweden. Bräune: nicht vorhanden (es ist Schweden). Mückenstiche: im strafbaren Bereich. Motivation, mein Homelab wieder anzufassen: durch die Decke. Zwei Wochen erzwungene Bildschirmpause in einem Wald haben irgendwas an sich, das dich nach Hause kommen lässt und sofort den Drang weckt, dich an einem Mittwoch um 23 Uhr perssh auf einen Kubernetes-Cluster einzuloggen. Keine Ferndiagnosen bitte. 💻 Die letzten Abende habe ich also gemacht, was jeder ausgeglichene Mensch nach einem Urlaub macht: 📚 mich in Kubernetes-Security-Material eingelesen, genauer gesagt in den Lehrplan des Certified Kubernetes Security Specialist (CKS). Damit das klar ist – ich habe aktuell nicht vor, die Prüfung wirklich abzulegen. Ich nerde mich einfach in das Thema rein, weil es wirklich spannend ist und direkt mit einem Cluster zu tun hat, den ich produktiv betreibe (na ja, „produktiv“ – es ist ein Blog). CKS ist in der Kubernetes-Welt der Lehrplan nach dem Motto „schön und gut, aber kannst du das Ding auch absichern?“ – RBAC-Härtung, Supply Chain, Runtime Security, Network Policies, das komplette Buffet an „Dingen, die so lange in Ordnung sind, bis sie es ganz gewaltig nicht mehr sind“. Und irgendwo in diesem Kaninchenbau habe ich ein Tool wiederentdeckt, das ich vor Jahren mal benutzt und dessen Existenz ich komplett vergessen hatte: kube-bench 🔍.🧐 Was ist kube-bench, und was ist überhaupt ein CIS Benchmark?
kube-bench ist ein kleines Go-Binary von Aqua Security, das die Konfiguration deines Kubernetes-Clusters gegen den CIS Kubernetes Benchmark prüft – eine große, langweilige, extrem nützliche Checkliste des Center for Internet Security, die Dinge sagt wie „hey, das Audit-Logging deines API-Servers sollte wahrscheinlich an sein“ oder „deine Kubelet-Zertifikate sollten nicht für alle lesbar sein, Genie“. Es hackt nichts, es scannt nicht nach CVEs, es arbeitet sich einfach durch Hunderte sehr spezifischer, sehr pedantischer Konfigurationsprüfungen – Dateirechte, API-Server-Flags, RBAC-Bindings, Pod-Security-Einstellungen – und meldet für jede PASS, FAIL oder WARN. Das Schöne an kube-bench ist, dass es Profile für die einzelnen Distributionen mitbringt. Ein Vanilla-kubeadm-Cluster sieht anders aus als EKS, das wieder anders aussieht als GKE, das wiederum sehr anders aussieht als k3s (meine Distribution der Wahl für dieses Homelab, denn wer hat schon RAM für eine „richtige“ Control Plane). Ich habe es mit dem Profilk3s-cis-1.9 gegen den k3s-Single-Node-Cluster laufen lassen, auf dem gerade, nun ja, genau dieser Blog läuft.🚦 Runde eins: 55 Pass, 4 Fail, 57 Warn
Der erste Lauf kam mit ✅ 55 PASS, ❌ 4 FAIL, ⚠️ 57 WARN, ℹ️ 14 INFO zurück. Keine Katastrophe, aber auch nicht nichts. Kurzer Hinweis, bevor ich weitermache: Die meisten dieser 57 WARNs sind Prüfungen, die kube-bench als „Manual“ markiert – das Tool kann sie also schlicht nicht automatisch verifizieren und gibt immer WARN aus, egal wie gut du tatsächlich konfiguriert bist, weil ein echter Mensch draufschauen und selbst urteilen muss. Eine hohe WARN-Zahl heißt also nicht automatisch „58 Probleme“, sondern eher „58 Dinge, auf die ein Mensch kurz schauen sollte, von denen einige schon in Ordnung sind“. Auf einen Haufen davon habe ich kurz geschaut. Mehr dazu unten. 👇 Hier die Tour durch das, was repariert wurde, was sich als Nicht-Problem herausgestellt hat und – weil keine Infrastrukturänderung den Kontakt mit der Realität überlebt – die paar wirklich lustigen Arten, wie ich Dinge kaputt gemacht habe, während ich andere repariert habe. 🙃1️⃣ 🔐 Zertifikatsdateien mit den Rechten einer Parkbank
k3s schreibt seine internen PKI-Zertifikate (die unter/var/lib/rancher/k3s/server/tls/) mit 644 – für alle lesbar. CIS will 600. Fairerweise: Das sind die öffentlichen Zertifikate, nicht die privaten Schlüssel (die waren schon korrekt abgeriegelt), das tatsächliche Risiko lag also eher bei „etwas unordentlich“ als bei „klaffendes Loch“. Behoben mit einem chmod, und weil k3s manche davon beim Neustart gelegentlich neu erzeugt, habe ich den Fix in der Ansible-Rolle idempotent gemacht, sodass er sich bei jedem Playbook-Lauf neu durchsetzt, statt still und heimlich zurückzufallen.2️⃣ 🎫 ServiceAccount-Tokens in Pods, die nie auch nur ein einziges Mal die Kubernetes-API aufrufen
Ein Klassiker. Standardmäßig bekommt jeder Pod ein Kubernetes-API-Token in sein Dateisystem gemountet, „nur für den Fall“. Mein WordPress-Pod, mein MariaDB-Pod und mein Redis-Pod reden in ihrem ganzen Leben nie mit der Kubernetes-API – sie liefern einen Blog aus, speichern Zeilen und cachen Dinge. Trotzdem lag bei allen ein gültiges, wenn auch niedrig privilegiertes API-Token in/var/run/secrets/kubernetes.io/serviceaccount/, einfach so, „warum nicht“. Hätte ein Angreifer jemals einen dieser Container geknackt (sagen wir über eine RCE in einem zwielichtigen Plugin – es ist WordPress, so was passiert 😅), wäre dieses Token ein gefundenes Fressen für die Reconnaissance gewesen. Ich habe automountServiceAccountToken: false überall gesetzt, wo es nicht gebraucht wird – an den Pods selbst und, wie ich auf die harte Tour gelernt habe, auch an den zugrunde liegenden ServiceAccount-Objekten, weil die automatische Prüfung von kube-bench offenbar den ServiceAccount inspiziert und nicht nur, ob irgendein Pod das zufällig überschreibt. Wo ich schon dabei war, habe ich das clusterweit für jeden Nicht-System-Namespace gepatcht. 🧹3️⃣ 🕵️ RBAC-Tiefenbohrung: Etwas erwartet, nichts gefunden
CIS hat einen ganzen Schwung Prüfungen zur RBAC-Minimierung – Wildcard-Berechtigungen, wer sich als wen ausgeben darf, wer Pods erstellen darf usw. Ich bin mit der Erwartung rein, mindestens ein zu breites Binding zu finden, das jemand (ich, vor sechs Monaten, um 1 Uhr nachts 🌙) herumliegen gelassen hat. Fehlanzeige. Jede eigene Rolle im Cluster war entweder ein komplett ungenutzter Kubernetes-Standard (admin/edit, an niemanden gebunden) oder eine von einem Helm-Chart installierte Rolle (cert-manager, ingress-nginx), die exakt auf das zugeschnitten ist, was diese Komponente braucht, und nicht mehr. Richtig befriedigend, das bestätigt zu sehen, statt es nur anzunehmen. ✨4️⃣ 🧱 NetworkPolicies: das, was ich vor mir hergeschoben hatte
Ein lustiges Stück Homelab-Geschichte: Mein Nextcloud-Stack hat schon seit Ewigkeiten NetworkPolicies, die über Calico durchgesetzt werden, aber der WordPress-/Blog-Stack hat nie dieselbe Behandlung bekommen. Die Begründung damals: Collabora (der Dokumenteneditor von Nextcloud) hat ein offensichtliches, sauberes Bedrohungsmodell – es parst nicht vertrauenswürdige Dokumente und hat null legitimen Grund, jemals die Datenbank anzufassen, also war das Abriegeln ein Selbstläufer. Bei WordPress war es trüber: Der WordPress-Pod muss legitim sowohl mit MariaDB als auch mit Redis reden, „einfach alles verbieten“ ist also nicht die richtige Form von Policy. Aber trüber heißt nicht sinnlos. Der eigentliche Wert liegt nicht darin, WordPress von seiner eigenen Datenbank zu isolieren – sondern sicherzustellen, dass MariaDB und Redis nur von WordPress aus erreichbar sind und nicht von jedem anderen Pod, der jemals in diesem Cluster landen könnte, und den Explosionsradius von WordPress selbst zu begrenzen, falls es mal geknackt wird. Also habe ich endlich drei NetworkPolicies geschrieben: WordPress darf MariaDB, Redis, DNS und das offene Internet auf 443 erreichen (für Plugin-/Core-Updates – warum sich Letzteres nicht enger fassen lässt, gleich mehr); MariaDB und Redis sind nur von WordPress aus erreichbar und von sonst nichts. Eine ehrliche Einschränkung gebe ich öffentlich zu, weil es unehrlich und außerdem sinnlos wäre, sie zu verschweigen: Standard-Kubernetes-NetworkPolicies können nur nach IP/CIDR filtern, nicht nach Domainnamen, und meine Calico-Installation läuft im kostenlosen „Policy-only“-Modus ohne schicke domainbasierte Regelsätze. WordPress Core, seine Plugins und mein WP-CLI-Bootstrap brauchen alle ausgehendes HTTPS zu einer wechselnden Besetzung von CDN-IPs (wordpress.org, GitHubs Release-CDN usw.), die ich schlicht nicht auflisten kann. Port 443 ins offene Internet bleibt also erlaubt. Was mir diese Policy tatsächlich bringt, ist das Blockieren von lateraler Bewegung innerhalb des Clusters – ein kompromittierter WordPress-Pod kann nicht mal eben bei cert-manager oder der Admin-Oberfläche des Ingress-Controllers herumstochern –, aber keine vollständige Abriegelung nach außen. Ich sage dir lieber ehrlich, wie weit eine Maßnahme reicht, als dich glauben zu lassen, sie könne mehr, als sie kann. 🙏5️⃣ 🐛🐛 Zwei Bugs, die nur auftauchten, weil ich wirklich getestet habe, statt grünen Häkchen zu vertrauen
Das ist der Teil des Beitrags, an dem Infrastrukturarbeit aufhört, eine Checkliste zu sein, und zu einer echten Ermittlung wird – und ehrlich gesagt der Teil, der mir am meisten Spaß gemacht hat. 🕵️♂️ Nach dem Ausrollen der NetworkPolicies habe ich den WordPress-Cron-Job (ein Kubernetes-CronJob, der alle 5 Minutenwp-cron.php ausführt) manuell angestoßen, um sicherzugehen, dass nichts kaputt ist. Er meldete STATUS: Complete ✅. Super, ab damit – nur dass ich mir tatsächlich die Logs angeschaut habe, statt dem grünen Status zu vertrauen, und eine komplette WordPress-Fatal-Error-Seite fand: „Error establishing a Redis connection.“ 💥 Bei jedem einzelnen Cron-Lauf. Lautlos. Offenbar schon ewig, denn ein PHP-wp_die() beendet sich mit Status 0, Kubernetes hat also absolut keine Ahnung, dass etwas schiefgelaufen ist. „Complete“ hieß nur, dass der Prozess beendet war, nicht, dass er erfolgreich war. Lektion auf die harte Tour neu gelernt: Ein grüner Status ist eine Behauptung, kein Beweis. Beim Nachgraben stellte sich heraus, dass da zwei separate Bugs übereinander gestapelt waren:- 🐛 Bug #1 – der echte, schon vorher vorhandene, der mit nichts von heute zu tun hat: In der Container-Definition des CronJobs war die Umgebungsvariable
WORDPRESS_CONFIG_EXTRAnie gesetzt – der Block, derWP_REDIS_HOSTund Co. definiert –, anders als im Haupt-Deployment von WordPress.WP_REDIS_HOSTwar also bei jedem einzelnen Cron-Lauf seit jeher undefiniert, das Redis-Object-Cache-Plugin fiel still auf seinen eigenen Standardwert127.0.0.1zurück, und natürlich lauscht in diesem Pod nichts auf localhost. Das hatte nichts mit NetworkPolicies, Calico oder irgendetwas von heute zu tun – es war kaputt, seit der CronJob geschrieben wurde, und ist nur nie aufgefallen, weil „Complete“ alle angelogen hat, mich eingeschlossen, wer weiß wie lange 🙈. Behoben, indem ich exakt denselben Config-Block aus dem Haupt-Deployment kopiert habe. - 🐛 Bug #2 – der wirklich neue, verursacht durch die NetworkPolicy, die ich gerade hinzugefügt hatte: Nachdem ich Bug #1 behoben und den Cron-Pod auf den richtigen Redis-Hostnamen gezeigt hatte, schlug er immer noch fehl – diesmal aber mit einem schlichten „Connection refused“ statt eines WordPress-Fatal-Errors, was eine ganz andere Geschmacksrichtung von Fehler ist und sofort nach einem Netzwerk- statt einem Konfigurationsproblem roch. Es stellte sich heraus: Der allererste ausgehende Verbindungsversuch eines brandneuen Pods, ungefähr in der ersten Sekunde seiner Existenz, kann abgewiesen werden, weil Calicos Felix-Agent die Firewall-Regeln für genau diesen Pod noch nicht fertig programmiert hat ⏱️. Langlebige Pods merken davon nie etwas, weil ihre Readiness-Probes eine großzügige Schonfrist von 20 Sekunden haben, bevor überhaupt jemand nach ihnen schaut. Ein CronJob-Pod, der in der ersten halben Sekunde seines Lebens mit Redis reden will, hat diesen Luxus nicht. Ich konnte das mit einem isolierten Test zuverlässig reproduzieren (nackter TCP-Connect, kein WordPress beteiligt) – schlägt auf einem frischen Pod sofort fehl, klappt mit 5 Sekunden Sleep vorher jedes Mal. Fix: ein
sleep 5 &&vor dem eigentlichen Cron-Befehl. Nicht elegant. Extrem wirksam. 🛠️
6️⃣ 🤦 Das eine Mal, als ich beim Hinzufügen eines Sicherheits-Defaults versehentlich einen anderen abgeschaltet habe
Mein liebstes Eigentor der ganzen Übung. Ich habe dem API-Server das Admission-PluginEventRateLimit hinzugefügt, damit ein Pod in der Crash-Schleife nicht den Event-Stream fluten kann. Standard-Kubernetes behandelt --enable-admission-plugins additiv – es fügt dein Plugin zu dem hinzu, was standardmäßig schon aktiviert ist. k3s teilt diese Philosophie, wie sich herausstellt, nicht: Das Setzen dieses Flags ersetzt seine Standardliste komplett. Meine einzeilige „lass uns ein bisschen Rate Limiting hinzufügen“-Änderung hat also still und leise NodeRestriction abgeschaltet, ein Standard-Admission-Plugin von k3s, das verhindert, dass ein kompromittiertes Kubelet an API-Objekten herumpfuscht, die es nicht anfassen sollte. Aufgefallen ist mir das nur, weil ich kube-bench danach zur Kontrolle noch mal laufen ließ und zusah, wie eine zuvor bestandene Prüfung direkt auf FAIL kippte 📉. Moral von der Geschicht’: Liste deine Defaults immer explizit auf, wenn du ein Flag wie dieses anfasst, und lass dein Prüftool nach jeder einzelnen Änderung erneut laufen, nicht nur einmal ganz am Ende.7️⃣ 🗝️ Secrets als Dateien statt als Umgebungsvariablen
Datenbankpasswörter lagen als Klartext-Umgebungsvariablen in den Containern – lesbar über/proc/<pid>/environ, kubectl describe, Crash-Dumps oder jeden Error-Handler, der dumm genug ist, bei einem fatalen Fehler seine Umgebung auszugeben. Sowohl das offizielle WordPress- als auch das MariaDB-Docker-Image können Secrets stattdessen aus einem Dateipfad lesen (die Konvention mit dem Suffix _FILE), also habe ich alles umgestellt. Der eine nicht offensichtliche Haken ⚠️: Die eigenen Health-Check-Probes von MariaDB lasen das Root-Passwort direkt aus genau dieser Umgebungsvariable. Variable in eine Datei umwandeln, ohne die Probes anzufassen, und MariaDB hätte sich ab dem Moment des Rollouts dauerhaft als ungesund gemeldet. Beim Review entdeckt, Probes so angepasst, dass sie die Datei direkt lesen, und live getestet, bevor ich es als erledigt abgehakt habe. 👍8️⃣ 📦 Image-Herkunft, aber ehrlich
CIS will „Image Provenance using ImagePolicyWebhook“ – ein echtes Kubernetes-Feature, aber die Umsetzung heißt, einen kompletten externen Webhook-Dienst aufzusetzen und zu hosten, der Images beim Admission-Zeitpunkt genehmigt oder ablehnt. Das ist eine ordentliche Portion neuer Infrastruktur für einen Homelab-Blog, und ich hatte nicht vor, einen ganzen Microservice zu bauen, nur um ein Compliance-Kästchen glücklich zu machen 🙅. Stattdessen habe ichValidatingAdmissionPolicy verwendet – einen nativen, in den API-Server eingebauten Mechanismus (GA seit Kubernetes 1.30, keine zusätzlichen beweglichen Teile) –, um jeden Pod im WordPress-Namespace abzulehnen, dessen Container-Image nicht auf einer expliziten Allowlist genau der Repositories steht, die dieser Stack tatsächlich nutzt. Gleiches Kontrollziel, null neue Infrastruktur, die ich um 2 Uhr nachts babysitten muss. 😴🤷 Was ich bewusst so gelassen habe
Nicht alles, was rot auftaucht, muss repariert werden, und ich will offen sagen, was noch markiert ist und warum – denn „wir haben wirklich alles behoben“ ist meist ein Zeichen, dass niemand genau genug hingeschaut hat, und weil nichts davon einem Angreifer tatsächlich etwas Nützliches in die Hand gibt:- 🗄️ Eine „etcd“-Prüfung, die bei mir überhaupt nicht greift. Das ist ein k3s-Single-Node-Cluster mit seinem eingebetteten SQLite-Datastore, nicht etcd. Eine CIS-Prüfung, die nach einer etcd-CA-Datei fragt, prüft eine Komponente, die hier schlicht nicht läuft. Kein Befund, nur ein Benchmark, der generisch ist.
- 🧩 Eine Wildcard-RBAC-Berechtigung, die Calico selbst gehört und die es braucht, um seine eigenen Custom Resources zu verwalten. Das ist der sichtbare Preis dafür, die oben beschriebene Durchsetzung der NetworkPolicies einzuschalten – sie von Hand zu stutzen riskiert, dass die Network-Policy-Engine selbst nicht mehr startet, und das fand ich einen schlechteren Tausch als „ein Wildcard-Grant für eine Komponente, der ohnehin das Cluster-Netzwerk anvertraut ist“.
- 🔧 Eine Handvoll Komponenten, die ihre Kubernetes-API-Tokens noch gemountet haben – konkret cert-manager, mein Ingress-Controller und Calicos eigene Controller. Alle drei brauchen diesen Zugriff für ihren eigentlichen Job (Zertifikate, Ingress-Objekte bzw. Network Policies beobachten). Sie rauszureißen würde nichts härten, sondern nur die TLS-Ausstellung kaputt machen.




