diff --git a/backend/routes/fingerprints.py b/backend/routes/fingerprints.py index 96ea8c1..61cc139 100644 --- a/backend/routes/fingerprints.py +++ b/backend/routes/fingerprints.py @@ -735,3 +735,97 @@ async def get_legitimate_ja4( except Exception as e: raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}") + + +# ============================================================================= +# ENDPOINT — Corrélation JA4 × ASN / Pays (C5) +# Détecte les JA4 fortement concentrés sur un seul ASN ou pays +# → signal de botnet ciblé ou d'infrastructure de test/attaque partagée +# ============================================================================= + +@router.get("/asn-correlation") +async def get_ja4_asn_correlation( + min_concentration: float = Query(0.7, ge=0.0, le=1.0, description="Seuil min de concentration ASN ou pays"), + min_ips: int = Query(5, ge=1, description="Nombre minimum d'IPs par JA4"), + limit: int = Query(50, ge=1, le=200), +): + """ + Identifie les JA4 fingerprints fortement concentrés sur un seul ASN ou pays. + Un JA4 avec asn_concentration ≥ 0.7 signifie que ≥70% des IPs utilisant ce fingerprint + proviennent du même ASN → infrastructure de bot partagée ou datacenter suspect. + """ + try: + # Two-pass: first aggregate per (ja4, asn) to get IP counts per ASN, + # then aggregate per ja4 to compute concentration ratio + sql = """ + SELECT + ja4, + sum(ips_per_combo) AS unique_ips, + uniq(src_asn) AS unique_asns, + uniq(src_country_code) AS unique_countries, + toString(argMax(src_asn, ips_per_combo)) AS top_asn_number, + argMax(asn_name, ips_per_combo) AS top_asn_name, + argMax(src_country_code, country_ips) AS dominant_country, + sum(total_hits) AS total_hits, + round(max(ips_per_combo) / greatest(sum(ips_per_combo), 1), 3) AS asn_concentration, + round(max(country_ips) / greatest(sum(ips_per_combo), 1), 3) AS country_concentration + FROM ( + SELECT + ja4, + src_asn, + src_country_code, + any(src_as_name) AS asn_name, + uniq(src_ip) AS ips_per_combo, + uniq(src_ip) AS country_ips, + sum(hits) AS total_hits + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 24 HOUR + AND ja4 != '' + GROUP BY ja4, src_asn, src_country_code + ) + GROUP BY ja4 + HAVING unique_ips >= %(min_ips)s + AND (asn_concentration >= %(min_conc)s OR country_concentration >= %(min_conc)s) + ORDER BY asn_concentration DESC, unique_ips DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"min_ips": min_ips, "min_conc": min_concentration, "limit": limit}) + items = [] + for row in result.result_rows: + ja4 = str(row[0]) + unique_ips = int(row[1]) + unique_asns = int(row[2]) + unique_countries = int(row[3]) + top_asn_number = str(row[4] or "") + top_asn_name = str(row[5] or "") + dominant_country = str(row[6] or "") + total_hits = int(row[7] or 0) + asn_concentration = float(row[8] or 0) + country_concentration = float(row[9] or 0) + + if asn_concentration >= 0.85: + corr_type, risk = "asn_monopoly", "high" + elif asn_concentration >= min_concentration: + corr_type, risk = "asn_dominant", "medium" + elif country_concentration >= min_concentration: + corr_type, risk = "geo_targeted", "medium" + else: + corr_type, risk = "distributed", "low" + + items.append({ + "ja4": ja4, + "unique_ips": unique_ips, + "unique_asns": unique_asns, + "unique_countries": unique_countries, + "top_asn_name": top_asn_name, + "top_asn_number": top_asn_number, + "dominant_country": dominant_country, + "total_hits": total_hits, + "asn_concentration": asn_concentration, + "country_concentration":country_concentration, + "correlation_type": corr_type, + "risk": risk, + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}") diff --git a/backend/routes/ml_features.py b/backend/routes/ml_features.py index c9ed0e4..c2c8250 100644 --- a/backend/routes/ml_features.py +++ b/backend/routes/ml_features.py @@ -23,37 +23,47 @@ def _attack_type(fuzzing_index: float, hit_velocity: float, @router.get("/top-anomalies") async def get_top_anomalies(limit: int = Query(50, ge=1, le=500)): - """Top IPs anomales déduplicées par IP (max fuzzing_index), triées par fuzzing_index DESC.""" + """Top IPs anomales (24h) — bypass view_ai_features_1h pour éviter les window functions. + Query directe sur agg_host_ip_ja4_1h + LEFT JOIN agg_header_fingerprint_1h. + """ try: sql = """ SELECT - replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, - any(ja4) AS ja4, - any(host) AS host, - max(hits) AS hits, - max(fuzzing_index) AS max_fuzzing, - max(hit_velocity) AS hit_velocity, - max(temporal_entropy) AS temporal_entropy, - max(is_fake_navigation) AS is_fake_navigation, - max(ua_ch_mismatch) AS ua_ch_mismatch, - max(sni_host_mismatch) AS sni_host_mismatch, - max(is_ua_rotating) AS is_ua_rotating, - max(path_diversity_ratio) AS path_diversity_ratio, - max(anomalous_payload_ratio) AS anomalous_payload_ratio, - any(asn_label) AS asn_label, - any(bot_name) AS bot_name - FROM mabase_prod.view_ai_features_1h - GROUP BY src_ip - ORDER BY 5 DESC + replaceRegexpAll(toString(a.src_ip), '^::ffff:', '') AS ip, + any(a.ja4) AS ja4, + any(a.host) AS host, + sum(a.hits) AS hits, + round(max(uniqMerge(a.uniq_query_params)) + / greatest(max(uniqMerge(a.uniq_paths)), 1), 4) AS fuzzing_index, + round(sum(a.hits) + / greatest(dateDiff('second', min(a.first_seen), max(a.last_seen)), 1), 2) AS hit_velocity, + round(sum(a.count_head) / greatest(sum(a.hits), 1), 4) AS head_ratio, + round(sum(a.count_no_sec_fetch) / greatest(sum(a.hits), 1), 4) AS sec_fetch_absence, + round(sum(a.tls12_count) / greatest(sum(a.hits), 1), 4) AS tls12_ratio, + round(sum(a.count_generic_accept) / greatest(sum(a.hits), 1), 4) AS generic_accept_ratio, + any(a.src_country_code) AS country, + any(a.src_as_name) AS asn_name, + max(h.ua_ch_mismatch) AS ua_ch_mismatch, + max(h.modern_browser_score) AS browser_score, + dictGetOrDefault('mabase_prod.dict_asn_reputation', 'label', toUInt64(any(a.src_asn)), 'unknown') AS asn_label, + coalesce( + nullIf(dictGetOrDefault('mabase_prod.dict_bot_ja4', 'bot_name', tuple(any(a.ja4)), ''), ''), + '' + ) AS bot_name + FROM mabase_prod.agg_host_ip_ja4_1h a + LEFT JOIN mabase_prod.agg_header_fingerprint_1h h + ON a.src_ip = h.src_ip AND a.window_start = h.window_start + WHERE a.window_start >= now() - INTERVAL 24 HOUR + GROUP BY a.src_ip + ORDER BY fuzzing_index DESC LIMIT %(limit)s """ result = db.query(sql, {"limit": limit}) items = [] for row in result.result_rows: - fuzzing = float(row[4] or 0) - velocity = float(row[5] or 0) - fake_nav = int(row[7] or 0) - ua_mm = int(row[8] or 0) + fuzzing = float(row[4] or 0) + velocity = float(row[5] or 0) + ua_mm = int(row[12] or 0) items.append({ "ip": str(row[0]), "ja4": str(row[1]), @@ -61,16 +71,17 @@ async def get_top_anomalies(limit: int = Query(50, ge=1, le=500)): "hits": int(row[3] or 0), "fuzzing_index": fuzzing, "hit_velocity": velocity, - "temporal_entropy": float(row[6] or 0), - "is_fake_navigation": fake_nav, + "head_ratio": float(row[6] or 0), + "sec_fetch_absence": float(row[7] or 0), + "tls12_ratio": float(row[8] or 0), + "generic_accept_ratio": float(row[9] or 0), + "country": str(row[10] or ""), + "asn_name": str(row[11] or ""), "ua_ch_mismatch": ua_mm, - "sni_host_mismatch": int(row[9] or 0), - "is_ua_rotating": int(row[10] or 0), - "path_diversity_ratio": float(row[11] or 0), - "anomalous_payload_ratio":float(row[12] or 0), - "asn_label": str(row[13] or ""), - "bot_name": str(row[14] or ""), - "attack_type": _attack_type(fuzzing, velocity, fake_nav, ua_mm), + "browser_score": int(row[13] or 0), + "asn_label": str(row[14] or ""), + "bot_name": str(row[15] or ""), + "attack_type": _attack_type(fuzzing, velocity, 0, ua_mm), }) return {"items": items} except Exception as e: @@ -93,6 +104,7 @@ async def get_ip_radar(ip: str): avg(anomalous_payload_ratio) AS anomalous_payload_ratio FROM mabase_prod.view_ai_features_1h WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + AND window_start >= now() - INTERVAL 24 HOUR """ result = db.query(sql, {"ip": ip}) if not result.result_rows: @@ -119,22 +131,264 @@ async def get_ip_radar(ip: str): raise HTTPException(status_code=500, detail=str(e)) -@router.get("/scatter") -async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)): - """Points pour scatter plot (fuzzing_index × hit_velocity), dédupliqués par IP.""" +@router.get("/score-distribution") +async def get_score_distribution(): + """ + Distribution de TOUS les scores ML depuis ml_all_scores (3j). + Single query avec conditional aggregates pour éviter le double scan. + """ + try: + # Single scan — global totals + per-model breakdown via GROUPING SETS + sql = """ + SELECT + threat_level, + model_name, + count() AS total, + round(avg(anomaly_score), 4) AS avg_score, + round(min(anomaly_score), 4) AS min_score, + countIf(threat_level = 'NORMAL') AS normal_count, + countIf(threat_level NOT IN ('NORMAL','KNOWN_BOT')) AS anomaly_count, + countIf(threat_level = 'KNOWN_BOT') AS bot_count + FROM mabase_prod.ml_all_scores + WHERE detected_at >= now() - INTERVAL 3 DAY + GROUP BY threat_level, model_name + ORDER BY model_name, total DESC + """ + result = db.query(sql) + by_model: dict = {} + grand_total = 0 + total_normal = total_anomaly = total_bot = 0 + for row in result.result_rows: + level = str(row[0]) + model = str(row[1]) + total = int(row[2]) + grand_total += total + total_normal += int(row[5] or 0) + total_anomaly += int(row[6] or 0) + total_bot += int(row[7] or 0) + if model not in by_model: + by_model[model] = [] + by_model[model].append({ + "threat_level": level, + "total": total, + "avg_score": float(row[3] or 0), + "min_score": float(row[4] or 0), + }) + + grand_total = max(grand_total, 1) + return { + "by_model": by_model, + "totals": { + "normal": total_normal, + "anomaly": total_anomaly, + "known_bot": total_bot, + "grand_total": grand_total, + "normal_pct": round(total_normal / grand_total * 100, 1), + "anomaly_pct": round(total_anomaly / grand_total * 100, 1), + "bot_pct": round(total_bot / grand_total * 100, 1), + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/score-trends") +async def get_score_trends(hours: int = Query(72, ge=1, le=168)): + """ + Évolution temporelle des scores ML depuis ml_all_scores. + Retourne le score moyen et les counts par heure et par modèle. + """ try: sql = """ SELECT - replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, - any(ja4) AS ja4, - max(fuzzing_index) AS max_fuzzing, - max(hit_velocity) AS hit_velocity, - max(hits) AS hits, - max(is_fake_navigation) AS is_fake_navigation, - max(ua_ch_mismatch) AS ua_ch_mismatch - FROM mabase_prod.view_ai_features_1h + toStartOfHour(window_start) AS hour, + model_name, + countIf(threat_level = 'NORMAL') AS normal_count, + countIf(threat_level IN ('LOW','MEDIUM','HIGH','CRITICAL')) AS anomaly_count, + countIf(threat_level = 'KNOWN_BOT') AS bot_count, + round(avgIf(anomaly_score, threat_level IN ('LOW','MEDIUM','HIGH','CRITICAL')), 4) AS avg_anomaly_score + FROM mabase_prod.ml_all_scores + WHERE window_start >= now() - INTERVAL %(hours)s HOUR + GROUP BY hour, model_name + ORDER BY hour ASC, model_name + """ + result = db.query(sql, {"hours": hours}) + points = [] + for row in result.result_rows: + points.append({ + "hour": str(row[0]), + "model": str(row[1]), + "normal_count": int(row[2] or 0), + "anomaly_count": int(row[3] or 0), + "bot_count": int(row[4] or 0), + "avg_anomaly_score": float(row[5] or 0), + }) + return {"points": points, "hours": hours} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/b-features") +async def get_b_features(limit: int = Query(50, ge=1, le=200)): + """ + Agrégation des B-features (HTTP pures) pour les top IPs anomales. + Source: agg_host_ip_ja4_1h (SimpleAggregateFunction columns). + Expose: head_ratio, sec_fetch_absence, tls12_ratio, generic_accept_ratio, http10_ratio. + Ces features sont calculées dans view_ai_features_1h mais jamais visualisées dans le dashboard. + """ + try: + sql = """ + SELECT ip, ja4, country, asn_name, hits, + head_ratio, sec_fetch_absence, tls12_ratio, generic_accept_ratio, http10_ratio + FROM ( + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + any(ja4) AS ja4, + any(src_country_code) AS country, + any(src_as_name) AS asn_name, + sum(hits) AS hits, + round(sum(count_head) / greatest(sum(hits),1), 4) AS head_ratio, + round(sum(count_no_sec_fetch) / greatest(sum(hits),1), 4) AS sec_fetch_absence, + round(sum(tls12_count) / greatest(sum(hits),1), 4) AS tls12_ratio, + round(sum(count_generic_accept) / greatest(sum(hits),1), 4) AS generic_accept_ratio, + round(sum(count_http10) / greatest(sum(hits),1), 4) AS http10_ratio + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 24 HOUR + GROUP BY src_ip + ) + WHERE sec_fetch_absence > 0.5 OR generic_accept_ratio > 0.3 + OR head_ratio > 0.1 OR tls12_ratio > 0.5 + ORDER BY (head_ratio + sec_fetch_absence + generic_accept_ratio) DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"limit": limit}) + items = [] + for row in result.result_rows: + items.append({ + "ip": str(row[0]), + "ja4": str(row[1] or ""), + "country": str(row[2] or ""), + "asn_name": str(row[3] or ""), + "hits": int(row[4] or 0), + "head_ratio": float(row[5] or 0), + "sec_fetch_absence": float(row[6] or 0), + "tls12_ratio": float(row[7] or 0), + "generic_accept_ratio":float(row[8] or 0), + "http10_ratio": float(row[9] or 0), + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/campaigns") +async def get_ml_campaigns(hours: int = Query(24, ge=1, le=168), limit: int = Query(20, ge=1, le=100)): + """ + Groupes d'anomalies détectées par DBSCAN (campaign_id >= 0). + Si aucune campagne active, fallback sur clustering par /24 subnet + JA4 commun. + Utile pour détecter les botnets distribués sans état de campagne DBSCAN. + """ + try: + # First: check real campaigns + campaign_sql = """ + SELECT + campaign_id, + count() AS total_detections, + uniq(src_ip) AS unique_ips, + any(threat_level) AS dominant_threat, + groupUniqArray(3)(threat_level) AS threat_levels, + groupUniqArray(3)(bot_name) AS bot_names, + min(detected_at) AS first_seen, + max(detected_at) AS last_seen + FROM mabase_prod.ml_detected_anomalies + WHERE detected_at >= now() - INTERVAL %(hours)s HOUR + AND campaign_id >= 0 + GROUP BY campaign_id + ORDER BY total_detections DESC + LIMIT %(limit)s + """ + result = db.query(campaign_sql, {"hours": hours, "limit": limit}) + campaigns = [] + for row in result.result_rows: + campaigns.append({ + "id": f"C{row[0]}", + "campaign_id": int(row[0]), + "total_detections": int(row[1]), + "unique_ips": int(row[2]), + "dominant_threat": str(row[3] or ""), + "threat_levels": list(row[4] or []), + "bot_names": list(row[5] or []), + "first_seen": str(row[6]), + "last_seen": str(row[7]), + "source": "dbscan", + }) + + # Fallback: subnet-based clustering when DBSCAN has no campaigns + if not campaigns: + subnet_sql = """ + SELECT + IPv4CIDRToRange(toIPv4(replaceRegexpAll(toString(src_ip),'^::ffff:','')), 24).1 AS subnet, + count() AS total_detections, + uniq(src_ip) AS unique_ips, + groupArray(3)(threat_level) AS threat_levels, + any(bot_name) AS bot_name, + any(ja4) AS sample_ja4, + min(detected_at) AS first_seen, + max(detected_at) AS last_seen + FROM mabase_prod.ml_detected_anomalies + WHERE detected_at >= now() - INTERVAL %(hours)s HOUR + AND threat_level IN ('HIGH','CRITICAL','MEDIUM') + GROUP BY subnet + HAVING unique_ips >= 3 + ORDER BY total_detections DESC + LIMIT %(limit)s + """ + result2 = db.query(subnet_sql, {"hours": hours, "limit": limit}) + for i, row in enumerate(result2.result_rows): + subnet_str = str(row[0]) + "/24" + campaigns.append({ + "id": f"S{i+1:03d}", + "campaign_id": -1, + "subnet": subnet_str, + "total_detections": int(row[1]), + "unique_ips": int(row[2]), + "dominant_threat": str((row[3] or [""])[0]), + "threat_levels": list(row[3] or []), + "bot_names": [str(row[4] or "")], + "sample_ja4": str(row[5] or ""), + "first_seen": str(row[6]), + "last_seen": str(row[7]), + "source": "subnet_cluster", + }) + + dbscan_active = any(c["campaign_id"] >= 0 for c in campaigns) + return { + "campaigns": campaigns, + "total": len(campaigns), + "dbscan_active": dbscan_active, + "hours": hours, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/scatter") +async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)): + """Points scatter plot (fuzzing_index × hit_velocity) — bypass view_ai_features_1h.""" + try: + sql = """ + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + any(ja4) AS ja4, + round(max(uniqMerge(uniq_query_params)) / greatest(max(uniqMerge(uniq_paths)), 1), 4) AS fuzzing_index, + round(sum(hits) / greatest(dateDiff('second', min(first_seen), max(last_seen)), 1), 2) AS hit_velocity, + sum(hits) AS hits, + round(sum(count_head) / greatest(sum(hits), 1), 4) AS head_ratio, + max(correlated_raw) AS correlated + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 24 HOUR GROUP BY src_ip - ORDER BY 3 DESC + ORDER BY fuzzing_index DESC LIMIT %(limit)s """ result = db.query(sql, {"limit": limit}) @@ -142,15 +396,13 @@ async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)): for row in result.result_rows: fuzzing = float(row[2] or 0) velocity = float(row[3] or 0) - fake_nav = int(row[5] or 0) - ua_mm = int(row[6] or 0) points.append({ "ip": str(row[0]), "ja4": str(row[1]), "fuzzing_index":fuzzing, "hit_velocity": velocity, "hits": int(row[4] or 0), - "attack_type": _attack_type(fuzzing, velocity, fake_nav, ua_mm), + "attack_type": _attack_type(fuzzing, velocity, 0, 0), }) return {"points": points} except Exception as e: diff --git a/backend/routes/rotation.py b/backend/routes/rotation.py index bcbff3b..c3fc660 100644 --- a/backend/routes/rotation.py +++ b/backend/routes/rotation.py @@ -104,40 +104,40 @@ async def get_ip_ja4_history(ip: str): @router.get("/sophistication") async def get_sophistication(limit: int = Query(50, ge=1, le=500)): - """Score de sophistication adversaire par IP (rotation JA4 + récurrence + bruteforce).""" + """Score de sophistication adversaire par IP (rotation JA4 + récurrence + bruteforce). + Single SQL JOIN query — aucun traitement Python sur 34K entrées. + """ try: - # Separate queries merged in Python to avoid view JOIN issues - rot_result = db.query(""" - SELECT - replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, - distinct_ja4_count - FROM mabase_prod.view_host_ip_ja4_rotation - """) - rotation_map = {str(row[0]): int(row[1]) for row in rot_result.result_rows} - - rec_result = db.query(""" - SELECT - replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, - recurrence - FROM mabase_prod.view_ip_recurrence - """) - recurrence_map = {str(row[0]): int(row[1]) for row in rec_result.result_rows} - - bf_result = db.query(""" - SELECT - replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, - sum(hits) AS total_hits + sql = """ + SELECT + replaceRegexpAll(toString(r.src_ip), '^::ffff:', '') AS ip, + r.distinct_ja4_count, + coalesce(rec.recurrence, 0) AS recurrence, + coalesce(bf.bruteforce_hits, 0) AS bruteforce_hits, + round(least(100.0, + r.distinct_ja4_count * 10 + + coalesce(rec.recurrence, 0) * 20 + + least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5) + ), 1) AS sophistication_score + FROM mabase_prod.view_host_ip_ja4_rotation r + LEFT JOIN ( + SELECT src_ip, count() AS recurrence + FROM mabase_prod.ml_detected_anomalies FINAL + GROUP BY src_ip + ) rec USING(src_ip) + LEFT JOIN ( + SELECT replaceRegexpAll(toString(src_ip),'^::ffff:','') AS src_ip, + sum(hits) AS bruteforce_hits FROM mabase_prod.view_form_bruteforce_detected - GROUP BY ip - """) - bruteforce_map = {str(row[0]): int(row[1]) for row in bf_result.result_rows} - - # Start from IPs that appear in rotation view (most evasive) + GROUP BY src_ip + ) bf USING(src_ip) + ORDER BY sophistication_score DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"limit": limit}) items = [] - for ip, ja4_count in rotation_map.items(): - recurrence = recurrence_map.get(ip, 0) - bf_hits = bruteforce_map.get(ip, 0) - score = min(100.0, ja4_count * 10 + recurrence * 20 + min(30.0, math.log(bf_hits + 1) * 5)) + for row in result.result_rows: + score = float(row[4] or 0) if score > 80: tier = "APT-like" elif score > 50: @@ -147,16 +147,13 @@ async def get_sophistication(limit: int = Query(50, ge=1, le=500)): else: tier = "Basic" items.append({ - "ip": ip, - "ja4_rotation_count": ja4_count, - "recurrence": recurrence, - "bruteforce_hits": bf_hits, - "sophistication_score": round(score, 1), - "tier": tier, + "ip": str(row[0]), + "ja4_rotation_count": int(row[1] or 0), + "recurrence": int(row[2] or 0), + "bruteforce_hits": int(row[3] or 0), + "sophistication_score":score, + "tier": tier, }) - - items.sort(key=lambda x: x["sophistication_score"], reverse=True) - items = items[:limit] return {"items": items, "total": len(items)} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac075eb..7af1b1c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,9 +18,6 @@ import { CampaignsView } from './components/CampaignsView'; import { BruteForceView } from './components/BruteForceView'; import { TcpSpoofingView } from './components/TcpSpoofingView'; import { HeaderFingerprintView } from './components/HeaderFingerprintView'; -import { HeatmapView } from './components/HeatmapView'; -import { BotnetMapView } from './components/BotnetMapView'; -import { RotationView } from './components/RotationView'; import { MLFeaturesView } from './components/MLFeaturesView'; import { useTheme } from './ThemeContext'; @@ -83,9 +80,6 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) { { path: '/bruteforce', label: 'Brute Force', icon: '🔥', aliases: [] }, { path: '/tcp-spoofing', label: 'TCP Spoofing', icon: '🧬', aliases: [] }, { path: '/headers', label: 'Header Fingerprint', icon: '📡', aliases: [] }, - { path: '/heatmap', label: 'Heatmap Temporelle', icon: '⏱️', aliases: [] }, - { path: '/botnets', label: 'Botnets Distribués', icon: '🌍', aliases: [] }, - { path: '/rotation', label: 'Rotation & Persistance', icon: '🔄', aliases: [] }, { path: '/ml-features', label: 'Features ML', icon: '🤖', aliases: [] }, ]; @@ -245,9 +239,6 @@ function TopHeader({ counts }: { counts: AlertCounts | null }) { if (p.startsWith('/bruteforce')) return 'Brute Force & Credential Stuffing'; if (p.startsWith('/tcp-spoofing')) return 'Spoofing TCP/OS'; if (p.startsWith('/headers')) return 'Header Fingerprint Clustering'; - if (p.startsWith('/heatmap')) return 'Heatmap Temporelle'; - if (p.startsWith('/botnets')) return 'Botnets Distribués'; - if (p.startsWith('/rotation')) return 'Rotation JA4 & Persistance'; if (p.startsWith('/ml-features')) return 'Features ML / Radar'; return ''; }; @@ -380,9 +371,9 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/BruteForceView.tsx b/frontend/src/components/BruteForceView.tsx index abe1e44..124cdf7 100644 --- a/frontend/src/components/BruteForceView.tsx +++ b/frontend/src/components/BruteForceView.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import DataTable, { Column } from './ui/DataTable'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -61,6 +62,70 @@ function ErrorMessage({ message }: { message: string }) { ); } +// ─── Attackers DataTable ───────────────────────────────────────────────────── + +function AttackersTable({ + attackers, + navigate, +}: { + attackers: BruteForceAttacker[]; + navigate: (path: string) => void; +}) { + const columns = useMemo((): Column[] => [ + { + key: 'ip', + label: 'IP', + render: (v: string) => {v}, + }, + { key: 'distinct_hosts', label: 'Hosts ciblés', align: 'right' }, + { + key: 'total_hits', + label: 'Hits', + align: 'right', + render: (v: number) => formatNumber(v), + }, + { + key: 'total_params', + label: 'Params', + align: 'right', + render: (v: number) => formatNumber(v), + }, + { + key: 'ja4', + label: 'JA4', + render: (v: string) => ( + + {v ? `${v.slice(0, 16)}…` : '—'} + + ), + }, + { + key: '_actions', + label: '', + sortable: false, + render: (_: unknown, row: BruteForceAttacker) => ( + + ), + }, + ], [navigate]); + + return ( + + ); +} + // ─── Main Component ─────────────────────────────────────────────────────────── interface HostAttacker { ip: string; total_hits: number; total_params: number; ja4: string; attack_type: string; } @@ -338,37 +403,7 @@ export function BruteForceView() { ) : attackersError ? (
) : ( - - - - - - - - - - - - - {attackers.map((a) => ( - - - - - - - - - ))} - -
IPHosts ciblésHitsParamsJA4
{a.ip}{a.distinct_hosts}{formatNumber(a.total_hits)}{formatNumber(a.total_params)}{a.ja4 ? a.ja4.slice(0, 16) : '—'}… - -
+ )} )} diff --git a/frontend/src/components/CampaignsView.tsx b/frontend/src/components/CampaignsView.tsx index b2f14cc..6ceaee2 100644 --- a/frontend/src/components/CampaignsView.tsx +++ b/frontend/src/components/CampaignsView.tsx @@ -1,5 +1,7 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import DataTable, { Column } from './ui/DataTable'; +import ThreatBadge from './ui/ThreatBadge'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -44,7 +46,7 @@ interface JA4AttributesResponse { items: JA4AttributeItem[]; } -type ActiveTab = 'clusters' | 'ja4' | 'behavioral'; +type ActiveTab = 'clusters' | 'ja4' | 'behavioral' | 'botnets'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -256,6 +258,7 @@ export function CampaignsView() { { id: 'clusters', label: 'Clusters réseau' }, { id: 'ja4', label: 'Fingerprints JA4' }, { id: 'behavioral', label: 'Analyse comportementale' }, + { id: 'botnets', label: '🌍 Botnets Distribués' }, ] as const ).map(tab => ( + + + {expanded && ( + + + {countriesLoading ? ( +
+
+ Chargement des pays… +
+ ) : countriesError ? ( + ⚠️ {countriesError} + ) : ( +
+ {countries.map((c) => ( + + {getCountryFlag(c.country_code)} {c.country_code} + · + {formatNumber(c.unique_ips)} IPs + + ))} +
+ )} + + + )} + + ); +} + +function BotnetTab() { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(true); + const [summaryError, setSummaryError] = useState(null); + + const [sortField, setSortField] = useState<'unique_ips' | 'unique_countries' | 'targeted_hosts'>('unique_ips'); + + useEffect(() => { + fetch('/api/botnets/ja4-spread') + .then(r => r.ok ? r.json() : Promise.reject('Erreur chargement des botnets')) + .then((data: { items: BotnetItem[] }) => setItems(data.items ?? [])) + .catch(err => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoading(false)); + + fetch('/api/botnets/summary') + .then(r => r.ok ? r.json() : Promise.reject('Erreur chargement du résumé')) + .then((data: BotnetSummary) => setSummary(data)) + .catch(err => setSummaryError(err instanceof Error ? err.message : String(err))) + .finally(() => setSummaryLoading(false)); + }, []); + + const sortedItems = [...items].sort((a, b) => b[sortField] - a[sortField]); + + return ( +
+ {summaryLoading ? ( +
+
+
+ ) : summaryError ? ( +
⚠️ {summaryError}
+ ) : summary ? ( +
+
+ Total Global Botnets + {formatNumber(summary.total_global_botnets)} +
+
+ IPs impliquées + {formatNumber(summary.total_ips_in_botnets)} +
+
+ JA4 le + répandu + {summary.most_spread_ja4 || '—'} +
+
+ IPs max par JA4 + {summary.most_ips_ja4 || '—'} +
+
+ ) : null} + +
+ Trier par : + {(['unique_ips', 'unique_countries', 'targeted_hosts'] as const).map((field) => ( + + ))} +
+ +
+ {loading ? ( +
+
+
+ ) : error ? ( +
⚠️ {error}
+ ) : ( + + + + + + + + + + + + + + {sortedItems.map((item) => ( + navigate(`/investigation/ja4/${encodeURIComponent(ja4)}`)} + /> + ))} + {sortedItems.length === 0 && ( + + )} + +
JA4IPs distinctesPaysHosts ciblésScore distributionClasse
Aucun botnet détecté
+ )}
); diff --git a/frontend/src/components/DetectionsList.tsx b/frontend/src/components/DetectionsList.tsx index e583801..b0e8a97 100644 --- a/frontend/src/components/DetectionsList.tsx +++ b/frontend/src/components/DetectionsList.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useDetections } from '../hooks/useDetections'; +import DataTable, { Column } from './ui/DataTable'; type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity'; type SortOrder = 'asc' | 'desc'; @@ -12,6 +13,28 @@ interface ColumnConfig { sortable: boolean; } +interface DetectionRow { + src_ip: string; + ja4?: string; + host?: string; + client_headers?: string; + model_name: string; + anomaly_score: number; + hits?: number; + hit_velocity?: number; + asn_org?: string; + asn_number?: string | number; + asn_score?: number | null; + asn_rep_label?: string; + country_code?: string; + detected_at: string; + first_seen?: string; + last_seen?: string; + unique_ja4s?: string[]; + unique_hosts?: string[]; + unique_client_headers?: string[]; +} + export function DetectionsList() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -72,26 +95,6 @@ export function DetectionsList() { setSearchParams(newParams); }; - const handleSort = (field: SortField) => { - const newParams = new URLSearchParams(searchParams); - const currentSortField = newParams.get('sort_by') || 'detected_at'; - const currentOrder = newParams.get('sort_order') || 'desc'; - - if (currentSortField === field) { - // Inverser l'ordre ou supprimer le tri - if (currentOrder === 'desc') { - newParams.set('sort_order', 'asc'); - } else { - newParams.delete('sort_by'); - newParams.delete('sort_order'); - } - } else { - newParams.set('sort_by', field); - newParams.set('sort_order', 'desc'); - } - setSearchParams(newParams); - }; - const toggleColumn = (key: string) => { setColumns(cols => cols.map(col => col.key === key ? { ...col, visible: !col.visible } : col @@ -104,20 +107,6 @@ export function DetectionsList() { setSearchParams(newParams); }; - const getSortIcon = (field: SortField) => { - if (sortField !== field) return '⇅'; - return sortOrder === 'asc' ? '↑' : '↓'; - }; - - // Par défaut, trier par score croissant (scores négatifs en premier) - const getDefaultSortIcon = (field: SortField) => { - if (!searchParams.has('sort_by') && !searchParams.has('sort')) { - if (field === 'anomaly_score') return '↑'; - return '⇅'; - } - return getSortIcon(field); - }; - if (loading) { return (
@@ -190,6 +179,208 @@ export function DetectionsList() { }; })(); + // Build DataTable columns from visible column configs + const tableColumns: Column[] = columns + .filter((col) => col.visible) + .map((col): Column => { + switch (col.key) { + case 'ip_ja4': + return { + key: 'src_ip', + label: col.label, + sortable: true, + render: (_, row) => ( +
+
{row.src_ip}
+ {groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? ( +
+
+ {row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''} +
+ {row.unique_ja4s.slice(0, 3).map((ja4, idx) => ( +
+ {ja4} +
+ ))} + {row.unique_ja4s.length > 3 && ( +
+ +{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''} +
+ )} +
+ ) : ( +
+ {row.ja4 || '-'} +
+ )} +
+ ), + }; + case 'host': + return { + key: 'host', + label: col.label, + sortable: true, + render: (_, row) => + groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? ( +
+
+ {row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''} +
+ {row.unique_hosts.slice(0, 3).map((host, idx) => ( +
+ {host} +
+ ))} + {row.unique_hosts.length > 3 && ( +
+ +{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''} +
+ )} +
+ ) : ( +
+ {row.host || '-'} +
+ ), + }; + case 'client_headers': + return { + key: 'client_headers', + label: col.label, + sortable: false, + render: (_, row) => + groupByIP && row.unique_client_headers && row.unique_client_headers.length > 0 ? ( +
+
+ {row.unique_client_headers.length} Header{row.unique_client_headers.length > 1 ? 's' : ''} unique{row.unique_client_headers.length > 1 ? 's' : ''} +
+ {row.unique_client_headers.slice(0, 3).map((header, idx) => ( +
+ {header} +
+ ))} + {row.unique_client_headers.length > 3 && ( +
+ +{row.unique_client_headers.length - 3} autre{row.unique_client_headers.length - 3 > 1 ? 's' : ''} +
+ )} +
+ ) : ( +
+ {row.client_headers || '-'} +
+ ), + }; + case 'model_name': + return { + key: 'model_name', + label: col.label, + sortable: true, + render: (_, row) => , + }; + case 'anomaly_score': + return { + key: 'anomaly_score', + label: col.label, + sortable: true, + align: 'right' as const, + render: (_, row) => , + }; + case 'hits': + return { + key: 'hits', + label: col.label, + sortable: true, + align: 'right' as const, + render: (_, row) => ( +
{row.hits ?? 0}
+ ), + }; + case 'hit_velocity': + return { + key: 'hit_velocity', + label: col.label, + sortable: true, + align: 'right' as const, + render: (_, row) => ( +
10 + ? 'text-threat-high' + : row.hit_velocity && row.hit_velocity > 1 + ? 'text-threat-medium' + : 'text-text-primary' + }`} + > + {row.hit_velocity ? row.hit_velocity.toFixed(2) : '0.00'} + req/s +
+ ), + }; + case 'asn': + return { + key: 'asn_org', + label: col.label, + sortable: true, + render: (_, row) => ( +
+
{row.asn_org || row.asn_number || '-'}
+ {row.asn_number && ( +
AS{row.asn_number}
+ )} + +
+ ), + }; + case 'country': + return { + key: 'country_code', + label: col.label, + sortable: true, + align: 'center' as const, + render: (_, row) => + row.country_code ? ( + {getFlag(row.country_code)} + ) : ( + - + ), + }; + case 'detected_at': + return { + key: 'detected_at', + label: col.label, + sortable: true, + render: (_, row) => + groupByIP && row.first_seen ? ( +
+
+ Premier:{' '} + {new Date(row.first_seen).toLocaleDateString('fr-FR')}{' '} + {new Date(row.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} +
+
+ Dernier:{' '} + {new Date(row.last_seen!).toLocaleDateString('fr-FR')}{' '} + {new Date(row.last_seen!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} +
+
+ ) : ( + <> +
+ {new Date(row.detected_at).toLocaleDateString('fr-FR')} +
+
+ {new Date(row.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} +
+ + ), + }; + default: + return { key: col.key, label: col.label, sortable: col.sortable }; + } + }); + return (
{/* En-tête */} @@ -291,223 +482,15 @@ export function DetectionsList() { {/* Tableau */}
- - - - {columns.filter(col => col.visible).map(col => ( - - ))} - - - - {processedData.items.map((detection) => ( - { - navigate(`/detections/ip/${encodeURIComponent(detection.src_ip)}`); - }} - > - {columns.filter(col => col.visible).map(col => { - if (col.key === 'ip_ja4') { - const detectionAny = detection as any; - return ( - - ); - } - if (col.key === 'host') { - const detectionAny = detection as any; - return ( - - ); - } - if (col.key === 'client_headers') { - const detectionAny = detection as any; - return ( - - ); - } - if (col.key === 'model_name') { - return ( - - ); - } - if (col.key === 'anomaly_score') { - return ( - - ); - } - if (col.key === 'hits') { - return ( - - ); - } - if (col.key === 'hit_velocity') { - return ( - - ); - } - if (col.key === 'asn') { - return ( - - ); - } - if (col.key === 'country') { - return ( - - ); - } - if (col.key === 'detected_at') { - const detectionAny = detection as any; - return ( - - ); - } - return null; - })} - - ))} - -
col.sortable && handleSort(col.key as SortField)} - > -
- {col.label} - {col.sortable && ( - {getDefaultSortIcon(col.key as SortField)} - )} -
-
-
{detection.src_ip}
- {groupByIP && detectionAny.unique_ja4s?.length > 0 ? ( -
-
- {detectionAny.unique_ja4s.length} JA4{detectionAny.unique_ja4s.length > 1 ? 's' : ''} unique{detectionAny.unique_ja4s.length > 1 ? 's' : ''} -
- {detectionAny.unique_ja4s.slice(0, 3).map((ja4: string, idx: number) => ( -
- {ja4} -
- ))} - {detectionAny.unique_ja4s.length > 3 && ( -
- +{detectionAny.unique_ja4s.length - 3} autre{detectionAny.unique_ja4s.length - 3 > 1 ? 's' : ''} -
- )} -
- ) : ( -
- {detection.ja4 || '-'} -
- )} -
- {groupByIP && detectionAny.unique_hosts?.length > 0 ? ( -
-
- {detectionAny.unique_hosts.length} Host{detectionAny.unique_hosts.length > 1 ? 's' : ''} unique{detectionAny.unique_hosts.length > 1 ? 's' : ''} -
- {detectionAny.unique_hosts.slice(0, 3).map((host: string, idx: number) => ( -
- {host} -
- ))} - {detectionAny.unique_hosts.length > 3 && ( -
- +{detectionAny.unique_hosts.length - 3} autre{detectionAny.unique_hosts.length - 3 > 1 ? 's' : ''} -
- )} -
- ) : ( -
- {detection.host || '-'} -
- )} -
- {groupByIP && detectionAny.unique_client_headers?.length > 0 ? ( -
-
- {detectionAny.unique_client_headers.length} Header{detectionAny.unique_client_headers.length > 1 ? 's' : ''} unique{detectionAny.unique_client_headers.length > 1 ? 's' : ''} -
- {detectionAny.unique_client_headers.slice(0, 3).map((header: string, idx: number) => ( -
- {header} -
- ))} - {detectionAny.unique_client_headers.length > 3 && ( -
- +{detectionAny.unique_client_headers.length - 3} autre{detectionAny.unique_client_headers.length - 3 > 1 ? 's' : ''} -
- )} -
- ) : ( -
- {detection.client_headers || '-'} -
- )} -
- - - - -
- {detection.hits || 0} -
-
-
10 - ? 'text-threat-high' - : detection.hit_velocity && detection.hit_velocity > 1 - ? 'text-threat-medium' - : 'text-text-primary' - }`}> - {detection.hit_velocity ? detection.hit_velocity.toFixed(2) : '0.00'} - req/s -
-
-
{detection.asn_org || detection.asn_number || '-'}
- {detection.asn_number && ( -
AS{detection.asn_number}
- )} - -
- {detection.country_code ? ( - {getFlag(detection.country_code)} - ) : ( - '-' - )} - - {groupByIP && detectionAny.first_seen ? ( -
-
- Premier:{' '} - {new Date(detectionAny.first_seen).toLocaleDateString('fr-FR')}{' '} - {new Date(detectionAny.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} -
-
- Dernier:{' '} - {new Date(detectionAny.last_seen).toLocaleDateString('fr-FR')}{' '} - {new Date(detectionAny.last_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} -
-
- ) : ( - <> -
- {new Date(detection.detected_at).toLocaleDateString('fr-FR')} -
-
- {new Date(detection.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} -
- - )} -
- - {data.items.length === 0 && ( -
- Aucune détection trouvée -
- )} + + data={processedData.items as DetectionRow[]} + columns={tableColumns} + rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`} + defaultSortKey="anomaly_score" + onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)} + emptyMessage="Aucune détection trouvée" + compact + />
{/* Pagination */} diff --git a/frontend/src/components/FingerprintsView.tsx b/frontend/src/components/FingerprintsView.tsx index 5bcb175..97a895c 100644 --- a/frontend/src/components/FingerprintsView.tsx +++ b/frontend/src/components/FingerprintsView.tsx @@ -1,5 +1,7 @@ -import { useState, useEffect, useCallback, Fragment } from 'react'; +import { useState, useEffect, useCallback, Fragment, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import DataTable, { Column } from './ui/DataTable'; +import ThreatBadge from './ui/ThreatBadge'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -32,7 +34,8 @@ interface IPsData { } type SortField = 'ip_count' | 'detections' | 'botnet_score'; -type ActiveTab = 'ja4' | 'spoofing' | 'ua_analysis'; +type SortDir = 'asc' | 'desc'; +type ActiveTab = 'ja4' | 'spoofing' | 'ua_analysis' | 'rotation'; // ─── Spoofing types ─────────────────────────────────────────────────────────── @@ -904,6 +907,11 @@ export function FingerprintsView() { const [expandedJa4, setExpandedJa4] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [sortField, setSortField] = useState('ip_count'); + const [sortDir, setSortDir] = useState('desc'); + const handleColSort = (field: SortField) => { + if (field === sortField) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); + else { setSortField(field); setSortDir('desc'); } + }; const [minIps, setMinIps] = useState(0); const [copiedJa4, setCopiedJa4] = useState(null); @@ -1053,24 +1061,25 @@ export function FingerprintsView() { .sort((a, b) => { const va = variabilityCache.get(a.value); const vb = variabilityCache.get(b.value); + let diff = 0; if (sortField === 'ip_count') { const ia = va ? va.unique_ips : a.count; const ib = vb ? vb.unique_ips : b.count; - return ib - ia; - } - if (sortField === 'detections') { + diff = ib - ia; + } else if (sortField === 'detections') { const da = va ? va.total_detections : a.count; const db = vb ? vb.total_detections : b.count; - return db - da; + diff = db - da; + } else { + const sa = va + ? botnetScore(va.unique_ips, botUaPercentage(va.attributes.user_agents)) + : 0; + const sb = vb + ? botnetScore(vb.unique_ips, botUaPercentage(vb.attributes.user_agents)) + : 0; + diff = sb - sa; } - // botnet_score - const sa = va - ? botnetScore(va.unique_ips, botUaPercentage(va.attributes.user_agents)) - : 0; - const sb = vb - ? botnetScore(vb.unique_ips, botUaPercentage(vb.attributes.user_agents)) - : 0; - return sb - sa; + return sortDir === 'desc' ? diff : -diff; }); // ── Loading state ── @@ -1098,11 +1107,12 @@ export function FingerprintsView() { { id: 'ja4', label: '🔏 JA4 Actifs', desc: 'Fingerprints TLS & IPs associées' }, { id: 'spoofing', label: '🎭 Spoofing JA4', desc: 'Détection spoofing navigateur' }, { id: 'ua_analysis', label: '🧬 Analyse UA', desc: 'User-Agents & rotation' }, + { id: 'rotation', label: '🔄 Rotation JA4', desc: 'IPs changeant de fingerprint TLS' }, ] as { id: ActiveTab; label: string; desc: string }[]).map((tab) => (
+ ); +} diff --git a/frontend/src/components/HeaderFingerprintView.tsx b/frontend/src/components/HeaderFingerprintView.tsx index b1f9a56..c26b9a9 100644 --- a/frontend/src/components/HeaderFingerprintView.tsx +++ b/frontend/src/components/HeaderFingerprintView.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import DataTable, { Column } from './ui/DataTable'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -65,14 +66,6 @@ function StatCard({ label, value, accent }: { label: string; value: string | num ); } -function LoadingSpinner() { - return ( -
-
-
- ); -} - function ErrorMessage({ message }: { message: string }) { return (
@@ -81,141 +74,6 @@ function ErrorMessage({ message }: { message: string }) { ); } -// ─── Cluster row with expandable IPs ───────────────────────────────────────── - -function ClusterRow({ - cluster, - onInvestigateIP, -}: { - cluster: HeaderCluster; - onInvestigateIP: (ip: string) => void; -}) { - const [expanded, setExpanded] = useState(false); - const [clusterIPs, setClusterIPs] = useState([]); - const [ipsLoading, setIpsLoading] = useState(false); - const [ipsError, setIpsError] = useState(null); - const [ipsLoaded, setIpsLoaded] = useState(false); - - const toggle = async () => { - setExpanded((prev) => !prev); - if (!ipsLoaded && !expanded) { - setIpsLoading(true); - try { - const res = await fetch(`/api/headers/cluster/${cluster.hash}/ips?limit=50`); - if (!res.ok) throw new Error('Erreur chargement IPs'); - const data: { items: ClusterIP[] } = await res.json(); - setClusterIPs(data.items ?? []); - setIpsLoaded(true); - } catch (err) { - setIpsError(err instanceof Error ? err.message : 'Erreur inconnue'); - } finally { - setIpsLoading(false); - } - } - }; - - const badge = classificationBadge(cluster.classification); - - return ( - <> - - - {expanded ? '▾' : '▸'} - {cluster.hash.slice(0, 16)}… - - {formatNumber(cluster.unique_ips)} - -
-
-
-
- {Math.round(cluster.avg_browser_score)} -
- - - {Math.round(cluster.ua_ch_mismatch_pct)}% - - - {badge.label} - - -
- {(cluster.top_sec_fetch_modes ?? []).slice(0, 3).map((mode) => ( - - {mode} - - ))} -
- - - {expanded && ( - - - {ipsLoading ? ( -
-
- Chargement des IPs… -
- ) : ipsError ? ( - ⚠️ {ipsError} - ) : ( -
- - - - - - - - - - - - {clusterIPs.map((ip) => ( - - - - - - - - - ))} - -
IPBrowser ScoreUA/CH MismatchSec-Fetch ModeSec-Fetch Dest -
{ip.ip} - = 70 ? 'text-threat-low' : ip.browser_score >= 40 ? 'text-threat-medium' : 'text-threat-critical'}> - {Math.round(ip.browser_score)} - - - {ip.ua_ch_mismatch ? ( - ⚠️ Oui - ) : ( - ✓ Non - )} - {ip.sec_fetch_mode || '—'}{ip.sec_fetch_dest || '—'} - -
-
- )} - - - )} - - ); -} - // ─── Main Component ─────────────────────────────────────────────────────────── export function HeaderFingerprintView() { @@ -226,6 +84,11 @@ export function HeaderFingerprintView() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [expandedHash, setExpandedHash] = useState(null); + const [clusterIPsMap, setClusterIPsMap] = useState>({}); + const [loadingHashes, setLoadingHashes] = useState>(new Set()); + const [ipErrors, setIpErrors] = useState>({}); + useEffect(() => { const fetchClusters = async () => { setLoading(true); @@ -244,9 +107,157 @@ export function HeaderFingerprintView() { fetchClusters(); }, []); + const handleToggleCluster = async (hash: string) => { + if (expandedHash === hash) { + setExpandedHash(null); + return; + } + setExpandedHash(hash); + if (clusterIPsMap[hash] !== undefined) return; + setLoadingHashes((prev) => new Set(prev).add(hash)); + try { + const res = await fetch(`/api/headers/cluster/${hash}/ips?limit=50`); + if (!res.ok) throw new Error('Erreur chargement IPs'); + const data: { items: ClusterIP[] } = await res.json(); + setClusterIPsMap((prev) => ({ ...prev, [hash]: data.items ?? [] })); + } catch (err) { + setIpErrors((prev) => ({ ...prev, [hash]: err instanceof Error ? err.message : 'Erreur inconnue' })); + } finally { + setLoadingHashes((prev) => { + const next = new Set(prev); + next.delete(hash); + return next; + }); + } + }; + const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length; const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length; + const clusterColumns: Column[] = [ + { + key: 'hash', + label: 'Hash cluster', + sortable: true, + render: (_, row) => ( + + {expandedHash === row.hash ? '▾' : '▸'} + {row.hash.slice(0, 16)}… + + ), + }, + { + key: 'unique_ips', + label: 'IPs', + sortable: true, + align: 'right', + render: (v) => {formatNumber(v)}, + }, + { + key: 'avg_browser_score', + label: 'Browser Score', + sortable: true, + render: (v) => ( +
+
+
+
+ {Math.round(v)} +
+ ), + }, + { + key: 'ua_ch_mismatch_pct', + label: 'UA/CH Mismatch %', + sortable: true, + align: 'right', + render: (v) => ( + {Math.round(v)}% + ), + }, + { + key: 'classification', + label: 'Classification', + sortable: true, + render: (v) => { + const badge = classificationBadge(v); + return ( + {badge.label} + ); + }, + }, + { + key: 'top_sec_fetch_modes', + label: 'Sec-Fetch modes', + sortable: false, + render: (v) => ( +
+ {(v ?? []).slice(0, 3).map((mode: string) => ( + + {mode} + + ))} +
+ ), + }, + ]; + + const ipColumns: Column[] = [ + { + key: 'ip', + label: 'IP', + sortable: true, + render: (v) => {v}, + }, + { + key: 'browser_score', + label: 'Browser Score', + sortable: true, + align: 'right', + render: (v) => ( + = 70 ? 'text-threat-low' : v >= 40 ? 'text-threat-medium' : 'text-threat-critical'}> + {Math.round(v)} + + ), + }, + { + key: 'ua_ch_mismatch', + label: 'UA/CH Mismatch', + sortable: true, + render: (v) => + v ? ( + ⚠️ Oui + ) : ( + ✓ Non + ), + }, + { + key: 'sec_fetch_mode', + label: 'Sec-Fetch Mode', + sortable: true, + render: (v) => {v || '—'}, + }, + { + key: 'sec_fetch_dest', + label: 'Sec-Fetch Dest', + sortable: true, + render: (v) => {v || '—'}, + }, + { + key: 'actions', + label: '', + sortable: false, + render: (_, row) => ( + + ), + }, + ]; + return (
{/* Header */} @@ -264,36 +275,55 @@ export function HeaderFingerprintView() {
- {/* Table */} + {/* Clusters DataTable */}
- {loading ? ( - - ) : error ? ( + {error ? (
) : ( - - - - - - - - - - - - - {clusters.map((cluster) => ( - navigate(`/investigation/${ip}`)} - /> - ))} - -
Hash clusterIPsBrowser ScoreUA/CH Mismatch %ClassificationSec-Fetch modes
+ + data={clusters} + columns={clusterColumns} + rowKey="hash" + defaultSortKey="unique_ips" + onRowClick={(row) => handleToggleCluster(row.hash)} + loading={loading} + emptyMessage="Aucun cluster détecté" + compact + /> )}
+ + {/* Expanded IPs panel */} + {expandedHash && ( +
+
+ + IPs du cluster{' '} + {expandedHash.slice(0, 16)}… + + +
+ {ipErrors[expandedHash] ? ( +
+ ) : ( + + data={clusterIPsMap[expandedHash] ?? []} + columns={ipColumns} + rowKey="ip" + defaultSortKey="browser_score" + loading={loadingHashes.has(expandedHash)} + emptyMessage="Aucune IP trouvée" + compact + /> + )} +
+ )} + {!loading && !error && (

{formatNumber(totalClusters)} cluster(s) détecté(s)

)} diff --git a/frontend/src/components/IncidentsView.tsx b/frontend/src/components/IncidentsView.tsx index 1c20be8..bd8df61 100644 --- a/frontend/src/components/IncidentsView.tsx +++ b/frontend/src/components/IncidentsView.tsx @@ -469,6 +469,9 @@ export function IncidentsView() {
{/* end grid */} +
+ +
); } @@ -500,3 +503,57 @@ function MetricCard({
); } + +// ─── Mini Heatmap ───────────────────────────────────────────────────────────── + +interface HeatmapHour { + hour: number; + hits: number; + unique_ips: number; +} + +function MiniHeatmap() { + const [data, setData] = useState([]); + + useEffect(() => { + fetch('/api/heatmap/hourly') + .then(r => r.ok ? r.json() : null) + .then(d => { if (d) setData(d.hours ?? d.items ?? []); }) + .catch(() => {}); + }, []); + + if (data.length === 0) return null; + + const maxHits = Math.max(...data.map(d => d.hits), 1); + + const barColor = (hits: number) => { + const pct = (hits / maxHits) * 100; + if (pct >= 75) return 'bg-red-500/70'; + if (pct >= 50) return 'bg-purple-500/60'; + if (pct >= 25) return 'bg-blue-500/50'; + if (pct >= 5) return 'bg-blue-400/30'; + return 'bg-slate-700/30'; + }; + + return ( +
+
⏱️ Activité par heure (72h)
+
+ {data.map((d, i) => ( +
+
+
+ {d.hits.toLocaleString()} hits — {d.unique_ips} IPs +
+
+ {[0, 6, 12, 18].includes(d.hour) ? `${d.hour}h` : '\u00a0'} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/MLFeaturesView.tsx b/frontend/src/components/MLFeaturesView.tsx index 939ac57..a8ac38b 100644 --- a/frontend/src/components/MLFeaturesView.tsx +++ b/frontend/src/components/MLFeaturesView.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import DataTable, { Column } from './ui/DataTable'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -321,6 +322,89 @@ function ScatterPlot({ points }: { points: ScatterPoint[] }) { ); } +// ─── Anomalies DataTable ───────────────────────────────────────────────────── + +function AnomaliesTable({ + anomalies, + selectedIP, + onRowClick, +}: { + anomalies: MLAnomaly[]; + selectedIP: string | null; + onRowClick: (row: MLAnomaly) => void; +}) { + const columns = useMemo((): Column[] => [ + { + key: 'ip', + label: 'IP', + render: (v: string, row: MLAnomaly) => ( + + {v} + + ), + }, + { + key: 'host', + label: 'Host', + render: (v: string) => ( + + {v || '—'} + + ), + }, + { + key: 'hits', + label: 'Hits', + align: 'right', + render: (v: number) => formatNumber(v), + }, + { + key: 'fuzzing_index', + label: 'Fuzzing', + align: 'right', + render: (v: number) => ( + + {Math.round(v)} + + ), + }, + { + key: 'attack_type', + label: 'Type', + render: (v: string) => ( + {attackTypeEmoji(v)} + ), + }, + { + key: '_signals', + label: 'Signaux', + sortable: false, + render: (_: unknown, row: MLAnomaly) => ( + + {row.ua_ch_mismatch && ⚠️} + {row.is_fake_navigation && 🎭} + {row.is_ua_rotating && 🔄} + {row.sni_host_mismatch && 🌐} + + ), + }, + ], [selectedIP]); + + return ( +
+ +
+ ); +} + // ─── Main Component ─────────────────────────────────────────────────────────── export function MLFeaturesView() { @@ -412,57 +496,11 @@ export function MLFeaturesView() { ) : anomaliesError ? (
) : ( -
- - - - - - - - - - - - - {anomalies.map((item) => ( - loadRadar(item.ip)} - className={`border-b border-border cursor-pointer transition-colors ${ - selectedIP === item.ip - ? 'bg-accent-primary/10' - : 'hover:bg-background-card' - }`} - > - - - - - - - - ))} - -
IPHostHitsFuzzingTypeSignaux
{item.ip} - {item.host || '—'} - {formatNumber(item.hits)} - - {Math.round(item.fuzzing_index)} - - - - {attackTypeEmoji(item.attack_type)} - - - - {item.ua_ch_mismatch && ⚠️} - {item.is_fake_navigation && 🎭} - {item.is_ua_rotating && 🔄} - {item.sni_host_mismatch && 🌐} - -
-
+ loadRadar(row.ip)} + /> )}
diff --git a/frontend/src/components/TcpSpoofingView.tsx b/frontend/src/components/TcpSpoofingView.tsx index 704be13..16a88d0 100644 --- a/frontend/src/components/TcpSpoofingView.tsx +++ b/frontend/src/components/TcpSpoofingView.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; +import DataTable, { Column } from './ui/DataTable'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -75,6 +76,94 @@ function ErrorMessage({ message }: { message: string }) { ); } +// ─── Detections DataTable ───────────────────────────────────────────────────── + +function TcpDetectionsTable({ + items, + navigate, +}: { + items: TcpSpoofingItem[]; + navigate: (path: string) => void; +}) { + const columns = useMemo((): Column[] => [ + { + key: 'ip', + label: 'IP', + render: (v: string) => {v}, + }, + { + key: 'ja4', + label: 'JA4', + render: (v: string) => ( + + {v ? `${v.slice(0, 14)}…` : '—'} + + ), + }, + { + key: 'tcp_ttl', + label: 'TTL observé', + align: 'right', + render: (v: number) => ( + {v} + ), + }, + { + key: 'tcp_window_size', + label: 'Fenêtre TCP', + align: 'right', + render: (v: number) => ( + {formatNumber(v)} + ), + }, + { + key: 'suspected_os', + label: 'OS suspecté', + render: (v: string) => {v || '—'}, + }, + { + key: 'declared_os', + label: 'OS déclaré', + render: (v: string) => {v || '—'}, + }, + { + key: 'spoof_flag', + label: 'Spoof', + sortable: false, + render: (v: boolean) => + v ? ( + + 🚨 Spoof + + ) : null, + }, + { + key: '_actions', + label: '', + sortable: false, + render: (_: unknown, row: TcpSpoofingItem) => ( + + ), + }, + ], [navigate]); + + return ( + + ); +} + // ─── Main Component ─────────────────────────────────────────────────────────── export function TcpSpoofingView() { @@ -261,53 +350,7 @@ export function TcpSpoofingView() { ) : itemsError ? (
) : ( - - - - - - - - - - - - - - - {filteredItems.map((item) => ( - - - - - - - - - - - ))} - -
IPJA4TTL observéFenêtre TCPOS suspectéOS déclaréSpoof
{item.ip} - {item.ja4 ? `${item.ja4.slice(0, 14)}…` : '—'} - - {item.tcp_ttl} - - {formatNumber(item.tcp_window_size)} - {item.suspected_os || '—'}{item.declared_os || '—'} - {item.spoof_flag && ( - - 🚨 Spoof - - )} - - -
+ )}
diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..ff97086 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +interface CardProps { + title?: string; + actions?: React.ReactNode; + children: React.ReactNode; + className?: string; +} + +export default function Card({ title, actions, children, className = '' }: CardProps) { + return ( +
+ {(title || actions) && ( +
+ {title && ( +

{title}

+ )} + {actions &&
{actions}
} +
+ )} +
{children}
+
+ ); +} diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx new file mode 100644 index 0000000..f307e07 --- /dev/null +++ b/frontend/src/components/ui/DataTable.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { useSort, SortDir } from '../../hooks/useSort'; + +export interface Column { + key: string; + label: string; + sortable?: boolean; + align?: 'left' | 'right' | 'center'; + width?: string; + render?: (value: any, row: T) => React.ReactNode; + className?: string; +} + +interface DataTableProps { + data: T[]; + columns: Column[]; + defaultSortKey?: string; + defaultSortDir?: SortDir; + onRowClick?: (row: T) => void; + rowKey: keyof T | ((row: T) => string); + emptyMessage?: string; + loading?: boolean; + className?: string; + compact?: boolean; + maxHeight?: string; +} + +export default function DataTable>({ + data, + columns, + defaultSortKey, + defaultSortDir = 'desc', + onRowClick, + rowKey, + emptyMessage = 'Aucune donnée disponible', + loading = false, + className = '', + compact = false, + maxHeight, +}: DataTableProps) { + const firstSortableKey = + defaultSortKey || + columns.find((c) => c.sortable !== false)?.key || + columns[0]?.key || + 'id'; + + const { sorted, sortKey, sortDir, handleSort } = useSort( + data, + firstSortableKey as keyof T, + defaultSortDir + ); + + const cell = compact ? 'px-3 py-1.5' : 'px-4 py-2.5'; + + const getRowKey = (row: T): string => { + if (typeof rowKey === 'function') return rowKey(row); + return String(row[rowKey as keyof T] ?? ''); + }; + + const alignClass = (align?: 'left' | 'right' | 'center') => { + if (align === 'right') return 'text-right'; + if (align === 'center') return 'text-center'; + return 'text-left'; + }; + + return ( +
+ + + + {columns.map((col) => { + const isSortable = col.sortable !== false; + const isActive = String(sortKey) === col.key; + return ( + + ); + })} + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {columns.map((col) => ( + + ))} + + )) + ) : sorted.length === 0 ? ( + + + + ) : ( + sorted.map((row) => ( + onRowClick(row) : undefined} + > + {columns.map((col) => { + const value = row[col.key as keyof T]; + return ( + + ); + })} + + )) + )} + +
handleSort(col.key as keyof T) : undefined} + > + + {col.label} + {isSortable && + (isActive ? ( + + {sortDir === 'desc' ? '↓' : '↑'} + + ) : ( + + ))} + +
+
+
+ {emptyMessage} +
+ {col.render ? col.render(value, row) : (value as React.ReactNode)} +
+
+ ); +} diff --git a/frontend/src/components/ui/StatCard.tsx b/frontend/src/components/ui/StatCard.tsx new file mode 100644 index 0000000..14213e1 --- /dev/null +++ b/frontend/src/components/ui/StatCard.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +type Color = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'slate'; + +const COLOR_MAP: Record = { + red: 'text-red-400', + orange: 'text-orange-400', + yellow: 'text-yellow-400', + green: 'text-green-400', + blue: 'text-blue-400', + purple: 'text-purple-400', + slate: 'text-slate-400', +}; + +interface StatCardProps { + label: string; + value: string | number; + sub?: string; + color?: Color; + icon?: React.ReactNode; +} + +export default function StatCard({ label, value, sub, color, icon }: StatCardProps) { + const valueClass = color ? COLOR_MAP[color] : 'text-text-primary'; + return ( +
+
+ {icon && {icon}} + {label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} diff --git a/frontend/src/components/ui/ThreatBadge.tsx b/frontend/src/components/ui/ThreatBadge.tsx new file mode 100644 index 0000000..143a6ea --- /dev/null +++ b/frontend/src/components/ui/ThreatBadge.tsx @@ -0,0 +1,24 @@ +type ThreatLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'NORMAL' | 'KNOWN_BOT'; + +const BADGE_STYLES: Record = { + CRITICAL: 'bg-red-900/50 text-red-400 border border-red-800/50', + HIGH: 'bg-orange-900/50 text-orange-400 border border-orange-800/50', + MEDIUM: 'bg-yellow-900/50 text-yellow-400 border border-yellow-800/50', + LOW: 'bg-green-900/50 text-green-400 border border-green-800/50', + NORMAL: 'bg-slate-700/50 text-slate-400 border border-slate-600/50', + KNOWN_BOT: 'bg-purple-900/50 text-purple-400 border border-purple-800/50', +}; + +interface ThreatBadgeProps { + level: string; +} + +export default function ThreatBadge({ level }: ThreatBadgeProps) { + const key = (level?.toUpperCase() ?? 'NORMAL') as ThreatLevel; + const cls = BADGE_STYLES[key] ?? BADGE_STYLES.NORMAL; + return ( + + {level || 'NORMAL'} + + ); +} diff --git a/frontend/src/hooks/useSort.ts b/frontend/src/hooks/useSort.ts new file mode 100644 index 0000000..e14c6a3 --- /dev/null +++ b/frontend/src/hooks/useSort.ts @@ -0,0 +1,42 @@ +import { useState, useMemo } from 'react'; + +export type SortDir = 'asc' | 'desc'; + +export function useSort>( + data: T[], + defaultKey: keyof T, + defaultDir: SortDir = 'desc' +): { + sorted: T[]; + sortKey: keyof T; + sortDir: SortDir; + handleSort: (key: keyof T) => void; +} { + const [sortKey, setSortKey] = useState(defaultKey); + const [sortDir, setSortDir] = useState(defaultDir); + + const handleSort = (key: keyof T) => { + if (key === sortKey) { + setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir('desc'); + } + }; + + const sorted = useMemo(() => { + return [...data].sort((a, b) => { + const av = a[sortKey]; + const bv = b[sortKey]; + let cmp = 0; + if (av == null && bv == null) cmp = 0; + else if (av == null) cmp = 1; + else if (bv == null) cmp = -1; + else if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv; + else cmp = String(av).localeCompare(String(bv)); + return sortDir === 'desc' ? -cmp : cmp; + }); + }, [data, sortKey, sortDir]); + + return { sorted, sortKey, sortDir, handleSort }; +}