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:
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user