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
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}
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()

View File

@ -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=...`."""

View File

@ -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]}

View File

@ -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}
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
problems: List[str] = []
if mode == "update" and rc != 0:

View File

@ -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 += ' <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();
});
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');