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 @@ - - -
- - -Aucun snapshot disponible. Lance un premier calcul.
+ +| Catégorie | +Total | +🔴 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 }}% + | +
| ENV \ POS | + {% for p in pos_list %}{{ p }} | {% endfor %} +|
|---|---|---|
| {{ e }} | + {% for p in pos_list %} + {% set cell = matrix.get((e,p)) %} + {% if cell and cell.total > 0 %} +
+
+ {{ cell.vuln_total }}/{{ cell.scanned }}
+ {{ cell.pct_vuln }}%
+
+ |
+ {% else %}
+ - | + {% endif %} + {% endfor %} +
Format : vulnérables / scannés — couleur = % vulnérables (rouge ≥50%, orange ≥25%, jaune >0)
+| Date | +Total | +🔴 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 }} | +