From c139dfbaa233418919175b96e52542395aad3b49 Mon Sep 17 00:00:00 2001 From: Khalid MOUTAOUAKIL Date: Mon, 6 Apr 2026 23:04:48 +0200 Subject: [PATCH] =?UTF-8?q?Cache=20m=C3=A9moire=2010min=20pour=20Qualys=20?= =?UTF-8?q?API,=20bouton=20Resync=20temps=20r=C3=A9el,=20page=20Agents=20(?= =?UTF-8?q?activation=20keys=20+=20versions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routers/qualys.py | 39 +++++++++--- app/services/cache.py | 48 ++++++++++++++ app/services/qualys_service.py | 103 +++++++++++++++++++++++++++++-- app/templates/base.html | 1 + app/templates/qualys_agents.html | 77 +++++++++++++++++++++++ app/templates/qualys_search.html | 10 ++- 6 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 app/services/cache.py create mode 100644 app/templates/qualys_agents.html diff --git a/app/routers/qualys.py b/app/routers/qualys.py index 7d3d593..d17e4fe 100644 --- a/app/routers/qualys.py +++ b/app/routers/qualys.py @@ -9,6 +9,7 @@ from ..services.qualys_service import ( sync_server_qualys, search_assets_api, get_all_tags_api, create_tag_api, delete_tag_api, add_tag_to_asset_api, remove_tag_from_asset_api, resync_all_tags, get_vuln_counts, + get_activation_keys, get_agents_summary, invalidate_search_cache, get_cache_stats, ) from ..config import APP_NAME @@ -328,12 +329,16 @@ async def qualys_search(request: Request, db=Depends(get_db), assets = [] api_msg = None source = None + force = request.query_params.get("force", "") == "1" + cache_info = get_cache_stats() + if search: - # Vérifier si on a des données récentes (< 24h) en base from datetime import datetime, timedelta cutoff = datetime.now() - timedelta(hours=24) - if field == "hostname": + if force: + fresh = 0 # Forcer le resync API + elif field == "hostname": fresh = db.execute(text(""" SELECT COUNT(*) FROM qualys_assets WHERE (hostname ILIKE :q OR name ILIKE :q OR fqdn ILIKE :q) @@ -360,13 +365,13 @@ async def qualys_search(request: Request, db=Depends(get_db), # Données fraiches en base — pas d'appel API source = "base (< 24h)" else: - # Appel API + stockage en base + # Appel API (cache 10min) + stockage en base if field == "hostname": - result = search_assets_api(db, search, field="name", operator="CONTAINS") + result = search_assets_api(db, search, field="name", operator="CONTAINS", force_refresh=force) elif field == "ip": - result = search_assets_api(db, search, field="address", operator="CONTAINS") + result = search_assets_api(db, search, field="address", operator="CONTAINS", force_refresh=force) elif field == "tag": - result = search_assets_api(db, search, field="tagName", operator="EQUALS") + result = search_assets_api(db, search, field="tagName", operator="EQUALS", force_refresh=force) else: result = {"ok": False, "msg": "Champ inconnu", "assets": []} @@ -415,7 +420,7 @@ async def qualys_search(request: Request, db=Depends(get_db), ips = [ip for ip in ips if ip and ip != "None"] if ips: try: - vuln_map = get_vuln_counts(db, ",".join(ips[:50])) + vuln_map = get_vuln_counts(db, ",".join(ips[:50]), force_refresh=force) except Exception: pass @@ -426,12 +431,32 @@ async def qualys_search(request: Request, db=Depends(get_db), "app_name": APP_NAME, "assets": assets, "search": search, "field": field, "api_msg": api_msg, "all_tags": all_tags, "vuln_map": vuln_map, + "cache_info": cache_info, "can_edit_qualys": can_edit(perms, "qualys"), "msg": request.query_params.get("msg"), }) return templates.TemplateResponse("qualys_search.html", ctx) +@router.get("/qualys/agents", response_class=HTMLResponse) +async def qualys_agents_page(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_view(perms, "qualys"): + return RedirectResponse(url="/dashboard") + + keys = get_activation_keys(db) + summary = get_agents_summary(db) + + ctx = base_context(request, db, user) + ctx.update({ + "app_name": APP_NAME, "keys": keys, "summary": summary, + }) + return templates.TemplateResponse("qualys_agents.html", ctx) + + @router.get("/qualys/vulns/{ip}", response_class=HTMLResponse) async def qualys_vulns_detail(request: Request, ip: str, db=Depends(get_db)): """Retourne le detail des vulns severity 3,4,5 pour une IP (fragment HTMX)""" diff --git a/app/services/cache.py b/app/services/cache.py new file mode 100644 index 0000000..5313e6f --- /dev/null +++ b/app/services/cache.py @@ -0,0 +1,48 @@ +"""Cache memoire simple avec TTL — pas de dependance externe""" +import time +import threading + +_store = {} +_lock = threading.Lock() +DEFAULT_TTL = 600 # 10 minutes + + +def get(key): + with _lock: + entry = _store.get(key) + if entry is None: + return None + if time.time() > entry["expires"]: + del _store[key] + return None + return entry["value"] + + +def set(key, value, ttl=DEFAULT_TTL): + with _lock: + _store[key] = {"value": value, "expires": time.time() + ttl} + + +def delete(key): + with _lock: + _store.pop(key, None) + + +def clear_prefix(prefix): + with _lock: + keys = [k for k in _store if k.startswith(prefix)] + for k in keys: + del _store[k] + + +def clear_all(): + with _lock: + _store.clear() + + +def stats(): + with _lock: + now = time.time() + total = len(_store) + active = sum(1 for v in _store.values() if now <= v["expires"]) + return {"total": total, "active": active} diff --git a/app/services/qualys_service.py b/app/services/qualys_service.py index 8187b0c..7bc7e5f 100644 --- a/app/services/qualys_service.py +++ b/app/services/qualys_service.py @@ -1,12 +1,15 @@ -"""Service Qualys — sync tags pour un serveur via API""" +"""Service Qualys — sync tags pour un serveur via API + cache memoire""" import re import requests import urllib3 from sqlalchemy import text from .secrets_service import get_secret +from . import cache as _cache urllib3.disable_warnings() +CACHE_TTL = 600 # 10 minutes + def _get_qualys_creds(db): """Recupere les credentials Qualys depuis les secrets chiffres""" @@ -24,8 +27,17 @@ def parse_xml(txt, tag): return re.findall(f"<{tag}>([^<]*)", txt) -def search_assets_api(db, query, field="name", operator="CONTAINS"): - """Recherche des assets via l'API Qualys en temps réel""" +def search_assets_api(db, query, field="name", operator="CONTAINS", force_refresh=False): + """Recherche des assets via l'API Qualys — cache 10 min""" + cache_key = f"qualys:search:{field}:{query}" + + if not force_refresh: + cached = _cache.get(cache_key) + if cached is not None: + cached["msg"] = cached.get("msg", "") + " (cache)" + cached["from_cache"] = True + return cached + qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db) if not qualys_user: return {"ok": False, "msg": "Credentials Qualys non configurés", "assets": []} @@ -51,7 +63,9 @@ def search_assets_api(db, query, field="name", operator="CONTAINS"): return {"ok": False, "msg": f"API HTTP {r.status_code}", "assets": []} assets = _parse_assets_full(r.text) - return {"ok": True, "msg": f"{len(assets)} résultat(s)", "assets": assets} + result = {"ok": True, "msg": f"{len(assets)} résultat(s)", "assets": assets, "from_cache": False} + _cache.set(cache_key, result, CACHE_TTL) + return result def get_all_tags_api(db): @@ -371,11 +385,17 @@ def _find_asset_by_hostname(qualys_url, qualys_user, qualys_pass, hostname, prox return None -def get_vuln_counts(db, ip_list): +def get_vuln_counts(db, ip_list, force_refresh=False): """Recupere le nombre de vulnerabilites actives severity 3,4,5 pour une liste d'IPs. ip_list: str (IPs separees par virgules) Retourne dict {ip: {severity3, severity4, severity5, total, confirmed, potential}} """ + cache_key = f"qualys:vulns:{ip_list}" + if not force_refresh: + cached = _cache.get(cache_key) + if cached is not None: + return cached + qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db) if not qualys_user or not ip_list: return {} @@ -434,4 +454,77 @@ def get_vuln_counts(db, ip_list): results[host_ip] = counts + _cache.set(cache_key, results, CACHE_TTL) return results + + +def get_activation_keys(db): + """Recupere les activation keys Qualys""" + cache_key = "qualys:actkeys" + cached = _cache.get(cache_key) + if cached is not None: + return cached + + qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db) + if not qualys_user: + return [] + proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None + + try: + r = requests.post( + f"{qualys_url}/qps/rest/1.0/search/ca/agentactkey", + json={"ServiceRequest": {"preferences": {"limitResults": 20}}}, + auth=(qualys_user, qualys_pass), + verify=False, timeout=30, proxies=proxies, + headers={"X-Requested-With": "Python", "Content-Type": "application/json"}) + except Exception: + return [] + + if r.status_code != 200: + return [] + + keys = [] + for block in r.text.split("")[1:]: + block = block.split("")[0] + keys.append({ + "id": (parse_xml(block, "id") or [""])[0], + "key": (parse_xml(block, "activationKey") or [""])[0], + "status": (parse_xml(block, "status") or [""])[0], + "title": (parse_xml(block, "title") or [""])[0], + "used": (parse_xml(block, "countUsed") or ["0"])[0], + "type": (parse_xml(block, "type") or [""])[0], + }) + + _cache.set(cache_key, keys, 3600) # 1h pour les keys + return keys + + +def get_agents_summary(db): + """Resume des agents installes depuis les assets en base""" + rows = db.execute(text(""" + SELECT agent_status, COUNT(*) as cnt, + COUNT(*) FILTER (WHERE agent_version IS NOT NULL AND agent_version != '') as with_version + FROM qualys_assets + WHERE agent_status IS NOT NULL AND agent_status != '' + GROUP BY agent_status ORDER BY cnt DESC + """)).fetchall() + + versions = db.execute(text(""" + SELECT agent_version, COUNT(*) as cnt + FROM qualys_assets + WHERE agent_version IS NOT NULL AND agent_version != '' + GROUP BY agent_version ORDER BY cnt DESC LIMIT 20 + """)).fetchall() + + return {"statuses": rows, "versions": versions} + + +def invalidate_search_cache(): + """Invalide tout le cache de recherche Qualys""" + _cache.clear_prefix("qualys:search:") + _cache.clear_prefix("qualys:vulns:") + + +def get_cache_stats(): + """Stats du cache""" + return _cache.stats() diff --git a/app/templates/base.html b/app/templates/base.html index fe62471..bbe27f1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -59,6 +59,7 @@ {% if p.qualys %}Qualys{% endif %} {% if p.qualys %}Tags{% endif %} {% if p.qualys %}Décodeur{% endif %} + {% if p.qualys %}Agents{% endif %} {% if p.campaigns %}Safe Patching{% endif %} {% if p.planning %}Planning{% endif %} {% if p.audit %}Audit{% endif %} diff --git a/app/templates/qualys_agents.html b/app/templates/qualys_agents.html new file mode 100644 index 0000000..2c195fc --- /dev/null +++ b/app/templates/qualys_agents.html @@ -0,0 +1,77 @@ +{% extends 'base.html' %} +{% block title %}Agents Qualys{% endblock %} +{% block content %} +
+
+

Agents Qualys

+

Activation keys et versions des agents déployés

+
+ Recherche +
+ + +
+

Activation Keys

+ + + + + + + + + + {% for k in keys %} + + + + + + + + {% endfor %} + +
TitreStatutTypeUtilisésClé
{{ k.title }}{{ k.status }}{{ k.type }}{{ k.used }}{{ k.key }}
+
+ + +
+
+

Statut des agents

+ {% if summary.statuses %} + + + + {% for s in summary.statuses %} + + + + + {% endfor %} + +
StatutNombre
{{ s.agent_status }}{{ s.cnt }}
+ {% else %} +

Aucune donnée

+ {% endif %} +
+ +
+

Versions déployées

+ {% if summary.versions %} + + + + {% for v in summary.versions %} + + + + + {% endfor %} + +
VersionNombre
{{ v.agent_version }}{{ v.cnt }}
+ {% else %} +

Aucune donnée

+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/qualys_search.html b/app/templates/qualys_search.html index 057a3db..d939b85 100644 --- a/app/templates/qualys_search.html +++ b/app/templates/qualys_search.html @@ -37,9 +37,13 @@ {% endif %} {% if search %} -

- {% if api_msg %}{{ api_msg }}{% else %}{{ assets|length }} résultat(s){% endif %} -

+
+

+ {% if api_msg %}{{ api_msg }}{% else %}{{ assets|length }} résultat(s){% endif %} + {% if cache_info %}(cache: {{ cache_info.active }} entrées){% endif %} +

+ Resync temps réel +
{% endif %}