From 4590e89ff646ae625143de0ce67d123f00591503 Mon Sep 17 00:00:00 2001 From: "MOUTAOUAKIL-ext Khalid (admin)" Date: Thu, 18 Jun 2026 15:42:00 +0200 Subject: [PATCH] feat(securite/ldap): cookie Secure, logs debug LDAPS, .gitignore durci - auth.py: flag Secure + path=/ sur le cookie d'authentification - ldap_service.py: logging debug des connexions LDAPS vers logs/ldap_debug.log (jamais les mots de passe) - .gitignore: protege cles/certs TLS (ssl/, *.key, *.crt) + artefacts lourds (db/, sitepkgs.zip, *.bak, dump) - inclut aussi des modifs en cours: planning_import, patch_run_service, patching_iexec Co-Authored-By: Claude Opus 4.8 --- .gitignore | 11 ++++ app/routers/auth.py | 2 +- app/routers/planning_import.py | 25 ++++++++ app/services/ldap_service.py | 69 +++++++++++++++++++++-- app/services/patch_run_service.py | 94 ++++++++++++++++++++++++++----- app/templates/patching_iexec.html | 23 +++++++- 6 files changed, 203 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 1dcd76a..6d686d4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,14 @@ __pycache__/ import.log backups/ + +# Cles/certificats TLS - ne jamais committer +ssl/ +*.key +*.crt + +# Donnees lourdes / artefacts locaux - ne pas committer +db/ +sitepkgs.zip +*.bak +patchcenter_db.sql diff --git a/app/routers/auth.py b/app/routers/auth.py index acb06f9..a71b9f0 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -107,7 +107,7 @@ async def login(request: Request, username: str = Form(...), password: str = For modules = {r.module for r in perms} redirect_url = "/quickwin" if modules == {"quickwin"} else "/dashboard" response = RedirectResponse(url=redirect_url, status_code=303) - response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600) + response.set_cookie(key="access_token", value=token, httponly=True, secure=True, samesite="lax", max_age=3600, path="/") return response finally: db.close() diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 806ef85..7c06c57 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -1453,6 +1453,31 @@ async def pct_prevenance_send(request: Request, db=Depends(get_db)): return row, None +def _common_iexec_row_check(row_id, db, user, perms): + """Validation commune des endpoints d'exécution iexec (dry-run, yum, capture, + reboot, status…). Retourne (row, None) si OK, sinon (None, JSONResponse). + La row expose hostname, asset_name et effective_excludes (via v_servers), + attributs utilisés par tous les appelants.""" + if not (can_view(perms, "planning") or can_view(perms, "campaigns")): + return None, JSONResponse({"ok": False, "detail": "Permission refusée"}, status_code=403) + row = db.execute(text(""" + SELECT r.id, r.asset_name, r.os, r.is_eligible, r.server_id, + s.hostname, vs.effective_excludes + FROM patch_planning_import_rows r + LEFT JOIN servers s ON s.id = r.server_id + LEFT JOIN v_servers vs ON vs.id = r.server_id + WHERE r.id = :id + """), {"id": row_id}).fetchone() + if not row: + return None, JSONResponse({"ok": False, "detail": "Ligne introuvable"}, status_code=404) + if not row.is_eligible: + return None, JSONResponse({"ok": False, "detail": "Ligne non éligible"}, status_code=400) + hostname = (row.hostname or row.asset_name or "").strip() + if not hostname: + return None, JSONResponse({"ok": False, "detail": "Pas de hostname"}, status_code=400) + return row, None + + @router.post("/patching/iexec/yum-dryrun/{row_id}") async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)): """Step 3 — pré-vol : `sudo -n yum update --assumeno --exclude=...`.""" diff --git a/app/services/ldap_service.py b/app/services/ldap_service.py index 6ee262f..26a9096 100644 --- a/app/services/ldap_service.py +++ b/app/services/ldap_service.py @@ -8,18 +8,57 @@ Configuration via settings (clés) : - ldap_bind_pwd : mot de passe (stocké chiffré via secrets_service) - ldap_user_filter : filtre utilisateur (ex: (sAMAccountName={username})) - ldap_tls : "true" / "false" + +Debug : journalise le détail des connexions LDAPS dans logs/ldap_debug.log +(niveau DEBUG). Les mots de passe ne sont JAMAIS écrits dans les logs. """ +import os import logging +from logging.handlers import RotatingFileHandler from sqlalchemy import text -log = logging.getLogger(__name__) +# --- Logger dédié -> logs/ldap_debug.log (DEBUG) ------------------------------- +# Chemin : .../app/services/ldap_service.py -> remonte de 3 niveaux = racine projet +_LOG_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "logs", +) +log = logging.getLogger("patchcenter.ldap") +if not log.handlers: + try: + os.makedirs(_LOG_DIR, exist_ok=True) + _handler = RotatingFileHandler( + os.path.join(_LOG_DIR, "ldap_debug.log"), + maxBytes=2_000_000, backupCount=5, encoding="utf-8", + ) + _handler.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-7s [ldap] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + )) + log.addHandler(_handler) + log.setLevel(logging.DEBUG) + log.propagate = False + except Exception as _e: # pragma: no cover - ne jamais casser l'app pour un log + logging.getLogger(__name__).warning("Impossible d'initialiser ldap_debug.log: %s", _e) try: from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls import ssl LDAP_OK = True + # Active la journalisation protocolaire ldap3 vers le meme fichier. + # NOTE: ldap3 masque les donnees sensibles (mots de passe) par defaut. + try: + from ldap3.utils.log import set_library_log_detail_level, EXTENDED + set_library_log_detail_level(EXTENDED) + _l3 = logging.getLogger("ldap3") + _l3.setLevel(logging.DEBUG) + if log.handlers and not _l3.handlers: + _l3.addHandler(log.handlers[0]) + except Exception: + pass except ImportError: LDAP_OK = False + log.warning("Module ldap3 non installe : authentification LDAP indisponible") def _get_config(db): @@ -57,17 +96,26 @@ def authenticate(db, username, password): return {"ok": False, "msg": "Module ldap3 non installé"} cfg = _get_config(db) + use_ssl = cfg["server"].startswith("ldaps://") + log.debug( + "AUTH demande user=%s | server=%s ssl=%s tls=%s base_dn=%s bind_dn=%s filter=%s required_group=%s", + username, cfg["server"], use_ssl, cfg["tls"], cfg["base_dn"], cfg["bind_dn"], + cfg["user_filter"], cfg["required_group"] or "(aucun)", + ) if not cfg["enabled"]: + log.debug("AUTH user=%s -> LDAP desactive", username) return {"ok": False, "msg": "LDAP désactivé"} if not cfg["server"] or not cfg["base_dn"]: + log.debug("AUTH user=%s -> LDAP non configure (server/base_dn manquant)", username) return {"ok": False, "msg": "LDAP non configuré"} # 1. Bind service account pour chercher le DN de l'utilisateur try: - server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://")) + server = Server(cfg["server"], get_info=ALL, use_ssl=use_ssl) conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True) + log.debug("AUTH user=%s -> bind compte de service OK (result=%s)", username, getattr(conn, "result", None)) except Exception as e: - log.error(f"LDAP bind failed: {e}") + log.error("AUTH user=%s -> bind compte de service ECHEC: %s", username, e) return {"ok": False, "msg": f"Connexion LDAP échouée: {e}"} # 2. Recherche de l'utilisateur @@ -75,11 +123,15 @@ def authenticate(db, username, password): try: conn.search(cfg["base_dn"], user_filter, attributes=[cfg["email_attr"], cfg["name_attr"], "distinguishedName", "memberOf"]) + log.debug("AUTH user=%s -> recherche filter=%s base=%s entries=%d", + username, user_filter, cfg["base_dn"], len(conn.entries)) except Exception as e: + log.error("AUTH user=%s -> recherche ECHEC: %s", username, e) conn.unbind() return {"ok": False, "msg": f"Recherche LDAP échouée: {e}"} if not conn.entries: + log.debug("AUTH user=%s -> utilisateur introuvable", username) conn.unbind() return {"ok": False, "msg": "Utilisateur introuvable dans LDAP"} @@ -89,6 +141,7 @@ def authenticate(db, username, password): name = str(getattr(entry, cfg["name_attr"], "")) or username groups = list(entry.memberOf.values) if hasattr(entry, "memberOf") and entry.memberOf else [] conn.unbind() + log.debug("AUTH user=%s -> trouve dn=%s email=%s nb_groupes=%d", username, user_dn, email, len(groups)) # 3. Verification appartenance groupe (si configure) required = (cfg.get("required_group") or "").strip() @@ -96,16 +149,20 @@ def authenticate(db, username, password): # Match insensible a la casse + espaces normalises norm_required = required.lower().replace(" ", "") is_member = any(norm_required == g.lower().replace(" ", "") for g in groups) + log.debug("AUTH user=%s -> controle groupe requis=%s membre=%s", username, required, is_member) if not is_member: return {"ok": False, "msg": f"Acces refuse: utilisateur non membre du groupe requis"} # 4. Bind avec les credentials fournis try: user_conn = Connection(server, user=user_dn, password=password, auto_bind=True) + log.debug("AUTH user=%s -> bind utilisateur OK (result=%s)", username, getattr(user_conn, "result", None)) user_conn.unbind() except Exception as e: + log.warning("AUTH user=%s -> bind utilisateur ECHEC (mot de passe incorrect ?): %s", username, e) return {"ok": False, "msg": "Mot de passe incorrect"} + log.info("AUTH SUCCES user=%s dn=%s", username, user_dn) return {"ok": True, "dn": user_dn, "email": email, "name": name, "groups": groups, "default_role": cfg.get("default_role", "operator")} @@ -117,10 +174,14 @@ def test_connection(db): cfg = _get_config(db) if not cfg["server"]: return {"ok": False, "msg": "Serveur non configuré"} + use_ssl = cfg["server"].startswith("ldaps://") + log.debug("TEST connexion -> server=%s ssl=%s bind_dn=%s", cfg["server"], use_ssl, cfg["bind_dn"]) try: - server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://")) + server = Server(cfg["server"], get_info=ALL, use_ssl=use_ssl) conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True) + log.info("TEST connexion -> OK (result=%s)", getattr(conn, "result", None)) conn.unbind() return {"ok": True, "msg": "Connexion réussie"} except Exception as e: + log.error("TEST connexion -> ECHEC: %s", e) return {"ok": False, "msg": str(e)[:200]} diff --git a/app/services/patch_run_service.py b/app/services/patch_run_service.py index 2a4f3b2..bfb9e81 100644 --- a/app/services/patch_run_service.py +++ b/app/services/patch_run_service.py @@ -9,10 +9,11 @@ import base64 import logging import re import socket +import time from datetime import datetime from typing import Dict, Any, List -from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK +from .realtime_audit_service import _resolve, _connect, _candidate_targets, PARAMIKO_OK log = logging.getLogger("patchcenter.patch_run") @@ -20,6 +21,14 @@ log = logging.getLogger("patchcenter.patch_run") TIMEOUT_DRYRUN = 60 TIMEOUT_UPDATE = 1800 # 30 min — yum update peut être long +# Streaming SSE : battement de cœur pendant les phases silencieuses de yum +# (refresh metadata / résolution de deps n'émettent rien sur stdout pendant +# plusieurs secondes → sans heartbeat, un proxy idle coupe la connexion). +HEARTBEAT_SECS = 10 +# Durée max d'un stream avant abandon (le dry-run reste borné, l'update est long). +STREAM_MAX_DRYRUN = 300 +STREAM_MAX_UPDATE = TIMEOUT_UPDATE + # Whitelist caractères autorisés dans un nom de paquet (anti-injection shell) EXCLUDE_RE = re.compile(r"^[A-Za-z0-9._*\-/+]+$") @@ -55,15 +64,31 @@ def _exec(client, cmd: str, timeout: int) -> Dict[str, Any]: def _open_ssh(hostname: str): - target = _resolve(hostname) - if not target: - return None, None, "DNS résolution impossible" + """Ouvre une session SSH en réutilisant la logique du check pré-patching : + on itère les candidats DNS (_candidate_targets) et on tente _connect sur + chacun. _connect gère PSMP / clé / password — il NE faut PAS filtrer sur + un TCP/22 direct (cf. _resolve), sinon tout hôte PSMP (port 22 non joignable + en direct) échoue avec un faux '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 + return None, None, "paramiko non disponible côté serveur PatchCenter" + candidates = _candidate_targets(hostname) + if not candidates: + return None, None, "Aucun candidat DNS pour cet hôte" + errors: List[str] = [] + tried: List[str] = [] + for cand in candidates: + tried.append(cand) + errs: List[str] = [] + try: + client = _connect(cand, hostname, errors=errs) + except Exception as e: + errs.append(f"{type(e).__name__}: {e}") + client = None + if client: + return client, cand, None + errors.append(f"{cand}: " + (" | ".join(errs) if errs else "échec")) + detail = "Connexion SSH échouée — " + " ; ".join(errors[-3:]) + return None, (tried[0] if tried else None), detail def extract_problem_packages(stdout: str) -> List[str]: @@ -180,14 +205,57 @@ def yum_stream_lines(hostname: str, excludes_raw, mode: str): yield {"type": "cmd", "cmd": cmd, "target": target, "excludes": excludes, "hostname": hostname} full_lines: List[str] = [] + max_secs = STREAM_MAX_UPDATE if mode == "update" else STREAM_MAX_DRYRUN try: + # Keepalive transport : évite que la session SSH meure pendant un long + # silence de yum (résolution de deps, téléchargements). + try: + client.get_transport().set_keepalive(HEARTBEAT_SECS) + except Exception: + pass stdin, stdout, stderr = client.exec_command(cmd, get_pty=False) - # Lecture ligne par ligne ; yum bufferise peu son stdout sur opérations longues - for line in iter(stdout.readline, ""): - ln = line.rstrip("\n") + chan = stdout.channel + # Lecture non bloquante : recv() rend la main toutes les secondes pour + # qu'on puisse émettre un heartbeat même si yum n'a rien écrit. + chan.settimeout(1.0) + buf = "" + start = time.monotonic() + last_beat = start + while True: + try: + chunk = chan.recv(8192) + except socket.timeout: + chunk = None + if chunk: + buf += chunk.decode("utf-8", "replace") + while "\n" in buf: + ln, buf = buf.split("\n", 1) + ln = ln.rstrip("\r") + full_lines.append(ln) + yield {"type": "line", "data": ln} + last_beat = time.monotonic() + continue + if chunk == b"": # EOF : la commande distante est terminée + break + # chunk is None → timeout de lecture (yum silencieux) + now = time.monotonic() + if now - start > max_secs: + yield {"type": "error", + "msg": f"timeout stream ({max_secs}s) — commande interrompue côté PatchCenter"} + try: + chan.close() + except Exception: + pass + return + if now - last_beat >= HEARTBEAT_SECS: + last_beat = now + yield {"type": "ping"} + # Flush d'une éventuelle dernière ligne sans \n final + if buf: + ln = buf.rstrip("\r") full_lines.append(ln) yield {"type": "line", "data": ln} - rc = stdout.channel.recv_exit_status() + rc = chan.recv_exit_status() # Détection de paquets problématiques si KO problems: List[str] = [] if mode == "update" and rc != 0: diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index 6bde898..01074cc 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -391,7 +391,11 @@ function toggleDetails(){ ev.onmessage = (m) => { let j; try { j = JSON.parse(m.data); } catch(e) { return; } - if (j.type === 'cmd') { + if (j.type === 'ping') { + // Heartbeat serveur pendant les phases silencieuses de yum : + // garde la connexion SSE vivante, rien à afficher. + return; + } else if (j.type === 'cmd') { appendTerm(' # host : ' + (j.hostname||'') + ' (' + (j.target||'') + ')\n'); appendTerm(' # cmd : ' + (j.cmd||'') + '\n'); appendTerm(' # excludes (' + (j.excludes||[]).length + ')\n'); @@ -498,6 +502,8 @@ function toggleDetails(){ const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok').length; const snapAttempted = trs.some(tr => tr._snapData); const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok).length; + // snapEff : snapshot OK *ou* échec forcé (override) — autorise la suite sans snapshot + const snapEff = trs.filter(tr => tr._snapData && (tr._snapData.ok || tr._snapData.override)).length; const dryAttempted = trs.some(tr => tr._dryData); const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok).length; const preAttempted = trs.some(tr => tr._preData); @@ -528,7 +534,7 @@ function toggleDetails(){ const snapState = deriveState(ckOk, snapAttempted, snapOk); setBtnState(btnStep2, snapState); setStepState('snap', snapState); - const dryState = deriveState(snapOk, dryAttempted, dryOk); + const dryState = deriveState(snapEff, dryAttempted, dryOk); setBtnState(btnDryrun, dryState); setStepState('dry', dryState); const preState = deriveState(dryOk, preAttempted, preOk); @@ -587,12 +593,23 @@ function toggleDetails(){ } } summary.innerHTML += ' · Snapshot : ✓ ' + okCount + ' / ✗ ' + koCount; + // Échec snapshot : déblocage direct de l'étape suivante (sans confirmation) + if (koCount > 0) { + const failed = okTrs.filter(tr => tr._snapData && !tr._snapData.ok); + for (const tr of failed) { + tr._snapData.override = true; + const cell = tr.querySelector('.cell-snap'); + if (cell) cell.innerHTML += ' (forcé)'; + const c3 = tr.querySelector('td:nth-child(3)'); + termLine('⚠', 'snapshot en échec — poursuite forcée sans snapshot : ' + (c3 ? c3.textContent.trim() : tr.dataset.rowId)); + } + } refreshStepButtons(); }); btnDryrun.addEventListener('click', async () => { const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); - const targets = trs.filter(tr => tr._snapData && tr._snapData.ok); + const targets = trs.filter(tr => tr._snapData && (tr._snapData.ok || tr._snapData.override)); if (!targets.length) { alert('Aucun serveur avec snapshot OK'); return; } if (!confirm('Lancer dry-run yum (simulation) sur ' + targets.length + ' serveur(s) ?\nLog en temps réel dans le terminal.')) return; setBtnState(btnDryrun, 'running'); setStepState('dry', 'running');