"""Service exécution patch yum : - pre_patch_capture() : capture services + ports avant patch (wiki SANEF) - yum_dryrun() : `sudo -n yum update --assumeno --exclude=...` - yum_update() : `sudo -n yum update -y --exclude=...` - post_patch_compare() : compare services/ports avant/après + rapport Réutilise _resolve/_connect du realtime_audit_service. """ import base64 import logging import re from typing import Dict, Any, List from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK log = logging.getLogger("patchcenter.patch_run") # Timeouts SSH (secondes) TIMEOUT_DRYRUN = 60 TIMEOUT_UPDATE = 1800 # 30 min — yum update peut être long # Whitelist caractères autorisés dans un nom de paquet (anti-injection shell) EXCLUDE_RE = re.compile(r"^[A-Za-z0-9._*\-/+]+$") def _safe_excludes(raw) -> List[str]: """Normalise et filtre la string `effective_excludes` (séparée par espaces). Rejette les patterns suspects pour éviter toute injection.""" if not raw: return [] parts = str(raw).split() return [p for p in parts if EXCLUDE_RE.match(p)] def _build_cmd(mode: str, excludes: List[str]) -> str: """mode = 'dryrun' (--assumeno) ou 'update' (-y).""" flag = "--assumeno" if mode == "dryrun" else "-y" parts = ["sudo", "-n", "yum", "update", flag] for e in excludes: parts.append(f"--exclude={e}") parts.append("2>&1") return " ".join(parts) def _exec(client, cmd: str, timeout: int) -> Dict[str, Any]: try: stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout) out = stdout.read().decode("utf-8", "replace") err = stderr.read().decode("utf-8", "replace") rc = stdout.channel.recv_exit_status() return {"rc": rc, "stdout": out, "stderr": err} except Exception as e: return {"rc": -1, "stdout": "", "stderr": f"exec error: {e}"} def _open_ssh(hostname: str): target = _resolve(hostname) if not target: return None, None, "DNS résolution impossible" if not PARAMIKO_OK: return None, target, "paramiko non disponible côté serveur PatchCenter" client = _connect(target, hostname) if not client: return None, target, "Connexion SSH échouée" return client, target, None def _summary(stdout: str) -> List[str]: """Extrait les lignes-clé du plan yum (compte de paquets, 'Nothing to do'…).""" keys = ( "package(s)", "paquet(s)", "nothing to do", "rien à faire", "transaction summary", "résumé de la transaction", "install ", "installation ", "upgrade ", "mise à niveau", "complete!", "terminé !", "no packages marked for update", "no package", ) summary = [] for ln in stdout.splitlines(): ll = ln.lower().strip() if any(k in ll for k in keys): summary.append(ln.strip()) if len(summary) >= 25: break return summary def yum_dryrun(hostname: str, excludes_raw) -> Dict[str, Any]: """Lance un dry-run (--assumeno). rc=0 → rien à faire, rc=1 → updates dispo (normal).""" excludes = _safe_excludes(excludes_raw) client, target, err = _open_ssh(hostname) if err: return {"ok": False, "detail": err, "target": target, "excludes": excludes} try: cmd = _build_cmd("dryrun", excludes) r = _exec(client, cmd, TIMEOUT_DRYRUN) finally: try: client.close() except Exception: pass # Avec --assumeno : rc=0 si "Nothing to do", rc=1 si plan d'updates dispo ok = r["rc"] in (0, 1) return { "ok": ok, "rc": r["rc"], "cmd": cmd, "target": target, "excludes": excludes, "summary": _summary(r["stdout"]), "stdout_tail": r["stdout"][-3000:], "stderr": r["stderr"][:500] if r["stderr"] else "", } def yum_update(hostname: str, excludes_raw) -> Dict[str, Any]: """Lance le vrai patch (-y).""" excludes = _safe_excludes(excludes_raw) client, target, err = _open_ssh(hostname) if err: return {"ok": False, "detail": err, "target": target, "excludes": excludes} try: cmd = _build_cmd("update", excludes) r = _exec(client, cmd, TIMEOUT_UPDATE) finally: try: client.close() except Exception: pass ok = (r["rc"] == 0) return { "ok": ok, "rc": r["rc"], "cmd": cmd, "target": target, "excludes": excludes, "summary": _summary(r["stdout"]), "stdout_tail": r["stdout"][-5000:], "stderr": r["stderr"][:500] if r["stderr"] else "", } # ─── B3.4 + B3.5 — Pre/Post patch capture (scripts du wiki SANEF) ───────── PRE_PATCH_SCRIPT = r"""#!/bin/bash # Généré par PatchCenter — capture pré-patching (wiki SANEF "Patch Linux") HOSTNAME=$(hostname) SNAPSHOT_DIR="/tmp" sudo -n systemctl list-units --type=service --state=running --no-pager \ | awk '{print $1}' | grep '\.service$' \ > ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt SVC=$(wc -l < ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) echo "OK services_avant=${SVC} -> ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt" sudo -n ss -tlnup > ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt sudo -n ss -tlnup | awk 'NR>1 && $7 != "" { match($7, /users:\(\("([^"]+)"/, arr) split($5, addr, ":") port = addr[length(addr)] if (arr[1] != "" && port+0 < 32768) print port, arr[1] }' | sort -u > ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt PORTS=$(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt || echo 0) echo "OK ports_avant=${PORTS} -> ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt" """ POST_PATCH_SCRIPT = r"""#!/bin/bash # Généré par PatchCenter — comparaison post-patching (wiki SANEF "Patch Linux") HOSTNAME=$(hostname) SNAPSHOT_DIR="/tmp" RAPPORT="/tmp/rapport_patching_${HOSTNAME}_$(date +%Y%m%d_%H%M).txt" sudo -n systemctl list-units --type=service --state=running --no-pager \ | awk '{print $1}' | grep '\.service$' \ > ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt DISPARUS_SVC=$(comm -23 \ <(sort ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) \ <(sort ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt) \ | grep -v "user@") APPARUS_SVC=$(comm -13 \ <(sort ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) \ <(sort ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt) \ | grep -Ev "setroubleshootd|user@") { echo "=== Rapport patching ${HOSTNAME} - $(date '+%Y-%m-%d %H:%M') ===" echo echo "--- SERVICES ---" echo "Avant : $(wc -l < ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) | Après : $(wc -l < ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt)" if [ -z "$DISPARUS_SVC" ]; then echo "OK_services_disparus=0"; else echo "KO_services_disparus:"; echo "$DISPARUS_SVC"; fi if [ -z "$APPARUS_SVC" ]; then echo "OK_services_apparus=0"; else echo "WARN_services_apparus:"; echo "$APPARUS_SVC"; fi } | tee ${RAPPORT} sudo -n ss -tlnup > ${SNAPSHOT_DIR}/secops_ports_apres_${HOSTNAME}.txt sudo -n ss -tlnup | awk 'NR>1 && $7 != "" { match($7, /users:\(\("([^"]+)"/, arr) split($5, addr, ":") port = addr[length(addr)] if (arr[1] != "" && port+0 < 32768) print port, arr[1] }' | sort -u > ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt PORTS_DISPARUS=$(comm -23 \ <(sort ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt) \ <(sort ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt)) PORTS_APPARUS=$(comm -13 \ <(sort ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt) \ <(sort ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt)) { echo echo "--- PORTS ---" echo "Avant : $(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt) | Après : $(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_apres_${HOSTNAME}.txt)" if [ -z "$PORTS_DISPARUS" ]; then echo "OK_ports_disparus=0"; else echo "KO_ports_disparus:"; echo "$PORTS_DISPARUS"; fi if [ -z "$PORTS_APPARUS" ]; then echo "OK_ports_apparus=0"; else echo "WARN_ports_apparus:"; echo "$PORTS_APPARUS"; fi echo echo "Rapport : ${RAPPORT}" } | tee -a ${RAPPORT} """ def _push_and_run(client, remote_path: str, script_content: str, timeout: int) -> Dict[str, Any]: """Pousse le script (encode base64) puis l'exécute. Retourne {rc, stdout, stderr}.""" b64 = base64.b64encode(script_content.encode("utf-8")).decode("ascii") cmd = (f"echo '{b64}' | base64 -d > {remote_path} && " f"chmod +x {remote_path} && bash {remote_path} 2>&1") return _exec(client, cmd, timeout) def _parse_capture_report(stdout: str) -> Dict[str, Any]: """Parse les marqueurs OK_/KO_/WARN_ du script post-patch.""" parsed = { "services_disparus": [], "services_apparus": [], "ports_disparus": [], "ports_apparus": [], "services_avant": None, "services_apres": None, "ports_avant": None, "ports_apres": None, } section = None # 'svc_dis', 'svc_app', 'port_dis', 'port_app' for ln in stdout.splitlines(): s = ln.strip() if s.startswith("KO_services_disparus"): section = "svc_dis"; continue if s.startswith("WARN_services_apparus"): section = "svc_app"; continue if s.startswith("KO_ports_disparus"): section = "port_dis"; continue if s.startswith("WARN_ports_apparus"): section = "port_app"; continue if s.startswith("OK_") or s.startswith("---") or not s: section = None continue if section == "svc_dis": parsed["services_disparus"].append(s) elif section == "svc_app": parsed["services_apparus"].append(s) elif section == "port_dis": parsed["ports_disparus"].append(s) elif section == "port_app": parsed["ports_apparus"].append(s) # Compteurs avant/après depuis lignes "Avant : X | Après : Y" for ln in stdout.splitlines(): m = re.match(r"\s*Avant\s*:\s*(\d+).*Apr.s\s*:\s*(\d+)", ln) if not m: continue if parsed["services_avant"] is None: parsed["services_avant"] = int(m.group(1)) parsed["services_apres"] = int(m.group(2)) elif parsed["ports_avant"] is None: parsed["ports_avant"] = int(m.group(1)) parsed["ports_apres"] = int(m.group(2)) return parsed def pre_patch_capture(hostname: str) -> Dict[str, Any]: """B3.4 — Capture services+ports avant patch (snapshot dans /tmp côté serveur).""" client, target, err = _open_ssh(hostname) if err: return {"ok": False, "detail": err, "target": target} try: r = _push_and_run(client, "/tmp/secops_pre_patching.sh", PRE_PATCH_SCRIPT, timeout=60) finally: try: client.close() except Exception: pass ok = (r["rc"] == 0) return { "ok": ok, "rc": r["rc"], "target": target, "stdout": r["stdout"][-2000:], "stderr": r["stderr"][:500] if r["stderr"] else "", } def post_patch_compare(hostname: str) -> Dict[str, Any]: """B3.5 — Compare services+ports avant/après patch + génère rapport. À exécuter après le yum update + reboot, le serveur doit avoir ses fichiers /tmp/secops_*_avant_*.txt déjà présents (= pre_patch_capture).""" client, target, err = _open_ssh(hostname) if err: return {"ok": False, "detail": err, "target": target} try: r = _push_and_run(client, "/tmp/secops_post_patching.sh", POST_PATCH_SCRIPT, timeout=60) finally: try: client.close() except Exception: pass parsed = _parse_capture_report(r["stdout"]) has_disparus = bool(parsed["services_disparus"]) or bool(parsed["ports_disparus"]) has_apparus = bool(parsed["services_apparus"]) or bool(parsed["ports_apparus"]) if r["rc"] != 0: status = "ko" elif has_disparus: status = "ko" elif has_apparus: status = "warn" else: status = "ok" return { "ok": (r["rc"] == 0), "status": status, "rc": r["rc"], "target": target, "report": parsed, "stdout": r["stdout"][-3000:], "stderr": r["stderr"][:500] if r["stderr"] else "", }