"""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)