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 <noreply@anthropic.com>
This commit is contained in:
MOUTAOUAKIL-ext Khalid (admin) 2026-06-18 15:42:00 +02:00
parent 3c451156d5
commit 4590e89ff6
6 changed files with 203 additions and 21 deletions

11
.gitignore vendored
View File

@ -15,3 +15,14 @@ __pycache__/
import.log import.log
backups/ backups/
# Cles/certificats TLS - ne jamais committer
ssl/
*.key
*.crt
# Donnees lourdes / artefacts locaux - ne pas committer
db/
sitepkgs.zip
*.bak
patchcenter_db.sql

View File

@ -107,7 +107,7 @@ async def login(request: Request, username: str = Form(...), password: str = For
modules = {r.module for r in perms} modules = {r.module for r in perms}
redirect_url = "/quickwin" if modules == {"quickwin"} else "/dashboard" redirect_url = "/quickwin" if modules == {"quickwin"} else "/dashboard"
response = RedirectResponse(url=redirect_url, status_code=303) 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 return response
finally: finally:
db.close() db.close()

View File

@ -1453,6 +1453,31 @@ async def pct_prevenance_send(request: Request, db=Depends(get_db)):
return row, None 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}") @router.post("/patching/iexec/yum-dryrun/{row_id}")
async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)): async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)):
"""Step 3 — pré-vol : `sudo -n yum update --assumeno --exclude=...`.""" """Step 3 — pré-vol : `sudo -n yum update --assumeno --exclude=...`."""

View File

@ -8,18 +8,57 @@ Configuration via settings (clés) :
- ldap_bind_pwd : mot de passe (stocké chiffré via secrets_service) - ldap_bind_pwd : mot de passe (stocké chiffré via secrets_service)
- ldap_user_filter : filtre utilisateur (ex: (sAMAccountName={username})) - ldap_user_filter : filtre utilisateur (ex: (sAMAccountName={username}))
- ldap_tls : "true" / "false" - 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 import logging
from logging.handlers import RotatingFileHandler
from sqlalchemy import text 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: try:
from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls
import ssl import ssl
LDAP_OK = True 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: except ImportError:
LDAP_OK = False LDAP_OK = False
log.warning("Module ldap3 non installe : authentification LDAP indisponible")
def _get_config(db): def _get_config(db):
@ -57,17 +96,26 @@ def authenticate(db, username, password):
return {"ok": False, "msg": "Module ldap3 non installé"} return {"ok": False, "msg": "Module ldap3 non installé"}
cfg = _get_config(db) 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"]: if not cfg["enabled"]:
log.debug("AUTH user=%s -> LDAP desactive", username)
return {"ok": False, "msg": "LDAP désactivé"} return {"ok": False, "msg": "LDAP désactivé"}
if not cfg["server"] or not cfg["base_dn"]: 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é"} return {"ok": False, "msg": "LDAP non configuré"}
# 1. Bind service account pour chercher le DN de l'utilisateur # 1. Bind service account pour chercher le DN de l'utilisateur
try: 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) 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: 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}"} return {"ok": False, "msg": f"Connexion LDAP échouée: {e}"}
# 2. Recherche de l'utilisateur # 2. Recherche de l'utilisateur
@ -75,11 +123,15 @@ def authenticate(db, username, password):
try: try:
conn.search(cfg["base_dn"], user_filter, conn.search(cfg["base_dn"], user_filter,
attributes=[cfg["email_attr"], cfg["name_attr"], "distinguishedName", "memberOf"]) 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: except Exception as e:
log.error("AUTH user=%s -> recherche ECHEC: %s", username, e)
conn.unbind() conn.unbind()
return {"ok": False, "msg": f"Recherche LDAP échouée: {e}"} return {"ok": False, "msg": f"Recherche LDAP échouée: {e}"}
if not conn.entries: if not conn.entries:
log.debug("AUTH user=%s -> utilisateur introuvable", username)
conn.unbind() conn.unbind()
return {"ok": False, "msg": "Utilisateur introuvable dans LDAP"} 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 name = str(getattr(entry, cfg["name_attr"], "")) or username
groups = list(entry.memberOf.values) if hasattr(entry, "memberOf") and entry.memberOf else [] groups = list(entry.memberOf.values) if hasattr(entry, "memberOf") and entry.memberOf else []
conn.unbind() 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) # 3. Verification appartenance groupe (si configure)
required = (cfg.get("required_group") or "").strip() required = (cfg.get("required_group") or "").strip()
@ -96,16 +149,20 @@ def authenticate(db, username, password):
# Match insensible a la casse + espaces normalises # Match insensible a la casse + espaces normalises
norm_required = required.lower().replace(" ", "") norm_required = required.lower().replace(" ", "")
is_member = any(norm_required == g.lower().replace(" ", "") for g in groups) 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: if not is_member:
return {"ok": False, "msg": f"Acces refuse: utilisateur non membre du groupe requis"} return {"ok": False, "msg": f"Acces refuse: utilisateur non membre du groupe requis"}
# 4. Bind avec les credentials fournis # 4. Bind avec les credentials fournis
try: try:
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True) 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() user_conn.unbind()
except Exception as e: 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"} 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, return {"ok": True, "dn": user_dn, "email": email, "name": name,
"groups": groups, "default_role": cfg.get("default_role", "operator")} "groups": groups, "default_role": cfg.get("default_role", "operator")}
@ -117,10 +174,14 @@ def test_connection(db):
cfg = _get_config(db) cfg = _get_config(db)
if not cfg["server"]: if not cfg["server"]:
return {"ok": False, "msg": "Serveur non configuré"} 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: 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) 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() conn.unbind()
return {"ok": True, "msg": "Connexion réussie"} return {"ok": True, "msg": "Connexion réussie"}
except Exception as e: except Exception as e:
log.error("TEST connexion -> ECHEC: %s", e)
return {"ok": False, "msg": str(e)[:200]} return {"ok": False, "msg": str(e)[:200]}

