StatusTypeRelevance

vulnerability alerts

Architektur, Design-Entscheidungen und Konfiguration

Dieses Dokument beschreibt, wie und warum wir Vulnerability-Alerts aus Trivy über Prometheus nach MS Teams senden - und warum das Setup bewusst so gebaut ist.

Ziel ist:

  • reproduzierbares Alerting
  • klare Semantik
  • kein Alert-Spam
  • nachvollziehbare Entscheidungen

1. Gesamtüberblick (Signalfluss)

Trivy Operator
  ↓ (Prometheus Metrics)
Prometheus
  ↓ (Recording Rules + Alert Rules)
Alertmanager
  ↓ (Webhook)
prometheus-msteams
  ↓
Microsoft Teams Channel

2. Warum Prometheus für Trivy-Alerts?

Der Trivy Operator erzeugt keine Events, sondern:

  • Kubernetes Custom Resources (VulnerabilityReport)
  • Prometheus-Metriken (trivy_image_vulnerabilities{...})

Prometheus ist damit:

  • die einzige Quelle, die Zeitvergleiche erlaubt
  • das Tool, das “neu vs alt” korrekt bewerten kann
  • notwendig für Logik wie:
    • “Count ist gestiegen”
    • “Image gab es vorher nicht”

Ein reines Event-basiertes Alerting ist hier nicht möglich.


3. Warum eine Recording Rule?

Problem ohne Recording Rule

Die Rohmetrik trivy_image_vulnerabilities:

  • existiert pro Vulnerability
  • enthält sehr viele Labels
  • ist für Zeitvergleiche unhandlich

Beispiel:

trivy_image_vulnerabilities{severity="Critical"}

→ liefert eine Zeitreihe pro CVE, nicht pro Image.


Lösung: Aggregation als Recording Rule

record: trivy:critical_vuln_count_by_image
expr: |
  sum by (
    namespace,
    image_registry,
    image_repository,
    image_tag,
    image_digest,
    resource_kind
  ) (
    trivy_image_vulnerabilities{severity="Critical"}
  )

Warum das sinnvoll ist

  • erzeugt eine saubere Zeitreihe pro Image
  • reduziert Komplexität in Alert-Queries
  • macht offset, max_over_time, unless stabil nutzbar
  • verbessert Performance
  • ermöglicht Filterung nach Workload-Typ (resource_kind), um Spam durch Workflows / Jobs zu verhindern

Wichtig:
Recording Rules sind stateless.
Der “Zustand” liegt ausschließlich in der Prometheus-TSDB.


4. Warum zwei Alerts statt einem?

Es gibt zwei fachlich unterschiedliche Ereignisse, die zufällig dieselbe Metrik verändern:

A) Bestehendes Image bekommt neue Vulnerabilities

  • Ursache: CVE-Datenbank-Update
  • Image hat sich nicht geändert
  • Security-relevant

B) Neues Image wird deployed und ist bereits verwundbar

  • Ursache: Deployment / Change
  • Vulnerabilities waren schon bekannt
  • Plattform-/Ops-relevant

👉 Ein einzelner Alert kann diese Semantik nicht korrekt unterscheiden.
Deshalb: zwei Alerts, klar getrennt.


5. Alert A - CRITICAL-Count im bestehenden Image gestiegen

Fachliche Bedeutung

„Dieses Image war bereits im Cluster und hat jetzt mehr CRITICAL Vulnerabilities als vorher.“

Typischer Auslöser:

  • neue CVE
  • CVSS-Neubewertung
  • Trivy-DB-Update

Warum Einschränkung auf langlebige Workloads?

In unserer Umgebung laufen u.a. Argo CronWorkflows, deren Pods nach erfolgreichem Lauf gelöscht werden. Dadurch entstehen ständig neue VulnerabilityReports und neue kurzlebige Ressourcen - was ohne Filter zu Alert-Spam führt.

Daher wird Alert A bewusst nur auf “stabile Workloads” eingeschränkt:

  • Deployment / ReplicaSet
  • StatefulSet
  • DaemonSet

PromQL-Logik

