From 117bb582de58dcff61ac14021f8a754c99b62fdf Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 7 Oct 2026 10:47:30 +0200 Subject: [PATCH] first commit --- README.md | 162 ++++++ collector/Dockerfile | 13 + collector/collector.py | 495 ++++++++++++++++++ common/sshpull_hosts.py | 172 ++++++ config/certs.yml | 16 + config/wazuh_cluster/wazuh_manager.conf | 311 +++++++++++ .../wazuh_dashboard/opensearch_dashboards.yml | 16 + config/wazuh_indexer/wazuh.indexer.yml | 36 ++ docker-compose.yml | 144 +++++ hosts.yml.example | 81 +++ manager/Dockerfile | 10 + manager/agentless/sshpull_generic_diff | 35 ++ manager/agentless/sshpull_integrity_linux | 159 ++++++ manager/entrypoint-scripts/50-sshpull.sh | 9 + manager/generate.py | 137 +++++ manager/rules/sshpull_rules.xml | 85 +++ setup.sh | 179 +++++++ ssh/.gitkeep | 0 18 files changed, 2060 insertions(+) create mode 100644 README.md create mode 100644 collector/Dockerfile create mode 100644 collector/collector.py create mode 100644 common/sshpull_hosts.py create mode 100755 config/certs.yml create mode 100644 config/wazuh_cluster/wazuh_manager.conf create mode 100644 config/wazuh_dashboard/opensearch_dashboards.yml create mode 100644 config/wazuh_indexer/wazuh.indexer.yml create mode 100644 docker-compose.yml create mode 100644 hosts.yml.example create mode 100644 manager/Dockerfile create mode 100755 manager/agentless/sshpull_generic_diff create mode 100755 manager/agentless/sshpull_integrity_linux create mode 100755 manager/entrypoint-scripts/50-sshpull.sh create mode 100644 manager/generate.py create mode 100644 manager/rules/sshpull_rules.xml create mode 100755 setup.sh create mode 100644 ssh/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..73a42c5 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Wazuh in Docker – Daten per SSH abholen + +Wazuh 4.14 (Manager, Indexer, Dashboard) als Single-Node in Docker. Die überwachten Hosts brauchen +**keinen Agent und keine eingehende Verbindung zum Server**. Alles läuft über SSH, das der Server +zu den Hosts aufbaut. + +``` + ┌──────────────────────── Docker ───────────────────────────┐ + Host web01 ◄──ssh──┐ │ sshpull.collector ──► /remote-logs//*.log ──┐ │ + Host db01 ◄──ssh──┼─┤ (journalctl -f / tail -F, Reconnect + Resume) │ │ + Host ... ◄──ssh──┤ │ ▼ │ + │ │ wazuh.manager ◄── (Decoder/Regeln) │ + └─┤ wazuh-agentlessd: FIM + Befehls-Diffs per ssh │ + │ │ filebeat │ + │ ▼ │ + │ wazuh.indexer ◄── wazuh.dashboard :443 │ + └───────────────────────────────────────────────────────────┘ +``` + +## Was pro Host abgeholt wird + +| Baustein | Wie | Ergebnis in Wazuh | +|---|---|---| +| `logs: journal` | `journalctl -o json -f` über eine dauerhafte SSH-Verbindung. Nach einem Abbruch geht es per Journal-Cursor lückenlos weiter. | Alle Standard-Decoder/-Regeln (sshd, sudo, PAM, Kernel, …), z. B. Brute-Force-Erkennung | +| `logs: file` | `tail -F` auf eine Datei. Nach einem Abbruch geht es über Inode und Byte-Offset weiter. | Wie oben, `format` wählbar (`syslog`, `apache`, `json`, `audit`, …) | +| `integrity` | Agentless: Größe, Rechte, Besitzer und SHA256 aller Dateien unter den Pfaden, periodisch. Der Vergleich läuft gegen eine eigene Baseline pro Host. | Alarme bei neuen, geänderten oder gelöschten Dateien (siehe Regeln unten) | +| `commands` | Agentless: Befehle ausführen und die Ausgabe mit dem letzten Lauf vergleichen. | Alarm „agentless: Change detected“ mit Diff (z. B. neuer offener Port, neuer Benutzer) | +| `agent_tunnel` | Optional: Reverse-Tunnel `ssh -R 1514/1515`. Ein echter Wazuh-Agent auf dem Host verbindet sich mit `127.0.0.1`. | Voller Agent-Umfang (SCA, Schwachstellen, Inventar, Echtzeit-FIM) | + +Die Agentless-Skripte (`manager/agentless/sshpull_*`) sind eigene Versionen ohne `expect`. Sie +nutzen Schlüssel-Login und optional `sudo -n`. Bei den mitgelieferten Wazuh-Skripten wirkt die +`use_sudo`-Option nicht. + +Der Integritätscheck nutzt bewusst **nicht** das FIM-Protokoll von Wazuh. Damit landen alle +Agentless-Hosts in der FIM-Datenbank des Managers, nur nach Pfad geordnet. `/etc/passwd` von +Host A, Host B und vom Manager würde sich gegenseitig überschreiben, und der erste Lauf erzeugt +pro Datei einen „File added“-Alarm. Hier gilt stattdessen: Der erste Lauf legt nur die Baseline +an (ein Alarm), danach werden nur echte Änderungen gemeldet. + +### Eigene Regeln (`manager/rules/sshpull_rules.xml`) + +| ID | Level | Bedeutung | +|---|---|---| +| 100501 | 3 | FIM-Baseline für einen Host angelegt | +| 100502 | 5 | Datei hinzugefügt | +| 100503 | 7 | Datei geändert (Inhalt, Größe, Rechte oder Besitzer) | +| 100504 | 7 | Datei gelöscht | +| 100505 | 10 | Mehr als 200 Änderungen in einem Lauf: nur eine Zusammenfassung (Update oder Kompromittierung?) | +| 100506 | 10 | Sicherheitskritische Datei (passwd, shadow, sudoers, sshd_config, pam.d, cron, authorized_keys, ld.so.preload) | +| 100507 | 10 | Neue oder geänderte Datei mit SUID/SGID-Bit | +| 100511 | 7 | SSH-Abholung gestört (Log-Stream, Tunnel oder Agentless-Check). Wird nur beim Zustandswechsel gemeldet. | +| 100512 | 3 | SSH-Abholung wieder verbunden | + +Befehls-Diffs kommen über die Wazuh-Standardregel 555 („Integrity checksum for agentless +device changed“), mit dem Diff im Alarmtext. + +## Installation + +Voraussetzungen: Docker und docker-compose, ca. 8 GB RAM frei, ca. 10 GB Platz für Images und +Daten. Für den Indexer: + +```bash +echo 'vm.max_map_count=262144' | sudo tee /etc/sysctl.d/99-wazuh.conf && sudo sysctl --system +``` + +```bash +./setup.sh # Passwörter (.env), TLS-Zertifikate, SSH-Key ssh/id_ed25519, Images bauen +nano hosts.yml # Hosts eintragen (siehe hosts.yml.example) +``` + +### Zielhosts vorbereiten + +Auf jedem Host wird ein Benutzer `wazuh-pull` angelegt. Er bekommt die Gruppen +`systemd-journal` und `adm` (Leserechte auf Logs) und den öffentlichen Schlüssel mit +`restrict` (kein PTY, keine Weiterleitungen): + +```bash +./setup.sh host-script | ssh admin@web01 sudo sh +# Optionen: --sudo NOPASSWD-sudo (nur nötig für root-only Dateien, z.B. /etc/shadow im FIM) +# --tunnel erlaubt Reverse-Forwarding auf 127.0.0.1:1514/1515 (für agent_tunnel) +``` + +Das Skript erst ansehen, dann ausführen: `./setup.sh host-script` + +### Host-Keys, Test, Start + +```bash +./setup.sh keyscan # Host-Keys -> ssh/known_hosts, Fingerprints mit den Hosts vergleichen! +./setup.sh test # prüft Login, Gruppen, Journal-/Datei-Leserechte +docker-compose up -d +./setup.sh passwords # Login fürs Dashboard: https:/// (admin / ...) +``` + +Der erste Start dauert 1–3 Minuten, bis der Indexer bereit ist. + +### Hosts ändern + +`hosts.yml` bearbeiten, dann `./setup.sh apply`. Damit werden Manager und Collector neu +erzeugt. Der Manager schreibt seine Agentless- und Localfile-Konfiguration beim Start aus +`hosts.yml` neu (`manager/generate.py`). + +## Wo finde ich die Daten im Dashboard? + +Per SSH geholte Logs und Agentless-Ergebnisse laufen **über den Manager**. Im Dashboard +erscheinen sie deshalb beim Agent `000` (`wazuh.manager`). Unterscheiden lassen sich die Hosts +so: + +- Logs: `location: /remote-logs/web01/journal.log` oder `predecoder.hostname: web01` +- FIM: `data.sshpull_fim.host: web01`, Befehls-Diffs: `location` enthält `wazuh-pull@web01` +- Verbindungsstatus: `rule.groups: sshpull_status` + +Beispiel-Filter in *Threat Hunting*: `rule.groups: sshpull` oder `location:*web01*` + +Hinweis: Rohlogs ohne Regeltreffer werden (wie bei Wazuh üblich) nicht indexiert, nur Alerts. +Alles archivieren: in `config/wazuh_cluster/wazuh_manager.conf` `yes` +setzen und in Filebeat das Archiv aktivieren (siehe Wazuh-Doku „archives“). + +## Optional: voller Agent über SSH-Tunnel + +Wenn auf einem Host ein Wazuh-Agent installiert werden darf, er aber den Server nicht erreicht: + +1. In `hosts.yml` beim Host `agent_tunnel: true` setzen. Host mit `--tunnel` vorbereiten. +2. Agent auf dem Host installieren (Paket von packages.wazuh.com, gleiche Version wie der + Manager) mit `WAZUH_MANAGER=127.0.0.1`, bzw. in `ossec.conf` `
127.0.0.1
`. +3. `./setup.sh apply`. Der Collector hält den Tunnel offen, der Agent registriert sich über + `127.0.0.1:1515` und erscheint als eigener Agent im Dashboard. + +Ports 1514 und 1515 sind nicht nach außen veröffentlicht. Nur der Collector erreicht sie. + +## Dateien + +| Pfad | Zweck | +|---|---| +| `hosts.yml` | Die einzige Stelle für Hosts und Optionen | +| `docker-compose.yml` | Stack. Nach außen geht nur das Dashboard (`DASHBOARD_PORT`) | +| `.env` | Version, Zeitzone, Passwörter (von `setup.sh` erzeugt, nicht committen) | +| `ssh/` | `id_ed25519` (privater Schlüssel, nicht committen), `known_hosts` | +| `common/sshpull_hosts.py` | Laden und Prüfen von `hosts.yml`, SSH-Config erzeugen (Manager und Collector) | +| `collector/` | Log-Streaming-Dienst | +| `manager/` | Manager-Image: openssh-client, Agentless-Skripte, Config-Generator | +| `config/` | Wazuh-Konfiguration (Basis: wazuh/wazuh-docker single-node) | + +## Fehlersuche + +```bash +docker-compose logs -f sshpull.collector # Verbindungen/Reconnects pro Host +docker-compose exec wazuh.manager grep -i agentless /var/ossec/logs/ossec.log +docker-compose exec wazuh.manager ls -la /remote-logs/web01/ # kommen Logs an? +``` + +- `journal=WARNUNG:nur-eigene-Logs…` bei `./setup.sh test`: Der Benutzer ist nicht in der + Gruppe `systemd-journal`. Danach neu einloggen bzw. den Collector neu starten. +- `Host key verification failed`: `./setup.sh keyscan` ausführen, oder in `hosts.yml` + `ssh.host_key_checking: accept-new` setzen. +- Agentless-Checks laufen das erste Mal kurz nach dem Start, danach alle `interval` Sekunden. + Der erste Lauf legt nur die Baseline an, Alarme gibt es erst bei Änderungen. Die Checks + laufen nacheinander. Bei vielen Hosts mit großen Pfaden (`/usr`) das `interval` großzügig + wählen. +- Baseline eines Hosts neu anlegen (z. B. nach einem gewollten Update): + `docker-compose exec wazuh.manager rm /var/ossec/agentless/.sshpull/web01.json` +- Ohne `sudo` sind root-only Dateien (z. B. `/etc/shadow`, Host-Keys) nur über Größe, Rechte + und Besitzer überwacht, nicht über den Inhalt. diff --git a/collector/Dockerfile b/collector/Dockerfile new file mode 100644 index 0000000..cb73c2c --- /dev/null +++ b/collector/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-client tzdata \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir pyyaml==6.0.2 + +WORKDIR /app +COPY common/sshpull_hosts.py collector/collector.py /app/ + +ENV PYTHONUNBUFFERED=1 +ENTRYPOINT ["python", "/app/collector.py"] +CMD ["run"] diff --git a/collector/collector.py b/collector/collector.py new file mode 100644 index 0000000..4cd587a --- /dev/null +++ b/collector/collector.py @@ -0,0 +1,495 @@ +"""sshpull-collector: holt Logs per SSH von den Hosts und schreibt sie nach +/remote-logs//.log, wo der Wazuh-Manager sie per liest. + +Quellen pro Host (hosts.yml): + journal journalctl -o json -f; Fortsetzung nach Verbindungsabbruch per Cursor + file tail -F auf eine Datei; Fortsetzung per Inode + Byte-Offset +Optional: + agent_tunnel ssh -R 1514/1515 -> Wazuh-Agent auf dem Host meldet sich an 127.0.0.1 + +Unterbefehle: + run (Standard) Dauerbetrieb + keyscan Host-Keys holen und nach /ssh/known_hosts schreiben + test Verbindung + Rechte auf allen Hosts pruefen +""" + +import asyncio +import json +import logging +import os +import re +import shlex +import signal +import sys +import time + +sys.path.insert(0, "/app") +import sshpull_hosts # noqa: E402 + +HOSTS_FILE = os.environ.get("HOSTS_FILE", "/sshpull/hosts.yml") +SSH_DIR = "/ssh" +KNOWN_HOSTS = f"{SSH_DIR}/known_hosts" +SSH_CONFIG = "/run/sshpull_ssh_config" +LOG_DIR = os.environ.get("LOG_DIR", "/remote-logs") +STATE_DIR = os.environ.get("STATE_DIR", "/state") +MAX_LOG_BYTES = int(os.environ.get("MAX_LOG_MB", "50")) * 1024 * 1024 +MANAGER = os.environ.get("MANAGER_HOST", "wazuh.manager") + +MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split() +CURSOR_RE = re.compile(r"^[A-Za-z0-9=;]+$") + +log = logging.getLogger("sshpull") + + +def syslog_ts(epoch): + t = time.localtime(epoch) + return f"{MONTHS[t.tm_mon - 1]} {t.tm_mday:2d} {t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}" + + +def ssh_cmd(host, *extra): + return ["ssh", "-F", SSH_CONFIG, *extra, host] + + +class LogWriter: + """Haengt Zeilen an eine Datei an und rotiert bei MAX_LOG_BYTES (eine .1-Datei).""" + + def __init__(self, path): + self.path = path + os.makedirs(os.path.dirname(path), exist_ok=True) + self.fh = open(path, "a", encoding="utf-8", errors="replace") + + def write(self, line): + self.fh.write(line.replace("\n", " ").replace("\r", " ") + "\n") + self.fh.flush() + if self.fh.tell() > MAX_LOG_BYTES: + self.fh.close() + os.replace(self.path, self.path + ".1") + self.fh = open(self.path, "a", encoding="utf-8", errors="replace") + + +class Status: + """Meldet Verbindungswechsel (up/down) als JSON nach /remote-logs/_status/status.log. + Nur Zustandswechsel werden geschrieben, damit Reconnect-Schleifen keine Alarmflut erzeugen.""" + + def __init__(self): + self.writer = None + self.last = {} + + def open(self): + # Datei sofort anlegen: der Wazuh-Logcollector oeffnet spaeter auftauchende + # Dateien am Ende und wuerde die ersten Meldungen verpassen. + self.writer = LogWriter(f"{LOG_DIR}/_status/status.log") + + def set(self, host, check, status, message=""): + key = (host, check) + if self.last.get(key, "up") == status: # Startzustand gilt als "up" + self.last[key] = status + return + self.last[key] = status + if self.writer is None: + self.writer = LogWriter(f"{LOG_DIR}/_status/status.log") + self.writer.write(json.dumps({"sshpull_status": { + "host": host, "check": check, "status": status, "message": message[:500]}})) + log.info("%s/%s: Status %s", host, check, status) + + +STATUS = Status() + + +async def watch_up(proc, host, check, delay=15): + """Laeuft die Verbindung nach Sekunden noch, gilt sie als aufgebaut.""" + await asyncio.sleep(delay) + if proc.returncode is None: + STATUS.set(host, check, "up") + + +class State: + def __init__(self, host, name): + self.path = os.path.join(STATE_DIR, host, f"{name}.json") + os.makedirs(os.path.dirname(self.path), exist_ok=True) + try: + with open(self.path, encoding="utf-8") as fh: + self.data = json.load(fh) + except (OSError, ValueError): + self.data = {} + self._saved = 0.0 + + def save(self, force=False): + now = time.monotonic() + if not force and now - self._saved < 5: + return + tmp = self.path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(self.data, fh) + os.replace(tmp, self.path) + self._saved = now + + +class Backoff: + def __init__(self): + self.delay = 5 + + async def wait(self, ran_for): + if ran_for > 120: + self.delay = 5 + await asyncio.sleep(self.delay) + self.delay = min(self.delay * 2, 300) + + +async def drain_stderr(stream, prefix, on_line=None, last=None): + async for raw in stream: + line = raw.decode("utf-8", "replace").rstrip() + if line: + log.warning("%s: %s", prefix, line) + if last is not None: + last[:] = [line] + if on_line: + on_line(line) + + +# -------------------------------------------------------------------------- +# journal +# -------------------------------------------------------------------------- + +def journal_line(host, entry): + msg = entry.get("MESSAGE", "") + if isinstance(msg, list): # nicht-UTF-8-Nachrichten kommen als Byte-Array + msg = bytes(b & 0xFF for b in msg if isinstance(b, int)).decode("utf-8", "replace") + elif msg is None: + msg = "" + ident = entry.get("SYSLOG_IDENTIFIER") or entry.get("_COMM") or "unknown" + pid = entry.get("SYSLOG_PID") or entry.get("_PID") + try: + ts = int(entry.get("__REALTIME_TIMESTAMP", 0)) / 1e6 + except (TypeError, ValueError): + ts = time.time() + tag = f"{ident}[{pid}]" if pid and ident != "kernel" else ident + return f"{syslog_ts(ts)} {host} {tag}: {msg}" + + +def journal_script(src, sudo, cursor): + args = ["journalctl", "--no-pager", "-o", "json", "-f"] + for unit in src.get("units", []): + args += ["-u", unit] + if src.get("priority"): + args += ["-p", src["priority"]] + if cursor: + args += [f"--after-cursor={cursor}"] + else: + args += ["-n", "0"] + prefix = "sudo -n " if sudo else "" + return f"exec {prefix}{shlex.join(args)}\n" + + +async def run_journal(h, src, stop): + name = h["name"] + writer = LogWriter(f"{LOG_DIR}/{name}/{src['name']}.log") + state = State(name, src["name"]) + backoff = Backoff() + tag = f"{name}/{src['name']}" + while not stop.is_set(): + cursor = state.data.get("cursor") + if cursor and not CURSOR_RE.match(cursor): + cursor = None + started = time.monotonic() + got_lines = 0 + proc = await asyncio.create_subprocess_exec( + *ssh_cmd(name), "sh", "-s", + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, limit=1024 * 1024) + proc.stdin.write(journal_script(src, h["sudo"], cursor).encode()) + await proc.stdin.drain() + proc.stdin.close() + log.info("%s: verbunden (%s)", tag, "ab Cursor" if cursor else "ab jetzt") + last_err = [] + err_task = asyncio.create_task(drain_stderr(proc.stderr, tag, last=last_err)) + up_task = asyncio.create_task(watch_up(proc, name, src["name"])) + stop_task = asyncio.create_task(stop.wait()) + try: + while True: + read = asyncio.create_task(proc.stdout.readline()) + done, _ = await asyncio.wait({read, stop_task}, return_when=asyncio.FIRST_COMPLETED) + if read not in done: + read.cancel() + break + raw = read.result() + if not raw: + break + try: + entry = json.loads(raw) + except ValueError: + continue + writer.write(journal_line(name, entry)) + got_lines += 1 + if got_lines == 1: + STATUS.set(name, src["name"], "up") + if entry.get("__CURSOR"): + state.data["cursor"] = entry["__CURSOR"] + state.save() + except ValueError: # Zeile > limit + log.error("%s: ueberlange Zeile, Verbindung wird neu aufgebaut", tag) + finally: + stop_task.cancel() + state.save(force=True) + if proc.returncode is None: + proc.terminate() + rc = await proc.wait() + await err_task + up_task.cancel() + if stop.is_set(): + break + STATUS.set(name, src["name"], "down", last_err[0] if last_err else f"ssh beendet (rc={rc})") + ran = time.monotonic() - started + # Cursor existiert nicht mehr (Journal rotiert) -> ab jetzt neu beginnen + if cursor and got_lines == 0 and ran < 10 and rc not in (0, 255): + log.warning("%s: Cursor ungueltig, starte ohne Cursor", tag) + state.data.pop("cursor", None) + state.save(force=True) + log.warning("%s: Verbindung beendet (rc=%s), neuer Versuch", tag, rc) + await backoff.wait(ran) + + +# -------------------------------------------------------------------------- +# file +# -------------------------------------------------------------------------- + +def file_script(src, sudo, ino, offset): + path = shlex.quote(src["path"]) + tail = "sudo -n tail" if sudo else "tail" + stat = "sudo -n stat" if sudo else "stat" + return f"""f={path} +ino=$({stat} -c %i "$f" 2>/dev/null); size=$({stat} -c %s "$f" 2>/dev/null) +if [ -n "$ino" ] && [ "$ino" = {shlex.quote(str(ino or ''))} ] && [ "$size" -ge {int(offset or 0)} ]; then + echo "@@SSHPULL $ino {int(offset or 0)}" + exec {tail} -F -c +{int(offset or 0) + 1} "$f" +else + echo "@@SSHPULL ${{ino:-none}} ${{size:-0}}" + exec {tail} -F -n 0 "$f" +fi +""" + + +async def run_file(h, src, stop): + name = h["name"] + writer = LogWriter(f"{LOG_DIR}/{name}/{src['name']}.log") + state = State(name, src["name"]) + backoff = Backoff() + tag = f"{name}/{src['name']}" + + def on_stderr(line): + # tail -F meldet Rotation/Truncate -> Offset ab hier nicht mehr eindeutig + if "has been replaced" in line or "truncated" in line or "has appeared" in line: + state.data["ino"] = None + + while not stop.is_set(): + started = time.monotonic() + proc = await asyncio.create_subprocess_exec( + *ssh_cmd(name), "sh", "-s", + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, limit=1024 * 1024) + proc.stdin.write(file_script(src, h["sudo"], state.data.get("ino"), + state.data.get("offset")).encode()) + await proc.stdin.drain() + proc.stdin.close() + last_err = [] + err_task = asyncio.create_task(drain_stderr(proc.stderr, tag, on_stderr, last_err)) + up_task = asyncio.create_task(watch_up(proc, name, src["name"])) + stop_task = asyncio.create_task(stop.wait()) + header = True + try: + while True: + read = asyncio.create_task(proc.stdout.readline()) + done, _ = await asyncio.wait({read, stop_task}, return_when=asyncio.FIRST_COMPLETED) + if read not in done: + read.cancel() + break + raw = read.result() + if not raw or not raw.endswith(b"\n"): + break # EOF; unvollstaendige Zeile wird beim naechsten Mal neu gelesen + if header: + header = False + parts = raw.decode(errors="replace").split() + if len(parts) == 3 and parts[0] == "@@SSHPULL": + state.data["ino"] = None if parts[1] == "none" else parts[1] + state.data["offset"] = int(parts[2]) if parts[2].isdigit() else 0 + log.info("%s: verbunden (inode %s, offset %s)", tag, parts[1], parts[2]) + continue + writer.write(raw.decode("utf-8", "replace").rstrip("\n")) + if state.data.get("ino"): + state.data["offset"] = state.data.get("offset", 0) + len(raw) + state.save() + except ValueError: + log.error("%s: ueberlange Zeile, Verbindung wird neu aufgebaut", tag) + state.data["ino"] = None + finally: + stop_task.cancel() + state.save(force=True) + if proc.returncode is None: + proc.terminate() + rc = await proc.wait() + await err_task + up_task.cancel() + if stop.is_set(): + break + STATUS.set(name, src["name"], "down", last_err[0] if last_err else f"ssh beendet (rc={rc})") + log.warning("%s: Verbindung beendet (rc=%s), neuer Versuch", tag, rc) + await backoff.wait(time.monotonic() - started) + + +# -------------------------------------------------------------------------- +# Agent-Tunnel +# -------------------------------------------------------------------------- + +async def run_tunnel(h, stop): + name = h["name"] + fwd = [] + for port in h["tunnel_ports"]: + fwd += ["-R", f"127.0.0.1:{port}:{MANAGER}:{port}"] + backoff = Backoff() + while not stop.is_set(): + started = time.monotonic() + proc = await asyncio.create_subprocess_exec( + *ssh_cmd(name, "-N", "-o", "ExitOnForwardFailure=yes", *fwd), + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE) + log.info("%s/tunnel: aufgebaut (Ports %s)", name, h["tunnel_ports"]) + last_err = [] + err_task = asyncio.create_task(drain_stderr(proc.stderr, f"{name}/tunnel", last=last_err)) + up_task = asyncio.create_task(watch_up(proc, name, "tunnel")) + wait_task = asyncio.create_task(proc.wait()) + stop_task = asyncio.create_task(stop.wait()) + await asyncio.wait({wait_task, stop_task}, return_when=asyncio.FIRST_COMPLETED) + stop_task.cancel() + if proc.returncode is None: + proc.terminate() + rc = await wait_task + await err_task + up_task.cancel() + if stop.is_set(): + break + STATUS.set(name, "tunnel", "down", last_err[0] if last_err else f"ssh beendet (rc={rc})") + log.warning("%s/tunnel: beendet (rc=%s), neuer Versuch", name, rc) + await backoff.wait(time.monotonic() - started) + + +# -------------------------------------------------------------------------- + +def prepare(cfg): + with open(SSH_CONFIG, "w", encoding="utf-8") as fh: + fh.write(sshpull_hosts.render_ssh_config(cfg, SSH_DIR, KNOWN_HOSTS)) + os.chmod(SSH_CONFIG, 0o600) + if not os.path.exists(KNOWN_HOSTS): + open(KNOWN_HOSTS, "a").close() + + +async def cmd_run(cfg): + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, stop.set) + + STATUS.open() + tasks = [] + for h in cfg["hosts"]: + for src in h["logs"]: + fn = run_journal if src["type"] == "journal" else run_file + tasks.append(asyncio.create_task(fn(h, src, stop))) + if h["agent_tunnel"]: + tasks.append(asyncio.create_task(run_tunnel(h, stop))) + if not tasks: + log.warning("keine Hosts/Quellen konfiguriert - warte") + log.info("%d Host(s), %d Stream(s) gestartet", len(cfg["hosts"]), len(tasks)) + await stop.wait() + await asyncio.gather(*tasks, return_exceptions=True) + log.info("beendet") + + +async def cmd_keyscan(cfg): + existing = open(KNOWN_HOSTS, encoding="utf-8").read() if os.path.exists(KNOWN_HOSTS) else "" + added = [] + for h in cfg["hosts"]: + key = h["address"] if h["port"] == 22 else f"[{h['address']}]:{h['port']}" + if any(line.split(" ", 1)[0].split(",")[0] == key for line in existing.splitlines()): + print(f"{h['name']:20} bereits in known_hosts ({key})") + continue + proc = await asyncio.create_subprocess_exec( + "ssh-keyscan", "-T", "10", "-p", str(h["port"]), h["address"], + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL) + out, _ = await proc.communicate() + lines = [ln for ln in out.decode().splitlines() if ln and not ln.startswith("#")] + if not lines: + print(f"{h['name']:20} FEHLER: keine Host-Keys von {h['address']}:{h['port']}") + continue + added += lines + fp = await asyncio.create_subprocess_exec( + "ssh-keygen", "-lf", "-", stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE) + fps, _ = await fp.communicate("\n".join(lines).encode()) + print(f"{h['name']:20} hinzugefuegt:") + for ln in fps.decode().splitlines(): + print(f"{'':20} {ln}") + if added: + with open(KNOWN_HOSTS, "a", encoding="utf-8") as fh: + fh.write("\n".join(added) + "\n") + print("\nBitte die Fingerprints mit den Hosts vergleichen " + "(dort: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub)") + + +async def cmd_test(cfg): + ok = True + for h in cfg["hosts"]: + sudo = "sudo -n " if h["sudo"] else "" + checks = ["echo user=$(id -un) groups=$(id -Gn)"] + if any(s["type"] == "journal" for s in h["logs"]): + checks.append(f"{sudo}journalctl -n 1 -q -o cat >/dev/null 2>&1 && echo journal=ok " + f"|| echo journal=FEHLER") + checks.append(f"{sudo}journalctl -n 0 2>&1 | grep -qi 'not seeing messages' " + f"&& echo journal=WARNUNG:nur-eigene-Logs-sichtbar-Gruppe-systemd-journal-fehlt " + f"|| true") + for s in h["logs"]: + if s["type"] == "file": + p = shlex.quote(s["path"]) + checks.append(f"{sudo}test -r {p} && echo {s['name']}=ok || echo {s['name']}=FEHLER") + if h["agent_tunnel"]: + checks.append("(command -v ss >/dev/null && ss -ltn | grep -qE '127.0.0.1:(1514|1515) ') " + "&& echo tunnelport=BELEGT || echo tunnelport=frei") + proc = await asyncio.create_subprocess_exec( + *ssh_cmd(h["name"]), "sh", "-s", stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + try: + out, err = await asyncio.wait_for(proc.communicate("\n".join(checks).encode()), 60) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + out, err = b"", b"Timeout" + res = out.decode().split() + good = proc.returncode == 0 and not any("FEHLER" in r for r in res) + ok &= good + print(f"{'OK ' if good else 'ERR'} {h['name']:20} {' '.join(res) or err.decode().strip()}") + return 0 if ok else 1 + + +def main(): + logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)-7s %(message)s", stream=sys.stdout) + cmd = sys.argv[1] if len(sys.argv) > 1 else "run" + try: + cfg = sshpull_hosts.load(HOSTS_FILE) + except (sshpull_hosts.ConfigError, OSError, ValueError) as exc: + log.error("hosts.yml: %s", exc) + return 1 + prepare(cfg) + if cmd == "run": + asyncio.run(cmd_run(cfg)) + return 0 + if cmd == "keyscan": + asyncio.run(cmd_keyscan(cfg)) + return 0 + if cmd == "test": + return asyncio.run(cmd_test(cfg)) + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/common/sshpull_hosts.py b/common/sshpull_hosts.py new file mode 100644 index 0000000..283e046 --- /dev/null +++ b/common/sshpull_hosts.py @@ -0,0 +1,172 @@ +"""Gemeinsame Logik fuer Manager und Collector: hosts.yml laden, pruefen, +Defaults anwenden und die SSH-Client-Konfiguration erzeugen. + +Wird sowohl vom Wazuh-Python (3.10, im Manager-Image) als auch vom +Collector (Python 3.12) importiert -> nur stdlib + PyYAML. +""" + +import copy +import re + +import yaml + +NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$") +# Wazuh-Logformate, die fuer per SSH geholte Dateien sinnvoll sind +FILE_FORMATS = { + "syslog", "json", "apache", "nginx", "snort-full", "squid", "iis", + "audit", "mysql_log", "postgresql_log", "djb-multilog", "multi-line", +} + +BUILTIN_DEFAULTS = { + "user": "wazuh-pull", + "port": 22, + "identity_file": "id_ed25519", + "sudo": False, + "agent_tunnel": False, + "tunnel_ports": [1514, 1515], + "logs": [{"type": "journal"}], + "integrity": { + "enabled": True, + "interval": 3600, + "paths": ["/etc", "/usr/bin", "/usr/sbin", "/bin", "/sbin", "/boot"], + }, + "commands": { + "enabled": True, + "interval": 3600, + "run": [ + "ss -tulpn 2>/dev/null || netstat -tulpn", + "cat /etc/passwd", + "cat /etc/group", + "find /etc/sudoers /etc/sudoers.d -type f -exec md5sum {} + 2>/dev/null", + "crontab -l -u root 2>/dev/null; ls -la /etc/cron.d", + "systemctl list-unit-files --state=enabled --no-pager 2>/dev/null", + ], + }, +} + +SSH_SETTINGS = { + "host_key_checking": "yes", # yes | accept-new + "connect_timeout": 15, + "server_alive_interval": 30, +} + + +class ConfigError(Exception): + pass + + +def _merge(base, override): + """Rekursiver Merge fuer dicts, Listen werden komplett ersetzt.""" + out = copy.deepcopy(base) + for key, val in (override or {}).items(): + if isinstance(val, dict) and isinstance(out.get(key), dict): + out[key] = _merge(out[key], val) + else: + out[key] = copy.deepcopy(val) + return out + + +def _check_safe(value, what): + # Pfade/Units landen in Remote-Shellskripten (gequotet) und ossec.conf. + if not isinstance(value, str) or not value or any(c in value for c in "\n\r\0<>&\"'"): + raise ConfigError(f"Ungueltiger Wert fuer {what}: {value!r}") + return value + + +def _normalize_logs(host, logs): + result = [] + names = set() + for i, src in enumerate(logs or []): + if not isinstance(src, dict) or src.get("type") not in ("journal", "file"): + raise ConfigError(f"{host}: logs[{i}] braucht type: journal oder type: file") + src = dict(src) + if src["type"] == "journal": + src.setdefault("name", "journal") + src["units"] = [_check_safe(u, f"{host}.units") for u in src.get("units", [])] + if "priority" in src: + src["priority"] = _check_safe(str(src["priority"]), f"{host}.priority") + else: + path = _check_safe(src.get("path"), f"{host}.logs[{i}].path") + if not path.startswith("/"): + raise ConfigError(f"{host}: logs[{i}].path muss absolut sein") + default_name = re.sub(r"[^A-Za-z0-9._-]", "_", path.strip("/")) + src.setdefault("name", default_name) + src.setdefault("format", "syslog") + if src["format"] not in FILE_FORMATS: + raise ConfigError(f"{host}: unbekanntes format {src['format']!r}") + if not NAME_RE.match(src["name"]): + raise ConfigError(f"{host}: ungueltiger Log-Name {src['name']!r}") + if src["name"] in names: + raise ConfigError(f"{host}: Log-Name {src['name']!r} doppelt") + names.add(src["name"]) + result.append(src) + return result + + +def load(path): + with open(path, encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + + defaults = _merge(BUILTIN_DEFAULTS, raw.get("defaults")) + ssh = _merge(SSH_SETTINGS, raw.get("ssh")) + if ssh["host_key_checking"] not in ("yes", "accept-new"): + raise ConfigError("ssh.host_key_checking muss 'yes' oder 'accept-new' sein") + + hosts = [] + seen = set() + for i, entry in enumerate(raw.get("hosts") or []): + if not isinstance(entry, dict): + raise ConfigError(f"hosts[{i}] ist kein Objekt") + h = _merge(defaults, entry) + name = h.get("name") + if not isinstance(name, str) or not NAME_RE.match(name): + raise ConfigError(f"hosts[{i}]: ungueltiger name {name!r}") + if name in seen: + raise ConfigError(f"Host {name!r} doppelt") + seen.add(name) + h.setdefault("address", name) + _check_safe(str(h["address"]), f"{name}.address") + _check_safe(str(h["user"]), f"{name}.user") + _check_safe(str(h["identity_file"]), f"{name}.identity_file") + if "/" in h["identity_file"]: + raise ConfigError(f"{name}: identity_file ist ein Dateiname im ssh/-Ordner") + h["port"] = int(h["port"]) + h["logs"] = _normalize_logs(name, h.get("logs")) + h["integrity"]["paths"] = [ + _check_safe(p, f"{name}.integrity.paths") for p in h["integrity"].get("paths", []) + ] + if any(" " in p for p in h["integrity"]["paths"]): + raise ConfigError(f"{name}: integrity.paths duerfen keine Leerzeichen enthalten") + h["tunnel_ports"] = [int(p) for p in h["tunnel_ports"]] + hosts.append(h) + return {"ssh": ssh, "hosts": hosts} + + +def render_ssh_config(cfg, key_dir, known_hosts): + """OpenSSH-Client-Config: Host-Alias = name aus hosts.yml.""" + ssh = cfg["ssh"] + lines = ["# Automatisch erzeugt aus hosts.yml - nicht von Hand bearbeiten", ""] + for h in cfg["hosts"]: + lines += [ + f"Host {h['name']}", + f" HostName {h['address']}", + f" Port {h['port']}", + f" User {h['user']}", + f" IdentityFile {key_dir}/{h['identity_file']}", + "", + ] + lines += [ + "Host *", + " IdentitiesOnly yes", + " BatchMode yes", + " PasswordAuthentication no", + " KbdInteractiveAuthentication no", + f" StrictHostKeyChecking {ssh['host_key_checking']}", + f" UserKnownHostsFile {known_hosts}", + f" ConnectTimeout {int(ssh['connect_timeout'])}", + f" ServerAliveInterval {int(ssh['server_alive_interval'])}", + " ServerAliveCountMax 3", + " LogLevel ERROR", + "", + ] + return "\n".join(lines) diff --git a/config/certs.yml b/config/certs.yml new file mode 100755 index 0000000..c3e017b --- /dev/null +++ b/config/certs.yml @@ -0,0 +1,16 @@ +nodes: + # Wazuh indexer server nodes + indexer: + - name: wazuh.indexer + ip: wazuh.indexer + + # Wazuh server nodes + # Use node_type only with more than one Wazuh manager + server: + - name: wazuh.manager + ip: wazuh.manager + + # Wazuh dashboard node + dashboard: + - name: wazuh.dashboard + ip: wazuh.dashboard diff --git a/config/wazuh_cluster/wazuh_manager.conf b/config/wazuh_cluster/wazuh_manager.conf new file mode 100644 index 0000000..c5f16dd --- /dev/null +++ b/config/wazuh_cluster/wazuh_manager.conf @@ -0,0 +1,311 @@ + + + yes + yes + no + no + no + smtp.example.wazuh.com + wazuh@example.wazuh.com + recipient@example.wazuh.com + 12 + alerts.log + 10m + 0 + + + + 3 + 12 + + + + + plain + + + + secure + 1514 + tcp + 131072 + + + + + no + yes + yes + yes + yes + yes + yes + yes + + + 43200 + + etc/rootcheck/rootkit_files.txt + etc/rootcheck/rootkit_trojans.txt + + yes + + + + yes + 1800 + 1d + yes + + wodles/java + wodles/ciscat + + + + + yes + yes + /var/log/osquery/osqueryd.results.log + /etc/osquery/osquery.conf + yes + + + + + no + 1h + yes + yes + yes + yes + yes + yes + yes + + + + 10 + + + + + yes + yes + 12h + yes + + + + yes + yes + 60m + + + + yes + + https://wazuh.indexer:9200 + + + + /etc/ssl/root-ca.pem + + /etc/ssl/filebeat.pem + /etc/ssl/filebeat.key + + + + + + no + + + 43200 + + yes + + + yes + + + no + + + /etc,/usr/bin,/usr/sbin + /bin,/sbin,/boot + + + /etc/mtab + /etc/hosts.deny + /etc/mail/statistics + /etc/random-seed + /etc/random.seed + /etc/adjtime + /etc/httpd/logs + /etc/utmpx + /etc/wtmpx + /etc/cups/certs + /etc/dumpdates + /etc/svc/volatile + + + .log$|.swp$ + + + /etc/ssl/private.key + + yes + yes + yes + yes + + + 10 + + + 100 + + + + yes + 5m + 1h + 10 + + + + + + 127.0.0.1 + ^localhost.localdomain$ + + + + disable-account + disable-account + yes + + + + restart-wazuh + restart-wazuh + + + + firewall-drop + firewall-drop + yes + + + + host-deny + host-deny + yes + + + + route-null + route-null + yes + + + + win_route-null + route-null.exe + yes + + + + netsh + netsh.exe + yes + + + + + + + command + df -P + 360 + + + + full_command + netstat -tulpn | sed 's/\([[:alnum:]]\+\)\ \+[[:digit:]]\+\ \+[[:digit:]]\+\ \+\(.*\):\([[:digit:]]*\)\ \+\([0-9\.\:\*]\+\).\+\ \([[:digit:]]*\/[[:alnum:]\-]*\).*/\1 \2 == \3 == \4 \5/' | sort -k 4 -g | sed 's/ == \(.*\) ==/:\1/' | sed 1,2d + netstat listening ports + 360 + + + + full_command + last -n 20 + 360 + + + + + ruleset/decoders + ruleset/rules + 0215-policy_rules.xml + etc/lists/audit-keys + etc/lists/amazon/aws-eventnames + etc/lists/security-eventchannel + etc/lists/malicious-ioc/malicious-ip + etc/lists/malicious-ioc/malicious-domains + etc/lists/malicious-ioc/malware-hashes + + + etc/decoders + etc/rules + + + + yes + 1 + 64 + 15m + + + + + no + 1515 + no + yes + no + HIGH:!ADH:!EXP:!MD5:!RC4:!3DES:!CAMELLIA:@STRENGTH + + no + etc/sslmanager.cert + etc/sslmanager.key + no + + + + wazuh + node01 + master + aa093264ef885029653eea20dfcf51ae + 1516 + 0.0.0.0 + + wazuh.manager + + no + yes + + + + + + + syslog + /var/ossec/logs/active-responses.log + + + diff --git a/config/wazuh_dashboard/opensearch_dashboards.yml b/config/wazuh_dashboard/opensearch_dashboards.yml new file mode 100644 index 0000000..601f3bb --- /dev/null +++ b/config/wazuh_dashboard/opensearch_dashboards.yml @@ -0,0 +1,16 @@ +server.host: 0.0.0.0 +server.port: 5601 +opensearch.hosts: https://wazuh.indexer:9200 +opensearch.ssl.verificationMode: certificate +opensearch.requestHeadersWhitelist: ["securitytenant","Authorization"] +opensearch_security.multitenancy.enabled: false +opensearch_security.readonly_mode.roles: ["kibana_read_only"] +server.ssl.enabled: true +server.ssl.key: "/usr/share/wazuh-dashboard/certs/wazuh-dashboard-key.pem" +server.ssl.certificate: "/usr/share/wazuh-dashboard/certs/wazuh-dashboard.pem" +opensearch.ssl.certificateAuthorities: ["/usr/share/wazuh-dashboard/certs/root-ca.pem"] +uiSettings.overrides.defaultRoute: /app/wz-home +# Session expiration settings +opensearch_security.cookie.ttl: 900000 +opensearch_security.session.ttl: 900000 +opensearch_security.session.keepalive: true diff --git a/config/wazuh_indexer/wazuh.indexer.yml b/config/wazuh_indexer/wazuh.indexer.yml new file mode 100644 index 0000000..cdd0aeb --- /dev/null +++ b/config/wazuh_indexer/wazuh.indexer.yml @@ -0,0 +1,36 @@ +network.host: "0.0.0.0" +node.name: "wazuh.indexer" +cluster.name: "wazuh-cluster" +path.data: /var/lib/wazuh-indexer +path.logs: /var/log/wazuh-indexer +discovery.type: single-node +compatibility.override_main_response_version: true +plugins.security.ssl.http.pemcert_filepath: /usr/share/wazuh-indexer/config/certs/wazuh.indexer.pem +plugins.security.ssl.http.pemkey_filepath: /usr/share/wazuh-indexer/config/certs/wazuh.indexer.key +plugins.security.ssl.http.pemtrustedcas_filepath: /usr/share/wazuh-indexer/config/certs/root-ca.pem +plugins.security.ssl.transport.pemcert_filepath: /usr/share/wazuh-indexer/config/certs/wazuh.indexer.pem +plugins.security.ssl.transport.pemkey_filepath: /usr/share/wazuh-indexer/config/certs/wazuh.indexer.key +plugins.security.ssl.transport.pemtrustedcas_filepath: /usr/share/wazuh-indexer/config/certs/root-ca.pem +plugins.security.ssl.http.enabled: true +plugins.security.ssl.transport.enforce_hostname_verification: false +plugins.security.ssl.transport.resolve_hostname: false +plugins.security.ssl.http.enabled_ciphers: + - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" + - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" + - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" +plugins.security.ssl.http.enabled_protocols: + - "TLSv1.2" +plugins.security.authcz.admin_dn: +- "CN=admin,OU=Wazuh,O=Wazuh,L=California,C=US" +plugins.security.check_snapshot_restore_write_privileges: true +plugins.security.enable_snapshot_restore_privilege: true +plugins.security.nodes_dn: +- "CN=wazuh.indexer,OU=Wazuh,O=Wazuh,L=California,C=US" +plugins.security.restapi.roles_enabled: +- "all_access" +- "security_rest_api_access" +plugins.security.system_indices.enabled: true +plugins.security.system_indices.indices: [".opendistro-alerting-config", ".opendistro-alerting-alert*", ".opendistro-anomaly-results*", ".opendistro-anomaly-detector*", ".opendistro-anomaly-checkpoints", ".opendistro-anomaly-detection-state", ".opendistro-reports-*", ".opendistro-notifications-*", ".opendistro-notebooks", ".opensearch-observability", ".opendistro-asynchronous-search-response*", ".replication-metadata-store"] +plugins.security.allow_default_init_securityindex: true +cluster.routing.allocation.disk.threshold_enabled: false \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0829a2f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,144 @@ +# Wazuh single-node (basiert auf wazuh/wazuh-docker single-node) +# + sshpull-collector: holt Logs per SSH von Hosts, auf die nur per SSH zugegriffen werden kann +# + Manager-Erweiterung: Agentless-Integritaets- und Befehls-Checks per SSH +# +# Vor dem ersten Start: ./setup.sh + +services: + wazuh.manager: + build: + context: . + dockerfile: manager/Dockerfile + args: + WAZUH_VERSION: ${WAZUH_VERSION} + image: sshpull/wazuh-manager:${WAZUH_VERSION} + hostname: wazuh.manager + restart: always + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 655360 + hard: 655360 + # Keine Ports nach aussen: Agents (falls ueberhaupt) kommen ueber den SSH-Tunnel + # des Collectors. Fuer klassisch angebundene Agents freischalten: + # ports: + # - "1514:1514" + # - "1515:1515" + environment: + - TZ=${TZ} + - INDEXER_URL="https://wazuh.indexer:9200" # Anfuehrungszeichen gehoeren dazu (filebeat.yml) + - INDEXER_USERNAME=admin + - INDEXER_PASSWORD=${INDEXER_PASSWORD} + - FILEBEAT_SSL_VERIFICATION_MODE=full + - SSL_CERTIFICATE_AUTHORITIES=/etc/ssl/root-ca.pem + - SSL_CERTIFICATE=/etc/ssl/filebeat.pem + - SSL_KEY=/etc/ssl/filebeat.key + - API_USERNAME=wazuh-wui + - API_PASSWORD=${API_PASSWORD} + volumes: + - wazuh_api_configuration:/var/ossec/api/configuration + - wazuh_etc:/var/ossec/etc + - wazuh_logs:/var/ossec/logs + - wazuh_queue:/var/ossec/queue + - wazuh_var_multigroups:/var/ossec/var/multigroups + - wazuh_integrations:/var/ossec/integrations + - wazuh_active_response:/var/ossec/active-response/bin + - wazuh_agentless:/var/ossec/agentless + - wazuh_wodles:/var/ossec/wodles + - filebeat_etc:/etc/filebeat + - filebeat_var:/var/lib/filebeat + - ./config/wazuh_indexer_ssl_certs/root-ca-manager.pem:/etc/ssl/root-ca.pem:ro + - ./config/wazuh_indexer_ssl_certs/wazuh.manager.pem:/etc/ssl/filebeat.pem:ro + - ./config/wazuh_indexer_ssl_certs/wazuh.manager-key.pem:/etc/ssl/filebeat.key:ro + - ./config/wazuh_cluster/wazuh_manager.conf:/wazuh-config-mount/etc/ossec.conf:ro + # sshpull + - ./hosts.yml:/sshpull/hosts.yml:ro + - ./ssh:/sshpull/ssh:ro + - remote_logs:/remote-logs:ro + + wazuh.indexer: + image: wazuh/wazuh-indexer:${WAZUH_VERSION} + hostname: wazuh.indexer + restart: always + environment: + - "OPENSEARCH_JAVA_OPTS=-Xms${INDEXER_HEAP} -Xmx${INDEXER_HEAP}" + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + volumes: + - wazuh-indexer-data:/var/lib/wazuh-indexer + - ./config/wazuh_indexer_ssl_certs/root-ca.pem:/usr/share/wazuh-indexer/config/certs/root-ca.pem:ro + - ./config/wazuh_indexer_ssl_certs/wazuh.indexer-key.pem:/usr/share/wazuh-indexer/config/certs/wazuh.indexer.key:ro + - ./config/wazuh_indexer_ssl_certs/wazuh.indexer.pem:/usr/share/wazuh-indexer/config/certs/wazuh.indexer.pem:ro + - ./config/wazuh_indexer_ssl_certs/admin.pem:/usr/share/wazuh-indexer/config/certs/admin.pem:ro + - ./config/wazuh_indexer_ssl_certs/admin-key.pem:/usr/share/wazuh-indexer/config/certs/admin-key.pem:ro + - ./config/wazuh_indexer/wazuh.indexer.yml:/usr/share/wazuh-indexer/config/opensearch.yml:ro + - ./config/wazuh_indexer/internal_users.yml:/usr/share/wazuh-indexer/config/opensearch-security/internal_users.yml:ro + + wazuh.dashboard: + image: wazuh/wazuh-dashboard:${WAZUH_VERSION} + hostname: wazuh.dashboard + restart: always + ports: + - "${DASHBOARD_PORT}:5601" + environment: + - INDEXER_USERNAME=admin + - INDEXER_PASSWORD=${INDEXER_PASSWORD} + - WAZUH_API_URL=https://wazuh.manager + - DASHBOARD_USERNAME=kibanaserver + - DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD} + - API_USERNAME=wazuh-wui + - API_PASSWORD=${API_PASSWORD} + volumes: + - ./config/wazuh_indexer_ssl_certs/wazuh.dashboard.pem:/usr/share/wazuh-dashboard/certs/wazuh-dashboard.pem:ro + - ./config/wazuh_indexer_ssl_certs/wazuh.dashboard-key.pem:/usr/share/wazuh-dashboard/certs/wazuh-dashboard-key.pem:ro + - ./config/wazuh_indexer_ssl_certs/root-ca.pem:/usr/share/wazuh-dashboard/certs/root-ca.pem:ro + - ./config/wazuh_dashboard/opensearch_dashboards.yml:/usr/share/wazuh-dashboard/config/opensearch_dashboards.yml:ro + - wazuh-dashboard-config:/usr/share/wazuh-dashboard/data/wazuh/config + - wazuh-dashboard-custom:/usr/share/wazuh-dashboard/plugins/wazuh/public/assets/custom + depends_on: + - wazuh.indexer + - wazuh.manager + + sshpull.collector: + build: + context: . + dockerfile: collector/Dockerfile + image: sshpull/collector:latest + hostname: sshpull.collector + restart: always + environment: + - TZ=${TZ} + - MANAGER_HOST=wazuh.manager + - MAX_LOG_MB=${COLLECTOR_MAX_LOG_MB} + volumes: + - ./hosts.yml:/sshpull/hosts.yml:ro + - ./ssh:/ssh # rw, damit "keyscan" known_hosts schreiben kann + - remote_logs:/remote-logs + - collector_state:/state + depends_on: + - wazuh.manager + +volumes: + wazuh_api_configuration: + wazuh_etc: + wazuh_logs: + wazuh_queue: + wazuh_var_multigroups: + wazuh_integrations: + wazuh_active_response: + wazuh_agentless: + wazuh_wodles: + filebeat_etc: + filebeat_var: + wazuh-indexer-data: + wazuh-dashboard-config: + wazuh-dashboard-custom: + remote_logs: + collector_state: diff --git a/hosts.yml.example b/hosts.yml.example new file mode 100644 index 0000000..178d419 --- /dev/null +++ b/hosts.yml.example @@ -0,0 +1,81 @@ +# Hosts, die per SSH ueberwacht werden. +# Nach Aenderungen: docker-compose up -d --force-recreate wazuh.manager sshpull.collector +# +# Pro Host gibt es drei Bausteine (alle optional, Defaults siehe unten): +# logs Logs werden dauerhaft per SSH gestreamt (journald und/oder Dateien) +# -> Wazuh-Regeln/Decoder laufen darauf wie bei einem Agent +# integrity Datei-Integritaet (FIM) per Agentless: Hashes/Rechte von Dateien, +# Alarm bei Aenderung +# commands Befehlsausgaben per Agentless, Alarm wenn sich die Ausgabe aendert +# (offene Ports, Benutzer, sudoers, Cronjobs, ...) +# agent_tunnel Optional: echter Wazuh-Agent auf dem Host, der sich ueber einen +# SSH-Reverse-Tunnel (-R 1514/1515) beim Manager meldet + +ssh: + # yes = nur Hosts aus ssh/known_hosts (vorher: ./setup.sh keyscan) + # accept-new = Host-Key beim ersten Kontakt automatisch akzeptieren (TOFU) + host_key_checking: "yes" + connect_timeout: 15 + server_alive_interval: 30 + +defaults: + user: wazuh-pull # SSH-Benutzer auf den Zielhosts + port: 22 + identity_file: id_ed25519 # Datei in ./ssh/ + sudo: false # true = Befehle mit "sudo -n" (NOPASSWD noetig) + logs: + - type: journal # komplettes Journal (Benutzer braucht Gruppe systemd-journal) + integrity: + enabled: true + interval: 3600 # Sekunden + paths: [/etc, /usr/bin, /usr/sbin, /bin, /sbin, /boot] + commands: + enabled: true + interval: 3600 + run: + - ss -tulpn 2>/dev/null || netstat -tulpn + - cat /etc/passwd + - cat /etc/group + - find /etc/sudoers /etc/sudoers.d -type f -exec md5sum {} + 2>/dev/null + - crontab -l -u root 2>/dev/null; ls -la /etc/cron.d + - systemctl list-unit-files --state=enabled --no-pager 2>/dev/null + agent_tunnel: false + +hosts: + - name: web01 # Anzeigename / SSH-Alias (A-Z a-z 0-9 . _ -) + address: 192.168.10.21 + + - name: web02 + address: web02.example.lan + port: 2222 + logs: + - type: journal + units: [ssh, nginx] # nur diese Units (optional) + - type: file + path: /var/log/nginx/access.log + format: apache # Wazuh log_format (syslog, apache, json, audit, ...) + - type: file + path: /var/log/nginx/error.log + + - name: db01 + address: 10.0.0.5 + sudo: true # z.B. fuer /etc/shadow im Integritaetscheck + integrity: + paths: [/etc, /usr/bin, /usr/sbin, /var/lib/postgresql/16/main/pg_hba.conf] + commands: + enabled: false + + - name: legacy01 # Alter Host ohne journald + address: 10.0.0.9 + logs: + - type: file + path: /var/log/auth.log + - type: file + path: /var/log/syslog + + # - name: app01 # Voller Wazuh-Agent ueber SSH-Tunnel + # address: 10.0.0.30 + # agent_tunnel: true + # logs: [] # Logs liefert dann der Agent selbst + # integrity: {enabled: false} + # commands: {enabled: false} diff --git a/manager/Dockerfile b/manager/Dockerfile new file mode 100644 index 0000000..5615762 --- /dev/null +++ b/manager/Dockerfile @@ -0,0 +1,10 @@ +ARG WAZUH_VERSION=4.14.8 +FROM wazuh/wazuh-manager:${WAZUH_VERSION} + +# openssh-clients: Agentless per SSH; diffutils: wazuh-agentlessd vergleicht Befehlsausgaben mit diff +RUN dnf install -y openssh-clients diffutils && dnf clean all && rm -rf /var/cache/dnf + +COPY common/sshpull_hosts.py manager/generate.py /opt/sshpull/ +COPY manager/agentless/ /opt/sshpull/agentless/ +COPY manager/rules/ /opt/sshpull/rules/ +COPY manager/entrypoint-scripts/ /entrypoint-scripts/ diff --git a/manager/agentless/sshpull_generic_diff b/manager/agentless/sshpull_generic_diff new file mode 100755 index 0000000..385123e --- /dev/null +++ b/manager/agentless/sshpull_generic_diff @@ -0,0 +1,35 @@ +#!/bin/bash +# Agentless-Befehls-Diff per SSH (Schluessel, ohne expect). +# Aufruf durch wazuh-agentlessd: [use_sudo] +# Fuehrt /var/ossec/etc/sshpull/commands/.sh auf dem Host aus; +# wazuh-agentlessd vergleicht die Ausgabe mit dem letzten Lauf und alarmiert bei Aenderungen. + +[ "$1" = "test" ] && [ "$2" = "test" ] && exit 0 + +SUDO="" +if [ "$1" = "use_sudo" ]; then SUDO="sudo -n"; shift; fi +TARGET="$1" +CMDS="/var/ossec/etc/sshpull/commands/$2.sh" +case "$2" in */*|"") echo "ERROR: ungueltiger Hostname '$2'"; exit 1;; esac +[ -r "$CMDS" ] || { echo "ERROR: $CMDS fehlt"; exit 1; } + +ERR=$(mktemp) +OUT=$(mktemp) +trap 'rm -f "$ERR" "$OUT"' EXIT + +echo "INFO: Starting." +# LC_ALL=C: stabile Ausgabe, sonst gibt es Diffs nur wegen Locale-Wechseln +{ echo "export LC_ALL=C; unset HISTFILE"; cat "$CMDS"; } \ + | ssh -F /var/ossec/etc/sshpull/ssh_config "$TARGET" $SUDO sh -s >"$OUT" 2>"$ERR" +RC=$? +# 255 = ssh selbst ist gescheitert; andere Codes kommen vom letzten Befehl +if [ "$RC" -eq 255 ] || { [ "$RC" -ne 0 ] && [ ! -s "$OUT" ]; }; then + MSG=$(tr '\n"\\' ' ' < "$ERR" | cut -c1-400) + echo "LOG: {\"sshpull_status\":{\"host\":\"${TARGET#*@}\",\"check\":\"commands\",\"status\":\"down\",\"message\":\"rc=$RC $MSG\"}}" + echo "ERROR: ssh zu $TARGET fehlgeschlagen (rc=$RC): $MSG" + exit 1 +fi +echo "STORE: now" +# Zeilen, die wie Steuerzeilen von agentlessd aussehen, entschaerfen +sed -e 's/^\(ERROR\|INFO\|FWD\|LOG\|STORE\): /\1 : /' "$OUT" +exit 0 diff --git a/manager/agentless/sshpull_integrity_linux b/manager/agentless/sshpull_integrity_linux new file mode 100755 index 0000000..0ea68d1 --- /dev/null +++ b/manager/agentless/sshpull_integrity_linux @@ -0,0 +1,159 @@ +#!/var/ossec/framework/python/bin/python3 +"""Agentless-Datei-Integritaetspruefung per SSH (Schluessel, ohne expect). + +Aufruf durch wazuh-agentlessd: [use_sudo] [ ...] + +Warum nicht das FWD:-Protokoll von Wazuh? Damit landen alle Hosts in der +FIM-Datenbank des Managers (Agent 000), nur nach Pfad geordnet -> /etc/passwd +von Host A, Host B und vom Manager selbst ueberschreiben sich gegenseitig, und +der erste Lauf jedes Hosts erzeugt pro Datei einen "File added"-Alarm. + +Stattdessen: eigene Baseline pro Host (agentless/.sshpull/.json), Vergleich +hier, und nur echte Aenderungen als JSON ueber "LOG: " an analysisd +(Regeln: etc/rules/sshpull_rules.xml). +""" + +import json +import os +import subprocess +import sys + +SSH_CONFIG = "/var/ossec/etc/sshpull/ssh_config" +STATE_DIR = "/var/ossec/agentless/.sshpull" +MAX_EVENTS = 200 # mehr Aenderungen pro Lauf -> nur Zusammenfassung +MAX_LINE = 2000 # agentlessd liest Zeilen mit max. 2048 Bytes +TIMEOUT = 3000 + +REMOTE = r""" +export LC_ALL=C +unset HISTFILE +if find / -maxdepth 0 -printf '' >/dev/null 2>&1; then + find {paths} -xdev -type f -printf 'S\t%s\t%m\t%U\t%G\t%p\n' 2>/dev/null +else + find {paths} -xdev -type f 2>/dev/null | while IFS= read -r f; do + printf 'S\t%s\t%s\t%s\t%s\t%s\n' $(stat -c '%s %a %u %g' "$f" 2>/dev/null) "$f" + done +fi +find {paths} -xdev -type f -print0 2>/dev/null | xargs -0 -r sha256sum 2>/dev/null | sed 's/^/H\t/' +printf 'E\tEND\n' +""" + + +def out(prefix, text): + print(f"{prefix}: {text}", flush=True) + + +def log_event(data): + line = json.dumps({"sshpull_fim": data}, separators=(",", ":")) + if len(line) > MAX_LINE: + for k in ("sha256_before", "sha256_after"): + data.pop(k, None) + line = json.dumps({"sshpull_fim": data}, separators=(",", ":")) + if len(line) <= MAX_LINE: + out("LOG", line) + + +def scan(target, sudo, paths): + quoted = " ".join("'" + p + "'" for p in paths) + cmd = ["ssh", "-F", SSH_CONFIG, target] + (["sudo", "-n"] if sudo else []) + ["sh", "-s"] + proc = subprocess.run(cmd, input=REMOTE.replace("{paths}", quoted), capture_output=True, + text=True, errors="replace", timeout=TIMEOUT) + lines = proc.stdout.split("\n") + if proc.returncode == 255 or "E\tEND" not in lines: + raise RuntimeError(f"rc={proc.returncode} {proc.stderr.strip()[:300]}") + + files = {} + hashes = {} + for ln in lines: + if ln.startswith("S\t"): + parts = ln.split("\t", 5) # S, size, perm, uid, gid, path + if len(parts) == 6 and parts[1].isdigit(): + files[parts[5]] = [int(parts[1]), parts[2], parts[3], parts[4], ""] + elif ln.startswith("H\t") and len(ln) > 68 and not ln.startswith("H\t\\"): + hashes[ln[68:]] = ln[2:66] + for path, h in hashes.items(): + if path in files: + files[path][4] = h + return files + + +def compare(host, old, new): + events = [] + for path in sorted(new.keys() - old.keys()): + s, p, u, g, h = new[path] + events.append({"event": "added", "host": host, "path": path, "size_after": s, + "perm_after": p, "uid_after": u, "gid_after": g, "sha256_after": h}) + for path in sorted(old.keys() - new.keys()): + s, p, u, g, h = old[path] + events.append({"event": "deleted", "host": host, "path": path, "size_before": s, + "perm_before": p, "uid_before": u, "gid_before": g, "sha256_before": h}) + for path in sorted(old.keys() & new.keys()): + o, n = old[path], new[path] + changed = [name for i, name in enumerate(("size", "perm", "uid", "gid")) + if o[i] != n[i]] + if o[4] and n[4] and o[4] != n[4]: # leerer Hash = Datei war nicht lesbar + changed.append("sha256") + if changed: + ev = {"event": "modified", "host": host, "path": path, "changed": ",".join(changed)} + for i, name in enumerate(("size", "perm", "uid", "gid", "sha256")): + ev[f"{name}_before"], ev[f"{name}_after"] = o[i], n[i] + events.append(ev) + return events + + +def main(argv): + if argv[:2] == ["test", "test"]: + return 0 + sudo = False + if argv and argv[0] == "use_sudo": + sudo, argv = True, argv[1:] + if len(argv) < 2: + out("ERROR", "Aufruf: [use_sudo] ") + return 1 + target, paths = argv[0], argv[1:] + host = target.split("@", 1)[-1] + state_file = os.path.join(STATE_DIR, f"{host}.json") + + out("INFO", "Starting.") + try: + new = scan(target, sudo, paths) + except (RuntimeError, subprocess.TimeoutExpired) as exc: + print("LOG: " + json.dumps({"sshpull_status": {"host": host, "check": "integrity", + "status": "down", "message": str(exc)[:500]}})) + out("ERROR", f"ssh zu {target} fehlgeschlagen: {exc}") + return 1 + + try: + with open(state_file, encoding="utf-8") as fh: + state = json.load(fh) + except (OSError, ValueError): + state = None + + if state is None or state.get("paths") != paths or not state.get("files"): + log_event({"event": "baseline", "host": host, "files": len(new)}) + out("INFO", f"Baseline angelegt: {len(new)} Dateien") + elif not new and state["files"]: + # Kein einziges Ergebnis, vorher aber Dateien -> eher Rechte-/Mountproblem als Loeschung + out("ERROR", f"Scan von {target} lieferte 0 Dateien (vorher {len(state['files'])}) - Baseline bleibt") + return 1 + else: + events = compare(host, state["files"], new) + for ev in events[:MAX_EVENTS]: + log_event(ev) + if len(events) > MAX_EVENTS: + counts = {k: sum(1 for e in events if e["event"] == k) for k in ("added", "modified", "deleted")} + log_event({"event": "summary", "host": host, "total": len(events), + "reported": MAX_EVENTS, **counts}) + out("INFO", f"{len(new)} Dateien, {len(events)} Aenderung(en)") + + os.makedirs(STATE_DIR, mode=0o750, exist_ok=True) + tmp = state_file + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump({"paths": paths, "files": new}, fh, separators=(",", ":")) + os.replace(tmp, state_file) + out("INFO", "Finished.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/manager/entrypoint-scripts/50-sshpull.sh b/manager/entrypoint-scripts/50-sshpull.sh new file mode 100755 index 0000000..1c8d0ff --- /dev/null +++ b/manager/entrypoint-scripts/50-sshpull.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Wird von /etc/cont-init.d/2-manager vor "wazuh-control start" ausgefuehrt. +set -e + +# Eigene Agentless-Skripte und Regeln (bei jedem Start aktualisieren) +install -o root -g wazuh -m 750 /opt/sshpull/agentless/* /var/ossec/agentless/ +install -o wazuh -g wazuh -m 660 /opt/sshpull/rules/sshpull_rules.xml /var/ossec/etc/rules/ + +/var/ossec/framework/python/bin/python3 /opt/sshpull/generate.py diff --git a/manager/generate.py b/manager/generate.py new file mode 100644 index 0000000..4585eaa --- /dev/null +++ b/manager/generate.py @@ -0,0 +1,137 @@ +"""Erzeugt beim Start des Managers aus /sshpull/hosts.yml: + + * /var/ossec/etc/sshpull/ssh_config SSH-Client-Config (Alias = Hostname) + * /var/ossec/etc/sshpull/keys/* Kopie der Schluessel (Owner wazuh) + * /var/ossec/etc/sshpull/commands/*.sh Befehle fuer den Diff-Check pro Host + * einen -Block in ossec.conf mit und + +Laeuft mit dem Wazuh-eigenen Python, bevor wazuh-control startet. +""" + +import grp +import os +import pwd +import shutil +import sys +from xml.sax.saxutils import escape + +sys.path.insert(0, "/opt/sshpull") +import sshpull_hosts # noqa: E402 + +HOSTS_FILE = "/sshpull/hosts.yml" +SSH_SRC = "/sshpull/ssh" +LOG_DIR = "/remote-logs" +BASE = "/var/ossec/etc/sshpull" +OSSEC_CONF = "/var/ossec/etc/ossec.conf" +BEGIN = "" +END = "" + +UID = pwd.getpwnam("wazuh").pw_uid +GID = grp.getgrnam("wazuh").gr_gid + + +def write(path, content, mode): + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + os.chown(path, UID, GID) + os.chmod(path, mode) + + +def mkdir(path, mode=0o750): + os.makedirs(path, exist_ok=True) + os.chown(path, UID, GID) + os.chmod(path, mode) + + +def agentless_block(h): + host = f"{'use_sudo ' if h['sudo'] else ''}{h['user']}@{h['name']}" + out = [] + integ = h["integrity"] + if integ.get("enabled") and integ.get("paths"): + out.append(f""" + sshpull_integrity_linux + {int(integ['interval'])} + {escape(host)} + periodic + {escape(' '.join(integ['paths']))} + """) + cmds = h["commands"] + if cmds.get("enabled") and cmds.get("run"): + out.append(f""" + sshpull_generic_diff + {int(cmds['interval'])} + {escape(host)} + periodic_diff + {escape(h['name'])} + """) + return out + + +def localfile_block(h): + out = [] + for src in h["logs"]: + fmt = "syslog" if src["type"] == "journal" else src["format"] + out.append(f""" + {fmt} + {LOG_DIR}/{h['name']}/{src['name']}.log + """) + return out + + +def main(): + if not os.path.exists(HOSTS_FILE): + print("sshpull: keine hosts.yml gefunden - ueberspringe") + return 0 + try: + cfg = sshpull_hosts.load(HOSTS_FILE) + except (sshpull_hosts.ConfigError, OSError, ValueError) as exc: + print(f"sshpull: FEHLER in hosts.yml: {exc}", file=sys.stderr) + return 1 + + mkdir(BASE) + keys = os.path.join(BASE, "keys") + cmd_dir = os.path.join(BASE, "commands") + for d in (keys, cmd_dir): + shutil.rmtree(d, ignore_errors=True) + mkdir(d, 0o700 if d == keys else 0o750) + + for key in {h["identity_file"] for h in cfg["hosts"]}: + src = os.path.join(SSH_SRC, key) + if not os.path.isfile(src): + print(f"sshpull: WARNUNG: Schluessel {src} fehlt", file=sys.stderr) + continue + dst = os.path.join(keys, key) + shutil.copyfile(src, dst) + os.chown(dst, UID, GID) + os.chmod(dst, 0o600) + + write(os.path.join(BASE, "ssh_config"), + sshpull_hosts.render_ssh_config(cfg, keys, f"{SSH_SRC}/known_hosts"), 0o640) + + blocks = [] + for h in cfg["hosts"]: + write(os.path.join(cmd_dir, f"{h['name']}.sh"), + "\n".join(h["commands"].get("run") or []) + "\n", 0o640) + blocks += agentless_block(h) + localfile_block(h) + + # Verbindungsstatus des Collectors (Zustandswechsel up/down) + blocks.append(f""" + json + {LOG_DIR}/_status/status.log + """) + + with open(OSSEC_CONF, encoding="utf-8") as fh: + conf = fh.read() + if BEGIN in conf: # alten Block entfernen (falls ossec.conf nicht neu gemountet wurde) + conf = conf[:conf.index(BEGIN)] + conf[conf.index(END) + len(END):] + conf = conf.rstrip() + "\n\n" + BEGIN + "\n\n" + \ + "\n\n".join(blocks) + "\n\n" + END + "\n" + with open(OSSEC_CONF, "w", encoding="utf-8") as fh: + fh.write(conf) + + print(f"sshpull: {len(cfg['hosts'])} Host(s) in ossec.conf eingetragen") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/manager/rules/sshpull_rules.xml b/manager/rules/sshpull_rules.xml new file mode 100644 index 0000000..a0f5746 --- /dev/null +++ b/manager/rules/sshpull_rules.xml @@ -0,0 +1,85 @@ + + + + + + json + \.+ + sshpull FIM-Ereignis + + + + 100500 + ^baseline$ + $(sshpull_fim.host): FIM-Baseline angelegt ($(sshpull_fim.files) Dateien) + sshpull_fim, + + + + 100500 + ^added$ + $(sshpull_fim.host): Datei hinzugefuegt: $(sshpull_fim.path) + sshpull_fim,syscheck_entry_added,pci_dss_11.5,gdpr_II_5.1.f, + + + + 100500 + ^modified$ + $(sshpull_fim.host): Datei geaendert ($(sshpull_fim.changed)): $(sshpull_fim.path) + sshpull_fim,syscheck_entry_modified,pci_dss_11.5,gdpr_II_5.1.f, + + + + 100500 + ^deleted$ + $(sshpull_fim.host): Datei geloescht: $(sshpull_fim.path) + sshpull_fim,syscheck_entry_deleted,pci_dss_11.5,gdpr_II_5.1.f, + + + + 100500 + ^summary$ + $(sshpull_fim.host): $(sshpull_fim.total) Dateiaenderungen auf einmal (neu $(sshpull_fim.added), geaendert $(sshpull_fim.modified), geloescht $(sshpull_fim.deleted)) - Update oder Kompromittierung? + sshpull_fim, + + + + 100502,100503,100504 + ^/etc/(passwd|shadow|group|gshadow|sudoers|ssh/sshd_config|pam\.d/)|^/etc/sudoers\.d/|/\.ssh/authorized_keys|^/etc/(crontab|cron\.d/)|^/etc/ld\.so\.preload$ + $(sshpull_fim.host): Sicherheitskritische Datei $(sshpull_fim.event): $(sshpull_fim.path) + sshpull_fim, + + + + 100502,100503 + ^[2-7][0-7]{3}$ + $(sshpull_fim.host): Datei mit SUID/SGID-Bit ($(sshpull_fim.perm_after)): $(sshpull_fim.path) + sshpull_fim, + + + + + json + \.+ + sshpull Statusmeldung + + + + 100510 + ^down$ + $(sshpull_status.host): SSH-Abholung ($(sshpull_status.check)) gestoert: $(sshpull_status.message) + sshpull_status, + + + + 100510 + ^up$ + $(sshpull_status.host): SSH-Abholung ($(sshpull_status.check)) wieder verbunden + sshpull_status, + + + diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..9419857 --- /dev/null +++ b/setup.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# Einrichtung und Hilfsbefehle fuer den Wazuh-SSH-Pull-Stack. +# +# ./setup.sh Erstinstallation (Passwoerter, Zertifikate, SSH-Key, Images) +# ./setup.sh host-script Skript zum Vorbereiten eines Zielhosts ausgeben +# ./setup.sh host-script | ssh admin@host sudo sh +# Optionen: --sudo (NOPASSWD-sudo), --tunnel (fuer agent_tunnel), +# --user=NAME (Standard wazuh-pull) +# ./setup.sh keyscan Host-Keys aller Hosts aus hosts.yml nach ssh/known_hosts +# ./setup.sh test SSH-Zugriff und Rechte auf allen Hosts pruefen +# ./setup.sh apply hosts.yml uebernehmen (Manager + Collector neu starten) +# ./setup.sh passwords Zugangsdaten anzeigen +set -euo pipefail +cd "$(dirname "$0")" + +if docker compose version >/dev/null 2>&1; then DC=(docker compose); else DC=(docker-compose); fi + +info() { echo -e "\e[1;32m==>\e[0m $*"; } +warn() { echo -e "\e[1;33m!!\e[0m $*" >&2; } +die() { echo -e "\e[1;31mFEHLER:\e[0m $*" >&2; exit 1; } + +genpw() { + # Erfuellt die Wazuh-API-Richtlinie (Gross, klein, Ziffer, Sonderzeichen) und + # enthaelt keine Zeichen, die in den sed-Aufrufen der Wazuh-Images stoeren. + echo "$(tr -dc 'A-Za-z0-9' > .env; fi +} + +env_get() { grep "^$1=" .env | cut -d= -f2-; } + +cmd_init() { + command -v docker >/dev/null || die "docker fehlt" + + # --- Kernel-Parameter fuer den Indexer --------------------------------- + local mmc + mmc=$(cat /proc/sys/vm/max_map_count) + if [ "$mmc" -lt 262144 ]; then + warn "vm.max_map_count ist $mmc (empfohlen 262144). Dauerhaft setzen mit:" + warn " echo 'vm.max_map_count=262144' | sudo tee /etc/sysctl.d/99-wazuh.conf && sudo sysctl --system" + fi + + # --- .env + Passwoerter ------------------------------------------------- + [ -f .env ] || cp .env.example .env + for k in INDEXER_PASSWORD DASHBOARD_PASSWORD API_PASSWORD; do + if [ -z "$(env_get $k)" ]; then set_env "$k" "$(genpw)"; info "$k erzeugt"; fi + done + chmod 600 .env + local version + version=$(env_get WAZUH_VERSION) + + # --- internal_users.yml mit Hashes ------------------------------------- + if [ ! -f config/wazuh_indexer/internal_users.yml ]; then + info "Erzeuge Indexer-Benutzer (Passwort-Hashes)" + hash() { + docker run --rm -e JAVA_HOME=/usr/share/wazuh-indexer/jdk --entrypoint bash \ + "wazuh/wazuh-indexer:$version" \ + /usr/share/wazuh-indexer/plugins/opensearch-security/tools/hash.sh -p "$1" 2>/dev/null | tail -1 + } + local h_admin h_kibana + h_admin=$(hash "$(env_get INDEXER_PASSWORD)") + h_kibana=$(hash "$(env_get DASHBOARD_PASSWORD)") + [[ $h_admin == \$2* && $h_kibana == \$2* ]] || die "Hash-Erzeugung fehlgeschlagen" + cat > config/wazuh_indexer/internal_users.yml <:$(env_get DASHBOARD_PORT) (admin / siehe ./setup.sh passwords) +EOF +} + +cmd_host_script() { + [ -f ssh/id_ed25519.pub ] || die "ssh/id_ed25519.pub fehlt - erst ./setup.sh ausfuehren" + local pub user="wazuh-pull" sudo=no opts="restrict" + pub=$(cat ssh/id_ed25519.pub) + for a in "$@"; do + case "$a" in + --sudo) sudo=yes ;; + --tunnel) opts='restrict,port-forwarding,permitlisten="127.0.0.1:1514",permitlisten="127.0.0.1:1515",permitopen="127.0.0.1:1"' ;; + --user=*) user="${a#--user=}" ;; + *) die "unbekannte Option $a" ;; + esac + done + cat </dev/null 2>&1; then + useradd --system --create-home --shell /bin/sh "\$U" 2>/dev/null || adduser -D -s /bin/sh "\$U" + usermod -p '*' "\$U" 2>/dev/null || true # kein Passwort, aber nicht gesperrt (sonst lehnt sshd ab) +fi +# Leserechte auf Logs: journald (systemd-journal) und /var/log (adm) +for g in systemd-journal adm; do + getent group "\$g" >/dev/null && usermod -aG "\$g" "\$U" 2>/dev/null || true +done +H=\$(getent passwd "\$U" | cut -d: -f6) +mkdir -p "\$H/.ssh" +# restrict: kein PTY, kein Agent-/X11-/Port-Forwarding. +# Mit --tunnel nur Reverse-Forwarding auf 127.0.0.1:1514/1515 (fuer agent_tunnel). +KEY='$opts $pub' +grep -qF '$pub' "\$H/.ssh/authorized_keys" 2>/dev/null || echo "\$KEY" >> "\$H/.ssh/authorized_keys" +chown -R "\$U:" "\$H/.ssh"; chmod 700 "\$H/.ssh"; chmod 600 "\$H/.ssh/authorized_keys" +EOF + if [ "$sudo" = yes ]; then + cat < /etc/sudoers.d/wazuh-pull +chmod 440 /etc/sudoers.d/wazuh-pull +visudo -cf /etc/sudoers.d/wazuh-pull +EOF + fi + echo 'echo "Host vorbereitet: Benutzer $U ($(id $U))"' +} + +case "${1:-init}" in + init) cmd_init ;; + host-script) shift; cmd_host_script "$@" ;; + keyscan) "${DC[@]}" run --rm --no-deps sshpull.collector keyscan ;; + test) "${DC[@]}" run --rm --no-deps sshpull.collector test ;; + apply) "${DC[@]}" up -d --force-recreate wazuh.manager sshpull.collector ;; + passwords) + echo "Dashboard: admin / $(env_get INDEXER_PASSWORD)" + echo "Wazuh-API: wazuh-wui / $(env_get API_PASSWORD)" + ;; + *) sed -n '2,13p' "$0"; exit 2 ;; +esac diff --git a/ssh/.gitkeep b/ssh/.gitkeep new file mode 100644 index 0000000..e69de29