The exporter pruned first_seen on every scrape, keeping only what it had just seen. A report that failed to parse - a file being written, a brief I/O error - was skipped by a silent `continue`, so its findings never entered seen_keys and their first-seen timestamps were deleted for good. Nothing reported it, and "first seen" simply restarted at now. Pruning is now limited to targets whose report was actually read this round. Proven both ways against a throwaway results directory rather than by reasoning: make one report unreadable and its entry survives while read_errors counts 1; fix the other report but drop its finding and that entry is pruned as before. The distinction is the point - the old behaviour was not too aggressive, it was indiscriminate. Two numbers now leave the exporter: trivy_reports_total and trivy_report_read_errors, with alerts on both. They cover what TrivyScanStale cannot reach by construction - a target that never produced a report has no series for time() to compare against, so it stays quiet no matter how long it has been broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
182 lines
9.1 KiB
YAML
182 lines
9.1 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"
|
|
|
|
# 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).
|
|
- 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 monatliche Restore-Probe laeuft nicht mehr. 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: "Restore-Probe 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"})
|
|
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"
|
|
|