# Alert-Regeln (gitops#32). Bewusst wenige, dafuer die real erlebten Fehlerklassen: # - TargetDown: "Exporter/Dienst tot und keiner merkt es" (coturn-Klasse) # - KubePodRestartLoop: Crashloops im k3s-Cluster (36k-coturn-Restarts-Klasse) # - HostOom/HostLowMemory/HostLowDisk/HostHighSwap: Ressourcendruck der Hosts groups: - name: axion-basics rules: - alert: TargetDown expr: up == 0 for: 5m labels: severity: critical annotations: summary: "Target {{ $labels.job }}/{{ $labels.instance }} ist seit 5m nicht erreichbar" - alert: KubePodRestartLoop expr: increase(kube_pod_container_status_restarts_total[1h]) > 3 for: 10m labels: severity: warning annotations: summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} restartet wiederholt ({{ $value | printf \"%.0f\" }}x in 1h)" - alert: HostOomKill expr: increase(node_vmstat_oom_kill[15m]) > 0 labels: severity: warning annotations: summary: "OOM-Kill auf {{ $labels.instance }}" - alert: HostLowMemory expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.08 for: 15m labels: severity: warning annotations: summary: "{{ $labels.instance }}: <8% RAM verfuegbar seit 15m" - alert: HostHighSwap expr: (node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes) / node_memory_SwapTotal_bytes > 0.5 for: 30m labels: severity: warning annotations: summary: "{{ $labels.instance }}: >50% Swap in Nutzung seit 30m" - alert: HostLowDisk expr: node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"} < 0.10 for: 15m labels: severity: critical annotations: summary: "{{ $labels.instance }}: <10% Platz auf / frei" # CVE-Pipeline (gitops#47): Funde aus den Trivy-Scans. Pflichtfelder je Alarm: # CVE-ID, Mitigation (Fix-Version + NVD-Link), Ort (host), Typ (target_type); # der Zeitstrahl kommt aus trivy_vuln_first_seen_timestamp + resolved-Edit. # Alle Regeln routen per room-Label in den Security-Raum. - name: axion-cve rules: # Aggregiert pro Image statt pro CVE (gitops#51, Entscheidung sorb # 2026-08-01): ~1000 Einzelalarme waeren Rauschen; die menschlich # handhabbare Einheit ist "Image X hat N kritische CVEs". Die # CVE-Details (Pflichtfelder) liegen im Grafana-Dashboard cve-overview, # die trivy_vuln_info-Einzelserien bleiben dafuer erhalten. - alert: TrivyCriticalVulns expr: count by (target, target_type, host) (trivy_vuln_info{severity="CRITICAL"}) > 0 labels: severity: critical room: security annotations: summary: "{{ $labels.target }} [{{ $labels.target_type }}@{{ $labels.host }}]: {{ $value }} CRITICAL-CVEs — Details: https://selendis.axion1337.de/d/cve-overview" - alert: TrivyHighVulns expr: count by (target, target_type, host) (trivy_vuln_info{severity="HIGH"}) > 0 for: 24h labels: severity: warning room: security annotations: summary: "{{ $labels.target }} [{{ $labels.target_type }}@{{ $labels.host }}]: {{ $value }} HIGH-CVEs (seit 24h offen) — Details: https://selendis.axion1337.de/d/cve-overview" - alert: TrivyScanStale expr: time() - trivy_last_scan_timestamp > 172800 labels: severity: warning room: security annotations: summary: "CVE-Scan fuer {{ $labels.target }} ist aelter als 2 Tage — Scanner pruefen (ein still gestorbener Scanner macht blind)" # TrivyScanStale hat eine Blindstelle, die es prinzipiell nicht schliessen # kann: ein Target OHNE je erfolgreichen Bericht hat gar keine Serie, an der # 'time() - ...' haengen koennte. Es bleibt also still. Die beiden Regeln # darunter fangen die zwei Faelle ab, die dahinterstecken. - alert: TrivyReportUnreadable expr: trivy_report_read_errors > 0 for: 30m labels: severity: warning room: security annotations: summary: "{{ $value }} CVE-Bericht(e) sind seit 30 min unlesbar — die betroffenen Targets werden NICHT ausgewertet und faellt sonst niemandem auf" - alert: TrivyNoReports expr: trivy_reports_total == 0 for: 1h labels: severity: critical room: security annotations: summary: "Der CVE-Exporter findet ueberhaupt keine Berichte mehr — der Scanner liefert nichts, die Schwachstellen-Sicht ist blind" # --- Deckung der Zielmenge (#0106, ADR-0026) --------------------- # Bis 2026-08-21 konnte die Pipeline nur sagen, WAS sie gescannt hat. # Was sie nie angesehen hatte, war unsichtbar: 27 von 51 laufenden # Images (52 %) - darunter der Web-Client, beide Traefik-Schichten und # die Registry selbst. Die Zielliste war handgepflegt. # 6h, weil eine volle Runde ueber ~65 Images dauert: nach einem # Ausrollen ist die Luecke voruebergehend echt und kein Fehler. - alert: CveTargetsMissing expr: cve_targets_missing > 0 for: 6h labels: severity: warning room: security annotations: summary: "{{ $value }} laufende Images werden NICHT auf CVEs geprueft — die Schwachstellen-Sicht hat ein Loch, das sie selbst nicht zeigt. Details: https://selendis.axion1337.de/d/cve-overview" - alert: CveTargetsOrphaned expr: cve_targets_orphaned > 0 for: 6h labels: severity: info room: security annotations: summary: "{{ $value }} CVE-Bericht(e) betreffen Images, die nirgends laufen — Befunde, auf die niemand handeln kann (Rauschen, das wie Signal aussieht)" # ⚠️ DIE WICHTIGSTE DER VIER. Faellt eine Quelle aus, wird die # Soll-Menge KLEINER - und die Deckung saehe damit BESSER aus, nicht # schlechter. Ein Ausfall waere ohne diese Regel von Erfolg nicht zu # unterscheiden. Schwelle: 3x das Herleitungs-Intervall (1h). - alert: CveTargetSourceStale expr: cve_target_source_stale > 10800 labels: severity: warning room: security annotations: summary: "Die Quelle '{{ $labels.quelle }}' der CVE-Zielmenge ist seit {{ $value }}s nicht erneuert worden — die Zielmenge schrumpft still, und die Deckung sieht dadurch BESSER aus statt schlechter" # Der Fall, den die drei darueber NICHT sehen: alle Quellen antworten # sauber, aber leer. Dann ist 'missing' 0 (leere Soll-Menge) und # 'stale' frisch - und es wird nichts mehr gescannt. In # test_targets.py als "leere Menge ist NICHT dasselbe wie Erfolg". - alert: CveTargetsNoneDesired expr: cve_targets_desired_total == 0 for: 30m labels: severity: critical room: security annotations: summary: "Die CVE-Zielmenge ist LEER — es wird nichts mehr geprueft, obwohl keine Quelle einen Fehler meldet" # Sicherungen (management #0030). Bis 2026-08-14 gab es hierzu KEINE Regel: ein # fehlgeschlagenes naechtliches Backup waere unbemerkt geblieben - genau der stille # Ausfall, den das Issue beschreibt. Metriken kommen aus kube-state-metrics im # Matrix-Cluster (Alloy -> remote_write, kube_job_*/kube_cronjob_* passieren den # Filter, der nur go_.*|process_.* verwirft). # --- Entschieden oder offen (#0051) ------------------------------- # ⚠️ Diese vier bewachen eine ENTSCHEIDUNG, kein Werkzeug. Ein CRITICAL, # zu dem es nichts zu tun gibt, ist erlaubt - aber nur benannt, begruendet # und mit Pruefdatum. Ohne diese Regeln waere 'entschieden' ein Wort in # einer Datei, das mit der Zeit still zu 'vergessen' wird. - alert: CveCriticalOffen expr: cve_critical_offen > 0 for: 24h labels: severity: warning room: security annotations: summary: "{{ $value }} CRITICAL auf laufenden Images ohne Entscheidung — weder behoben noch mit Begruendung und Pruefdatum in cve/entscheidungen.json. Details: https://selendis.axion1337.de/d/cve-overview" # Der Ablauf ist der Sinn der Sache: eine Entscheidung ohne Verfallsdatum # ist keine Entscheidung, sondern Wegschauen mit Zusatzschritt. - alert: CveEntscheidungAbgelaufen expr: cve_entscheidungen_abgelaufen > 0 for: 6h labels: severity: warning room: security annotations: summary: "{{ $value }} CVE-Entscheidung(en) haben ihr Pruefdatum ueberschritten — die Begruendung von damals gilt bis zum Gegenbeweis nicht mehr" - alert: CveEntscheidungOhneBefund expr: cve_entscheidungen_ohne_befund > 0 for: 24h labels: severity: info room: security annotations: summary: "{{ $value }} CVE-Entscheidung(en) treffen auf keinen Befund mehr — Altpapier in cve/entscheidungen.json, das kuenftig Befunde decken koennte, die niemand geprueft hat" # ⚠️ Eine unlesbare Datei laesst ALLES als offen zaehlen (so gebaut). Das # faellt ueber CveCriticalOffen auf - aber erst nach 24h und ohne den Grund # zu nennen. Diese Regel nennt ihn sofort. - alert: CveEntscheidungenUnlesbar expr: cve_entscheidungen_lesefehler > 0 for: 30m labels: severity: warning room: security annotations: summary: "cve/entscheidungen.json ist nicht lesbar — bis das behoben ist, zaehlt jeder CRITICAL als unentschieden" # ⚠️ Die Regel, die am 2026-08-23 gefehlt hat. Ein k3s-Neustart legte # kube-state-metrics und Alloy lahm, `kube_pod_container_info` war leer, # die Herleitung nahm das als Erfolg - und die Zielmenge fiel von 54 auf # 12. Der Scanner loeschte 42 Berichte, das Dashboard meldete Deckung 1,0. # Keine der vier Regeln darueber schlug an: die Menge war nicht LEER, und # die Zeitstempel waren FRISCH. Die Luecke sass genau dazwischen. # # ⚠️ Sie feuert auch bei einem gewollten Rueckbau. Das ist Absicht: ein # Einbruch der Pruefmenge um 40 % ist immer eine Zeile wert, egal warum. - alert: CveZielmengeEingebrochen expr: cve_targets_desired_total < 0.6 * max_over_time(cve_targets_desired_total[24h]) for: 15m labels: severity: warning room: security annotations: summary: "Die CVE-Zielmenge ist auf {{ $value }} Ziele eingebrochen — ueber 40 % weniger als in den letzten 24 h. Faellt eine Quelle still aus, sieht das Dashboard danach BESSER aus, nicht schlechter." - name: axion-backup rules: # Ein Backup- oder Probe-Job ist fehlgeschlagen. - alert: BackupJobFailed expr: max by (namespace, job_name) (kube_job_status_failed{job_name=~"(synapse|authentik|wikijs)-backup.*|restore-drill.*"}) > 0 for: 15m labels: severity: critical annotations: summary: "Backup-Job {{ $labels.namespace }}/{{ $labels.job_name }} ist fehlgeschlagen — ohne Sicherung ist der naechste Ausfall ein Datenverlust" # Der CronJob plant gar nicht mehr (ausgesetzt, geloescht, Cluster-Problem). # 26h Toleranz fuer die taeglichen Laeufe um 03:00/03:15/03:30. # Fallback auf die Anlagezeit: ein frisch (neu) angelegter CronJob hat noch # keine last_schedule_time-Serie - ein Ausdruck ueber eine fehlende Serie # liefert nichts, der Alarm koennte also nie feuern. max by(...) fasst beide # Metriken zu EINER Serie je CronJob zusammen; ohne das wuerde die nie # aktualisierte created-Serie nach Ablauf der Frist dauerhaft falsch feuern # ('or' matcht inkl. __name__, ergibt also eine Vereinigung beider Serien). - alert: BackupNotRunning expr: time() - max by (namespace, cronjob) (kube_cronjob_status_last_schedule_time{cronjob=~"(synapse|authentik|wikijs)-backup"} or kube_cronjob_created{cronjob=~"(synapse|authentik|wikijs)-backup"}) > 93600 labels: severity: critical annotations: summary: "Seit ueber 26h kein Lauf von {{ $labels.cronjob }} — CronJob ausgesetzt oder verschwunden?" # Die monatlichen Restore-Proben laufen nicht mehr - restore-drill (Datenbanken) # und restore-drill-media (Synapse-Medien). Praefix-Matcher und cronjob-Name in der # Meldung, sonst sagt der Alarm nicht, WELCHE Probe steht. Eine Sicherung, die nie # zurueckgespielt wurde, ist eine Vermutung — deshalb ist auch das Ausbleiben # der PRUEFUNG ein Alarm, nicht nur ihr Fehlschlag. 40 Tage = Monatsrhythmus + Puffer. # Bis zum ersten REGULAEREN Lauf gibt es keine last_schedule_time-Serie (ein # manuell ausgeloester Job setzt sie nicht) - ohne Fallback waere dieser # Alarm bis dahin blind. Gleiche max-by-Konstruktion wie oben. - alert: RestoreDrillStale expr: time() - max by (namespace, cronjob) (kube_cronjob_status_last_schedule_time{cronjob=~"restore-drill.*"} or kube_cronjob_created{cronjob=~"restore-drill.*"}) > 3456000 labels: severity: warning annotations: summary: "{{ $labels.cronjob }} seit ueber 40 Tagen nicht gelaufen — Wiederherstellbarkeit ist wieder unbewiesen (Verfahren: notfallhandbuch)" # Ein Backup-CronJob existiert gar nicht mehr (geloescht, Kustomization # entfernt, Namespace weg). Bei den Alarmen oben verschwindet dann die # Serie - und eine verschwundene Serie ist in Prometheus kein Alarm, # sondern Stille. Genau das war der Fehler, den dieses Regelwerk # verhindern soll, also ist das Fehlen selbst der Alarm. # for: 30m puffert kurze Luecken der Metrik-Pipeline ab. - alert: BackupCronJobMissing expr: >- absent(kube_cronjob_info{namespace="matrix", cronjob="synapse-backup"}) or absent(kube_cronjob_info{namespace="matrix", cronjob="wikijs-backup"}) or absent(kube_cronjob_info{namespace="authentik", cronjob="authentik-backup"}) or absent(kube_cronjob_info{namespace="matrix", cronjob="restore-drill"}) or absent(kube_cronjob_info{namespace="matrix", cronjob="restore-drill-media"}) for: 30m labels: severity: critical annotations: summary: "Ein Backup-/Probe-CronJob fehlt im Cluster — Sicherung oder Wiederherstellungs-Nachweis laeuft nicht mehr" # Die Alarmkette meldet ihr eigenes Reissen. Ironie inklusive: schlaegt die # Zustellung komplett fehl, kommt auch dieser Alarm nicht an - er ist dann # aber in Prometheus/Grafana sichtbar, statt dass gar nichts existiert. # Teilausfaelle (ein Receiver von mehreren, Rate-Limit 429) meldet er sauber. - alert: AlertDeliveryFailing expr: increase(alertmanager_notifications_failed_total[15m]) > 0 labels: severity: critical annotations: summary: "Alertmanager konnte Benachrichtigungen nicht zustellen ({{ $labels.integration }}) — Alarme laufen ins Leere"