Files
2026-10-07 10:47:30 +02:00

173 lines
6.3 KiB
Python

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