first commit

This commit is contained in:
duffyduck
2026-10-07 10:47:30 +02:00
commit 117bb582de
18 changed files with 2060 additions and 0 deletions
+162
View File
@@ -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/<host>/*.log ──┐ │
Host db01 ◄──ssh──┼─┤ (journalctl -f / tail -F, Reconnect + Resume) │ │
Host ... ◄──ssh──┤ │ ▼ │
│ │ wazuh.manager ◄── <localfile> (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://<server>/ (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` `<logall_json>yes</logall_json>`
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` `<address>127.0.0.1</address>`.
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.
+13
View File
@@ -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"]
+495
View File
@@ -0,0 +1,495 @@
"""sshpull-collector: holt Logs per SSH von den Hosts und schreibt sie nach
/remote-logs/<host>/<quelle>.log, wo der Wazuh-Manager sie per <localfile> 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 <delay> 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())
+172
View File
@@ -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)
+16
View File
@@ -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
+311
View File
@@ -0,0 +1,311 @@
<ossec_config>
<global>
<jsonout_output>yes</jsonout_output>
<alerts_log>yes</alerts_log>
<logall>no</logall>
<logall_json>no</logall_json>
<email_notification>no</email_notification>
<smtp_server>smtp.example.wazuh.com</smtp_server>
<email_from>wazuh@example.wazuh.com</email_from>
<email_to>recipient@example.wazuh.com</email_to>
<email_maxperhour>12</email_maxperhour>
<email_log_source>alerts.log</email_log_source>
<agents_disconnection_time>10m</agents_disconnection_time>
<agents_disconnection_alert_time>0</agents_disconnection_alert_time>
</global>
<alerts>
<log_alert_level>3</log_alert_level>
<email_alert_level>12</email_alert_level>
</alerts>
<!-- Choose between "plain", "json", or "plain,json" for the format of internal logs -->
<logging>
<log_format>plain</log_format>
</logging>
<remote>
<connection>secure</connection>
<port>1514</port>
<protocol>tcp</protocol>
<queue_size>131072</queue_size>
</remote>
<!-- Policy monitoring -->
<rootcheck>
<disabled>no</disabled>
<check_files>yes</check_files>
<check_trojans>yes</check_trojans>
<check_dev>yes</check_dev>
<check_sys>yes</check_sys>
<check_pids>yes</check_pids>
<check_ports>yes</check_ports>
<check_if>yes</check_if>
<!-- Frequency that rootcheck is executed - every 12 hours -->
<frequency>43200</frequency>
<rootkit_files>etc/rootcheck/rootkit_files.txt</rootkit_files>
<rootkit_trojans>etc/rootcheck/rootkit_trojans.txt</rootkit_trojans>
<skip_nfs>yes</skip_nfs>
</rootcheck>
<wodle name="cis-cat">
<disabled>yes</disabled>
<timeout>1800</timeout>
<interval>1d</interval>
<scan-on-start>yes</scan-on-start>
<java_path>wodles/java</java_path>
<ciscat_path>wodles/ciscat</ciscat_path>
</wodle>
<!-- Osquery integration -->
<wodle name="osquery">
<disabled>yes</disabled>
<run_daemon>yes</run_daemon>
<log_path>/var/log/osquery/osqueryd.results.log</log_path>
<config_path>/etc/osquery/osquery.conf</config_path>
<add_labels>yes</add_labels>
</wodle>
<!-- System inventory -->
<wodle name="syscollector">
<disabled>no</disabled>
<interval>1h</interval>
<scan_on_start>yes</scan_on_start>
<hardware>yes</hardware>
<os>yes</os>
<network>yes</network>
<packages>yes</packages>
<ports all="yes">yes</ports>
<processes>yes</processes>
<!-- Database synchronization settings -->
<synchronization>
<max_eps>10</max_eps>
</synchronization>
</wodle>
<sca>
<enabled>yes</enabled>
<scan_on_start>yes</scan_on_start>
<interval>12h</interval>
<skip_nfs>yes</skip_nfs>
</sca>
<vulnerability-detection>
<enabled>yes</enabled>
<index-status>yes</index-status>
<feed-update-interval>60m</feed-update-interval>
</vulnerability-detection>
<indexer>
<enabled>yes</enabled>
<hosts>
<host>https://wazuh.indexer:9200</host>
</hosts>
<ssl>
<certificate_authorities>
<ca>/etc/ssl/root-ca.pem</ca>
</certificate_authorities>
<certificate>/etc/ssl/filebeat.pem</certificate>
<key>/etc/ssl/filebeat.key</key>
</ssl>
</indexer>
<!-- File integrity monitoring -->
<syscheck>
<disabled>no</disabled>
<!-- Frequency that syscheck is executed default every 12 hours -->
<frequency>43200</frequency>
<scan_on_start>yes</scan_on_start>
<!-- Generate alert when new file detected -->
<alert_new_files>yes</alert_new_files>
<!-- Don't ignore files that change more than 'frequency' times -->
<auto_ignore frequency="10" timeframe="3600">no</auto_ignore>
<!-- Directories to check (perform all possible verifications) -->
<directories>/etc,/usr/bin,/usr/sbin</directories>
<directories>/bin,/sbin,/boot</directories>
<!-- Files/directories to ignore -->
<ignore>/etc/mtab</ignore>
<ignore>/etc/hosts.deny</ignore>
<ignore>/etc/mail/statistics</ignore>
<ignore>/etc/random-seed</ignore>
<ignore>/etc/random.seed</ignore>
<ignore>/etc/adjtime</ignore>
<ignore>/etc/httpd/logs</ignore>
<ignore>/etc/utmpx</ignore>
<ignore>/etc/wtmpx</ignore>
<ignore>/etc/cups/certs</ignore>
<ignore>/etc/dumpdates</ignore>
<ignore>/etc/svc/volatile</ignore>
<!-- File types to ignore -->
<ignore type="sregex">.log$|.swp$</ignore>
<!-- Check the file, but never compute the diff -->
<nodiff>/etc/ssl/private.key</nodiff>
<skip_nfs>yes</skip_nfs>
<skip_dev>yes</skip_dev>
<skip_proc>yes</skip_proc>
<skip_sys>yes</skip_sys>
<!-- Nice value for Syscheck process -->
<process_priority>10</process_priority>
<!-- Maximum output throughput -->
<max_eps>100</max_eps>
<!-- Database synchronization settings -->
<synchronization>
<enabled>yes</enabled>
<interval>5m</interval>
<max_interval>1h</max_interval>
<max_eps>10</max_eps>
</synchronization>
</syscheck>
<!-- Active response -->
<global>
<white_list>127.0.0.1</white_list>
<white_list>^localhost.localdomain$</white_list>
</global>
<command>
<name>disable-account</name>
<executable>disable-account</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>restart-wazuh</name>
<executable>restart-wazuh</executable>
</command>
<command>
<name>firewall-drop</name>
<executable>firewall-drop</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>host-deny</name>
<executable>host-deny</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>route-null</name>
<executable>route-null</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>win_route-null</name>
<executable>route-null.exe</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<command>
<name>netsh</name>
<executable>netsh.exe</executable>
<timeout_allowed>yes</timeout_allowed>
</command>
<!--
<active-response>
active-response options here
</active-response>
-->
<!-- Log analysis -->
<localfile>
<log_format>command</log_format>
<command>df -P</command>
<frequency>360</frequency>
</localfile>
<localfile>
<log_format>full_command</log_format>
<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</command>
<alias>netstat listening ports</alias>
<frequency>360</frequency>
</localfile>
<localfile>
<log_format>full_command</log_format>
<command>last -n 20</command>
<frequency>360</frequency>
</localfile>
<ruleset>
<!-- Default ruleset -->
<decoder_dir>ruleset/decoders</decoder_dir>
<rule_dir>ruleset/rules</rule_dir>
<rule_exclude>0215-policy_rules.xml</rule_exclude>
<list>etc/lists/audit-keys</list>
<list>etc/lists/amazon/aws-eventnames</list>
<list>etc/lists/security-eventchannel</list>
<list>etc/lists/malicious-ioc/malicious-ip</list>
<list>etc/lists/malicious-ioc/malicious-domains</list>
<list>etc/lists/malicious-ioc/malware-hashes</list>
<!-- User-defined ruleset -->
<decoder_dir>etc/decoders</decoder_dir>
<rule_dir>etc/rules</rule_dir>
</ruleset>
<rule_test>
<enabled>yes</enabled>
<threads>1</threads>
<max_sessions>64</max_sessions>
<session_timeout>15m</session_timeout>
</rule_test>
<!-- Configuration for wazuh-authd -->
<auth>
<disabled>no</disabled>
<port>1515</port>
<use_source_ip>no</use_source_ip>
<purge>yes</purge>
<use_password>no</use_password>
<ciphers>HIGH:!ADH:!EXP:!MD5:!RC4:!3DES:!CAMELLIA:@STRENGTH</ciphers>
<!-- <ssl_agent_ca></ssl_agent_ca> -->
<ssl_verify_host>no</ssl_verify_host>
<ssl_manager_cert>etc/sslmanager.cert</ssl_manager_cert>
<ssl_manager_key>etc/sslmanager.key</ssl_manager_key>
<ssl_auto_negotiate>no</ssl_auto_negotiate>
</auth>
<cluster>
<name>wazuh</name>
<node_name>node01</node_name>
<node_type>master</node_type>
<key>aa093264ef885029653eea20dfcf51ae</key>
<port>1516</port>
<bind_addr>0.0.0.0</bind_addr>
<nodes>
<node>wazuh.manager</node>
</nodes>
<hidden>no</hidden>
<disabled>yes</disabled>
</cluster>
</ossec_config>
<ossec_config>
<localfile>
<log_format>syslog</log_format>
<location>/var/ossec/logs/active-responses.log</location>
</localfile>
</ossec_config>
@@ -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
+36
View File
@@ -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
+144
View File
@@ -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:
+81
View File
@@ -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}
+10
View File
@@ -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/
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Agentless-Befehls-Diff per SSH (Schluessel, ohne expect).
# Aufruf durch wazuh-agentlessd: [use_sudo] <user@host> <hostname aus hosts.yml>
# Fuehrt /var/ossec/etc/sshpull/commands/<hostname>.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
+159
View File
@@ -0,0 +1,159 @@
#!/var/ossec/framework/python/bin/python3
"""Agentless-Datei-Integritaetspruefung per SSH (Schluessel, ohne expect).
Aufruf durch wazuh-agentlessd: [use_sudo] <user@host> <pfad> [<pfad> ...]
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/<host>.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] <user@host> <pfade...>")
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:]))
+9
View File
@@ -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
+137
View File
@@ -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 <ossec_config>-Block in ossec.conf mit <agentless> und <localfile>
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 = "<!-- SSHPULL BEGIN (automatisch erzeugt) -->"
END = "<!-- SSHPULL 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""" <agentless>
<type>sshpull_integrity_linux</type>
<frequency>{int(integ['interval'])}</frequency>
<host>{escape(host)}</host>
<state>periodic</state>
<arguments>{escape(' '.join(integ['paths']))}</arguments>
</agentless>""")
cmds = h["commands"]
if cmds.get("enabled") and cmds.get("run"):
out.append(f""" <agentless>
<type>sshpull_generic_diff</type>
<frequency>{int(cmds['interval'])}</frequency>
<host>{escape(host)}</host>
<state>periodic_diff</state>
<arguments>{escape(h['name'])}</arguments>
</agentless>""")
return out
def localfile_block(h):
out = []
for src in h["logs"]:
fmt = "syslog" if src["type"] == "journal" else src["format"]
out.append(f""" <localfile>
<log_format>{fmt}</log_format>
<location>{LOG_DIR}/{h['name']}/{src['name']}.log</location>
</localfile>""")
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""" <localfile>
<log_format>json</log_format>
<location>{LOG_DIR}/_status/status.log</location>
</localfile>""")
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<ossec_config>\n" + \
"\n\n".join(blocks) + "\n</ossec_config>\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())
+85
View File
@@ -0,0 +1,85 @@
<!--
Regeln fuer den SSH-Pull-Betrieb (wird bei jedem Manager-Start nach etc/rules kopiert).
sshpull_fim: Datei-Integritaet aus manager/agentless/sshpull_integrity_linux
sshpull_status: Verbindungsstatus (Collector und Agentless-Checks)
-->
<group name="sshpull,">
<!-- ===== Datei-Integritaet ===== -->
<rule id="100500" level="0">
<decoded_as>json</decoded_as>
<field name="sshpull_fim.event">\.+</field>
<description>sshpull FIM-Ereignis</description>
</rule>
<rule id="100501" level="3">
<if_sid>100500</if_sid>
<field name="sshpull_fim.event">^baseline$</field>
<description>$(sshpull_fim.host): FIM-Baseline angelegt ($(sshpull_fim.files) Dateien)</description>
<group>sshpull_fim,</group>
</rule>
<rule id="100502" level="5">
<if_sid>100500</if_sid>
<field name="sshpull_fim.event">^added$</field>
<description>$(sshpull_fim.host): Datei hinzugefuegt: $(sshpull_fim.path)</description>
<group>sshpull_fim,syscheck_entry_added,pci_dss_11.5,gdpr_II_5.1.f,</group>
</rule>
<rule id="100503" level="7">
<if_sid>100500</if_sid>
<field name="sshpull_fim.event">^modified$</field>
<description>$(sshpull_fim.host): Datei geaendert ($(sshpull_fim.changed)): $(sshpull_fim.path)</description>
<group>sshpull_fim,syscheck_entry_modified,pci_dss_11.5,gdpr_II_5.1.f,</group>
</rule>
<rule id="100504" level="7">
<if_sid>100500</if_sid>
<field name="sshpull_fim.event">^deleted$</field>
<description>$(sshpull_fim.host): Datei geloescht: $(sshpull_fim.path)</description>
<group>sshpull_fim,syscheck_entry_deleted,pci_dss_11.5,gdpr_II_5.1.f,</group>
</rule>
<rule id="100505" level="10">
<if_sid>100500</if_sid>
<field name="sshpull_fim.event">^summary$</field>
<description>$(sshpull_fim.host): $(sshpull_fim.total) Dateiaenderungen auf einmal (neu $(sshpull_fim.added), geaendert $(sshpull_fim.modified), geloescht $(sshpull_fim.deleted)) - Update oder Kompromittierung?</description>
<group>sshpull_fim,</group>
</rule>
<rule id="100506" level="10">
<if_sid>100502,100503,100504</if_sid>
<field name="sshpull_fim.path" type="pcre2">^/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$</field>
<description>$(sshpull_fim.host): Sicherheitskritische Datei $(sshpull_fim.event): $(sshpull_fim.path)</description>
<group>sshpull_fim,</group>
</rule>
<rule id="100507" level="10">
<if_sid>100502,100503</if_sid>
<field name="sshpull_fim.perm_after" type="pcre2">^[2-7][0-7]{3}$</field>
<description>$(sshpull_fim.host): Datei mit SUID/SGID-Bit ($(sshpull_fim.perm_after)): $(sshpull_fim.path)</description>
<group>sshpull_fim,</group>
</rule>
<!-- ===== Verbindungsstatus ===== -->
<rule id="100510" level="0">
<decoded_as>json</decoded_as>
<field name="sshpull_status.status">\.+</field>
<description>sshpull Statusmeldung</description>
</rule>
<rule id="100511" level="7">
<if_sid>100510</if_sid>
<field name="sshpull_status.status">^down$</field>
<description>$(sshpull_status.host): SSH-Abholung ($(sshpull_status.check)) gestoert: $(sshpull_status.message)</description>
<group>sshpull_status,</group>
</rule>
<rule id="100512" level="3">
<if_sid>100510</if_sid>
<field name="sshpull_status.status">^up$</field>
<description>$(sshpull_status.host): SSH-Abholung ($(sshpull_status.check)) wieder verbunden</description>
<group>sshpull_status,</group>
</rule>
</group>
Executable
+179
View File
@@ -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' </dev/urandom | head -c 24)Aa1."
}
set_env() { # set_env KEY VALUE
if grep -q "^$1=" .env; then sed -i "s|^$1=.*|$1=$2|" .env; else echo "$1=$2" >> .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 <<EOF
---
# Automatisch erzeugt von setup.sh. Passwoerter stehen in .env.
_meta:
type: "internalusers"
config_version: 2
admin:
hash: "$h_admin"
reserved: true
backend_roles:
- "admin"
description: "Admin user"
kibanaserver:
hash: "$h_kibana"
reserved: true
description: "Dashboard server user"
EOF
chmod 644 config/wazuh_indexer/internal_users.yml
fi
# --- TLS-Zertifikate ---------------------------------------------------
if [ ! -f config/wazuh_indexer_ssl_certs/root-ca.pem ]; then
info "Erzeuge TLS-Zertifikate"
mkdir -p config/wazuh_indexer_ssl_certs
docker run --rm \
-e CERT_TOOL_VERSION="${version%.*}" \
-v "$PWD/config/wazuh_indexer_ssl_certs/:/certificates/" \
-v "$PWD/config/certs.yml:/config/certs.yml:ro" \
wazuh/wazuh-certs-generator:0.0.4
[ -f config/wazuh_indexer_ssl_certs/root-ca.pem ] || die "Zertifikate wurden nicht erzeugt"
fi
# --- SSH-Schluessel ----------------------------------------------------
if [ ! -f ssh/id_ed25519 ]; then
info "Erzeuge SSH-Schluessel ssh/id_ed25519"
ssh-keygen -q -t ed25519 -N "" -C "wazuh-sshpull@$(hostname)" -f ssh/id_ed25519
fi
touch ssh/known_hosts
chmod 600 ssh/id_ed25519
[ -f hosts.yml ] || { cp hosts.yml.example hosts.yml; warn "hosts.yml aus Beispiel angelegt - bitte anpassen!"; }
info "Baue Images"
"${DC[@]}" build
cat <<EOF
Fertig. Naechste Schritte:
1. hosts.yml anpassen
2. Zielhosts vorbereiten: ./setup.sh host-script | ssh admin@HOST sudo sh
3. Host-Keys holen: ./setup.sh keyscan (Fingerprints pruefen!)
4. Zugriff testen: ./setup.sh test
5. Starten: ${DC[*]} up -d
6. Dashboard: https://<dieser-server>:$(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 <<EOF
#!/bin/sh
# Vorbereitung eines Hosts fuer Wazuh-SSH-Pull (als root ausfuehren)
set -e
U='$user'
if ! id "\$U" >/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 <<EOF
echo "\$U ALL=(root) NOPASSWD: ALL" > /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
View File