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}>([^<]*){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("
Activation keys et versions des agents déployés
+| Titre | +Statut | +Type | +Utilisés | +Clé | +
|---|---|---|---|---|
| {{ k.title }} | +{{ k.status }} | +{{ k.type }} | +{{ k.used }} | +{{ k.key }} | +
| Statut | Nombre |
|---|---|
| {{ s.agent_status }} | +{{ s.cnt }} | +
Aucune donnée
+ {% endif %} +| Version | Nombre |
|---|---|
| {{ v.agent_version }} | +{{ v.cnt }} | +
Aucune donnée
+ {% endif %} +- {% 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 +