feat(patching/iexec B3): step 3a dry-run (yum update --assumeno) + step 3b real patch (yum update -y) avec excludes effectifs depuis v_servers, validation anti-injection sur excludes, log audit, double confirmation pour patch reel

This commit is contained in:
Pierre & Lumière 2026-05-04 16:40:46 +02:00
parent 37e6f0d8f3
commit 6c92c71d17
3 changed files with 319 additions and 7 deletions

View File

@ -738,6 +738,93 @@ async def iexec_snapshot(request: Request, row_id: int, db=Depends(get_db)):
return JSONResponse(result)
def _common_iexec_row_check(row_id: int, db, user, perms):
"""Charge la row et applique les vérifs communes (éligible + Linux + hostname).
Retourne (row_obj, error_response). error_response is None si OK."""
if not _can_import(perms):
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,
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)
os_str = str(row.os or "").lower()
if "windows" in os_str:
return None, JSONResponse({"ok": False, "detail": "OS Windows non géré"}, status_code=400)
if not (row.hostname or row.asset_name):
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=...`."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
hostname = (row.hostname or row.asset_name).strip()
from ..services.patch_run_service import yum_dryrun
result = yum_dryrun(hostname, row.effective_excludes)
try:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'yum_dryrun', :de, :uid)
"""), {"rid": row_id,
"de": json.dumps({k: v for k, v in result.items() if k != "stdout_tail"},
ensure_ascii=False),
"uid": user.get("uid")})
db.commit()
except Exception as e:
print(f"[iexec_yum_dryrun] audit log failed: {e}")
result["row_id"] = row_id
return JSONResponse(result)
@router.post("/patching/iexec/yum-update/{row_id}")
async def iexec_yum_update(request: Request, row_id: int, db=Depends(get_db)):
"""Step 3 — vrai patch : `sudo -n yum update -y --exclude=...`."""
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401)
perms = get_user_perms(db, user)
row, err = _common_iexec_row_check(row_id, db, user, perms)
if err:
return err
hostname = (row.hostname or row.asset_name).strip()
from ..services.patch_run_service import yum_update
result = yum_update(hostname, row.effective_excludes)
try:
db.execute(text("""
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
VALUES (:rid, 'yum_update', :de, :uid)
"""), {"rid": row_id,
"de": json.dumps({k: v for k, v in result.items() if k != "stdout_tail"},
ensure_ascii=False),
"uid": user.get("uid")})
db.commit()
except Exception as e:
print(f"[iexec_yum_update] audit log failed: {e}")
result["row_id"] = row_id
return JSONResponse(result)
@router.post("/patching/import/{import_id}/delete")
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
user = get_current_user(request)

View File

@ -0,0 +1,138 @@
"""Service exécution patch yum :
- yum_dryrun() : `sudo -n yum update --assumeno --exclude=...`
- yum_update() : `sudo -n yum update -y --exclude=...`
Gère la résolution DNS, ouverture SSH (réutilise _resolve/_connect du
realtime_audit_service), validation des excludes (anti-injection shell),
capture de la sortie + heuristique de résumé du plan yum.
"""
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 "",
}

View File

@ -18,7 +18,7 @@
<span class="text-gray-500"></span>
<span class="px-3 py-1 rounded bg-cyber-green/20 text-cyber-green font-bold">2. Snapshot</span>
<span class="text-gray-500"></span>
<span class="px-3 py-1 rounded bg-cyber-border text-gray-500">3. Patch yum</span>
<span class="px-3 py-1 rounded bg-cyber-blue/20 text-cyber-blue font-bold">3. Patch yum</span>
</div>
<div class="card p-3 mb-4">
@ -50,6 +50,8 @@
<th class="text-left p-1">Satellite</th>
<th class="text-left p-1">Verdict</th>
<th class="text-left p-1">Snapshot</th>
<th class="text-left p-1">Dry-run</th>
<th class="text-left p-1">Patch</th>
</tr>
</thead>
<tbody id="check-tbody">
@ -67,9 +69,11 @@
<td class="p-1 cell-sat text-gray-500">·</td>
<td class="p-1 cell-overall text-gray-500">en attente</td>
<td class="p-1 cell-snap text-gray-500">·</td>
<td class="p-1 cell-dry text-gray-500">·</td>
<td class="p-1 cell-patch text-gray-500">·</td>
</tr>
{% else %}
<tr><td colspan="12" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
<tr><td colspan="14" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
{% endfor %}
</tbody>
</table>
@ -81,17 +85,27 @@
<pre id="details-pane" class="bg-cyber-bg p-2 text-[11px] whitespace-pre-wrap overflow-x-auto" style="max-height:400px;"></pre>
</div>
<div class="flex justify-between items-center mt-4">
<div class="flex justify-between items-center mt-4 flex-wrap gap-2">
<span id="run-summary" class="text-xs text-gray-400"></span>
<div class="flex gap-2 flex-wrap">
<button id="btn-step2" class="btn-sm bg-cyber-green/20 text-cyber-green px-4 py-2 text-xs" disabled>
→ Step 2 (snapshot vCenter)
</button>
<button id="btn-dryrun" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-4 py-2 text-xs" disabled title="yum update --assumeno : simule sans appliquer">
→ Step 3a (dry-run yum)
</button>
<button id="btn-step3" class="btn-sm bg-cyber-blue/20 text-cyber-blue px-4 py-2 text-xs" disabled title="yum update -y : applique réellement les patchs">
→ Step 3b (patcher yum)
</button>
</div>
</div>
<script>
(function(){
const btnRun = document.getElementById('btn-run-all');
const btnStep2 = document.getElementById('btn-step2');
const btnDryrun = document.getElementById('btn-dryrun');
const btnStep3 = document.getElementById('btn-step3');
const tbody = document.getElementById('check-tbody');
const summary = document.getElementById('run-summary');
const detailsCard = document.getElementById('details-card');
@ -174,6 +188,16 @@
btnStep2.disabled = (okCount === 0);
});
function refreshStepButtons(){
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok');
const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok);
const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok);
btnStep2.disabled = (ckOk.length === 0);
btnDryrun.disabled = (snapOk.length === 0);
btnStep3.disabled = (dryOk.length === 0);
}
btnStep2.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
const okTrs = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok');
@ -205,9 +229,72 @@
cell.innerHTML = '<span class="text-cyber-red">✗ erreur réseau</span>';
}
}
btnStep2.disabled = false;
btnRun.disabled = false;
summary.innerHTML += ' · Snapshot : ✓ ' + okCount + ' / ✗ ' + koCount;
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);
if (!targets.length) { alert('Aucun serveur avec snapshot OK'); return; }
if (!confirm('Lancer dry-run yum (simulation) sur ' + targets.length + ' serveur(s) ?')) return;
btnDryrun.disabled = true; btnStep3.disabled = true;
let okCount = 0, koCount = 0;
for (const tr of targets) {
const cell = tr.querySelector('.cell-dry');
cell.innerHTML = '<span class="text-cyber-yellow">… dry-run</span>';
try {
const r = await fetch('/patching/iexec/yum-dryrun/' + tr.dataset.rowId, {method:'POST'});
const j = await r.json();
tr._dryData = j;
if (j.ok) {
okCount++;
const sumLine = (j.summary || []).slice(-2).join(' / ') || 'plan OK';
cell.innerHTML = '<span class="text-cyber-green"></span><span class="text-[10px] text-gray-300" title="' + escapeHTML(sumLine) + '">' + escapeHTML(sumLine.slice(0,80)) + '</span>';
} else {
koCount++;
cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.detail || j.stderr || '') + '">✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + '</span>';
}
} catch(e) {
koCount++;
cell.innerHTML = '<span class="text-cyber-red">✗ erreur</span>';
}
}
summary.innerHTML += ' · Dry-run : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
});
btnStep3.addEventListener('click', async () => {
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
const targets = trs.filter(tr => tr._dryData && tr._dryData.ok);
if (!targets.length) { alert('Aucun serveur avec dry-run OK'); return; }
if (!confirm('⚠ ATTENTION ⚠\n\nLancer yum update -y (PATCH RÉEL) sur ' + targets.length + ' serveur(s) ?\nCes serveurs vont être modifiés. Snapshot pris en amont.')) return;
if (!confirm('Confirmer une 2e fois : patcher RÉELLEMENT ' + targets.length + ' serveur(s) ?')) return;
btnStep3.disabled = true; btnDryrun.disabled = true; btnStep2.disabled = true;
let okCount = 0, koCount = 0;
for (const tr of targets) {
const cell = tr.querySelector('.cell-patch');
cell.innerHTML = '<span class="text-cyber-yellow">… patch en cours</span>';
try {
const r = await fetch('/patching/iexec/yum-update/' + tr.dataset.rowId, {method:'POST'});
const j = await r.json();
tr._patchData = j;
if (j.ok) {
okCount++;
const sumLine = (j.summary || []).slice(-2).join(' / ') || 'patch OK';
cell.innerHTML = '<span class="text-cyber-green"></span><span class="text-[10px] text-gray-300" title="' + escapeHTML(sumLine) + '">' + escapeHTML(sumLine.slice(0,80)) + '</span>';
} else {
koCount++;
cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.detail || j.stderr || '') + '">✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + '</span>';
}
} catch(e) {
koCount++;
cell.innerHTML = '<span class="text-cyber-red">✗ erreur</span>';
}
}
summary.innerHTML += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;
refreshStepButtons();
});
// Click sur une ligne → afficher les détails