173 lines
6.3 KiB
Python
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)
|