diff --git a/services/dashboard/backend/routes/api.py b/services/dashboard/backend/routes/api.py index 826eebc..ab684c8 100644 --- a/services/dashboard/backend/routes/api.py +++ b/services/dashboard/backend/routes/api.py @@ -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 # --------------------------------------------------------------------------- diff --git a/services/dashboard/backend/routes/pages.py b/services/dashboard/backend/routes/pages.py index c4577bd..91c90c8 100644 --- a/services/dashboard/backend/routes/pages.py +++ b/services/dashboard/backend/routes/pages.py @@ -56,3 +56,8 @@ async def models(request: Request): @router.get("/network") async def network(request: Request): return templates.TemplateResponse("network.html", _ctx(request, "network")) + + +@router.get("/campaigns") +async def campaigns_page(request: Request): + return templates.TemplateResponse("campaigns.html", _ctx(request, "campaigns")) diff --git a/services/dashboard/backend/templates/base.html b/services/dashboard/backend/templates/base.html index 4f0082e..2efd68e 100644 --- a/services/dashboard/backend/templates/base.html +++ b/services/dashboard/backend/templates/base.html @@ -115,6 +115,10 @@ + + + +
diff --git a/services/dashboard/backend/templates/campaigns.html b/services/dashboard/backend/templates/campaigns.html new file mode 100644 index 0000000..908f08d --- /dev/null +++ b/services/dashboard/backend/templates/campaigns.html @@ -0,0 +1,721 @@ +{% extends "base.html" %} +{% block page_title %}Campagnes — Clusters HDBSCAN{% endblock %} +{% block content %} + + +Chaque bulle = une IP. Position : score d'anomalie (X) vs vitesse de requêtes (Y). + Taille = nombre de hits. Couleur = campagne.
+Lecture : Les clusters visuels correspondent aux campagnes HDBSCAN. + Les IPs éloignées du cluster principal sont les plus suspectes.
+Source : ml_detected_anomalies WHERE campaign_id ≥ 0
+Nœuds = IPs anomales, Arêtes = JA4 partagé au sein d'une campagne. + Couleur du nœud = campagne. Taille = nombre de hits.
+Lecture : Des IPs très connectées au centre du cluster + sont le « cœur » de la campagne. Les nœuds isolés en périphérie sont des IPs + moins caractéristiques.
+Source : ml_detected_anomalies JOINs par JA4
++ Radar des features moyennes normalisées de la campagne. + Comparez avec le profil d'un trafic normal pour identifier les écarts. +
++ Détections et IPs actives par heure. Des pics synchronisés confirment une coordination. +
+| IP | +JA4 | +Host | +Score | +Menace | +Hits | +Vélocité | +ASN | +Pays | +Date | +
|---|