(
  max_over_time(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"}[10m])
  -
  max_over_time(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"}[10m] offset 15m)
) > 0

Warum das funktioniert

  • offset 15m = Vergleich mit Vergangenheit
  • nur Images, die damals schon existierten, werden verglichen
  • kein Alert beim erstmaligen Auftauchen eines Images
  • kein Alert-Sturm nach Prometheus-Neustart

Alert-Definition

- alert: TrivyCriticalCountIncreasedForExistingImage
  expr: |
    (
      max_over_time(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"}[10m])
      -
      max_over_time(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"}[10m] offset 15m)
    ) > 0
  labels:
    severity: critical
    source: trivy
    alert_type: image_delta
  annotations:
    summary: "🚨 CRITICAL Vulnerabilities gestiegen"
    description: |
      Der CRITICAL-Vulnerability-Count ist für ein bestehendes Image gestiegen.
      Namespace: {{ $labels.namespace }}
      Image: {{ $labels.image_registry }}/{{ $labels.image_repository }}:{{ $labels.image_tag }}
      Digest: {{ $labels.image_digest }}

6. Alert B - Neues Image mit CRITICAL Vulnerabilities

Fachliche Bedeutung

„Ein Image ist neu im Cluster und enthält bereits bekannte CRITICAL Vulnerabilities.“

Das ist kein CVE-Update, sondern ein Deployment-Event.

Auch Alert B wird auf stabile Workloads eingeschränkt, damit jede CronWorkflow-Ausführung nicht als “neues Image” gezählt wird.


Warum unless und nicht absent()

absent():

  • global
  • liefert keine Image-Labels
  • ungeeignet für per-Image-Vergleiche

unless:

  • echte Mengen-Differenz
  • funktioniert pro Zeitreihe
  • genau das, was wir brauchen

PromQL-Logik

(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"} > 0)
unless
(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"} offset 15m)

Bedeutung:

  • jetzt vorhanden
  • vor 15 Minuten nicht vorhanden
  • → neues Image

Alert-Definition

- alert: TrivyNewImageWithCriticalVulnerabilitiesDetected
  expr: |
    (trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"} > 0)
    unless
    (trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"} offset 15m)
  labels:
    severity: warning
    source: trivy
    alert_type: image_new
  annotations:
    summary: "⚠️ Neues Image mit CRITICAL Vulnerabilities"
    description: |
      Ein neues Image ist im Cluster aufgetaucht und enthält CRITICAL Vulnerabilities.
      Namespace: {{ $labels.namespace }}
      Image: {{ $labels.image_registry }}/{{ $labels.image_repository }}:{{ $labels.image_tag }}
      Digest: {{ $labels.image_digest }}

7. Umgang mit Argo Workflows / kurzlebigen Pods

Problem

Argo CronWorkflows erzeugen kurzlebige Pods, die nach Abschluss gelöscht werden.
Der Trivy Operator erzeugt dazu jeweils neue VulnerabilityReports, wodurch Prometheus-Metriken “neu erscheinen” und Alerts getriggert würden.

Konsequenz

Ohne Filterung führt das dazu, dass bei jedem Workflow-Lauf Alerts entstehen, obwohl sich an der Security-Lage nichts geändert hat.

Lösung

Alerts werden bewusst auf stabile Workloads eingeschränkt, indem resource_kind gefiltert wird:

  • erlaubt: Deployment|StatefulSet|DaemonSet|ReplicaSet
  • ignoriert: Pod|Job|ReplicaSet|CronJob (je nach Trivy Operator Version/Labeling)

Damit werden Workflows weiterhin durch Trivy gescannt und im Dashboard sichtbar, aber lösen keine Event-Alerts aus.

Optional: Workflow-spezifische Alerts

Falls wir CronWorkflows dennoch überwachen wollen, sollte dafür ein separater Alert-Typ genutzt werden, z.B.:

  • “First seen in 24h” (max. 1 Alert pro Tag & Image)
  • oder nur Namespace-gebunden (z.B. namespace="argo")

Wichtig: Diese Alerts müssen stark gedrosselt werden (repeat_interval >= 24h), sonst ist MS Teams nutzlos.


8. Warum Alertmanager + prometheus-msteams?

Problem

Alertmanager kann:

  • Webhooks
  • Slack
  • Email
    aber kein MS Teams nativ.

Lösung: prometheus-msteams

  • übersetzt Alertmanager-Webhooks → Teams MessageCards
  • leichtgewichtig
  • etabliert
  • gut anpassbar per Template

Image:

quay.io/prometheusmsteams/prometheus-msteams

9. MS Teams Template - bewusste Entscheidungen

Warum activityTitle

  • stabil gerendert
  • keine Überraschungen mit Markdown
  • akzeptiert einzeiligen Text zuverlässig
"activityTitle": "{{ $alert.Annotations.description }}"

Warum kein ExternalURL

  • Alertmanager läuft intern
  • der Link war für Empfänger nicht nutzbar
  • besser: gar kein Link als ein kaputter

10. Alertmanager-Routing (Konzept)

Wir unterscheiden Alerts über Labels:

alert_type: image_delta
alert_type: image_new

Typische Nutzung:

  • image_delta → Security-Channel
  • image_new → Plattform-/Ops-Channel
  • send_resolved: false (Event-Alerts)

11. Testing & Betrieb

Wichtig für Tests

  • offset braucht Historie
  • ohne PVC → Prometheus-Restart = Historie weg
  • in Minikube: kleine Offsets (5-15m)

Nützliche Debug-Queries

trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"}
(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"} > 0)
unless
(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"} offset 15m)
max_over_time(trivy:critical_vuln_count_by_image{resource_kind=~"Deployment|StatefulSet|DaemonSet|ReplicaSet"}[10m])

12. Zusammenfassung

  • Trivy liefert Metriken, keine Events
  • Prometheus ist notwendig für Zeitvergleiche
  • Recording Rule = saubere Basis pro Image
  • Zwei Alerts, nicht einer
  • unless für „neu“, offset-Differenz für „gestiegen“
  • MS Teams via prometheus-msteams
  • Alert-Semantik bewusst getrennt