feat(qualys): page doublons + suppression API Qualys 1-clic

This commit is contained in:
Pierre & Lumière 2026-04-25 10:17:40 +00:00
parent 8f406f211d
commit 3d043af194
4 changed files with 257 additions and 0 deletions

View File

@ -1295,3 +1295,36 @@ async def qualys_dashboard_history(request: Request, db=Depends(get_db),
"dimension": dimension, "dim_value": dim_value,
"dim_options": dim_options, "runs_count": runs_count})
return templates.TemplateResponse("qualys_dashboard_history.html", ctx)
# === DOUBLONS QUALYS ===
@router.get("/qualys/duplicates", response_class=HTMLResponse)
async def qualys_duplicates(request: Request, db=Depends(get_db),
refresh: int = 0):
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 find_duplicate_hostnames
data = find_duplicate_hostnames(db, force_refresh=bool(refresh))
ctx = base_context(request, db, user)
ctx.update({"dupes_data": data,
"can_delete": can_edit(perms, "qualys")})
return templates.TemplateResponse("qualys_duplicates.html", ctx)
@router.post("/qualys/asset/{asset_id}/delete")
async def qualys_asset_delete(request: Request, asset_id: int, db=Depends(get_db)):
from fastapi.responses import JSONResponse
user = get_current_user(request)
if not user:
return JSONResponse({"ok": False, "msg": "Non authentifie"}, status_code=401)
perms = get_user_perms(db, user)
if not can_edit(perms, "qualys"):
return JSONResponse({"ok": False, "msg": "Permission refusee"}, status_code=403)
from app.services.qualys_service import delete_qualys_asset
res = delete_qualys_asset(db, asset_id)
return JSONResponse(res)

View File

@ -1104,3 +1104,119 @@ def load_vuln_history(db, period="day", days=30, dimension="global", dimension_v
"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())]
# ===========================================================================
# DOUBLONS QUALYS — liste + suppression via API
# ===========================================================================
def fetch_all_qualys_assets(db, with_progress=False):
"""Recupere TOUS les assets Qualys via API avec pagination (lastId).
Retourne liste de dicts {id, name, ip, last_check, agent_status}."""
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
all_assets = []
last_id = 0
while True:
body = {"ServiceRequest": {"preferences": {"limitResults": 1000}}}
if last_id:
body["ServiceRequest"]["filters"] = {"Criteria": [
{"field": "id", "operator": "GREATER", "value": str(last_id)}
]}
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/search/am/hostasset",
json=body, auth=(qualys_user, qualys_pass),
verify=False, timeout=180, proxies=proxies,
headers={"Content-Type": "application/json"}
)
except Exception:
break
if r.status_code != 200 or "SUCCESS" not in r.text:
break
batch = []
for block in r.text.split("<HostAsset>")[1:]:
block = block.split("</HostAsset>")[0]
aid = (parse_xml(block, "id") or [""])[0]
name = (parse_xml(block, "name") or [""])[0]
addr = (parse_xml(block, "address") or [""])[0]
last_check = ""
if "<lastCheckedIn>" in block:
last_check = (parse_xml(block, "lastCheckedIn") or [""])[0]
agent_status = ""
if "<agentInfo>" in block:
agent_status = (parse_xml(block, "status") or [""])[0]
if aid and aid.isdigit():
batch.append({"id": int(aid), "name": name, "ip": addr,
"last_check": last_check, "agent_status": agent_status})
if not batch:
break
all_assets.extend(batch)
last_id = max(b["id"] for b in batch)
if len(batch) < 1000:
break
return all_assets
def find_duplicate_hostnames(db, force_refresh=False):
"""Identifie les hostnames en doublon. Cache 10min."""
cache_key = "qualys:duplicates"
if not force_refresh:
cached = _cache.get(cache_key)
if cached is not None:
return cached
from collections import defaultdict
assets = fetch_all_qualys_assets(db)
groups = defaultdict(list)
for a in assets:
shortname = a["name"].split(".")[0].lower() if a["name"] else ""
if not shortname:
continue
# Filtrer les hostnames qui sont des IP (ex: "192", "10")
if shortname.replace(".", "").isdigit():
continue
groups[shortname].append(a)
dupes = []
for shortname, items in groups.items():
if len(items) <= 1:
continue
# Trier par last_check desc (plus recent en premier)
items.sort(key=lambda x: x["last_check"] or "", reverse=True)
dupes.append({
"shortname": shortname,
"count": len(items),
"assets": items,
"newest_check": items[0]["last_check"] or "",
"oldest_check": items[-1]["last_check"] or "",
})
# Trier par count desc puis shortname
dupes.sort(key=lambda x: (-x["count"], x["shortname"]))
result = {"total_assets": len(assets), "duplicate_hostnames": len(dupes),
"total_zombies": sum(d["count"] - 1 for d in dupes), "items": dupes}
_cache.set(cache_key, result, CACHE_TTL)
return result
def delete_qualys_asset(db, asset_id):
"""Supprime un asset Qualys via API. Retourne {ok, msg}."""
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
if not qualys_user:
return {"ok": False, "msg": "Pas de creds"}
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/delete/am/hostasset/{int(asset_id)}",
auth=(qualys_user, qualys_pass), verify=False, timeout=60, proxies=proxies,
headers={"Content-Type": "application/json"}
)
if r.status_code == 200 and "SUCCESS" in r.text:
# Invalider le cache pour forcer un refresh
_cache.delete("qualys:duplicates")
return {"ok": True, "msg": "Supprime"}
return {"ok": False, "msg": f"HTTP {r.status_code}: {r.text[:300]}"}
except Exception as e:
return {"ok": False, "msg": str(e)}

View File

