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.
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
sudo apt install postgresql
listen_addresses = 'localhost'
listen_addresses = '*'
sudo systemctl restart postgresql
sudo -u postgres psql
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;
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
sudo apt install bzip2
mkdir oscap
cd oscap
#!/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
#!/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
chmod +x oscap.sh
Ich rufe die crontab-Bearbeitung für root mit
sudo crontab -e
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
0 3 * * * /home/andreas/oscap.sh > /var/log/oscap.log 2>&1
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.
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.
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()
}
};
});
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);
<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>
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')
Server ohne Report: {{ $json.hostname }}
Für den Server {{ $json.hostname }} wurde heute kein Report erstellt.
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.
sudo su
<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>
mkdir /var/www/html/n8n-monitoring
cd /var/www/html/n8n-monitoring
<?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>
<?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));
}
?>
<?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>
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; }
systemctl a2enable n8n-monitoring
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.