View File

@ -9,10 +9,11 @@ import base64
import logging import logging
import re import re
import socket import socket
import time
from datetime import datetime from datetime import datetime
from typing import Dict, Any, List 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") log = logging.getLogger("patchcenter.patch_run")
@ -20,6 +21,14 @@ log = logging.getLogger("patchcenter.patch_run")
TIMEOUT_DRYRUN = 60 TIMEOUT_DRYRUN = 60
TIMEOUT_UPDATE = 1800 # 30 min — yum update peut être long 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) # Whitelist caractères autorisés dans un nom de paquet (anti-injection shell)
EXCLUDE_RE = re.compile(r"^[A-Za-z0-9._*\-/+]+$") 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): def _open_ssh(hostname: str):
target = _resolve(hostname) """Ouvre une session SSH en réutilisant la logique du check pré-patching :
if not target: on itère les candidats DNS (_candidate_targets) et on tente _connect sur
return None, None, "DNS résolution impossible" 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: if not PARAMIKO_OK:
return None, target, "paramiko non disponible côté serveur PatchCenter" return None, None, "paramiko non disponible côté serveur PatchCenter"
client = _connect(target, hostname) candidates = _candidate_targets(hostname)
if not client: if not candidates:
return None, target, "Connexion SSH échouée" return None, None, "Aucun candidat DNS pour cet hôte"
return client, target, None 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]: 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, yield {"type": "cmd", "cmd": cmd, "target": target,
"excludes": excludes, "hostname": hostname} "excludes": excludes, "hostname": hostname}
full_lines: List[str] = [] full_lines: List[str] = []
max_secs = STREAM_MAX_UPDATE if mode == "update" else STREAM_MAX_DRYRUN
try: 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) stdin, stdout, stderr = client.exec_command(cmd, get_pty=False)
# Lecture ligne par ligne ; yum bufferise peu son stdout sur opérations longues chan = stdout.channel
for line in iter(stdout.readline, ""): # Lecture non bloquante : recv() rend la main toutes les secondes pour
ln = line.rstrip("\n") # 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) full_lines.append(ln)
yield {"type": "line", "data": ln} yield {"type": "line", "data": ln}
rc = stdout.channel.recv_exit_status() 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 = chan.recv_exit_status()
# Détection de paquets problématiques si KO # Détection de paquets problématiques si KO
problems: List[str] = [] problems: List[str] = []
if mode == "update" and rc != 0: if mode == "update" and rc != 0:

View File

@ -391,7 +391,11 @@ function toggleDetails(){
ev.onmessage = (m) => { ev.onmessage = (m) => {
let j; let j;
try { j = JSON.parse(m.data); } catch(e) { return; } 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(' # host : ' + (j.hostname||'') + ' (' + (j.target||'') + ')\n');
appendTerm(' # cmd : ' + (j.cmd||'') + '\n'); appendTerm(' # cmd : ' + (j.cmd||'') + '\n');
appendTerm(' # excludes (' + (j.excludes||[]).length + ')\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 ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok').length;
const snapAttempted = trs.some(tr => tr._snapData); const snapAttempted = trs.some(tr => tr._snapData);
const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok).length; 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 dryAttempted = trs.some(tr => tr._dryData);
const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok).length; const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok).length;
const preAttempted = trs.some(tr => tr._preData); const preAttempted = trs.some(tr => tr._preData);
@ -528,7 +534,7 @@ function toggleDetails(){
const snapState = deriveState(ckOk, snapAttempted, snapOk); const snapState = deriveState(ckOk, snapAttempted, snapOk);
setBtnState(btnStep2, snapState); setStepState('snap', snapState); setBtnState(btnStep2, snapState); setStepState('snap', snapState);
const dryState = deriveState(snapOk, dryAttempted, dryOk); const dryState = deriveState(snapEff, dryAttempted, dryOk);
setBtnState(btnDryrun, dryState); setStepState('dry', dryState); setBtnState(btnDryrun, dryState); setStepState('dry', dryState);
const preState = deriveState(dryOk, preAttempted, preOk); const preState = deriveState(dryOk, preAttempted, preOk);
@ -587,12 +593,23 @@ function toggleDetails(){
} }
} }
summary.innerHTML += ' · Snapshot : ✓ ' + okCount + ' / ✗ ' + koCount; 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 += ' <span class="text-cyber-yellow text-[10px]">(forcé)</span>';
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(); refreshStepButtons();
}); });
btnDryrun.addEventListener('click', async () => { btnDryrun.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); 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 (!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; 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'); setBtnState(btnDryrun, 'running'); setStepState('dry', 'running');