Vulnerability-Check mit OpenScap und n8n

Einleitung

Die Prüfung von IT-Systemen auf Verwundbarkeit gegenüber bekannten Sicherheitslücken ist eine wichtige IT-Sicherheitsmaßnahme. Mit OpenSCAP kann man seine Systeme umfassend analysieren. OpenSCAP ist eine freie Implementierung des SCAP-Standards (Security Content Automation Protocol) und nutzt die internationale CVE-Datenbank. Canonical und Debian.org bieten ständig aktualisierte Kataloge für die Distributionen Ubuntu Linux und Debian Linux an. Mit diesen Katalogen lassen sich die Systeme prüfen. So erhält man eine Prüfung, ob die Systeme gegenüber aktuellen Sicherheitslücken verwundbar sind.

Die Überprüfung der einzelnen Server in meinem Netzwerk kann ich bequem per Shell-Script und Cronjob je Server einrichten. Ich möchte aber die Ergebnisse zentral in einer Datenbank speichern und über gefundene Schwachstellen umgehend informiert werden. Also dachte ich mir, dass das eine nette kleine Fingerübung für einen Workflow mit n8n wäre. Die Automatisierungsplattform n8n bietet eine komfortable grafische Oberfläche, um Workflows ohne oder mit wenig Scripting abzubilden. Daher spricht man hier auch von einer No-Code/Low-Code-Umgebung. Man kann n8n selbst hosten oder bei verschiedenen Anbietern Lösungsangebote in Anspruch nehmen. Die Community Edition ist für den Privateinsatz durchaus geeignet. Ich habe sie unter Docker installiert, was wirklich einfach ist.

Für meine Zwecke habe ich zwei einfache Workflows definiert.

Abbildung: Zwei Workflows in n8n
Abb. 1: Zwei Workflows in n8n
Der Erste nimmt die Meldungen der Server entgegen, verarbeitet sie, speichert sie in der Datenbank und sendet ggf. eine Mail, wenn etwas gefunden wurde. Der Zweite prüft, ob alle Server eine Meldung abgegeben haben und sendet ggf. eine Mail, wenn ein Server fehlt. Detailliert gehe ich im Folgenden noch auf diese Workflows ein.



Der Datenbankserver

Installation

Die Ergebnisse der regelmäßigen Überprüfungen möchte ich zunächst für ggf. weitere Recherchen zentral sammeln. Dazu verwende ich eine PostgreSQL-Datenbank. Da ich das auch für andere Zwecke (z.B. meine Paperless-Installationen) benötige, nutze ich einen dedizierten Linux-Server, auf dem ich PostreSQL installiere. Das könnte aber auch als Docker-Container auf einem anderen Server mit laufen. Ich nutze Debian in der Version 13. Mit anderen Linux-Servern z.B. Ubuntu sollte das aber genauso funktionieren. Ich melde mich auf der Konsole des Servers an, um die Befehle auf der Kommandozeile abzusetzen. Zunächst stelle ich sicher, dass das System auf dem aktuellen Stand ist mit


sudo apt update && sudo apt upgrade -y
Dann installiere ich zunächst den Postgresql-Server

sudo apt install postgresql
            
In der Datei "/etc/postgresql/{PostgreSQL-Version}/main/postgresql.conf" muss ich den Eintrag

listen_addresses = 'localhost'
            
in

listen_addresses = '*'
            
ändern, damit der Datenbankserver auf Anfragen aus dem Netz reagiert. Man könnte hier feste IP-Adressen eingeben, aber da die CIDR-Schreibweise für ganze Subnetze (z.B. 192.168.0.0/24) hier nicht funktioniert, gebe ich das System komplett frei. Der Server befindet sich in meinem lokalen Netz und ist von außen nicht erreichbar. Danach muss der PostgreSQL-Dienst mit

sudo systemctl restart postgresql
            
neu gestartet werden. Dann melde ich mich auf der lokalen Konsole an dem Datenbankserver an, um den User und die Datenbank anzulegen:

sudo -u postgres psql
            
und dann auf der Postgresql-Konsole:

