first commit
This commit is contained in:
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
Executable
+16
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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}
|
||||||
@@ -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/
|
||||||
Executable
+35
@@ -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
|
||||||
Executable
+159
@@ -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:]))
|
||||||
Executable
+9
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user