@ -139,6 +139,7 @@
<a href="/qualys/tagsv3" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3' in path and '/catalog' not in path and '/gap' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (vue)</a>
<a href="/qualys/tagsv3/catalog" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/catalog' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (catalogue)</a>
<a href="/qualys/tagsv3/gap" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/tagsv3/gap' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-8">↳ Tags V3 (gap)</a>
<a href="/qualys/duplicates" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if '/qualys/duplicates' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">🧟 Doublons</a>
<a href="/qualys/agents" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'agents' in path and 'deploy' not in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Agents</a>
{% if p.qualys in ('edit', 'admin') %}<a href="/qualys/deploy" class="block px-3 py-1.5 rounded-md text-xs hover:bg-cyber-border/30 {% if 'deploy' in path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %} pl-6">Déployer Agent</a>{% endif %}
</div>

View File

@ -0,0 +1,107 @@
{% extends 'base.html' %}
{% block title %}Doublons Qualys{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Doublons Qualys (assets zombies)</h2>
<a href="/qualys/duplicates?refresh=1" class="btn-sm bg-cyber-border text-cyber-accent"
data-loading="Scan complet API Qualys...|Recuperation des ~6000 assets, peut prendre 3 a 5 minutes">🔄 Re-scan API</a>
</div>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="card p-3 border-cyber-accent">
<div class="text-xs text-gray-500 uppercase">Total assets Qualys</div>
<div class="text-2xl font-bold text-cyber-accent">{{ dupes_data.total_assets }}</div>
</div>
<div class="card p-3 border-orange-700">
<div class="text-xs text-gray-500 uppercase">Hostnames en doublon</div>
<div class="text-2xl font-bold text-orange-500">{{ dupes_data.duplicate_hostnames }}</div>
</div>
<div class="card p-3 border-red-700">
<div class="text-xs text-gray-500 uppercase">Zombies a purger</div>
<div class="text-2xl font-bold text-red-500">{{ dupes_data.total_zombies }}</div>
<div class="text-xs text-gray-500">(= total - 1 par hostname doublonne)</div>
</div>
</div>
{% if not dupes_data.items %}
<div class="card p-6 text-center text-gray-400">
Aucun doublon detecte. Si la page semble vide, clique sur "Re-scan API" pour forcer un scan complet.
</div>
{% else %}
<div class="card p-3">
<p class="text-xs text-gray-400 mb-2">
Le plus recent (en haut de chaque groupe) est probablement l'asset actif. Les autres sont des zombies (anciennes installations, ré-IPs, doublons de scan).
Bouton <strong>Supprimer</strong> = appel API Qualys <code>POST /qps/rest/2.0/delete/am/hostasset/{id}</code>.
{% if not can_delete %}<br><span class="text-yellow-400">Tu n'as pas la permission edit pour supprimer.</span>{% endif %}
</p>
<table class="w-full text-xs">
<thead class="text-gray-400 border-b border-cyber-border">
<tr>
<th class="text-left py-1">Hostname</th>
<th class="text-right py-1">Nb</th>
<th class="text-left py-1">ID Qualys</th>
<th class="text-left py-1">IP</th>
<th class="text-left py-1">Agent</th>
<th class="text-left py-1">Last check-in</th>
<th class="text-center py-1">Action</th>
</tr>
</thead>
<tbody>
{% for grp in dupes_data.items %}
{% for a in grp.assets %}
<tr class="border-b border-cyber-border/30 hover:bg-cyber-card/50 {% if loop.first %}bg-green-900/10{% else %}bg-red-900/10{% endif %}">
<td class="py-1 font-mono">{% if loop.first %}<strong class="text-cyber-accent">{{ grp.shortname }}</strong> ({{ grp.count }}){% endif %}</td>
<td class="text-right py-1">{% if loop.first %}{{ loop.length }}{% endif %}</td>
<td class="py-1 font-mono text-gray-400">{{ a.id }}</td>
<td class="py-1 font-mono">{{ a.ip or '-' }}</td>
<td class="py-1">{{ a.agent_status or '-' }}</td>
<td class="py-1 font-mono {% if loop.first %}text-green-400{% else %}text-red-400{% endif %}">
{{ a.last_check[:16] if a.last_check else '(jamais)' }}
</td>
<td class="text-center py-1">
{% if loop.first %}
<span class="text-green-400 text-xs">✓ actif</span>
{% elif can_delete %}
<button onclick="delAsset({{ a.id }}, '{{ grp.shortname }}', '{{ a.ip }}', this)"
class="btn-sm bg-red-900/40 text-red-300 hover:bg-red-900/70" style="padding:2px 8px">
🗑 Supprimer
</button>
{% else %}
<span class="text-gray-500 text-xs">zombie</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
<script>
async function delAsset(id, name, ip, btn) {
if (!confirm("Supprimer DEFINITIVEMENT l'asset Qualys " + name + " (ID " + id + ", IP " + ip + ") ?\n\nCeci appelle l'API Qualys et ne peut pas etre annule.")) return;
btn.disabled = true;
btn.textContent = "...";
try {
const r = await fetch("/qualys/asset/" + id + "/delete", {method: "POST", credentials: "same-origin"});
const data = await r.json();
if (data.ok) {
btn.parentElement.parentElement.style.opacity = "0.3";
btn.outerHTML = '<span class="text-green-400 text-xs">✓ supprime</span>';
} else {
alert("Echec : " + data.msg);
btn.disabled = false;
btn.textContent = "🗑 Supprimer";
}
} catch (e) {
alert("Erreur reseau : " + e);
btn.disabled = false;
btn.textContent = "🗑 Supprimer";
}
}
</script>
{% endif %}
{% endblock %}