Files
threadnet-operating/monitoring/prometheus/alerts.yml
T
Thore Cimbal 9a3b1d1dd5 cve: a successful query can still be worthless, and it cost 42 reports
Restarting k3s took kube-state-metrics and alloy's log tailers down with it, so
kube_pod_container_info went empty in prometheus. The derivation asked, got a
clean response with zero rows, and counted it as success. Cluster targets went
from 39 to none, the total from 54 to 12, and the scan loop deleted every report
whose target had vanished. Coverage then read 1.0.

None of the four rules fired, and each for a defensible reason: the set was not
empty because the operating host still answered, and every timestamp was fresh
because an empty success updates it. The gap sat exactly between them.

A source that has delivered before and now delivers nothing is treated as a
failure: its previous targets are kept, its timestamp ages, and the stale rule
takes over. A fifth rule watches the total for a drop of more than 40 percent,
and it deliberately also fires on a deliberate shrink — losing 40 percent of the
checked estate is worth a line either way.

The six rancher decisions are gone too. They described versions that no longer
run: the k3s patch took all fifteen of their criticals with it.
2026-08-21 12:00:00 +00:00

291 lines
15 KiB
YAML

# 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"