From 8f8e8c4d8f104ffeff8abd1a159d96d60ca810fd Mon Sep 17 00:00:00 2001 From: Admin MPCZ Date: Fri, 24 Apr 2026 23:49:46 +0000 Subject: [PATCH] feat(qualys): dashboard vulnerabilites avec KPI + historique --- app/routers/qualys.py | 94 ++++- app/services/qualys_service.py | 208 ++++++++++ app/templates/base.html | 433 ++++++++++---------- app/templates/qualys_dashboard.html | 163 ++++++++ app/templates/qualys_dashboard_history.html | 124 ++++++ scripts/snapshot_runner.py | 14 + 6 files changed, 819 insertions(+), 217 deletions(-) create mode 100644 app/templates/qualys_dashboard.html create mode 100644 app/templates/qualys_dashboard_history.html create mode 100755 scripts/snapshot_runner.py diff --git a/app/routers/qualys.py b/app/routers/qualys.py index 647ea01..f90305a 100644 --- a/app/routers/qualys.py +++ b/app/routers/qualys.py @@ -466,13 +466,21 @@ async def qualys_search(request: Request, db=Depends(get_db), all_tags = db.execute(text("SELECT qualys_tag_id, name FROM qualys_tags ORDER BY name")).fetchall() - # KPI : total / avec vuln / sans vuln + filtrage vuln_filter (with|zero) + # KPI : total / avec vuln / sans vuln + filtrage vuln_filter (with|zero|critical|high|medium) vuln_filter = request.query_params.get("vuln_filter", "") def _vuln_total(a): vc = vuln_map.get(str(a.ip_address), {}) if isinstance(vc, dict): return int(vc.get("total", 0) or 0) return int(vc or 0) + def _max_sev(a): + vc = vuln_map.get(str(a.ip_address), {}) + if not isinstance(vc, dict): + return 0 + if vc.get("severity5", 0) > 0: return 5 + if vc.get("severity4", 0) > 0: return 4 + if vc.get("severity3", 0) > 0: return 3 + return 0 kpi_total = len(assets) if assets else 0 kpi_with_vuln = sum(1 for a in assets if _vuln_total(a) > 0) if assets else 0 kpi_zero_vuln = kpi_total - kpi_with_vuln @@ -480,6 +488,12 @@ async def qualys_search(request: Request, db=Depends(get_db), assets = [a for a in assets if _vuln_total(a) > 0] elif assets and vuln_filter == "zero": assets = [a for a in assets if _vuln_total(a) == 0] + elif assets and vuln_filter == "critical": + assets = [a for a in assets if _max_sev(a) == 5] + elif assets and vuln_filter == "high": + assets = [a for a in assets if _max_sev(a) == 4] + elif assets and vuln_filter == "medium": + assets = [a for a in assets if _max_sev(a) == 3] ctx = base_context(request, db, user) ctx.update({ @@ -1159,3 +1173,81 @@ async def qualys_deploy_check(request: Request, db=Depends(get_db)): "active": sum(1 for r in results if r["status"] == "ACTIVE"), "not_installed": sum(1 for r in results if r["status"] == "NOT_INSTALLED"), "failed": sum(1 for r in results if r["status"] == "CONNECTION_FAILED")}) + + +# === DASHBOARD VULNERABILITES (KPI + historique) === + +@router.get("/qualys/dashboard", response_class=HTMLResponse) +async def qualys_dashboard(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + # TODO: ajouter perm specifique 'qualys_dashboard_view' (admin + DSI) + if not can_view(perms, "qualys"): + return RedirectResponse(url="/dashboard") + from app.services.qualys_service import load_vuln_dashboard, is_dashboard_running + data = load_vuln_dashboard(db) + + env_set = sorted({d["name"] for d in data["env"]}) + pos_set = sorted({d["name"] for d in data["pos"]}) + matrix = {(c["name"], c["name2"]): c for c in data["env_pos"]} + + ctx = base_context(request, db, user) + ctx.update({"data": data, "env_list": env_set, "pos_list": pos_set, + "matrix": matrix, "is_running": is_dashboard_running()}) + return templates.TemplateResponse("qualys_dashboard.html", ctx) + + +@router.post("/qualys/dashboard/refresh") +async def qualys_dashboard_refresh(request: Request, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_edit(perms, "qualys"): + return RedirectResponse(url="/qualys/dashboard") + from app.services.qualys_service import compute_vuln_dashboard, is_dashboard_running + if is_dashboard_running(): + return RedirectResponse(url="/qualys/dashboard?msg=already_running", status_code=303) + import threading + def _runner(): + from app.database import SessionLocal + s = SessionLocal() + try: + compute_vuln_dashboard(s, triggered_by=f"manual:{user.username}") + finally: + s.close() + threading.Thread(target=_runner, daemon=True).start() + return RedirectResponse(url="/qualys/dashboard?msg=refresh_started", status_code=303) + + +@router.get("/qualys/dashboard/history", response_class=HTMLResponse) +async def qualys_dashboard_history(request: Request, db=Depends(get_db), + period: str = "day", days: int = 30, + dimension: str = "global", dim_value: str = "all"): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_view(perms, "qualys"): + return RedirectResponse(url="/dashboard") + from app.services.qualys_service import load_vuln_history + if period not in ("day", "week", "month"): + period = "day" + days = max(7, min(365, days)) + series = load_vuln_history(db, period=period, days=days, + dimension=dimension, dimension_value=dim_value) + + # Liste des dim/value disponibles dans les snapshots pour le selecteur + dim_options = db.execute(text("""SELECT DISTINCT dimension, dimension_value + FROM qualys_vuln_snapshot WHERE dimension IN ('global','env','pos','os') + ORDER BY dimension, dimension_value""")).fetchall() + + runs_count = db.execute(text("SELECT COUNT(*) FROM qualys_vuln_snapshot_run WHERE status='ok'")).scalar() + + ctx = base_context(request, db, user) + ctx.update({"series": series, "period": period, "days": days, + "dimension": dimension, "dim_value": dim_value, + "dim_options": dim_options, "runs_count": runs_count}) + return templates.TemplateResponse("qualys_dashboard_history.html", ctx) diff --git a/app/services/qualys_service.py b/app/services/qualys_service.py index 09ca3fa..e75d4cf 100644 --- a/app/services/qualys_service.py +++ b/app/services/qualys_service.py @@ -855,3 +855,211 @@ def cancel_refresh(): def is_refresh_running(): return _refresh_running + + +# =========================================================================== +# DASHBOARD VULNERABILITES — agregation par dimension + historique +# Tables: qualys_vuln_snapshot_run (un row par calcul) + qualys_vuln_snapshot +# Dimensions: global, env, pos, os, domain, env_pos +# Niveaux: critical (sev5), high (sev4), medium (sev3), sain, non_scanne +# Un serveur est compte dans son niveau MAX (1 seule colonne) +# =========================================================================== + +_dashboard_running = False + +def is_dashboard_running(): + return _dashboard_running + +def _classify_severity(vc): + if not isinstance(vc, dict): + return "sain" + if vc.get("severity5", 0) > 0: + return "critical" + if vc.get("severity4", 0) > 0: + return "high" + if vc.get("severity3", 0) > 0: + return "medium" + return "sain" + +def _is_scanned(asset_row, has_vuln_data): + status = (asset_row.agent_status or "").lower() + if status in ("active", "ok", "status_active"): + return True + if has_vuln_data: + return True + return False + +def compute_vuln_dashboard(db, triggered_by="manual"): + """Calcule un nouveau snapshot (insert run + snapshot rows). + Retourne dict {ok, msg, run_id, asset_count, duration_sec}.""" + global _dashboard_running + if _dashboard_running: + return {"ok": False, "msg": "Calcul deja en cours", "run_id": None, + "asset_count": 0, "duration_sec": 0} + _dashboard_running = True + import time + t0 = time.time() + run_id = None + try: + # 1. Creer le run en pending + run_id = db.execute(text(""" + INSERT INTO qualys_vuln_snapshot_run (status, triggered_by) + VALUES ('pending', :tb) RETURNING id + """), {"tb": triggered_by}).scalar() + db.commit() + + # 2. Charger tous les assets avec leurs tags + domaine AD + rows = db.execute(text(""" + SELECT qa.qualys_asset_id, qa.hostname, qa.ip_address, qa.agent_status, + qa.last_checkin, qa.os_family, qa.server_id, + COALESCE(string_agg(DISTINCT qt.name, '|' ORDER BY qt.name), '') as tag_names, + s.domain_ltd + FROM qualys_assets qa + LEFT JOIN qualys_asset_tags qat ON qat.qualys_asset_id = qa.qualys_asset_id + LEFT JOIN qualys_tags qt ON qt.qualys_tag_id = qat.qualys_tag_id + LEFT JOIN servers s ON s.id = qa.server_id + GROUP BY qa.qualys_asset_id, qa.hostname, qa.ip_address, qa.agent_status, + qa.last_checkin, qa.os_family, qa.server_id, s.domain_ltd + """)).fetchall() + asset_count = len(rows) + + # 3. Recuperer vulns par batch de 50 IPs (cache 10min via get_vuln_counts) + ip_to_vuln = {} + unique_ips = list({str(r.ip_address) for r in rows + if r.ip_address and str(r.ip_address) != "None"}) + for i in range(0, len(unique_ips), 50): + batch = unique_ips[i:i+50] + try: + vmap = get_vuln_counts(db, ",".join(batch)) + if vmap: + ip_to_vuln.update(vmap) + except Exception: + pass + + # 4. Classifier + agreger + agg = {} + def _bump(dim, val, val2, level): + key = (dim, val or "(none)", val2 or "") + if key not in agg: + agg[key] = {"total": 0, "critical": 0, "high": 0, "medium": 0, + "sain": 0, "non_scanne": 0} + agg[key]["total"] += 1 + agg[key][level] += 1 + + for r in rows: + ip = str(r.ip_address) if r.ip_address else None + vc = ip_to_vuln.get(ip) if ip else None + scanned = _is_scanned(r, vc is not None) + level = "non_scanne" if not scanned else _classify_severity(vc or {}) + + tags = (r.tag_names or "").split("|") if r.tag_names else [] + envs = [t for t in tags if t.startswith("ENV-")] + poses = [t for t in tags if t.startswith("POS-")] + oses = [t for t in tags if t.startswith("OS-")] + dom = r.domain_ltd or "(sans domaine)" + + _bump("global", "all", "", level) + for e in envs or ["(sans env)"]: + _bump("env", e, "", level) + for p in poses or ["(sans pos)"]: + _bump("pos", p, "", level) + for o in oses or ["(sans os)"]: + _bump("os", o, "", level) + _bump("domain", dom, "", level) + for e in envs or ["(sans env)"]: + for p in poses or ["(sans pos)"]: + _bump("env_pos", e, p, level) + + # 5. Persister les agregats + for (dim, val, val2), counts in agg.items(): + db.execute(text(""" + INSERT INTO qualys_vuln_snapshot + (run_id, dimension, dimension_value, dimension_value2, + total, critical, high, medium, sain, non_scanne) + VALUES (:rid, :dim, :v1, :v2, :tot, :c, :h, :m, :s, :ns) + """), {"rid": run_id, "dim": dim, "v1": val, "v2": val2 or None, + "tot": counts["total"], "c": counts["critical"], + "h": counts["high"], "m": counts["medium"], + "s": counts["sain"], "ns": counts["non_scanne"]}) + + duration = int(time.time() - t0) + db.execute(text("""UPDATE qualys_vuln_snapshot_run + SET status='ok', asset_count=:ac, duration_sec=:d, msg='OK' WHERE id=:rid"""), + {"ac": asset_count, "d": duration, "rid": run_id}) + db.commit() + return {"ok": True, "msg": "OK", "run_id": run_id, + "asset_count": asset_count, "duration_sec": duration} + except Exception as ex: + db.rollback() + if run_id: + try: + db.execute(text("""UPDATE qualys_vuln_snapshot_run + SET status='error', msg=:m, duration_sec=:d WHERE id=:rid"""), + {"m": str(ex)[:500], "d": int(time.time() - t0), "rid": run_id}) + db.commit() + except Exception: + pass + return {"ok": False, "msg": str(ex), "run_id": run_id, + "asset_count": 0, "duration_sec": int(time.time() - t0)} + finally: + _dashboard_running = False + + +def load_vuln_dashboard(db): + """Charge le dernier run reussi + ses snapshots.""" + last_run = db.execute(text("""SELECT id, run_at, asset_count, duration_sec, triggered_by + FROM qualys_vuln_snapshot_run WHERE status='ok' ORDER BY run_at DESC LIMIT 1""")).fetchone() + if not last_run: + return {"global": None, "env": [], "pos": [], "os": [], "domain": [], + "env_pos": [], "last_run": None} + rows = db.execute(text("""SELECT dimension, dimension_value, dimension_value2, + total, critical, high, medium, sain, non_scanne + FROM qualys_vuln_snapshot WHERE run_id=:rid + ORDER BY dimension, dimension_value, dimension_value2"""), {"rid": last_run.id}).fetchall() + out = {"global": None, "env": [], "pos": [], "os": [], "domain": [], + "env_pos": [], "last_run": last_run} + for r in rows: + d = {"name": r.dimension_value, "name2": r.dimension_value2, + "total": r.total, "critical": r.critical, "high": r.high, + "medium": r.medium, "sain": r.sain, "non_scanne": r.non_scanne, + "vuln_total": r.critical + r.high + r.medium, + "scanned": r.total - r.non_scanne} + d["pct_vuln"] = round(100 * d["vuln_total"] / d["scanned"], 1) if d["scanned"] > 0 else 0 + if r.dimension == "global": + out["global"] = d + elif r.dimension in out: + out[r.dimension].append(d) + return out + + +def load_vuln_history(db, period="day", days=30, dimension="global", dimension_value="all"): + """Retourne l'evolution dans le temps. + period: 'day' (1 point/jour), 'week' (lundi), 'month' (1er du mois) + dimension/dimension_value: filtre (defaut: global/all) + Renvoie list de dicts {bucket, total, critical, high, medium, sain, non_scanne}""" + if period == "month": + bucket_expr = "date_trunc('month', r.run_at)" + elif period == "week": + bucket_expr = "date_trunc('week', r.run_at)" + else: + bucket_expr = "date_trunc('day', r.run_at)" + + rows = db.execute(text(f""" + SELECT {bucket_expr} as bucket, + s.total, s.critical, s.high, s.medium, s.sain, s.non_scanne, + r.run_at + FROM qualys_vuln_snapshot s + JOIN qualys_vuln_snapshot_run r ON r.id = s.run_id + WHERE r.status='ok' AND r.run_at >= now() - (:days || ' days')::interval + AND s.dimension = :dim AND s.dimension_value = :dv + ORDER BY r.run_at + """), {"days": days, "dim": dimension, "dv": dimension_value}).fetchall() + + # Garder le dernier snapshot de chaque bucket + by_bucket = {} + for r in rows: + by_bucket[r.bucket] = r + return [{"bucket": b.isoformat(), "total": r.total, "critical": r.critical, + "high": r.high, "medium": r.medium, "sain": r.sain, + "non_scanne": r.non_scanne, "vuln_total": r.critical + r.high + r.medium} + for b, r in sorted(by_bucket.items())] diff --git a/app/templates/base.html b/app/templates/base.html index ac32699..d7c9529 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,217 +1,218 @@ - - - - - - {{ app_name }} - {% block title %}{% endblock %} - - - - - - - - {% if user %} -
- +
+ +
+
+ {{ user.display or user.sub }} + {% if user.auth == 'ldap' %}(AD){% endif %} + · + {{ user.sub }} + {{ user.role }} + Deconnexion +
+
+
+
+ {% block content %}{% endblock %} +
+
+
+
+
+
+ {% else %} + {% block fullpage %}{% endblock %} + {% endif %} +
+ + + + + + diff --git a/app/templates/qualys_dashboard.html b/app/templates/qualys_dashboard.html new file mode 100644 index 0000000..d24714e --- /dev/null +++ b/app/templates/qualys_dashboard.html @@ -0,0 +1,163 @@ +{% extends 'base.html' %} +{% block title %}Dashboard Vulnérabilités{% endblock %} +{% block content %} + +{% set msg = request.query_params.get('msg', '') %} +{% if msg == 'refresh_started' %} +
+ Recalcul lancé en arrière-plan. Rafraîchis la page dans 1-2 minutes pour voir les nouveaux chiffres. +
+{% elif msg == 'already_running' %} +
+ Un calcul est déjà en cours. Patiente puis rafraîchis. +
+{% endif %} + +
+

Dashboard Vulnérabilités

+
+ {% if data.last_run %} + + Dernier calcul : {{ data.last_run.run_at.strftime('%Y-%m-%d %H:%M') }} + ({{ data.last_run.asset_count }} assets, {{ data.last_run.duration_sec }}s) + + {% endif %} + 📈 Historique +
+ +
+
+
+ +{% if not data.last_run %} +
+

Aucun snapshot disponible. Lance un premier calcul.

+
+ +
+
+{% else %} + + +{% set g = data.global %} +
+
+
Total
+
{{ g.total if g else 0 }}
+
serveurs
+
+ +
🔴 Critique (sev 5)
+
{{ g.critical if g else 0 }}
+
{{ ((g.critical * 100 / g.scanned) | round(1)) if g and g.scanned > 0 else 0 }}%
+
+ +
🟠 High (sev 4)
+
{{ g.high if g else 0 }}
+
{{ ((g.high * 100 / g.scanned) | round(1)) if g and g.scanned > 0 else 0 }}%
+
+ +
🟡 Medium (sev 3)
+
{{ g.medium if g else 0 }}
+
{{ ((g.medium * 100 / g.scanned) | round(1)) if g and g.scanned > 0 else 0 }}%
+
+ +
🟢 Sans vuln
+
{{ g.sain if g else 0 }}
+
{{ ((g.sain * 100 / g.scanned) | round(1)) if g and g.scanned > 0 else 0 }}%
+
+
+
⚫ Non scanné
+
{{ g.non_scanne if g else 0 }}
+
{{ ((g.non_scanne * 100 / g.total) | round(1)) if g and g.total > 0 else 0 }}% du total
+
+
+ +{% macro pivot_table(title, items, tag_prefix='') %} +
+

{{ title }}

+ + + + + + + + + + + + + + + {% for it in items|sort(attribute='vuln_total', reverse=true) %} + + + + + + + + + + + {% endfor %} + +
CatégorieTotal🔴 Critique🟠 High🟡 Medium🟢 Sain⚫ Non scanné% vuln
{{ it.name }}{{ it.total }} + {% if it.critical > 0 %}{{ it.critical }}{% else %}-{% endif %} + + {% if it.high > 0 %}{{ it.high }}{% else %}-{% endif %} + + {% if it.medium > 0 %}{{ it.medium }}{% else %}-{% endif %} + + {% if it.sain > 0 %}{{ it.sain }}{% else %}-{% endif %} + {{ it.non_scanne if it.non_scanne > 0 else '-' }} + {{ it.pct_vuln }}% +
+
+{% endmacro %} + +{{ pivot_table('Par environnement (ENV-*)', data.env, 'ENV-') }} +{{ pivot_table('Par position réseau (POS-*)', data.pos, 'POS-') }} +{{ pivot_table('Par OS (OS-*)', data.os, 'OS-') }} +{{ pivot_table('Par domaine AD', data.domain, '') }} + + +
+

Matrice Environnement × Position (% vuln)

+ + + + + {% for p in pos_list %}{% endfor %} + + + + {% for e in env_list %} + + + {% for p in pos_list %} + {% set cell = matrix.get((e,p)) %} + {% if cell and cell.total > 0 %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + +
ENV \ POS{{ p }}
{{ e }} + +
{{ cell.vuln_total }}/{{ cell.scanned }}
+
{{ cell.pct_vuln }}%
+
+
-
+

Format : vulnérables / scannés — couleur = % vulnérables (rouge ≥50%, orange ≥25%, jaune >0)

+
+ +{% endif %} +{% endblock %} diff --git a/app/templates/qualys_dashboard_history.html b/app/templates/qualys_dashboard_history.html new file mode 100644 index 0000000..bf3338b --- /dev/null +++ b/app/templates/qualys_dashboard_history.html @@ -0,0 +1,124 @@ +{% extends 'base.html' %} +{% block title %}Historique Vulnérabilités{% endblock %} +{% block content %} + +
+

Historique Vulnérabilités

+ ← Dashboard +
+ +
+
+ + +
+
+ + +
+
+ + + + +
+ + {{ runs_count }} snapshot(s) total en base +
+ +{% if not series %} +
+ Aucune donnée historique pour ce périmètre/période. Lance des snapshots quotidiens pour alimenter. +
+{% else %} + +
+ +
+ +
+

Données ({{ series|length }} points)

+ + + + + + + + + + + + + + + {% for p in series|reverse %} + + + + + + + + + + + {% endfor %} + +
DateTotal🔴 Critique🟠 High🟡 Medium🟢 Sain⚫ Non scannéTotal vuln
{{ p.bucket[:10] }}{{ p.total }}{{ p.critical }}{{ p.high }}{{ p.medium }}{{ p.sain }}{{ p.non_scanne }}{{ p.vuln_total }}
+
+ + + + +{% endif %} +{% endblock %} diff --git a/scripts/snapshot_runner.py b/scripts/snapshot_runner.py new file mode 100755 index 0000000..66b3f32 --- /dev/null +++ b/scripts/snapshot_runner.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Lance un snapshot dashboard vuln. Appele par systemd timer (1x/jour).""" +import sys, os +sys.path.insert(0, "/opt/patchcenter") +from app.database import SessionLocal +from app.services.qualys_service import compute_vuln_dashboard + +s = SessionLocal() +try: + res = compute_vuln_dashboard(s, triggered_by="cron") + print(f"[snapshot] ok={res['ok']} run_id={res['run_id']} assets={res['asset_count']} dur={res['duration_sec']}s msg={res['msg']}") + sys.exit(0 if res["ok"] else 1) +finally: + s.close()