feat(dashboard): visualisation clusters HDBSCAN

- Page /campaigns dédiée avec 4 vues graphiques :
  · Scatter plot (score vs vélocité, bulles colorées par campagne)
  · Graphe réseau force-directed (IPs liées par JA4 partagé)
  · Grille de cartes campagne (KPIs, ASN, pays, JA4)
  · Panneau détail (radar comportemental, timeline horaire, table membres)
- 4 nouveaux endpoints API :
  · GET /api/campaigns (fix: campaign_id >= 0 au lieu de != '')
  · GET /api/campaigns/graph (nœuds + arêtes)
  · GET /api/campaigns/scatter (score/vélocité par IP)
  · GET /api/campaigns/{cid} (détail + profil + timeline)
- Sidebar: lien Campagnes ajouté dans Surveillance
- Overview: campagnes clickables → lien vers /campaigns

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-09 01:11:16 +02:00
parent f1547423b5
commit 396baa90d2
5 changed files with 870 additions and 3 deletions

View File

@ -821,9 +821,13 @@ async def campaigns() -> dict[str, Any]:
f"uniqExact(src_ip) AS unique_ips, "
f"groupUniqArray(10)(ja4) AS ja4_list, "
f"groupUniqArray(5)(asn_org) AS asn_list, "
f"groupUniqArray(5)(country_code) AS countries "
f"groupUniqArray(5)(country_code) AS countries, "
f"avg(hits) AS avg_hits, "
f"avg(hit_velocity) AS avg_velocity, "
f"avg(fuzzing_index) AS avg_fuzzing, "
f"avg(post_ratio) AS avg_post_ratio "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id != '' AND campaign_id != '0' "
"WHERE campaign_id >= 0 "
"AND detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY campaign_id "
"ORDER BY members DESC LIMIT 50"
@ -834,6 +838,139 @@ async def campaigns() -> dict[str, Any]:
return {"campaigns": []}
# ---------------------------------------------------------------------------
# GET /api/campaigns/graph — Network graph data (shared JA4/ASN links)
# ---------------------------------------------------------------------------
@router.get("/campaigns/graph")
async def campaigns_graph() -> dict[str, Any]:
"""Données de graphe réseau : nœuds (IPs) et liens (JA4/ASN partagés)."""
try:
# Nœuds : chaque IP avec ses attributs principaux
nodes = query(
f"SELECT toString(src_ip) AS id, "
f"campaign_id AS group, "
f"any(ja4) AS ja4, any(asn_org) AS asn_org, "
f"any(country_code) AS country, any(threat_level) AS threat, "
f"any(browser_family) AS browser_family, "
f"sum(hits) AS total_hits, "
f"min(anomaly_score) AS worst_score "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id >= 0 "
"AND detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY src_ip, campaign_id "
"ORDER BY campaign_id, worst_score ASC "
"LIMIT 500"
)
# Liens : IPs partageant le même JA4 dans la même campagne
edges = query(
f"SELECT "
f"toString(a.src_ip) AS source, "
f"toString(b.src_ip) AS target, "
f"a.ja4 AS shared_ja4 "
f"FROM {_DB}.ml_detected_anomalies a "
f"INNER JOIN {_DB}.ml_detected_anomalies b "
"ON a.ja4 = b.ja4 AND a.campaign_id = b.campaign_id "
"AND a.src_ip < b.src_ip "
"WHERE a.campaign_id >= 0 "
"AND a.detected_at >= now() - INTERVAL 7 DAY "
"AND b.detected_at >= now() - INTERVAL 7 DAY "
"LIMIT 2000"
)
return {"nodes": nodes, "edges": edges}
except Exception as exc:
logger.exception("campaigns graph query failed")
return {"nodes": [], "edges": []}
# ---------------------------------------------------------------------------
# GET /api/campaigns/scatter — Scatter plot data (score vs velocity per IP)
# ---------------------------------------------------------------------------
@router.get("/campaigns/scatter")
async def campaigns_scatter() -> dict[str, Any]:
"""Données scatter plot : score vs vélocité par IP, coloré par campagne."""
try:
rows = query(
f"SELECT toString(src_ip) AS ip, "
f"campaign_id, "
f"min(anomaly_score) AS score, "
f"avg(hit_velocity) AS velocity, "
f"sum(hits) AS total_hits, "
f"any(ja4) AS ja4, "
f"any(asn_org) AS asn_org, "
f"any(threat_level) AS threat "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id >= 0 "
"AND detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY src_ip, campaign_id "
"ORDER BY score ASC LIMIT 500"
)
return {"data": rows}
except Exception as exc:
logger.exception("campaigns scatter query failed")
return {"data": []}
# ---------------------------------------------------------------------------
# GET /api/campaigns/{cid} — Campaign detail (member IPs + features)
# ---------------------------------------------------------------------------
@router.get("/campaigns/{cid}")
async def campaign_detail(cid: int) -> dict[str, Any]:
"""Détail d'une campagne : IPs membres, features comportementales, timeline."""
try:
members = query(
f"SELECT toString(src_ip) AS src_ip, ja4, host, "
f"anomaly_score, raw_anomaly_score, threat_level, "
f"hits, hit_velocity, fuzzing_index, post_ratio, "
f"port_exhaustion_ratio, orphan_ratio, "
f"asn_org, asn_number, country_code, "
f"browser_family, bot_name, detected_at, reason "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id = {{cid:Int32}} "
"AND detected_at >= now() - INTERVAL 7 DAY "
"ORDER BY anomaly_score ASC LIMIT 200",
{"cid": cid},
)
# Profil agrégé de la campagne
profile = query(
f"SELECT "
f"avg(hits) AS avg_hits, avg(hit_velocity) AS avg_velocity, "
f"avg(fuzzing_index) AS avg_fuzzing, avg(post_ratio) AS avg_post_ratio, "
f"avg(port_exhaustion_ratio) AS avg_port_exhaustion, "
f"avg(orphan_ratio) AS avg_orphan, "
f"avg(anomaly_score) AS avg_score, max(anomaly_score) AS max_score, "
f"uniqExact(src_ip) AS unique_ips, uniqExact(ja4) AS unique_ja4, "
f"uniqExact(host) AS unique_hosts, uniqExact(asn_org) AS unique_asns, "
f"groupUniqArray(20)(ja4) AS ja4_list, "
f"groupUniqArray(10)(asn_org) AS asn_list, "
f"groupUniqArray(10)(country_code) AS country_list, "
f"groupUniqArray(10)(host) AS host_list, "
f"min(detected_at) AS first_seen, max(detected_at) AS last_seen "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id = {{cid:Int32}} "
"AND detected_at >= now() - INTERVAL 7 DAY",
{"cid": cid},
)
# Timeline horaire de la campagne
timeline = query(
f"SELECT toStartOfHour(detected_at) AS hour, "
f"count() AS detections, uniqExact(src_ip) AS active_ips "
f"FROM {_DB}.ml_detected_anomalies "
"WHERE campaign_id = {{cid:Int32}} "
"AND detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY hour ORDER BY hour",
{"cid": cid},
)
return {
"campaign_id": cid,
"members": members,
"profile": profile[0] if profile else {},
"timeline": timeline,
}
except Exception as exc:
logger.exception("campaign detail query failed for %s", cid)
return {"campaign_id": cid, "members": [], "profile": {}, "timeline": []}
# ---------------------------------------------------------------------------
# GET /api/brute-force — Form brute-force detection
# ---------------------------------------------------------------------------