feat(qualys): page doublons + suppression API Qualys 1-clic
This commit is contained in:
parent
8f406f211d
commit
3d043af194
@ -1295,3 +1295,36 @@ async def qualys_dashboard_history(request: Request, db=Depends(get_db),
|
|||||||
"dimension": dimension, "dim_value": dim_value,
|
"dimension": dimension, "dim_value": dim_value,
|
||||||
"dim_options": dim_options, "runs_count": runs_count})
|
"dim_options": dim_options, "runs_count": runs_count})
|
||||||
return templates.TemplateResponse("qualys_dashboard_history.html", ctx)
|
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)
|
||||||
|
|||||||
@ -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,
|
"high": r.high, "medium": r.medium, "sain": r.sain,
|
||||||
"non_scanne": r.non_scanne, "vuln_total": r.critical + r.high + r.medium}
|
"non_scanne": r.non_scanne, "vuln_total": r.critical + r.high + r.medium}
|
||||||
for b, r in sorted(by_bucket.items())]
|
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)}
|
||||||
|
|||||||
@ -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" 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/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/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>
|
<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 %}
|
{% 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>
|
</div>
|
||||||
|
|||||||
107
app/templates/qualys_duplicates.html
Normal file
107
app/templates/qualys_duplicates.html
Normal 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 %}
|
||||||
Loading…
Reference in New Issue
Block a user