CREATE USER n8n with password '123456';
CREATE DATABASE n8n;
\c paperless
GRANT ALL PRIVILEGES ON DATABASE n8n TO n8n;
CREATE TABLE server_scans (
    id SERIAL PRIMARY KEY,
    hostname TEXT,
    finding TEXT,
    detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
EXIT;
            
Hier wird also zunächst der User n8n mit dem (natürlich total unsicheren) Passwort 123456 angelegt. Dann wird die Datenbank n8n angelegt und zur Bearbeitung aufgerufen (\c [Datenbankname]). Hier wird nicht wie ansonsten immer bei Datenbankbefehlen ein Semikolon angehängt. Schließlich werden dem User n8n alle Rechte auf diese Datenbank eingerichtet. Die Datenbankbefehle enden immer mit einem Semikolon. Groß- und Kleinschreibung ist dabei aber egal.
Danach kann ich mich von dieser Konsole und auch von dem Server wieder abmelden.



Installation n8n

Die Installation von n8n habe ich hier: Serveranwendungen/n8n beschrieben. Im Weiteren setze ich darauf auf, dass mein n8n-Server unter der Adresse 192.168.0.93 dreht und der entsprechende Dienst auf Port 5678 lauscht.



Installation OpenSCAP auf den Servern und Einrichtung der Scans

OPenscap kann ganz einfach mit dem Befehl


sudo apt install openscap-scanner
installiert werden. Die Paketverwaltung erkennt die Abhängigkeiten und installiert sie (ggf. nach Nachfrage) gleich mit. Ich benötige auch noch bzip2, was unter Debian in meiner Minimalinstallation nicht installiert ist. Also installiere ich auch das mit

sudo apt install bzip2
Für OpenScap lege ich einen Ordner im Homeshare des Hauptbenutzers an. Es gibt gute Argumente, einen anderen Ort dafür zu verwenden, aber auf dogmatische Diskussionen darüber habe ich keine Lust.

mkdir oscap
cd oscap
Nun kann ich die Definitionsdateien für die Verwundbarkeitsprüfung herunterladen, entpacken und manuell für Prüfungen anwenden. Ich möchte das aber automatisieren und einmal am Tag ausführen lassen. Also nutze ich ein kleines Bash-Script, das über einen Cronjob regelmäßig ausgeführt wird. Für Debian nutze ich folgendes Script:

#!/bin/bash
RELEASE=$(lsb_release -cs)
OVAL_FILE="oval-definitions-$RELEASE.xml"

# 1. Download & Entpacken (leise)
wget -q -N https://www.debian.org/security/oval/$OVAL_FILE.bz2
bzip2 -df $OVAL_FILE.bz2

# 2. Scan ausführen und Ergebnisse als XML speichern
# Wir nutzen --results, um den maschinenlesbaren Output zu erhalten
oscap oval eval --results results.xml --report report.html $OVAL_FILE

# Ergebnisdatei loeschen
rm summary.txt
# Erzeugt eine kompakte Liste der fehlgeschlagenen Tests

# 1. IDs aller Treffer finden (ohne Inventory/OS-Check)
mapfile -t IDS < <(grep ' summary.txt
for id in "${IDS[@]}"; do
    # Extrahiere Titel und Referenzen/Links für die ID
    echo "--- Finding: $id ---" >> summary.txt
    
    # Sucht den Titel (metadata > title)
    sed -n "//p" results.xml | grep ']*>//g' | xargs >> summary.txt
    
    # Extrahiere alle Referenzen (Links/CVEs)
    sed -n "//p" results.xml | grep '> summary.txt
    
    echo "" >> summary.txt
done

# Falls die Datei danach leer ist, gab es keine Treffer.
if [ ! -s summary.txt ]; then
  echo "INFO: Keine Schwachstellen gefunden." > summary.txt
fi

# Sende nur die kompakte Zusammenfassung
curl -X POST "http://192.168.0.93:5678/webhook/oscap-report?server=$(hostname)" \
     -H "Content-Type: text/plain" \
     --data-binary @summary.txt

Für Ubuntu muss ich ein etwas anderes Script verwenden:

#!/bin/bash

RELEASE=$(lsb_release -cs)
OVAL_FILE="oval-definitions-$RELEASE.xml"

# 1. Download & Entpacken (leise)
wget -q -N https://security-metadata.canonical.com/oval/com.ubuntu.$(lsb_release -cs).usn.oval.xml.bz2
bzip2 -df com.ubuntu.$(lsb_release -cs).usn.oval.xml.bz2

# 2. Scan ausfuehren und Ergebnisse als XML speichern
# Mit --results erhalte ich maschinenlesbaren Output
# und mit--report eine schoene HTML-Uebersicht
oscap oval eval --results results.xml --report report.html com.ubuntu.$(lsb_release -cs).usn.oval.xml >> dev 0

# Ergebnisdatei loeschen
rm summary.txt
# Erzeugt eine kompakte Liste der fehlgeschlagenen Tests

# 1. IDs aller Treffer finden (ohne Inventory/OS-Check)
mapfile -t IDS < <(grep ' summary.txt
for id in "${IDS[@]}"; do
    # Extrahiere Titel und Referenzen/Links für die ID
    echo "--- Finding: $id ---" >> summary.txt
    
    # Sucht den Titel (metadata > title)
    sed -n "//p" results.xml | grep ']*>//g' | xargs >> summary.txt
    
    # Extrahiere alle Referenzen (Links/CVEs)
    sed -n "//p" results.xml | grep '> summary.txt
    
    echo "" >> summary.txt
done

# Falls die Datei danach leer ist, gab es keine Treffer.
if [ ! -s summary.txt ]; then
  echo "INFO: Keine Schwachstellen gefunden." > summary.txt
fi

# Sende nur die kompakte Zusammenfassung
curl -X POST "http://192.168.0.93:5678/webhook/oscap-report?server=$(hostname)" \
     -H "Content-Type: text/plain" \
     --data-binary @summary.txt

Hierbei nehme ich etwas vorweg: Ich habe die zusammengefassten Ergebnisse in diesen Scripts an einen n8n-Webhook geschickt. Das erläutere ich im Folgenden. Wenn keine Schwachstellen gefunden werden, dann steht nach Ausführung des Scripts in der Datei summary.txt "INFO: Keine Schwachstellen gefunden.". Ich speichere das Skript unter dem Namen "oscap.sh" und muss es zunächst ausführbar machen mit

chmod +x oscap.sh
Da ich das nachts automatisiert ausführen lassen möchte, lege ich noch einen Cronjob an. Dieser soll mit Administratorenrechten laufen, weil ich die entstehenden Ausgaben in eine Datei in das Verzeichnis /var/www/log umlenken möchte.

Ich rufe die crontab-Bearbeitung für root mit

sudo crontab -e
            
auf. Wenn ich bisher keinen Standard-Editor für die Bearbeitung der Crontab angegeben habe, werde ich nun danach gefragt. Hier wähle ich mit Enter den vorgeschlagenen Editor nano aus:

no crontab for root - using an empty one

Select an editor.  To change later, run 'select-editor'.
  1. /bin/nano        <---- easiest
  2. /usr/bin/vim.basic
  3. /usr/bin/vim.tiny
  4. /bin/ed

Choose 1-4 [1]: 1
            
An das Ende dieser Datei trage ich dann ein:

0 3 	* * *	/home/andreas/oscap.sh > /var/log/oscap.log 2>&1
            
Am Anfang der Zeile wird definiert, wann der Cronjob ausgeführt werden soll. An erster Stelle werden die Minuten, dann die Stunden, dann der Tag des Monats (1-31), der Monat (1-12), der Wochentag (Sonntag = 0, Samstag = 6) angegeben. Darauf folgt das auszuführende Kommando. Hier wird also definiert, dass jede Nacht um 3:00 Uhr das Script ausgeführt wird. Die dabei entstehenden Ausgaben werden in eine Datei oscap.log unter /var/log/ umgeleitet, damit der Root-User nicht mit Mails zugeschüttet wird.



Einrichtung der Workflows in n8n

Zur Vorbereitung: Einrichtung der Credentials für den Zugriff auf die Datenbank und für eMail

Auf der Startseite der Web-GUI von n8n rufe ich den Dialog Credentials auf und klicke auf Create credential. Im darauf folgenden Dialog wähle ich als Service, mit dem ich mich verbinden möchte, "Postgres" aus. Dann kann ich die Zugangsdaten für die eben angelegte Datenbank angeben. Unter Host trage ich die Domain (bei entsprechender Namensauflösung) oder IP-Adresse des Datenbankservers ein. Datenbank und User habe ich n8n genannt. Außerdem habe ich bei Anlage des Users ein Passwort vergeben, das ich hier nun eintrage. Alle anderen Parameter lasse ich unverändert. da ich hier unverschlüsselt im Netz unterwegs bin.

Abbildung: Einrichtung Credentials
Abb. 2: Einrichtung der Credentials für die DB
Dann rufe ich erneut den Dialog zur Einrichtung neuer Credentials auf und wähle diesmal als App oder Service "smtp" aus. Die hier einzutragenden Daten sind selbsterklärend.



Workflow zum Sammeln der Meldungen und Verschicken von Warnmeldungen

Einen neuen Workflow erstellt man, indem man auf der Weboberfläche von n8n links oben auf das "+"-Symbol klickt und in dem sich öffnenden Kontextmenü "Workflow" auswählt. Zunächst braucht ein Workflow immer einen Auslöser. Hier kommt ein sogenannter Webhook zum Einsatz. Das heißt, dass n8n später einen URL bereit stellt, den man aufrufen kann, um den Workflow zu starten. Ich klicke auf "Add first step..." und gelange in den Dialog der Triggerauswahl, wo ich "On webhook call" auswähle.

Abbildung: Einrichtung des Webhooks
Abb. 3: Einrichtung des Webhooks
In dem sich anschließenden Dialog ändere ich zunächst die Methode von "get" auf "post". Das bedeutet, der Webhook wird angestoßen und bekommt dabei Parameter übergeben. Path ist eine Variable, die ich frei wählen kann. Sie wird Bestandteil des Links, mit dem der Webhook dann aufgerufen wird. Authentifizierung brauche ich hier keine und der Parameter "Respond" muss "onReceived" lauten, damit der Webhook auf das Aufrufen des Links reagiert. In dieser Phase ist für den Aufruf noch ein Test-URL angezeigt. Damit kann ich den Workflow jederzeit testweise starten. Später nutze ich natürlich den Production URL.

Sollte beim Scannen etwas gefunden werden, dann steht das wie folgt in der Datei summary.txt:

--- Finding:oval:org.debian:def:329861743654059782092450077162787276103 ---\nCVE-2026-28387 openssl\nLink: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-28387

Von der Art können dann mehrere Einträge in der Datei stehen. Pro Eintrag soll ein Datensatz in die Datenbank geschrieben werden. Also übertrage ich die Einträge zunächst in ein Array. Dazu verwende ich in n8n eine Code Node. Ich klicke also im n8n-Editor auf das Plus-Symbol hinter der Trigger-Node und wähle im Auswahldialog für die neue Node "Code" und dann "Code in JavaScript" aus. Hier füge ich dann folgenden Code ein:


// Liest den gesamten Text der summary.txt ein (jetzt korrekt aus .body)
const rawText = items[0].json.body || ""; 
// Stelle sicher, dass das Feld 'server' im Input der Node existiert
const server = $input.first().json.query.server || "Unbekannter Server";

// Wir trennen den Text am String "--- Finding:"
const findingBlocks = rawText.split('--- Finding:').filter(f => f.trim().length > 0);

if (findingBlocks.length === 0) {
    return [{
        json: {
            server: server,
            finding: "Keine Schwachstellen gefunden"
        }
    }];
}

return findingBlocks.map(block => {
    return {
        json: {
            // WICHTIG: Nutze 'hostname', wenn dein Aggregations-Script danach sucht!
            server: server, 
            finding: "--- Finding:" + block.trim()
        }
    };
});
Nun füge ich die Node für die Datenbank-Aktion hinzu. Ich wähle aus der Node-Auswahl rechts zunächst Postgres aus und darunter die Aktion "Insert Rows in a Table". Hier wähle ich zunächst die Credentials für die Datenbank aus (denen ich durchaus einen sprechenderen Namen hätte geben können. Die Aktion ist "insert". Für die Auswahl der Datenbanktabelle stelle ich die Schemaauswahl auf "public" bzw. belasse sie dabei. Die Datenbank enthält lediglich eine Tabelle, nämlich "server_scans". Daher fällt die Auswahl leicht. Dann werden mir die in der Tabelle verfügbaren Datenfelder angeboten bzw. alle Felder werden direkt vorausgewählt. Für das Feld id sollen keine Daten übergeben werden. Das wird vom Datenankmanagementsystem befüllt. Darum lösche ich es hier mit dem Mülleimersymbol. Die weiteren Parameter kann ich bequem aus der Auswahl auf der linken Bildschirmseite herüberziehen. Ich greife sie einfach mit der Maus, ziehe sie über die entsprechenden Tabellenfelder und lasse los. Für das Feld detected_at füge ich die Expression "{{ $now.toISO() }}" ein (s. Abbildung).
Abbildung: Node Insert rows in a table
Abb. 4: Node Insert rows in a table
Ich möchte eine Mail bekommen, wenn bei den Scans etwas gefunden wird. Wenn nichts gefunden wird, dann möchte ich keine Mail bekommen. Daher muss ich noch einen Filter definieren, der nach der Datenbankaktion nur dann Daten weiter durchreicht, wenn etwas gefunden wurde. Hierfür wähle ich eine Node "Filter" aus. Hier ziehe ich das Feld "finding" in den linken Teil der Bedingung und wähle die Bedingung "does not contain" aus. Darunter schreibe ich dann die Zeichenkette "Keine Schwachstellen gefunden (ohne Anführungszeichen). Ich wähle noch aus "Convert types where required", damit n8n nötigenfalls eine Formatkonvertierung durchführt, wenn es nicht passen sollte. Da wäre zwar eigentlich nicht nötig, kann aber auch nicht schaden und macht das Ganze vielleicht etwas robuster. Nun wird die Verarbeitung nur weiter ausgeführt, wenn Schwachstellen gefunden wurden.

Das Ergebnis könnte ich nun schon in einer Mail verarbeiten. Allerdings möchte ich pro Server nur eine Mail und die möchte ich auch noch ein wenig aufhübschen und umformatieren. Ich möchte, dass jedes Einzelergebnis in einer separaten Zeile steht und im Betreff (einmal) der betroffene Servername. Mit einer Aggregation-Node könnte ich alle in einem Durchlauf übermittelten Datensätze zusammenfassen, damit nur eine Mail dafür generiert wird. Diese Node würde aber alle Felder entsprechend Aggregieren, so dass im Betreff der Servername mehrfach vorkommen würde. Ich muss also wieder etwas coden. Dazu wähle ich wieder eine Code-Node mit Code in JavaScript aus und füge folgenden Code ein:

const aggregated = {};

for (const item of items) {
    const server = item.json.hostname || "Unbekannt";
    const finding = item.json.finding;

    if (!aggregated[server]) {
        aggregated[server] = {
            json: {
                server: server,
                findings: [],
                // Nutze ISO für die DB oder lokal für die Mail
                scan_date: new Date().toLocaleString('de-DE') 
            }
        };
    }
    
    if (finding) {
        // Wir speichern das Finding. Da es nun mehrzeilig ist, 
        // wird es später in der Mail formatiert.
        aggregated[server].json.findings.push(finding);
    }
}
return Object.values(aggregated);
Nun kann ich eine Node Send an Email anfügen. Als Credential wähle ich die zuvor eingebenen SMTP-Credentials aus (s.o.) und Operation ist natürlich "Send". Als Absenderadresse kann ich irgend etwas eintragen. Gut wäre aber, die in den Credentials eingetragene eMail-Adresse zu verwenden. Manche Spamfilter sind da empfindlich. Im Feld"To Email" trage ich natürlich die gewünschte Empfängeradresse ein. Beim Betreff kann ich Felder aus den hier in dieser Node ankommenden Daten eintragen oder eine freie Zeichenkette. Ich tue beides. Ich trage zunächst die Zeichenkette "Sicherheits-Report" (ohne Anführungszeichen) ein und ziehe dann aus der linken Seite das Feld "server" in das Betreff-Feld. Als Format der Mail wähle ich HTML aus, dann kann ich sie mit HTML-Code formatieren. Ich schreibe folgenden HTML-Code in den dafür vorgesehenen Bereich:

<p>Hallo,</p>
<p>auf dem Server <b>{{ $json.server }}</b> wurden folgende Schwachstellen gefunden:</p>
<ul>
  <li>{{ $json.findings.join('</li><li>') }}</li>
</ul>
Die Stellen mit den geschweiften Klammern kann ich auch einfach erstellen, in dem die entsprechenden Parameter per Drag & Drop aus der linken Leiste herüberziehe.



Workflow für die Prüfung auf vollständige Berichte

Nun sollten alle Server, auf denen ich das eingerichtet habe, jede Nacht einen Bericht an n8n senden. Wenn ein Server aus irgendeinem Grund nichts sendet, dann möchte ich das mitbekommen, um darauf reagieren zu können. Dafür richte ich einen zweiten Workflow in n8n ein. Der Workflow soll jeden Morgen um 6:00 Uhr prüfen, ob alles in Ordnung ist. Also richte ich zunächst eine Trigger Node für eine zeitgesteuerte Ausführung ein. Ich wähle auf der Startoberfläche für Workflows aus "Create workflow" und klicke auf "Add first step...". Dann werden mir als erstes Trigger-Nodes angeboten und ich wähle "On a schedule" aus. Hier brauche ich nur den Wert unter "Trigger at Hour" auf "6am" zu ändern. Der Rest passt schon. Dann brauche ich die Node für die Datenbankabfrage. Ich klicke auf das Pluszeichen neben dem Trigger und wähle in dem erscheinenden Dialog "What happens next?" "Postgres" und dann "Execute a SQL query" aus. Hier wähle ich den zuvor eingerichteten Postgres Account aus und belasse die Operation aus "Execute Query". Unter Query trage ich ein:


SELECT DISTINCT hostname from server_scans WHERE hostname NOT IN (SELECT hostname FROM server_scans WHERE detected_at>NOW() - INTERVAL '24 hours')
So werden Datensätze gefunden für Server, die in der Vergangenheit mindestens einmal geliefert haben aber nicht innerhalb der letzten 24 Stunden. Wenn keine Datensätze gefunden werden, die dieser Bedingung entsprechen, dann endet der Workflow hier. Das brauche ich gar nicht mit irgendeiner Bedingungslogik zu definieren. Für den Fall, dass diese Abfrage Ergebnisse ergibt, füge ich eine Sendmail Node an. Ich klicke wieder auf das Pluszeichen der letzten Node und wähle diesmal "Send an Email" aus. Hier wähle ich zunächst den zuvor unter Credentials erstellten Email-Account aus. Die Operation ist natürlich "Send". Unter "From Email" und "To Email" trage ich die (vorhandenen) Email-Adressen für Absender und Empfänger der EMail ein. Unter Subject trage ich ein

Server ohne Report: {{ $json.hostname }}
So wird der Name des säumigen Servers im Email-Betreff aufgeführt. Dann whle ich das Format für die eigentliche Email-Nachricht, also "HTML" aus und trage hier ein:

Für den Server  {{ $json.hostname }} wurde heute kein Report erstellt.
Das war's schon. Nun wird jeden Morgen um 6:00 Uhr geprüft, ob es Server gibt, deren Meldungen fehlen und in dem Fall eine Mail gesendet.



Dashboard für die Ergebnisse

Wenn Schwachstellen gefunden werden, dann erhalte ich nun eine eMail darüber. Ich möchte aber außerdem eine Übersicht haben, in der mir alle Ergebnisse dargestellt werden, verbunden mit der Möglichkeit, Einzelergebnisse detaillierter ansehen zu können. Dazu baue ich mir eine kleine Website mit PHP, HTML und CSS.
Abbildung: Dashboard mit dem Ergebnissen
Abb. 5: Dashboard mit den Ergebnissen
Ich habe einen Apache Webserver in meinem Netzwerk. Wie man so etwas installiert und einrichtet, kann man z.B. hier wiki.ubuntuusers.de/apache_2.4 nachsehen. Da ich nun einige Aktionen mit Administratorenrechten ausführen muss, wechsele ich zunächst in den Root-Modus:

sudo su
Dann richte ich einen virtual Host ein, indem ich eine neue Datei unter /etc/apache2/sites-available mit dem Namen n8n-monitoring.conf erstelle mit folgendem Inhalt:


<VirtualHost *:80>
	# The ServerName directive sets the request scheme, hostname and port that
	# the server uses to identify itself. This is used when creating
	# redirection URLs. In the context of virtual hosts, the ServerName
	# specifies what hostname must appear in the request's Host: header to
	# match this virtual host. For the default virtual host (this file) this
	# value is not decisive as it is used as a last resort host regardless.
	# However, you must set it for any further virtual host explicitly.
	#ServerName www.example.com
	ServerName n8n-monitoring.kernke.lan
	ServerAdmin webmaster@localhost
	DocumentRoot /var/www/html/n8n-monitoring/

	# Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
	# error, crit, alert, emerg.
	# It is also possible to configure the loglevel for particular
	# modules, e.g.
	#LogLevel info ssl:warn

	ErrorLog ${APACHE_LOG_DIR}/error.log
	CustomLog ${APACHE_LOG_DIR}/access.log combined

	# For most configuration files from conf-available/, which are
	# enabled or disabled at a global level, it is possible to
	# include a line for only one particular virtual host. For example the
	# following line enables the CGI configuration for this host only
	# after it has been globally disabled with "a2disconf".
	#Include conf-available/serve-cgi-bin.conf
</VirtualHost>
Dann erstelle ich unter /var/www/html einen Unterorder n8n-monitoring und wechsle in selbigen.

mkdir /var/www/html/n8n-monitoring
cd /var/www/html/n8n-monitoring
Hier erstelle ich nun zunächst die PHP-Datei mit folgendem Inhalt:

<?php include 'config.php'; ?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>Open SCAP Dashboard</title>
    <link rel="stylesheet" href="style.css">
    <link rel="icon" type="image/png" href="favicon-32x32.png" sizes="32x32">
    <link rel="icon" type="image/png" href="favicon-16x16.png" sizes="16x16">

</head>
<body>
    <div class="container">
        <h1>🛡️  Open SCAP Dashboard</h1>
        <table>
            <thead>
                <tr>
                    <th>Status</th>
                    <th>Hostname</th>
                    <th>Letzter Scan</th>
                </tr>
            </thead>
<tbody>
    <?php
    // Die neue Abfrage: Holt den aktuellsten Scan UND zählt alle Findings von heute
    $sql = "SELECT DISTINCT ON (hostname) 
                hostname, 
                finding, 
                detected_at,
                (SELECT COUNT(*) FROM server_scans s2 
                 WHERE s2.hostname = server_scans.hostname 
                 AND s2.detected_at::date = CURRENT_DATE 
                 AND s2.finding NOT LIKE '%Keine Schwachstellen gefunden%') as finding_count
            FROM server_scans 
            ORDER BY hostname, detected_at DESC";

    $stmt = $pdo->query($sql);
    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

foreach ($rows as $row) {
    $isClean = (stripos($row['finding'], "Keine Schwachstellen gefunden") !== false);
    $statusClass = $isClean ? 'status-green' : 'status-red';
    $date = !empty($row['detected_at']) ? date("d.m.Y H:i", strtotime($row['detected_at'])) : "Unbekannt";
    
    // Anzahl der Findings (Subquery-Ergebnis aus dem vorigen Schritt)
    $count = (int)$row['finding_count'];
    
    // Wir färben die Zahl nur rot, wenn sie > 0 ist, sonst dezentes Grau
    $badgeColor = ($count > 0) ? "#e74c3c" : "#7f8c8d";
    $countDisplay = "<span class='count-badge' style='color: $badgeColor;'>($count)</span>";

    echo "<tr>";
    // ZELLE 1: Ampel + Zähler
    echo "<td><span class='dot $statusClass'> </span> $countDisplay</td>";
    
    // ZELLE 2: Hostname (jetzt ohne Klammer dahinter)
    echo "<td><a href='detail.php?host=" . urlencode($row['hostname']) . "'>" . htmlspecialchars($row['hostname']) . "</a></td>";
    
    // ZELLE 3: Datum
    echo "<td>" . $date . "</td>";
    echo "</tr>";
}

    ?>
</tbody>
        </table>
    </div>
</body>
</html>
Die Credentials für die Datenbank werden in der Datei config.php übergeben. Die erstelle ich mit folgendem Inhalt:

<?php
$host = "192.168.0.86";
$db   = "oscap";
$user = "n8nleser";
$pass = "PASSWORT";

$dsn = "pgsql:host=$host;port=5432;dbname=$db;";
try {
    $pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
} catch (PDOException $e) {
    die("Verbindung fehlgeschlagen: " . $e->getMessage());
}

// Hilfsfunktion für die Link-Konvertierung
function formatFinding($text) {
    // Sucht nach http/https Links und macht sie anklickbar
    $pattern = '/(https?:\/\/[^\s]+)/';
    $replacement = '<a href="$1" target="_blank">$1</a>';
    $text = preg_replace($pattern, $replacement, $text);
    // Zeilenumbrüche für HTML erhalten
    return nl2br(htmlspecialchars_decode($text));
}
?>
Für die Detailansicht der Ergebnisse brauche ich noch folgende Datei details.php:


<?php 
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
include 'config.php'; 
$host = $_GET['host'] ?? die("Kein Host angegeben.");
?>
<!DOCTYPE html>
<html lang="de">
<head>
    <meta charset="UTF-8">
    <title>Details: <?php echo htmlspecialchars($host); ?></title>
    <link rel="stylesheet" href="style.css">
    <link rel="stylesheet" href="style.css">
    <link rel="icon" type="image/png" href="favicon-32x32.png" sizes="32x32">
    <link rel="icon" type="image/png" href="favicon-16x16.png" sizes="16x16">

</head>
<body>
    <div class="container">
        <a href="index.php" class="back-link">← Zurück zur Übersicht</a>
        <h1>Details für: <?php echo htmlspecialchars($host); ?></h1>

        <section>
            <h2>Aktuelle Funde (Heute)</h2>
            <?php
            $sqlToday = "SELECT finding FROM server_scans 
                         WHERE hostname = ? AND detected_at::date = CURRENT_DATE 
                         ORDER BY detected_at DESC";
            $stmt = $pdo->prepare($sqlToday);
            $stmt->execute([$host]);
            $results = $stmt->fetchAll();

            if (!$results) echo "<p>Keine Einträge für heute gefunden.</p>";
            foreach ($results as $row) {
                echo "<div class='finding-box'>" . formatFinding($row['finding']) . "</div>";
            }
            ?>
        </section>

        <hr>

        <section>
            <h2>Historie (Letzte 5 Tage)</h2>
            <?php
            // Gruppierung nach Datum für die letzten 5 Tage (ohne heute)
            $sqlHist = "SELECT detected_at::date as scan_date, finding 
                        FROM server_scans 
                        WHERE hostname = ? AND detected_at::date < CURRENT_DATE 
                        AND detected_at::date >= CURRENT_DATE - INTERVAL '5 days'
                        ORDER BY scan_date DESC, detected_at DESC";
            $stmt = $pdo->prepare($sqlHist);
            $stmt->execute([$host]);
	    $hasHistory = false;
            $currentDate = "";
	            while ($row = $stmt->fetch()) {
			$hasHistory = true; // Sobald wir hier landen, gibt es mindestens einen Datensatz
        	        if ($currentDate != $row['scan_date']) {
                	    $currentDate = $row['scan_date'];
	                    echo "<h3 class='date-divider'>" . date("d.m.Y", strtotime($currentDate)) . "</h3>";
        	        }
                	echo "<div class='finding-box history'>" . formatFinding($row['finding']) . "</div>";
	   	}
		// Wenn die Schleife nie durchlaufen wurde, ist $hasHistory weiterhin false
	    	if (!$hasHistory) {
	        	echo "<div class='no-data-msg'>Keine historischen Daten für die letzten 5 Tage gefunden.</div>";
    		}
            ?>
        </section>
    </div>
</body>
</html>
Nun brauche ich noch die Styles, die in folgender Datei definiert werden:

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background-color: #222222;
    color: #333;
    line-height: 1.6;
    margin: 0;
    padding: 20px;
}

.container {
    max-width: 900px;
    margin: 0 auto;
    background-color: #404040;
    padding: 30px;
    border-radius: 8px;
    box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}

h1 { color: #007bff; border-bottom: 2px solid #eee; padding-bottom: 10px; }
h2 { color: white; margin-top: 30px; }

/* --- Tabellen-Anpassungen --- */

table { 
    width: 100%; 
    border-collapse: collapse; 
    margin-top: 20px;
    background-color: transparent; /* Verhindert weißes Durchscheinen */
}

/* Entfernt den weißen Balken im Header */
th { 
    text-align: left; 
    padding: 12px; 
    background-color: transparent !important; /* Entfernt den weißen Hintergrund */
    color: #5dade2 !important; /* Blau wie die Überschrift */
    border-bottom: 2px solid #34495e !important; /* Dunkle, dezente Trennlinie statt Weiß */
    text-transform: uppercase;
    font-size: 0.85rem;
}

/* Alle Zellen standardmäßig weiß färben */
td { 
    padding: 12px; 
    border-bottom: 1px solid #2c3e50 !important; /* Dunkle Linien zwischen den Zeilen */
    color: #ffffff !important; /* Weißer Text für alle Spalten */
    vertical-align: middle;
}

/* Die Links in der mittleren Spalte (Hostname) */
table td:nth-child(2) a {
    color: #3498db !important; /* Ein schönes Blau für die Links */
    font-weight: bold;
}

/* Spezifische Farbe für den Zeitstempel (falls du ihn leicht grau absetzen willst) */
table td:nth-child(3) {
    color: #bdc3c7 !important; /* Ein helles Grau für das Datum */
}

/* Entfernt die letzte Linie am Ende der Tabelle */
tr:last-child td {
    border-bottom: none !important;
}

/* Die Ampel-Punkte */
.dot {
    height: 12px;
    width: 12px;
    border-radius: 50%;
    display: inline-block;
    vertical-align: middle;
    margin-right: 5px;
    border: 1px solid rgba(0,0,0,0.1); /* Leichter Rand zur Abgrenzung */
}

.status-green { 
    background-color: #2ecc71 !important; 
    box-shadow: 0 0 5px #2ecc71; 
}

.status-red { 
    background-color: #e74c3c !important; 
    box-shadow: 0 0 5px #e74c3c; 
}

/* Tabellen-Styling für bessere Lesbarkeit */
table {
    width: 100%;
    border-collapse: collapse;
}

td {
    padding: 10px;
    vertical-align: middle;
}

/* Finding Boxen für die Detailseite (Dunkles Theme) */
.finding-box {
    background-color: #1e272e !important; /* Sehr dunkles Grau/Blau */
    border-left: 5px solid #d9534f;        /* Der rote Balken bleibt */
    padding: 15px;
    margin-bottom: 15px;
    font-family: 'Courier New', Courier, monospace;
    font-size: 14px;
    color: #ffffff !important;             /* Schrift auf Weiß setzen */
    white-space: pre-wrap;                 /* Erhält Zeilenumbrüche */
    box-shadow: 0 2px 4px rgba(0,0,0,0.3); /* Dezenter Schatten für Tiefe */
}

/* Anpassung für die Historie-Boxen (etwas dezenter) */
.finding-box.history {
    border-left-color: #576574;            /* Grauer Balken für alte Funde */
    background-color: #161d23 !important;  /* Noch ein Stück dunkler */
    color: #bdc3c7 !important;             /* Hellgraue Schrift für Historie */
}

/* Sicherstellen, dass Links in den Boxen lesbar bleiben */
.finding-box a {
    color: #5dade2 !important;             /* Hellblau für die Links */
    text-decoration: underline;
}

/* Die Trennlinie auf der Detailseite abdunkeln */
hr {
    border: 0;
    border-top: 1px solid #34495e;
    margin: 30px 0;
}

/* Der Zurück-Link oben */
.back-link {
    color: #bdc3c7 !important;
    text-decoration: none;
    font-size: 0.9rem;
}

.back-link:hover {
    color: #ffffff !important;
}

/* Datums-Trenner in der Historie (n8n14 Fix) */
.date-divider {
    color: white;
    padding: 10px 15px;
    margin-top: 30px;
    margin-bottom: 15px;
    border-radius: 4px;
    font-size: 14px;
    font-weight: bold;
    border-left: 4px solid #5dade2;      /* Akzentstreifen passend zur Schrift */
    box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}

/* Falls unter dem Datum noch Text steht, der weiß sein soll */
section h2 {
    color: white;
/* #d9534f; /* Das Rot für die Überschriften "Aktuelle Funde" / "Historie" */
    margin-bottom: 20px;
}

/* Der Text "Keine historischen Daten gefunden" (falls aktiv) */
section p {
    color: white;
    font-style: italic;
}

.no-data-msg {
    color: #bdc3c7; /* Dezentes Grau */
    font-style: italic;
    padding: 20px;
    background-color: rgba(255, 255, 255, 0.05);
    border-radius: 4px;
    text-align: center;
    margin-top: 10px;
}

/* Die Zähler-Klammer hinter der Ampel */
.count-badge {
    font-family: 'Courier New', Courier, monospace; /* Wirkt technischer */
    font-size: 0.9em;
    font-weight: bold;
    margin-left: 8px; /* Abstand zum Punkt */
    display: inline-block;
    vertical-align: middle;
}

/* Sicherstellen, dass die Ampel-Zelle genug Platz bietet */
table td:first-child {
    min-width: 80px;
    white-space: nowrap;
}

a { color: #007bff; text-decoration: none; }
a:hover { text-decoration: underline; }

.back-link { font-size: 14px; color: #666; }
Nun muss ich diesen virtual Host noch aktivieren mit

systemctl a2enable n8n-monitoring
und kann das Dashboard unter der Adresse "http://{Adresse des Webservers}/n8n-monitoring/ aufrufen. Eleganter ist es natürlich, wenn ich einen Proxy Host einrichte und das Dashboard unter der Adresse aufrufen kann, die ich vorher in der Definition des virtual Hosts als Servernamen eingegeben habe. So mache ich das natürlich.



Fazit

Ich habe diese Scans nun auf allen Servern in meinem Homelab eingerichtet. Wenn in der CVE-Datenbank neue Sicherheitslücken veröffentlicht werden, dann werden die Server auf die Anfälligkeit für diese Lücken geprüft. Wenn ein System Verwundbarkeiten aufweist, dann erhalte ich eine entsprechende Benachrichtigung per Email. Außerdem kann ich mir jederzeit einen Überblick über das hier beschriebene Dashboard verschaffen. Auf Sicherheitslücken muss ich dann natürlich reagieren. So habe ich z.B. die Erkenntnis gewonnen, dass Canonical Sicherheitslücken in manchen Bibliotheken für die Ubuntu Linux Version 24.04 LTS nur in der Pro Edition fixed. Das hat dazu geführt, dass ich meinen Nextcloud Server von Ubuntu nach Debian migriert habe. Das ist ein anderes Projekt, das ich hier bei Gelegenheit auch einmal dokumentieren könnte.