From e2bc4a47cd2cecbec7b3a210346a999a46b8c145 Mon Sep 17 00:00:00 2001 From: SOC Analyst Date: Sun, 15 Mar 2026 23:57:27 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20ajout=20de=207=20nouveaux=20dashboards?= =?UTF-8?q?=20d'analyse=20avanc=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - πŸ”₯ Brute Force & Credential Stuffing (view_form_bruteforce_detected) - 🧬 TCP/OS Spoofing (view_tcp_spoofing_detected, 86K dΓ©tections) - πŸ“‘ Header Fingerprint Clustering (agg_header_fingerprint_1h, 1374 clusters) - ⏱️ Heatmap Temporelle (agg_host_ip_ja4_1h, pic Γ  20h) - 🌍 Botnets DistribuΓ©s / JA4 spread (view_host_ja4_anomalies) - πŸ”„ Rotation JA4 & Persistance (view_host_ip_ja4_rotation + view_ip_recurrence) - πŸ€– Features ML / Radar (view_ai_features_1h, radar SVG + scatter plot) Backend: 7 nouveaux router FastAPI avec requΓͺtes ClickHouse optimisΓ©es Frontend: 7 nouveaux composants React + navigation 'Analyse AvancΓ©e' dans la sidebar Fixes: alias fuzzing_index β†’ max_fuzzing (ORDER BY ClickHouse), normalisation IPs ::ffff: Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/main.py | 8 + backend/routes/botnets.py | 105 ++++ backend/routes/bruteforce.py | 107 ++++ backend/routes/header_fingerprint.py | 101 ++++ backend/routes/heatmap.py | 145 +++++ backend/routes/ml_features.py | 157 ++++++ backend/routes/rotation.py | 101 ++++ backend/routes/tcp_spoofing.py | 163 ++++++ frontend/src/App.tsx | 52 +- frontend/src/components/BotnetMapView.tsx | 316 +++++++++++ frontend/src/components/BruteForceView.tsx | 361 ++++++++++++ .../src/components/HeaderFingerprintView.tsx | 302 ++++++++++ frontend/src/components/HeatmapView.tsx | 320 +++++++++++ frontend/src/components/MLFeaturesView.tsx | 529 ++++++++++++++++++ frontend/src/components/RotationView.tsx | 370 ++++++++++++ frontend/src/components/TcpSpoofingView.tsx | 363 ++++++++++++ 16 files changed, 3499 insertions(+), 1 deletion(-) create mode 100644 backend/routes/botnets.py create mode 100644 backend/routes/bruteforce.py create mode 100644 backend/routes/header_fingerprint.py create mode 100644 backend/routes/heatmap.py create mode 100644 backend/routes/ml_features.py create mode 100644 backend/routes/rotation.py create mode 100644 backend/routes/tcp_spoofing.py create mode 100644 frontend/src/components/BotnetMapView.tsx create mode 100644 frontend/src/components/BruteForceView.tsx create mode 100644 frontend/src/components/HeaderFingerprintView.tsx create mode 100644 frontend/src/components/HeatmapView.tsx create mode 100644 frontend/src/components/MLFeaturesView.tsx create mode 100644 frontend/src/components/RotationView.tsx create mode 100644 frontend/src/components/TcpSpoofingView.tsx diff --git a/backend/main.py b/backend/main.py index c5ed9ee..063a53a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,6 +13,7 @@ import os from .config import settings from .database import db from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints +from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features # Configuration logging logging.basicConfig( @@ -74,6 +75,13 @@ app.include_router(incidents.router) app.include_router(audit.router) app.include_router(reputation.router) app.include_router(fingerprints.router) +app.include_router(bruteforce.router) +app.include_router(tcp_spoofing.router) +app.include_router(header_fingerprint.router) +app.include_router(heatmap.router) +app.include_router(botnets.router) +app.include_router(rotation.router) +app.include_router(ml_features.router) # Route pour servir le frontend diff --git a/backend/routes/botnets.py b/backend/routes/botnets.py new file mode 100644 index 0000000..f3ed6ab --- /dev/null +++ b/backend/routes/botnets.py @@ -0,0 +1,105 @@ +""" +Endpoints pour l'analyse des botnets via la propagation des fingerprints JA4 +""" +from fastapi import APIRouter, HTTPException, Query + +from ..database import db + +router = APIRouter(prefix="/api/botnets", tags=["botnets"]) + + +def _botnet_class(unique_countries: int) -> str: + if unique_countries > 100: + return "global_botnet" + if unique_countries > 20: + return "regional_botnet" + return "concentrated" + + +@router.get("/ja4-spread") +async def get_ja4_spread(): + """Propagation des JA4 fingerprints Γ  travers les pays et les IPs.""" + try: + sql = """ + SELECT + ja4, + unique_ips, + unique_countries, + targeted_hosts + FROM mabase_prod.view_host_ja4_anomalies + ORDER BY unique_countries DESC + """ + result = db.query(sql) + items = [] + for row in result.result_rows: + ja4 = str(row[0]) + unique_ips = int(row[1]) + unique_countries = int(row[2]) + targeted_hosts = int(row[3]) + dist_score = round( + unique_countries / max(unique_ips ** 0.5, 0.001), 2 + ) + items.append({ + "ja4": ja4, + "unique_ips": unique_ips, + "unique_countries": unique_countries, + "targeted_hosts": targeted_hosts, + "distribution_score":dist_score, + "botnet_class": _botnet_class(unique_countries), + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/ja4/{ja4}/countries") +async def get_ja4_countries(ja4: str, limit: int = Query(30, ge=1, le=200)): + """Top pays pour un JA4 donnΓ© depuis agg_host_ip_ja4_1h.""" + try: + sql = """ + SELECT + src_country_code AS country_code, + uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips, + sum(hits) AS hits + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE ja4 = %(ja4)s + GROUP BY src_country_code + ORDER BY unique_ips DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"ja4": ja4, "limit": limit}) + items = [ + { + "country_code": str(row[0]), + "unique_ips": int(row[1]), + "hits": int(row[2]), + } + for row in result.result_rows + ] + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/summary") +async def get_botnets_summary(): + """Statistiques globales sur les botnets dΓ©tectΓ©s.""" + try: + sql = """ + SELECT + countIf(unique_countries > 100) AS total_global_botnets, + sumIf(unique_ips, unique_countries > 50) AS total_ips_in_botnets, + argMax(ja4, unique_countries) AS most_spread_ja4, + argMax(ja4, unique_ips) AS most_ips_ja4 + FROM mabase_prod.view_host_ja4_anomalies + """ + result = db.query(sql) + row = result.result_rows[0] + return { + "total_global_botnets": int(row[0]), + "total_ips_in_botnets": int(row[1]), + "most_spread_ja4": str(row[2]), + "most_ips_ja4": str(row[3]), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routes/bruteforce.py b/backend/routes/bruteforce.py new file mode 100644 index 0000000..77b9bc9 --- /dev/null +++ b/backend/routes/bruteforce.py @@ -0,0 +1,107 @@ +""" +Endpoints pour l'analyse des attaques par force brute sur les formulaires +""" +from fastapi import APIRouter, HTTPException, Query + +from ..database import db + +router = APIRouter(prefix="/api/bruteforce", tags=["bruteforce"]) + + +@router.get("/targets") +async def get_bruteforce_targets(): + """Liste des hΓ΄tes ciblΓ©s par brute-force, triΓ©s par total_hits DESC.""" + try: + sql = """ + SELECT + host, + uniq(src_ip) AS unique_ips, + sum(hits) AS total_hits, + sum(query_params_count) AS total_params, + groupArray(3)(ja4) AS top_ja4s + FROM mabase_prod.view_form_bruteforce_detected + GROUP BY host + ORDER BY total_hits DESC + """ + result = db.query(sql) + items = [] + for row in result.result_rows: + host = str(row[0]) + unique_ips = int(row[1]) + total_hits = int(row[2]) + total_params= int(row[3]) + top_ja4s = [str(j) for j in (row[4] or [])] + attack_type = ( + "credential_stuffing" + if total_hits > 0 and total_params / total_hits > 0.5 + else "enumeration" + ) + items.append({ + "host": host, + "unique_ips": unique_ips, + "total_hits": total_hits, + "total_params":total_params, + "attack_type": attack_type, + "top_ja4s": top_ja4s, + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/attackers") +async def get_bruteforce_attackers(limit: int = Query(50, ge=1, le=500)): + """Top IPs attaquantes triΓ©es par total_hits DESC.""" + try: + sql = """ + SELECT + src_ip AS ip, + uniq(host) AS distinct_hosts, + sum(hits) AS total_hits, + sum(query_params_count) AS total_params, + argMax(ja4, hits) AS ja4 + FROM mabase_prod.view_form_bruteforce_detected + GROUP BY src_ip + ORDER BY total_hits DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"limit": limit}) + items = [] + for row in result.result_rows: + items.append({ + "ip": str(row[0]), + "distinct_hosts":int(row[1]), + "total_hits": int(row[2]), + "total_params": int(row[3]), + "ja4": str(row[4]), + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/timeline") +async def get_bruteforce_timeline(): + """Hits par heure (derniΓ¨res 72h) depuis agg_host_ip_ja4_1h.""" + try: + sql = """ + SELECT + toHour(window_start) AS hour, + sum(hits) AS hits, + uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS ips + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 72 HOUR + GROUP BY hour + ORDER BY hour ASC + """ + result = db.query(sql) + hours = [] + for row in result.result_rows: + hours.append({ + "hour": int(row[0]), + "hits": int(row[1]), + "ips": int(row[2]), + }) + return {"hours": hours} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routes/header_fingerprint.py b/backend/routes/header_fingerprint.py new file mode 100644 index 0000000..927c5d3 --- /dev/null +++ b/backend/routes/header_fingerprint.py @@ -0,0 +1,101 @@ +""" +Endpoints pour l'analyse des empreintes d'en-tΓͺtes HTTP +""" +from fastapi import APIRouter, HTTPException, Query + +from ..database import db + +router = APIRouter(prefix="/api/headers", tags=["header_fingerprint"]) + + +@router.get("/clusters") +async def get_header_clusters(limit: int = Query(50, ge=1, le=200)): + """Clusters d'empreintes d'en-tΓͺtes groupΓ©s par header_order_hash.""" + try: + sql = """ + SELECT + header_order_hash AS hash, + uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips, + avg(modern_browser_score) AS avg_browser_score, + sum(ua_ch_mismatch) AS ua_ch_mismatch_count, + round(sum(ua_ch_mismatch) * 100.0 / count(), 2) AS ua_ch_mismatch_pct, + groupArray(5)(sec_fetch_mode) AS top_sec_fetch_modes, + round(sum(has_cookie) * 100.0 / count(), 2) AS has_cookie_pct, + round(sum(has_referer) * 100.0 / count(), 2) AS has_referer_pct + FROM mabase_prod.agg_header_fingerprint_1h + GROUP BY header_order_hash + ORDER BY unique_ips DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"limit": limit}) + + total_sql = """ + SELECT uniq(header_order_hash) + FROM mabase_prod.agg_header_fingerprint_1h + """ + total_clusters = int(db.query(total_sql).result_rows[0][0]) + + clusters = [] + for row in result.result_rows: + h = str(row[0]) + unique_ips = int(row[1]) + avg_browser_score = float(row[2] or 0) + ua_ch_mismatch_cnt = int(row[3]) + ua_ch_mismatch_pct = float(row[4] or 0) + top_modes = list(set(str(m) for m in (row[5] or []))) + has_cookie_pct = float(row[6] or 0) + has_referer_pct = float(row[7] or 0) + + if avg_browser_score >= 90 and ua_ch_mismatch_pct < 5: + classification = "legitimate" + elif ua_ch_mismatch_pct > 50: + classification = "bot_suspicious" + else: + classification = "mixed" + + clusters.append({ + "hash": h, + "unique_ips": unique_ips, + "avg_browser_score": round(avg_browser_score, 2), + "ua_ch_mismatch_count":ua_ch_mismatch_cnt, + "ua_ch_mismatch_pct": ua_ch_mismatch_pct, + "top_sec_fetch_modes": top_modes, + "has_cookie_pct": has_cookie_pct, + "has_referer_pct": has_referer_pct, + "classification": classification, + }) + return {"clusters": clusters, "total_clusters": total_clusters} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/cluster/{hash}/ips") +async def get_cluster_ips(hash: str, limit: int = Query(50, ge=1, le=500)): + """Liste des IPs appartenant Γ  un cluster d'en-tΓͺtes donnΓ©.""" + try: + sql = """ + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + any(modern_browser_score) AS browser_score, + any(ua_ch_mismatch) AS ua_ch_mismatch, + any(sec_fetch_mode) AS sec_fetch_mode, + any(sec_fetch_dest) AS sec_fetch_dest + FROM mabase_prod.agg_header_fingerprint_1h + WHERE header_order_hash = %(hash)s + GROUP BY src_ip + ORDER BY browser_score DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"hash": hash, "limit": limit}) + items = [] + for row in result.result_rows: + items.append({ + "ip": str(row[0]), + "browser_score": int(row[1] or 0), + "ua_ch_mismatch": int(row[2] or 0), + "sec_fetch_mode": str(row[3] or ""), + "sec_fetch_dest": str(row[4] or ""), + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routes/heatmap.py b/backend/routes/heatmap.py new file mode 100644 index 0000000..e559607 --- /dev/null +++ b/backend/routes/heatmap.py @@ -0,0 +1,145 @@ +""" +Endpoints pour la heatmap temporelle (hits par heure / hΓ΄te) +""" +from collections import defaultdict +from fastapi import APIRouter, HTTPException, Query + +from ..database import db + +router = APIRouter(prefix="/api/heatmap", tags=["heatmap"]) + + +@router.get("/hourly") +async def get_heatmap_hourly(): + """Hits agrΓ©gΓ©s par heure sur les 72 derniΓ¨res heures.""" + try: + sql = """ + SELECT + toHour(window_start) AS hour, + sum(hits) AS hits, + uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips, + max(max_requests_per_sec) AS max_rps + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 72 HOUR + GROUP BY hour + ORDER BY hour ASC + """ + result = db.query(sql) + hours = [ + { + "hour": int(row[0]), + "hits": int(row[1]), + "unique_ips": int(row[2]), + "max_rps": int(row[3]), + } + for row in result.result_rows + ] + return {"hours": hours} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/top-hosts") +async def get_heatmap_top_hosts(limit: int = Query(20, ge=1, le=100)): + """HΓ΄tes les plus ciblΓ©s avec rΓ©partition horaire sur 24h.""" + try: + # Aggregate overall stats per host + agg_sql = """ + SELECT + host, + sum(hits) AS total_hits, + uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips, + uniq(ja4) AS unique_ja4s + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 72 HOUR + GROUP BY host + ORDER BY total_hits DESC + LIMIT %(limit)s + """ + agg_res = db.query(agg_sql, {"limit": limit}) + top_hosts = [str(r[0]) for r in agg_res.result_rows] + host_stats = { + str(r[0]): { + "host": str(r[0]), + "total_hits": int(r[1]), + "unique_ips": int(r[2]), + "unique_ja4s":int(r[3]), + } + for r in agg_res.result_rows + } + + if not top_hosts: + return {"items": []} + + # Hourly breakdown per host + hourly_sql = """ + SELECT + host, + toHour(window_start) AS hour, + sum(hits) AS hits + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 72 HOUR + AND host IN %(hosts)s + GROUP BY host, hour + """ + hourly_res = db.query(hourly_sql, {"hosts": top_hosts}) + + hourly_map: dict = defaultdict(lambda: [0] * 24) + for row in hourly_res.result_rows: + h = str(row[0]) + hour = int(row[1]) + hits = int(row[2]) + hourly_map[h][hour] += hits + + items = [] + for host in top_hosts: + entry = dict(host_stats[host]) + entry["hourly_hits"] = hourly_map[host] + items.append(entry) + + return {"items": items} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/matrix") +async def get_heatmap_matrix(): + """Matrice top-15 hΓ΄tes Γ— 24 heures (sum hits) sur les 72 derniΓ¨res heures.""" + try: + top_sql = """ + SELECT host, sum(hits) AS total_hits + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 72 HOUR + GROUP BY host + ORDER BY total_hits DESC + LIMIT 15 + """ + top_res = db.query(top_sql) + top_hosts = [str(r[0]) for r in top_res.result_rows] + + if not top_hosts: + return {"hosts": [], "matrix": []} + + cell_sql = """ + SELECT + host, + toHour(window_start) AS hour, + sum(hits) AS hits + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE window_start >= now() - INTERVAL 72 HOUR + AND host IN %(hosts)s + GROUP BY host, hour + """ + cell_res = db.query(cell_sql, {"hosts": top_hosts}) + + matrix_map: dict = defaultdict(lambda: [0] * 24) + for row in cell_res.result_rows: + h = str(row[0]) + hour = int(row[1]) + hits = int(row[2]) + matrix_map[h][hour] += hits + + matrix = [matrix_map[h] for h in top_hosts] + return {"hosts": top_hosts, "matrix": matrix} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routes/ml_features.py b/backend/routes/ml_features.py new file mode 100644 index 0000000..c9ed0e4 --- /dev/null +++ b/backend/routes/ml_features.py @@ -0,0 +1,157 @@ +""" +Endpoints pour les features ML / IA (scores d'anomalies, radar, scatter) +""" +from fastapi import APIRouter, HTTPException, Query + +from ..database import db + +router = APIRouter(prefix="/api/ml", tags=["ml_features"]) + + +def _attack_type(fuzzing_index: float, hit_velocity: float, + is_fake_nav: int, ua_ch_mismatch: int) -> str: + if fuzzing_index > 50: + return "brute_force" + if hit_velocity > 1.0: + return "flood" + if is_fake_nav: + return "scraper" + if ua_ch_mismatch: + return "spoofing" + return "scanner" + + +@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.""" + 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 + 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) + items.append({ + "ip": str(row[0]), + "ja4": str(row[1]), + "host": str(row[2]), + "hits": int(row[3] or 0), + "fuzzing_index": fuzzing, + "hit_velocity": velocity, + "temporal_entropy": float(row[6] or 0), + "is_fake_navigation": fake_nav, + "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), + }) + return {"items": items} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/ip/{ip}/radar") +async def get_ip_radar(ip: str): + """Scores radar pour une IP spΓ©cifique (8 dimensions d'anomalie).""" + try: + sql = """ + SELECT + avg(fuzzing_index) AS fuzzing_index, + avg(hit_velocity) AS hit_velocity, + avg(is_fake_navigation) AS is_fake_navigation, + avg(ua_ch_mismatch) AS ua_ch_mismatch, + avg(sni_host_mismatch) AS sni_host_mismatch, + avg(orphan_ratio) AS orphan_ratio, + avg(path_diversity_ratio) AS path_diversity_ratio, + avg(anomalous_payload_ratio) AS anomalous_payload_ratio + FROM mabase_prod.view_ai_features_1h + WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + """ + result = db.query(sql, {"ip": ip}) + if not result.result_rows: + raise HTTPException(status_code=404, detail="IP not found") + row = result.result_rows[0] + + def _f(v) -> float: + return float(v or 0) + + return { + "ip": ip, + "fuzzing_score": min(100.0, _f(row[0])), + "velocity_score": min(100.0, _f(row[1]) * 100), + "fake_nav_score": _f(row[2]) * 100, + "ua_mismatch_score": _f(row[3]) * 100, + "sni_mismatch_score": _f(row[4]) * 100, + "orphan_score": min(100.0, _f(row[5]) * 100), + "path_repetition_score": max(0.0, 100 - _f(row[6]) * 100), + "payload_anomaly_score": min(100.0, _f(row[7]) * 100), + } + except HTTPException: + raise + 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 pour scatter plot (fuzzing_index Γ— hit_velocity), dΓ©dupliquΓ©s par IP.""" + 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 + GROUP BY src_ip + ORDER BY 3 DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"limit": limit}) + points = [] + 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), + }) + return {"points": points} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routes/rotation.py b/backend/routes/rotation.py new file mode 100644 index 0000000..d7507e8 --- /dev/null +++ b/backend/routes/rotation.py @@ -0,0 +1,101 @@ +""" +Endpoints pour la dΓ©tection de la rotation de fingerprints JA4 et des menaces persistantes +""" +from fastapi import APIRouter, HTTPException, Query + +from ..database import db + +router = APIRouter(prefix="/api/rotation", tags=["rotation"]) + + +@router.get("/ja4-rotators") +async def get_ja4_rotators(limit: int = Query(50, ge=1, le=500)): + """IPs qui effectuent le plus de rotation de fingerprints JA4.""" + try: + sql = """ + SELECT + src_ip AS ip, + distinct_ja4_count, + total_hits + FROM mabase_prod.view_host_ip_ja4_rotation + ORDER BY distinct_ja4_count DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"limit": limit}) + items = [] + for row in result.result_rows: + distinct = int(row[1]) + items.append({ + "ip": str(row[0]), + "distinct_ja4_count":distinct, + "total_hits": int(row[2]), + "evasion_score": min(100, distinct * 15), + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/persistent-threats") +async def get_persistent_threats(limit: int = Query(100, ge=1, le=1000)): + """Menaces persistantes triΓ©es par score de persistance.""" + try: + sql = """ + SELECT + src_ip AS ip, + recurrence, + worst_score, + worst_threat_level, + first_seen, + last_seen + FROM mabase_prod.view_ip_recurrence + ORDER BY (least(100, recurrence * 20 + worst_score * 50)) DESC + LIMIT %(limit)s + """ + result = db.query(sql, {"limit": limit}) + items = [] + for row in result.result_rows: + recurrence = int(row[1]) + worst_score = float(row[2] or 0) + items.append({ + "ip": str(row[0]), + "recurrence": recurrence, + "worst_score": worst_score, + "worst_threat_level":str(row[3] or ""), + "first_seen": str(row[4]), + "last_seen": str(row[5]), + "persistence_score": min(100, recurrence * 20 + worst_score * 50), + }) + return {"items": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/ip/{ip}/ja4-history") +async def get_ip_ja4_history(ip: str): + """Historique des JA4 utilisΓ©s par une IP donnΓ©e.""" + try: + sql = """ + SELECT + ja4, + sum(hits) AS hits, + min(window_start) AS first_seen, + max(window_start) AS last_seen + FROM mabase_prod.agg_host_ip_ja4_1h + WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s + GROUP BY ja4 + ORDER BY hits DESC + """ + result = db.query(sql, {"ip": ip}) + items = [ + { + "ja4": str(row[0]), + "hits": int(row[1]), + "first_seen":str(row[2]), + "last_seen": str(row[3]), + } + for row in result.result_rows + ] + return {"ip": ip, "ja4_history": items, "total": len(items)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/routes/tcp_spoofing.py b/backend/routes/tcp_spoofing.py new file mode 100644 index 0000000..13eb41c --- /dev/null +++ b/backend/routes/tcp_spoofing.py @@ -0,0 +1,163 @@ +""" +Endpoints pour la dΓ©tection du TCP spoofing (TTL / window size anormaux) +""" +from fastapi import APIRouter, HTTPException, Query + +from ..database import db + +router = APIRouter(prefix="/api/tcp-spoofing", tags=["tcp_spoofing"]) + + +def _suspected_os(ttl: int) -> str: + if 55 <= ttl <= 65: + return "Linux/Mac" + if 120 <= ttl <= 135: + return "Windows" + if ttl < 50: + return "Behind proxy (depleted)" + return "Unknown" + + +def _declared_os(ua: str) -> str: + ua = ua or "" + if "Windows" in ua: + return "Windows" + if "Mac OS X" in ua: + return "macOS" + if "Linux" in ua or "Android" in ua: + return "Linux/Android" + return "Unknown" + + +@router.get("/overview") +async def get_tcp_spoofing_overview(): + """Statistiques globales sur les dΓ©tections de spoofing TCP.""" + try: + sql = """ + SELECT + count() AS total_detections, + uniq(src_ip) AS unique_ips, + countIf(tcp_ttl < 60) AS low_ttl_count, + countIf(tcp_ttl = 0) AS zero_ttl_count + FROM mabase_prod.view_tcp_spoofing_detected + """ + result = db.query(sql) + row = result.result_rows[0] + total_detections = int(row[0]) + unique_ips = int(row[1]) + low_ttl_count = int(row[2]) + zero_ttl_count = int(row[3]) + + ttl_sql = """ + SELECT + tcp_ttl, + count() AS cnt, + uniq(src_ip) AS ips + FROM mabase_prod.view_tcp_spoofing_detected + GROUP BY tcp_ttl + ORDER BY cnt DESC + LIMIT 15 + """ + ttl_res = db.query(ttl_sql) + ttl_distribution = [ + {"ttl": int(r[0]), "count": int(r[1]), "ips": int(r[2])} + for r in ttl_res.result_rows + ] + + win_sql = """ + SELECT + tcp_window_size, + count() AS cnt + FROM mabase_prod.view_tcp_spoofing_detected + GROUP BY tcp_window_size + ORDER BY cnt DESC + LIMIT 10 + """ + win_res = db.query(win_sql) + window_size_distribution = [ + {"window_size": int(r[0]), "count": int(r[1])} + for r in win_res.result_rows + ] + + return { + "total_detections": total_detections, + "unique_ips": unique_ips, + "low_ttl_count": low_ttl_count, + "zero_ttl_count": zero_ttl_count, + "ttl_distribution": ttl_distribution, + "window_size_distribution":window_size_distribution, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/list") +async def get_tcp_spoofing_list( + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +): + """Liste paginΓ©e des dΓ©tections, triΓ©e par tcp_ttl ASC.""" + try: + count_sql = "SELECT count() FROM mabase_prod.view_tcp_spoofing_detected" + total = int(db.query(count_sql).result_rows[0][0]) + + sql = """ + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS src_ip, + ja4, tcp_ttl, tcp_window_size, first_ua + FROM mabase_prod.view_tcp_spoofing_detected + ORDER BY tcp_ttl ASC + LIMIT %(limit)s OFFSET %(offset)s + """ + result = db.query(sql, {"limit": limit, "offset": offset}) + items = [] + for row in result.result_rows: + ip = str(row[0]) + ja4 = str(row[1]) + ttl = int(row[2]) + window_size = int(row[3]) + ua = str(row[4] or "") + sus_os = _suspected_os(ttl) + dec_os = _declared_os(ua) + spoof_flag = sus_os != dec_os and sus_os != "Unknown" and dec_os != "Unknown" + items.append({ + "ip": ip, + "ja4": ja4, + "tcp_ttl": ttl, + "tcp_window_size": window_size, + "first_ua": ua, + "suspected_os": sus_os, + "declared_os": dec_os, + "spoof_flag": spoof_flag, + }) + return {"items": items, "total": total} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/matrix") +async def get_tcp_spoofing_matrix(): + """Cross-tab suspected_os Γ— declared_os avec comptage.""" + try: + sql = """ + SELECT src_ip, tcp_ttl, first_ua + FROM mabase_prod.view_tcp_spoofing_detected + """ + result = db.query(sql) + counts: dict = {} + for row in result.result_rows: + ttl = int(row[1]) + ua = str(row[2] or "") + sus_os = _suspected_os(ttl) + dec_os = _declared_os(ua) + key = (sus_os, dec_os) + counts[key] = counts.get(key, 0) + 1 + + matrix = [ + {"suspected_os": k[0], "declared_os": k[1], "count": v} + for k, v in counts.items() + ] + matrix.sort(key=lambda x: x["count"], reverse=True) + return {"matrix": matrix} + 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 282638e..ac075eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,13 @@ import { BulkClassification } from './components/BulkClassification'; import { PivotView } from './components/PivotView'; import { FingerprintsView } from './components/FingerprintsView'; 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'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -72,7 +79,17 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) { { path: '/threat-intel', label: 'Threat Intel', icon: 'πŸ“š', aliases: [] }, ]; - const isActive = (link: typeof navLinks[0]) => + const advancedLinks = [ + { 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: [] }, + ]; + + const isActive = (link: { path: string; aliases: string[] }) => location.pathname === link.path || link.aliases.some(a => location.pathname.startsWith(a)) || (link.path !== '/' && location.pathname.startsWith(`${link.path}/`)); @@ -109,6 +126,25 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) { ))} + {/* Advanced analysis nav */} + + {/* Alert stats */} {counts && (
@@ -206,6 +242,13 @@ function TopHeader({ counts }: { counts: AlertCounts | null }) { if (p.startsWith('/campaigns')) return 'Campagnes / Botnets'; if (p.startsWith('/pivot')) return 'Pivot / CorrΓ©lation'; if (p.startsWith('/bulk-classify')) return 'Classification en masse'; + 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 ''; }; @@ -334,6 +377,13 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/BotnetMapView.tsx b/frontend/src/components/BotnetMapView.tsx new file mode 100644 index 0000000..3fb281b --- /dev/null +++ b/frontend/src/components/BotnetMapView.tsx @@ -0,0 +1,316 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BotnetItem { + ja4: string; + unique_ips: number; + unique_countries: number; + targeted_hosts: number; + distribution_score: number; + botnet_class: string; +} + +interface BotnetSummary { + total_global_botnets: number; + total_ips_in_botnets: number; + most_spread_ja4: string; + most_ips_ja4: string; +} + +interface CountryEntry { + country_code: string; + unique_ips: number; + hits: number; +} + +type SortField = 'unique_ips' | 'unique_countries' | 'targeted_hosts'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatNumber(n: number): string { + return n.toLocaleString('fr-FR'); +} + +function getCountryFlag(code: string): string { + if (!code || code.length !== 2) return '🌐'; + return code + .toUpperCase() + .replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397)); +} + +function botnetClassBadge(cls: string): { bg: string; text: string; label: string } { + switch (cls) { + case 'global': + return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🌐 Global' }; + case 'regional': + return { bg: 'bg-threat-high/20', text: 'text-threat-high', label: 'πŸ—ΊοΈ RΓ©gional' }; + case 'concentrated': + return { bg: 'bg-threat-medium/20', text: 'text-threat-medium', label: 'πŸ“ ConcentrΓ©' }; + default: + return { bg: 'bg-background-card', text: 'text-text-secondary', label: cls }; + } +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatCard({ label, value, accent, mono }: { label: string; value: string | number; accent?: string; mono?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ ⚠️ {message} +
+ ); +} + +// ─── Botnet row with expandable countries ───────────────────────────────────── + +function BotnetRow({ + item, + onInvestigate, +}: { + item: BotnetItem; + onInvestigate: (ja4: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [countries, setCountries] = useState([]); + const [countriesLoading, setCountriesLoading] = useState(false); + const [countriesError, setCountriesError] = useState(null); + const [countriesLoaded, setCountriesLoaded] = useState(false); + + const toggle = async () => { + setExpanded((prev) => !prev); + if (!countriesLoaded && !expanded) { + setCountriesLoading(true); + try { + const res = await fetch(`/api/botnets/ja4/${encodeURIComponent(item.ja4)}/countries?limit=30`); + if (!res.ok) throw new Error('Erreur chargement des pays'); + const data: { countries: CountryEntry[] } = await res.json(); + setCountries(data.countries ?? []); + setCountriesLoaded(true); + } catch (err) { + setCountriesError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setCountriesLoading(false); + } + } + }; + + const badge = botnetClassBadge(item.botnet_class); + + return ( + <> + + + {expanded ? 'β–Ύ' : 'β–Έ'} + {item.ja4 ? item.ja4.slice(0, 20) : 'β€”'}… + + {formatNumber(item.unique_ips)} + + 🌍 {formatNumber(item.unique_countries)} + + {formatNumber(item.targeted_hosts)} + +
+
+
+
+ {Math.round(item.distribution_score)} +
+ + + {badge.label} + + + + + + {expanded && ( + + + {countriesLoading ? ( +
+
+ Chargement des pays… +
+ ) : countriesError ? ( + ⚠️ {countriesError} + ) : ( +
+ {countries.map((c) => ( + + {getCountryFlag(c.country_code)} {c.country_code} + Β· + {formatNumber(c.unique_ips)} IPs + + ))} +
+ )} + + + )} + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function BotnetMapView() { + 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'); + + useEffect(() => { + const fetchItems = async () => { + setLoading(true); + try { + const res = await fetch('/api/botnets/ja4-spread'); + if (!res.ok) throw new Error('Erreur chargement des botnets'); + const data: { items: BotnetItem[]; total: number } = await res.json(); + setItems(data.items ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setLoading(false); + } + }; + const fetchSummary = async () => { + setSummaryLoading(true); + try { + const res = await fetch('/api/botnets/summary'); + if (!res.ok) throw new Error('Erreur chargement du rΓ©sumΓ©'); + const data: BotnetSummary = await res.json(); + setSummary(data); + } catch (err) { + setSummaryError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setSummaryLoading(false); + } + }; + fetchItems(); + fetchSummary(); + }, []); + + const sortedItems = [...items].sort((a, b) => b[sortField] - a[sortField]); + + const sortButton = (field: SortField, label: string) => ( + + ); + + return ( +
+ {/* Header */} +
+

🌍 Botnets Distribués

+

+ Analyse de la distribution gΓ©ographique des fingerprints JA4 pour dΓ©tecter les botnets globaux. +

+
+ + {/* Stat cards */} + {summaryLoading ? ( + + ) : summaryError ? ( + + ) : summary ? ( +
+ + + + +
+ ) : null} + + {/* Sort controls */} +
+ Trier par : + {sortButton('unique_ips', 'πŸ–₯️ IPs')} + {sortButton('unique_countries', '🌍 Pays')} + {sortButton('targeted_hosts', '🎯 Hosts')} +
+ + {/* Table */} +
+ {loading ? ( + + ) : error ? ( +
+ ) : ( + + + + + + + + + + + + + + {sortedItems.map((item) => ( + navigate(`/investigation/ja4/${ja4}`)} + /> + ))} + +
JA4IPsPaysHosts ciblΓ©sScore distributionClasse
+ )} +
+
+ ); +} diff --git a/frontend/src/components/BruteForceView.tsx b/frontend/src/components/BruteForceView.tsx new file mode 100644 index 0000000..a605682 --- /dev/null +++ b/frontend/src/components/BruteForceView.tsx @@ -0,0 +1,361 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BruteForceTarget { + host: string; + unique_ips: number; + total_hits: number; + total_params: number; + attack_type: string; + top_ja4: string[]; +} + +interface BruteForceAttacker { + ip: string; + distinct_hosts: number; + total_hits: number; + total_params: number; + ja4: string; +} + +interface TimelineHour { + hour: number; + hits: number; + ips: number; +} + +type ActiveTab = 'targets' | 'attackers' | 'timeline'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatNumber(n: number): string { + return n.toLocaleString('fr-FR'); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ ⚠️ {message} +
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function BruteForceView() { + const navigate = useNavigate(); + + const [activeTab, setActiveTab] = useState('targets'); + + const [targets, setTargets] = useState([]); + const [targetsTotal, setTargetsTotal] = useState(0); + const [targetsLoading, setTargetsLoading] = useState(true); + const [targetsError, setTargetsError] = useState(null); + + const [attackers, setAttackers] = useState([]); + const [attackersLoading, setAttackersLoading] = useState(false); + const [attackersError, setAttackersError] = useState(null); + const [attackersLoaded, setAttackersLoaded] = useState(false); + + const [timeline, setTimeline] = useState([]); + const [timelineLoading, setTimelineLoading] = useState(false); + const [timelineError, setTimelineError] = useState(null); + const [timelineLoaded, setTimelineLoaded] = useState(false); + + useEffect(() => { + const fetchTargets = async () => { + setTargetsLoading(true); + try { + const res = await fetch('/api/bruteforce/targets'); + if (!res.ok) throw new Error('Erreur chargement des cibles'); + const data: { items: BruteForceTarget[]; total: number } = await res.json(); + setTargets(data.items ?? []); + setTargetsTotal(data.total ?? 0); + } catch (err) { + setTargetsError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setTargetsLoading(false); + } + }; + fetchTargets(); + }, []); + + const loadAttackers = async () => { + if (attackersLoaded) return; + setAttackersLoading(true); + try { + const res = await fetch('/api/bruteforce/attackers?limit=50'); + if (!res.ok) throw new Error('Erreur chargement des attaquants'); + const data: { items: BruteForceAttacker[] } = await res.json(); + setAttackers(data.items ?? []); + setAttackersLoaded(true); + } catch (err) { + setAttackersError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setAttackersLoading(false); + } + }; + + const loadTimeline = async () => { + if (timelineLoaded) return; + setTimelineLoading(true); + try { + const res = await fetch('/api/bruteforce/timeline'); + if (!res.ok) throw new Error('Erreur chargement de la timeline'); + const data: { hours: TimelineHour[] } = await res.json(); + setTimeline(data.hours ?? []); + setTimelineLoaded(true); + } catch (err) { + setTimelineError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setTimelineLoading(false); + } + }; + + const handleTabChange = (tab: ActiveTab) => { + setActiveTab(tab); + if (tab === 'attackers') loadAttackers(); + if (tab === 'timeline') loadTimeline(); + }; + + const totalHits = targets.reduce((s, t) => s + t.total_hits, 0); + + const maxHits = timeline.length > 0 ? Math.max(...timeline.map((h) => h.hits)) : 1; + const peakHour = timeline.reduce( + (best, h) => (h.hits > best.hits ? h : best), + { hour: 0, hits: 0, ips: 0 } + ); + + const tabs: { id: ActiveTab; label: string }[] = [ + { id: 'targets', label: '🎯 Cibles' }, + { id: 'attackers', label: 'βš”οΈ Attaquants' }, + { id: 'timeline', label: '⏱️ Timeline' }, + ]; + + return ( +
+ {/* Header */} +
+

πŸ”₯ Brute Force & Credential Stuffing

+

+ Détection des attaques par force brute, credential stuffing et énumération de paramètres. +

+
+ + {/* Stat cards */} +
+ + + +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Cibles tab */} + {activeTab === 'targets' && ( +
+ {targetsLoading ? ( + + ) : targetsError ? ( +
+ ) : ( + + + + + + + + + + + + + + {targets.map((t) => ( + + + + + + + + + + ))} + +
HostIPs distinctesTotal hitsParams combosType d'attaqueTop JA4
{t.host}{formatNumber(t.unique_ips)}{formatNumber(t.total_hits)}{formatNumber(t.total_params)} + {t.attack_type === 'credential_stuffing' ? ( + + πŸ’³ Credential Stuffing + + ) : ( + + πŸ” Γ‰numΓ©ration + + )} + +
+ {(t.top_ja4 ?? []).slice(0, 3).map((ja4) => ( + + {ja4.slice(0, 12)}… + + ))} +
+
+ +
+ )} +
+ )} + + {/* Attaquants tab */} + {activeTab === 'attackers' && ( +
+ {attackersLoading ? ( + + ) : 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) : 'β€”'}… + +
+ )} +
+ )} + + {/* Timeline tab */} + {activeTab === 'timeline' && ( +
+ {timelineLoading ? ( + + ) : timelineError ? ( + + ) : ( + <> +
+

ActivitΓ© par heure

+ {peakHour.hits > 0 && ( + + Pic : {peakHour.hour}h ({formatNumber(peakHour.hits)} hits) + + )} +
+
+ {Array.from({ length: 24 }, (_, i) => { + const entry = timeline.find((h) => h.hour === i) ?? { hour: i, hits: 0, ips: 0 }; + const pct = maxHits > 0 ? (entry.hits / maxHits) * 100 : 0; + const isPeak = entry.hour === peakHour.hour && entry.hits > 0; + return ( +
+
+
= 70 + ? 'bg-threat-high' + : pct >= 30 + ? 'bg-threat-medium' + : 'bg-accent-primary/60' + }`} + /> +
+ {i} +
+ ); + })} +
+
+ Pic + Γ‰levΓ© (β‰₯70%) + Moyen (β‰₯30%) + Faible +
+ + )} +
+ )} + + {/* Summary */} + {activeTab === 'targets' && !targetsLoading && !targetsError && ( +

{formatNumber(targetsTotal)} cible(s) dΓ©tectΓ©e(s)

+ )} +
+ ); +} diff --git a/frontend/src/components/HeaderFingerprintView.tsx b/frontend/src/components/HeaderFingerprintView.tsx new file mode 100644 index 0000000..a166f54 --- /dev/null +++ b/frontend/src/components/HeaderFingerprintView.tsx @@ -0,0 +1,302 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface HeaderCluster { + hash: string; + unique_ips: number; + avg_browser_score: number; + ua_ch_mismatch_count: number; + ua_ch_mismatch_pct: number; + top_sec_fetch_modes: string[]; + has_cookie_pct: number; + has_referer_pct: number; + classification: string; +} + +interface ClusterIP { + ip: string; + browser_score: number; + ua_ch_mismatch: boolean; + sec_fetch_mode: string; + sec_fetch_dest: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatNumber(n: number): string { + return n.toLocaleString('fr-FR'); +} + +function mismatchColor(pct: number): string { + if (pct > 50) return 'text-threat-critical'; + if (pct > 10) return 'text-threat-medium'; + return 'text-threat-low'; +} + +function browserScoreColor(score: number): string { + if (score >= 70) return 'bg-threat-low'; + if (score >= 40) return 'bg-threat-medium'; + return 'bg-threat-critical'; +} + +function classificationBadge(cls: string): { bg: string; text: string; label: string } { + switch (cls) { + case 'bot': + return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: 'πŸ€– Bot' }; + case 'suspicious': + return { bg: 'bg-threat-high/20', text: 'text-threat-high', label: '⚠️ Suspect' }; + case 'legitimate': + return { bg: 'bg-threat-low/20', text: 'text-threat-low', label: 'βœ… LΓ©gitime' }; + default: + return { bg: 'bg-background-card', text: 'text-text-secondary', label: cls }; + } +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ ⚠️ {message} +
+ ); +} + +// ─── 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: { ips: ClusterIP[] } = await res.json(); + setClusterIPs(data.ips ?? []); + 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() { + const navigate = useNavigate(); + + const [clusters, setClusters] = useState([]); + const [totalClusters, setTotalClusters] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchClusters = async () => { + setLoading(true); + try { + const res = await fetch('/api/headers/clusters?limit=50'); + if (!res.ok) throw new Error('Erreur chargement des clusters'); + const data: { clusters: HeaderCluster[]; total_clusters: number } = await res.json(); + setClusters(data.clusters ?? []); + setTotalClusters(data.total_clusters ?? 0); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setLoading(false); + } + }; + fetchClusters(); + }, []); + + const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length; + const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length; + + return ( +
+ {/* Header */} +
+

πŸ“‘ Fingerprint HTTP Headers

+

+ Clustering par ordre et composition des headers HTTP pour identifier les bots et fingerprints suspects. +

+
+ + {/* Stat cards */} +
+ + + +
+ + {/* Table */} +
+ {loading ? ( + + ) : error ? ( +
+ ) : ( + + + + + + + + + + + + + {clusters.map((cluster) => ( + navigate(`/investigation/${ip}`)} + /> + ))} + +
Hash clusterIPsBrowser ScoreUA/CH Mismatch %ClassificationSec-Fetch modes
+ )} +
+ {!loading && !error && ( +

{formatNumber(totalClusters)} cluster(s) dΓ©tectΓ©(s)

+ )} +
+ ); +} diff --git a/frontend/src/components/HeatmapView.tsx b/frontend/src/components/HeatmapView.tsx new file mode 100644 index 0000000..3eeb5d8 --- /dev/null +++ b/frontend/src/components/HeatmapView.tsx @@ -0,0 +1,320 @@ +import { useState, useEffect } from 'react'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface HourlyEntry { + hour: number; + hits: number; + unique_ips: number; + max_rps: number; +} + +interface TopHost { + host: string; + total_hits: number; + unique_ips: number; + unique_ja4s: number; + hourly_hits: number[]; +} + +interface HeatmapMatrix { + hosts: string[]; + matrix: number[][]; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatNumber(n: number): string { + return n.toLocaleString('fr-FR'); +} + +function heatmapCellStyle(value: number, maxValue: number): React.CSSProperties { + if (maxValue === 0 || value === 0) return { backgroundColor: 'transparent' }; + const ratio = value / maxValue; + if (ratio >= 0.75) return { backgroundColor: 'rgba(239, 68, 68, 0.85)' }; + if (ratio >= 0.5) return { backgroundColor: 'rgba(168, 85, 247, 0.7)' }; + if (ratio >= 0.25) return { backgroundColor: 'rgba(59, 130, 246, 0.6)' }; + if (ratio >= 0.05) return { backgroundColor: 'rgba(96, 165, 250, 0.35)' }; + return { backgroundColor: 'rgba(147, 197, 253, 0.15)' }; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ ⚠️ {message} +
+ ); +} + +// Mini sparkline for a host row +function Sparkline({ data }: { data: number[] }) { + const max = Math.max(...data, 1); + return ( +
+ {data.map((v, i) => { + const pct = (v / max) * 100; + return ( +
= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/50'}`} + /> + ); + })} +
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function HeatmapView() { + const [hourly, setHourly] = useState([]); + const [hourlyLoading, setHourlyLoading] = useState(true); + const [hourlyError, setHourlyError] = useState(null); + + const [topHosts, setTopHosts] = useState([]); + const [topHostsLoading, setTopHostsLoading] = useState(true); + const [topHostsError, setTopHostsError] = useState(null); + + const [matrixData, setMatrixData] = useState(null); + const [matrixLoading, setMatrixLoading] = useState(true); + const [matrixError, setMatrixError] = useState(null); + + useEffect(() => { + const fetchHourly = async () => { + try { + const res = await fetch('/api/heatmap/hourly'); + if (!res.ok) throw new Error('Erreur chargement courbe horaire'); + const data: { hours: HourlyEntry[] } = await res.json(); + setHourly(data.hours ?? []); + } catch (err) { + setHourlyError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setHourlyLoading(false); + } + }; + const fetchTopHosts = async () => { + try { + const res = await fetch('/api/heatmap/top-hosts?limit=20'); + if (!res.ok) throw new Error('Erreur chargement top hosts'); + const data: { hosts: TopHost[] } = await res.json(); + setTopHosts(data.hosts ?? []); + } catch (err) { + setTopHostsError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setTopHostsLoading(false); + } + }; + const fetchMatrix = async () => { + try { + const res = await fetch('/api/heatmap/matrix'); + if (!res.ok) throw new Error('Erreur chargement heatmap matrix'); + const data: HeatmapMatrix = await res.json(); + setMatrixData(data); + } catch (err) { + setMatrixError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setMatrixLoading(false); + } + }; + fetchHourly(); + fetchTopHosts(); + fetchMatrix(); + }, []); + + const peakHour = hourly.reduce((best, h) => (h.hits > best.hits ? h : best), { hour: 0, hits: 0, unique_ips: 0, max_rps: 0 }); + const totalHits = hourly.reduce((s, h) => s + h.hits, 0); + const maxHits = hourly.length > 0 ? Math.max(...hourly.map((h) => h.hits)) : 1; + + const matrixMax = matrixData + ? Math.max(...matrixData.matrix.flatMap((row) => row), 1) + : 1; + + const displayHosts = matrixData ? matrixData.hosts.slice(0, 15) : []; + + return ( +
+ {/* Header */} +
+

⏱️ Heatmap Temporelle d'Attaques

+

+ Distribution horaire de l'activitΓ© malveillante par host cible. +

+
+ + {/* Stat cards */} +
+ 0 ? `${peakHour.hour}h (${formatNumber(peakHour.hits)} hits)` : 'β€”'} + accent="text-threat-critical" + /> + + +
+ + {/* Section 1: Courbe horaire */} +
+

ActivitΓ© horaire β€” 24h

+ {hourlyLoading ? ( + + ) : hourlyError ? ( + + ) : ( + <> +
+ {Array.from({ length: 24 }, (_, i) => { + const entry = hourly.find((h) => h.hour === i) ?? { hour: i, hits: 0, unique_ips: 0, max_rps: 0 }; + const pct = maxHits > 0 ? (entry.hits / maxHits) * 100 : 0; + return ( +
+
+
= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/60' + }`} + /> +
+ {i} +
+ ); + })} +
+
+ Γ‰levΓ© (β‰₯70%) + Moyen (β‰₯30%) + Faible +
+ + )} +
+ + {/* Section 2: Heatmap matrix */} +
+

Heatmap Host Γ— Heure

+ {matrixLoading ? ( + + ) : matrixError ? ( + + ) : !matrixData || displayHosts.length === 0 ? ( +

Aucune donnΓ©e disponible.

+ ) : ( +
+
+ {/* Hour headers */} +
+ {Array.from({ length: 24 }, (_, i) => ( +
+ {i} +
+ ))} +
+ {/* Rows */} + {displayHosts.map((host, rowIdx) => { + const rowData = matrixData.matrix[rowIdx] ?? Array(24).fill(0); + return ( +
+
+ {host} +
+ {Array.from({ length: 24 }, (_, h) => { + const val = rowData[h] ?? 0; + return ( +
+ ); + })} +
+ ); + })} +
+
+ Intensité : + {[ + { label: 'Nul', style: { backgroundColor: 'transparent', border: '1px solid rgba(255,255,255,0.1)' } }, + { label: 'Très faible', style: { backgroundColor: 'rgba(147, 197, 253, 0.15)' } }, + { label: 'Faible', style: { backgroundColor: 'rgba(96, 165, 250, 0.35)' } }, + { label: 'Moyen', style: { backgroundColor: 'rgba(59, 130, 246, 0.6)' } }, + { label: 'Élevé', style: { backgroundColor: 'rgba(168, 85, 247, 0.7)' } }, + { label: 'Critique', style: { backgroundColor: 'rgba(239, 68, 68, 0.85)' } }, + ].map(({ label, style }) => ( + + + {label} + + ))} +
+
+ )} +
+ + {/* Section 3: Top hosts table */} +
+
+

Top Hosts ciblΓ©s

+
+ {topHostsLoading ? ( + + ) : topHostsError ? ( +
+ ) : ( + + + + + + + + + + + + {topHosts.map((h) => ( + + + + + + + + ))} + +
HostTotal hitsIPs uniquesJA4 uniquesActivitΓ© 24h
{h.host}{formatNumber(h.total_hits)}{formatNumber(h.unique_ips)}{formatNumber(h.unique_ja4s)} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/MLFeaturesView.tsx b/frontend/src/components/MLFeaturesView.tsx new file mode 100644 index 0000000..939ac57 --- /dev/null +++ b/frontend/src/components/MLFeaturesView.tsx @@ -0,0 +1,529 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface MLAnomaly { + ip: string; + ja4: string; + host: string; + hits: number; + fuzzing_index: number; + hit_velocity: number; + temporal_entropy: number; + is_fake_navigation: boolean; + ua_ch_mismatch: boolean; + sni_host_mismatch: boolean; + is_ua_rotating: boolean; + path_diversity_ratio: number; + anomalous_payload_ratio: number; + asn_label: string; + bot_name: string; + attack_type: string; +} + +interface RadarData { + ip: string; + fuzzing_score: number; + velocity_score: number; + fake_nav_score: number; + ua_mismatch_score: number; + sni_mismatch_score: number; + orphan_score: number; + path_repetition_score: number; + payload_anomaly_score: number; +} + +interface ScatterPoint { + ip: string; + ja4: string; + fuzzing_index: number; + hit_velocity: number; + hits: number; + attack_type: string; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatNumber(n: number): string { + return n.toLocaleString('fr-FR'); +} + +function attackTypeEmoji(type: string): string { + switch (type) { + case 'brute_force': return 'πŸ”‘'; + case 'flood': return '🌊'; + case 'scraper': return 'πŸ•·οΈ'; + case 'spoofing': return '🎭'; + case 'scanner': return 'πŸ”'; + default: return '❓'; + } +} + +function attackTypeColor(type: string): string { + switch (type) { + case 'brute_force': return '#ef4444'; + case 'flood': return '#3b82f6'; + case 'scraper': return '#a855f7'; + case 'spoofing': return '#f97316'; + case 'scanner': return '#eab308'; + default: return '#6b7280'; + } +} + +function fuzzingBadgeClass(value: number): string { + if (value >= 200) return 'bg-threat-critical/20 text-threat-critical'; + if (value >= 100) return 'bg-threat-high/20 text-threat-high'; + if (value >= 50) return 'bg-threat-medium/20 text-threat-medium'; + return 'bg-background-card text-text-secondary'; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ ⚠️ {message} +
+ ); +} + +// ─── Radar Chart (SVG octagonal) ───────────────────────────────────────────── + +const RADAR_AXES = [ + { key: 'fuzzing_score', label: 'Fuzzing' }, + { key: 'velocity_score', label: 'VΓ©locitΓ©' }, + { key: 'fake_nav_score', label: 'Fausse nav' }, + { key: 'ua_mismatch_score', label: 'UA/CH mismatch' }, + { key: 'sni_mismatch_score', label: 'SNI mismatch' }, + { key: 'orphan_score', label: 'Orphan ratio' }, + { key: 'path_repetition_score', label: 'RΓ©pΓ©tition URL' }, + { key: 'payload_anomaly_score', label: 'Payload anormal' }, +] as const; + +type RadarKey = typeof RADAR_AXES[number]['key']; + +function RadarChart({ data }: { data: RadarData }) { + const size = 280; + const cx = size / 2; + const cy = size / 2; + const maxR = 100; + const n = RADAR_AXES.length; + + const angleOf = (i: number) => (i * 2 * Math.PI) / n - Math.PI / 2; + + const pointFor = (i: number, r: number): [number, number] => { + const a = angleOf(i); + return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; + }; + + // Background rings (at 25%, 50%, 75%, 100%) + const rings = [25, 50, 75, 100]; + + const dataPoints = RADAR_AXES.map((axis, i) => { + const val = Math.min((data[axis.key as RadarKey] ?? 0), 100); + return pointFor(i, (val / 100) * maxR); + }); + + const polygonPoints = dataPoints.map(([x, y]) => `${x},${y}`).join(' '); + + return ( + + {/* Background rings */} + {rings.map((r) => { + const pts = RADAR_AXES.map((_, i) => { + const [x, y] = pointFor(i, (r / 100) * maxR); + return `${x},${y}`; + }).join(' '); + return ( + + ); + })} + + {/* Axis lines */} + {RADAR_AXES.map((_, i) => { + const [x, y] = pointFor(i, maxR); + return ( + + ); + })} + + {/* Data polygon */} + + + {/* Data dots */} + {dataPoints.map(([x, y], i) => ( + + ))} + + {/* Axis labels */} + {RADAR_AXES.map((axis, i) => { + const [x, y] = pointFor(i, maxR + 18); + const anchor = x < cx - 5 ? 'end' : x > cx + 5 ? 'start' : 'middle'; + return ( + + {axis.label} + + ); + })} + + {/* Percentage labels on vertical axis */} + {rings.map((r) => { + const [, y] = pointFor(0, (r / 100) * maxR); + return ( + + {r} + + ); + })} + + ); +} + +// ─── Scatter plot ───────────────────────────────────────────────────────────── + +function ScatterPlot({ points }: { points: ScatterPoint[] }) { + const [tooltip, setTooltip] = useState<{ ip: string; type: string; x: number; y: number } | null>(null); + + const W = 600; + const H = 200; + const padL = 40; + const padB = 30; + const padT = 10; + const padR = 20; + + const maxX = 350; + const maxY = 1; + + const toSvgX = (v: number) => padL + ((v / maxX) * (W - padL - padR)); + const toSvgY = (v: number) => padT + ((1 - v / maxY) * (H - padT - padB)); + + // X axis ticks + const xTicks = [0, 50, 100, 150, 200, 250, 300, 350]; + const yTicks = [0, 0.25, 0.5, 0.75, 1.0]; + + return ( +
+ setTooltip(null)} + > + {/* Grid lines */} + {xTicks.map((v) => ( + + ))} + {yTicks.map((v) => ( + + ))} + + {/* X axis */} + + {xTicks.map((v) => ( + {v} + ))} + Fuzzing Index β†’ + + {/* Y axis */} + + {yTicks.map((v) => ( + {v.toFixed(2)} + ))} + + {/* Data points */} + {points.map((pt, i) => { + const x = toSvgX(Math.min(pt.fuzzing_index, maxX)); + const y = toSvgY(Math.min(pt.hit_velocity, maxY)); + const color = attackTypeColor(pt.attack_type); + return ( + setTooltip({ ip: pt.ip, type: pt.attack_type, x, y })} + /> + ); + })} + + {/* Tooltip */} + {tooltip && ( + + + + {tooltip.ip} + + + {attackTypeEmoji(tooltip.type)} {tooltip.type} + + + )} + +
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function MLFeaturesView() { + const navigate = useNavigate(); + + const [anomalies, setAnomalies] = useState([]); + const [anomaliesLoading, setAnomaliesLoading] = useState(true); + const [anomaliesError, setAnomaliesError] = useState(null); + + const [scatter, setScatter] = useState([]); + const [scatterLoading, setScatterLoading] = useState(true); + const [scatterError, setScatterError] = useState(null); + + const [selectedIP, setSelectedIP] = useState(null); + const [radarData, setRadarData] = useState(null); + const [radarLoading, setRadarLoading] = useState(false); + const [radarError, setRadarError] = useState(null); + + useEffect(() => { + const fetchAnomalies = async () => { + try { + const res = await fetch('/api/ml/top-anomalies?limit=50'); + if (!res.ok) throw new Error('Erreur chargement des anomalies'); + const data: { items: MLAnomaly[] } = await res.json(); + setAnomalies(data.items ?? []); + } catch (err) { + setAnomaliesError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setAnomaliesLoading(false); + } + }; + const fetchScatter = async () => { + try { + const res = await fetch('/api/ml/scatter?limit=200'); + if (!res.ok) throw new Error('Erreur chargement du scatter'); + const data: { points: ScatterPoint[] } = await res.json(); + setScatter(data.points ?? []); + } catch (err) { + setScatterError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setScatterLoading(false); + } + }; + fetchAnomalies(); + fetchScatter(); + }, []); + + const loadRadar = async (ip: string) => { + if (selectedIP === ip) { + setSelectedIP(null); + setRadarData(null); + return; + } + setSelectedIP(ip); + setRadarLoading(true); + setRadarError(null); + try { + const res = await fetch(`/api/ml/ip/${encodeURIComponent(ip)}/radar`); + if (!res.ok) throw new Error('Erreur chargement du radar'); + const data: RadarData = await res.json(); + setRadarData(data); + } catch (err) { + setRadarError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setRadarLoading(false); + } + }; + + return ( +
+ {/* Header */} +
+

πŸ€– Analyse Features ML

+

+ Visualisation des features ML pour la dΓ©tection d'anomalies comportementales. +

+
+ + {/* Main two-column layout */} +
+ {/* Left: anomalies table */} +
+
+
+

Top anomalies

+
+ {anomaliesLoading ? ( + + ) : 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 && 🌐} + +
+
+ )} +
+
+ + {/* Right: Radar chart */} +
+
+

+ Radar ML {selectedIP ? β€” {selectedIP} : ''} +

+ {!selectedIP ? ( +
+ Cliquez sur une IP
pour afficher le radar +
+ ) : radarLoading ? ( +
+ +
+ ) : radarError ? ( + + ) : radarData ? ( + <> +
+ +
+ + + ) : null} +
+
+
+ + {/* Scatter plot */} +
+

Nuage de points β€” Fuzzing Index Γ— VΓ©locitΓ©

+ {scatterLoading ? ( + + ) : scatterError ? ( + + ) : ( + <> + + {/* Legend */} +
+ {['brute_force', 'flood', 'scraper', 'spoofing', 'scanner'].map((type) => ( + + + {attackTypeEmoji(type)} {type} + + ))} +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/RotationView.tsx b/frontend/src/components/RotationView.tsx new file mode 100644 index 0000000..3da3039 --- /dev/null +++ b/frontend/src/components/RotationView.tsx @@ -0,0 +1,370 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface JA4Rotator { + ip: string; + distinct_ja4_count: number; + total_hits: number; + evasion_score: number; +} + +interface PersistentThreat { + ip: string; + recurrence: number; + worst_score: number; + worst_threat_level: string; + first_seen: string; + last_seen: string; + persistence_score: number; +} + +interface JA4HistoryEntry { + ja4: string; + hits: number; + window_start: string; +} + +type ActiveTab = 'rotators' | 'persistent'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatNumber(n: number): string { + return n.toLocaleString('fr-FR'); +} + +function formatDate(iso: string): string { + if (!iso) return 'β€”'; + try { + return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + } catch { + return iso; + } +} + +function threatLevelBadge(level: string): { bg: string; text: string } { + switch (level?.toLowerCase()) { + case 'critical': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical' }; + case 'high': return { bg: 'bg-threat-high/20', text: 'text-threat-high' }; + case 'medium': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium' }; + case 'low': return { bg: 'bg-threat-low/20', text: 'text-threat-low' }; + default: return { bg: 'bg-background-card', text: 'text-text-secondary' }; + } +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ ⚠️ {message} +
+ ); +} + +// ─── Rotator row with expandable JA4 history ───────────────────────────────── + +function RotatorRow({ item }: { item: JA4Rotator }) { + const [expanded, setExpanded] = useState(false); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(null); + const [historyLoaded, setHistoryLoaded] = useState(false); + + const toggle = async () => { + setExpanded((prev) => !prev); + if (!historyLoaded && !expanded) { + setHistoryLoading(true); + try { + const res = await fetch(`/api/rotation/ip/${encodeURIComponent(item.ip)}/ja4-history`); + if (!res.ok) throw new Error('Erreur chargement historique JA4'); + const data: { history: JA4HistoryEntry[] } = await res.json(); + setHistory(data.history ?? []); + setHistoryLoaded(true); + } catch (err) { + setHistoryError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setHistoryLoading(false); + } + } + }; + + const isHighRotation = item.distinct_ja4_count > 5; + + return ( + <> + + + {expanded ? 'β–Ύ' : 'β–Έ'} + {item.ip} + + + + {item.distinct_ja4_count} JA4 + + + {formatNumber(item.total_hits)} + +
+
+
+
+ {Math.round(item.evasion_score)} +
+ + + {expanded && ( + + + {historyLoading ? ( +
+
+ Chargement de l'historique… +
+ ) : historyError ? ( + ⚠️ {historyError} + ) : history.length === 0 ? ( + Aucun historique disponible. + ) : ( +
+

Historique des JA4 utilisΓ©s :

+ {history.map((entry, idx) => ( +
+ + {entry.ja4} + + {formatNumber(entry.hits)} hits + {formatDate(entry.window_start)} +
+ ))} +
+ )} + + + )} + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function RotationView() { + const navigate = useNavigate(); + + const [activeTab, setActiveTab] = useState('rotators'); + + const [rotators, setRotators] = useState([]); + const [rotatorsLoading, setRotatorsLoading] = useState(true); + const [rotatorsError, setRotatorsError] = useState(null); + + const [persistent, setPersistent] = useState([]); + const [persistentLoading, setPersistentLoading] = useState(false); + const [persistentError, setPersistentError] = useState(null); + const [persistentLoaded, setPersistentLoaded] = useState(false); + + useEffect(() => { + const fetchRotators = async () => { + setRotatorsLoading(true); + try { + const res = await fetch('/api/rotation/ja4-rotators?limit=50'); + if (!res.ok) throw new Error('Erreur chargement des rotateurs'); + const data: { items: JA4Rotator[] } = await res.json(); + setRotators(data.items ?? []); + } catch (err) { + setRotatorsError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setRotatorsLoading(false); + } + }; + fetchRotators(); + }, []); + + const loadPersistent = async () => { + if (persistentLoaded) return; + setPersistentLoading(true); + try { + const res = await fetch('/api/rotation/persistent-threats?limit=100'); + if (!res.ok) throw new Error('Erreur chargement des menaces persistantes'); + const data: { items: PersistentThreat[] } = await res.json(); + // Sort by persistence_score DESC + const sorted = [...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score); + setPersistent(sorted); + setPersistentLoaded(true); + } catch (err) { + setPersistentError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setPersistentLoading(false); + } + }; + + const handleTabChange = (tab: ActiveTab) => { + setActiveTab(tab); + if (tab === 'persistent') loadPersistent(); + }; + + const maxEvasion = rotators.length > 0 ? Math.max(...rotators.map((r) => r.evasion_score)) : 0; + const maxPersistence = persistent.length > 0 ? Math.max(...persistent.map((p) => p.persistence_score)) : 0; + + const tabs: { id: ActiveTab; label: string }[] = [ + { id: 'rotators', label: '🎭 Rotateurs JA4' }, + { id: 'persistent', label: 'πŸ•°οΈ Menaces Persistantes' }, + ]; + + return ( +
+ {/* Header */} +
+

πŸ”„ Rotation JA4 & Persistance

+

+ DΓ©tection des IPs qui changent de fingerprint pour contourner les dΓ©tections, et des menaces persistantes. +

+
+ + {/* Stat cards */} +
+ + + + +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Rotateurs tab */} + {activeTab === 'rotators' && ( +
+ {rotatorsLoading ? ( + + ) : rotatorsError ? ( +
+ ) : ( + + + + + + + + + + + {rotators.map((item) => ( + + ))} + +
IPJA4 distinctsTotal hitsScore d'Γ©vasion
+ )} +
+ )} + + {/* Persistantes tab */} + {activeTab === 'persistent' && ( +
+ {persistentLoading ? ( + + ) : persistentError ? ( +
+ ) : ( + + + + + + + + + + + + + + + {persistent.map((item) => { + const badge = threatLevelBadge(item.worst_threat_level); + return ( + + + + + + + + + + + ); + })} + +
IPRécurrenceScore menaceNiveauPremière vueDernière vueScore persistance
{item.ip} + + {item.recurrence}j + + {Math.round(item.worst_score)} + + {item.worst_threat_level?.toUpperCase() || 'β€”'} + + {formatDate(item.first_seen)}{formatDate(item.last_seen)} +
+
+
+
+ {Math.round(item.persistence_score)} +
+
+ +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/TcpSpoofingView.tsx b/frontend/src/components/TcpSpoofingView.tsx new file mode 100644 index 0000000..0e87b51 --- /dev/null +++ b/frontend/src/components/TcpSpoofingView.tsx @@ -0,0 +1,363 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface TcpSpoofingOverview { + total_detections: number; + unique_ips: number; + ttl_distribution: { ttl: number; count: number; ips: number }[]; + window_size_distribution: { window_size: number; count: number }[]; + low_ttl_count: number; + zero_ttl_count: number; +} + +interface TcpSpoofingItem { + ip: string; + ja4: string; + tcp_ttl: number; + tcp_window_size: number; + first_ua: string; + suspected_os: string; + declared_os: string; + spoof_flag: boolean; +} + +interface OsMatrixEntry { + suspected_os: string; + declared_os: string; + count: number; +} + +type ActiveTab = 'detections' | 'matrix'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatNumber(n: number): string { + return n.toLocaleString('fr-FR'); +} + +function ttlColor(ttl: number): string { + if (ttl === 0) return 'text-threat-critical'; + if (ttl < 48 || ttl > 200) return 'text-threat-critical'; + if (ttl < 60 || (ttl > 70 && ttl <= 80)) return 'text-threat-medium'; + if (ttl >= 60 && ttl <= 70) return 'text-threat-low'; + return 'text-text-secondary'; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ ⚠️ {message} +
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function TcpSpoofingView() { + const navigate = useNavigate(); + + const [activeTab, setActiveTab] = useState('detections'); + + const [overview, setOverview] = useState(null); + const [overviewLoading, setOverviewLoading] = useState(true); + const [overviewError, setOverviewError] = useState(null); + + const [items, setItems] = useState([]); + const [itemsLoading, setItemsLoading] = useState(true); + const [itemsError, setItemsError] = useState(null); + + const [matrix, setMatrix] = useState([]); + const [matrixLoading, setMatrixLoading] = useState(false); + const [matrixError, setMatrixError] = useState(null); + const [matrixLoaded, setMatrixLoaded] = useState(false); + + const [filterText, setFilterText] = useState(''); + + useEffect(() => { + const fetchOverview = async () => { + setOverviewLoading(true); + try { + const res = await fetch('/api/tcp-spoofing/overview'); + if (!res.ok) throw new Error('Erreur chargement overview'); + const data: TcpSpoofingOverview = await res.json(); + setOverview(data); + } catch (err) { + setOverviewError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setOverviewLoading(false); + } + }; + const fetchItems = async () => { + setItemsLoading(true); + try { + const res = await fetch('/api/tcp-spoofing/list?limit=100'); + if (!res.ok) throw new Error('Erreur chargement des dΓ©tections'); + const data: { items: TcpSpoofingItem[]; total: number } = await res.json(); + setItems(data.items ?? []); + } catch (err) { + setItemsError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setItemsLoading(false); + } + }; + fetchOverview(); + fetchItems(); + }, []); + + const loadMatrix = async () => { + if (matrixLoaded) return; + setMatrixLoading(true); + try { + const res = await fetch('/api/tcp-spoofing/matrix'); + if (!res.ok) throw new Error('Erreur chargement matrice OS'); + const data: { matrix: OsMatrixEntry[] } = await res.json(); + setMatrix(data.matrix ?? []); + setMatrixLoaded(true); + } catch (err) { + setMatrixError(err instanceof Error ? err.message : 'Erreur inconnue'); + } finally { + setMatrixLoading(false); + } + }; + + const handleTabChange = (tab: ActiveTab) => { + setActiveTab(tab); + if (tab === 'matrix') loadMatrix(); + }; + + const filteredItems = items.filter( + (item) => + !filterText || + item.ip.includes(filterText) || + item.suspected_os.toLowerCase().includes(filterText.toLowerCase()) || + item.declared_os.toLowerCase().includes(filterText.toLowerCase()) + ); + + // Build matrix axes + const suspectedOSes = [...new Set(matrix.map((e) => e.suspected_os))].sort(); + const declaredOSes = [...new Set(matrix.map((e) => e.declared_os))].sort(); + const matrixMax = matrix.reduce((m, e) => Math.max(m, e.count), 1); + + function matrixCellColor(count: number): string { + if (count === 0) return 'bg-background-card'; + const ratio = count / matrixMax; + if (ratio >= 0.75) return 'bg-threat-critical/80'; + if (ratio >= 0.5) return 'bg-threat-high/70'; + if (ratio >= 0.25) return 'bg-threat-medium/60'; + return 'bg-threat-low/40'; + } + + const tabs: { id: ActiveTab; label: string }[] = [ + { id: 'detections', label: 'πŸ“‹ DΓ©tections' }, + { id: 'matrix', label: 'πŸ“Š Matrice OS' }, + ]; + + return ( +
+ {/* Header */} +
+

🧬 Spoofing TCP/OS

+

+ DΓ©tection des incohΓ©rences entre TTL/fenΓͺtre TCP et l'OS dΓ©clarΓ©. +

+
+ + {/* Stat cards */} + {overviewLoading ? ( + + ) : overviewError ? ( + + ) : overview ? ( +
+ + + + +
+ ) : null} + + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* DΓ©tections tab */} + {activeTab === 'detections' && ( + <> +
+ setFilterText(e.target.value)} + className="bg-background-card border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-72" + /> +
+
+ {itemsLoading ? ( + + ) : 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 + + )} + + +
+ )} +
+ + )} + + {/* Matrice OS tab */} + {activeTab === 'matrix' && ( +
+ {matrixLoading ? ( + + ) : matrixError ? ( + + ) : matrix.length === 0 ? ( +

Aucune donnΓ©e disponible.

+ ) : ( +
+

OS SuspectΓ© Γ— OS DΓ©clarΓ©

+ + + + + {declaredOSes.map((os) => ( + + ))} + + + + + {suspectedOSes.map((sos) => { + const rowEntries = declaredOSes.map((dos) => { + const entry = matrix.find((e) => e.suspected_os === sos && e.declared_os === dos); + return entry?.count ?? 0; + }); + const rowTotal = rowEntries.reduce((s, v) => s + v, 0); + return ( + + + {rowEntries.map((count, ci) => ( + + ))} + + + ); + })} + + + {declaredOSes.map((dos) => { + const colTotal = matrix + .filter((e) => e.declared_os === dos) + .reduce((s, e) => s + e.count, 0); + return ( + + ); + })} + + + +
+ SuspectΓ© \ DΓ©clarΓ© + + {os} + Total
+ {sos} + 0 ? 'text-text-primary' : 'text-text-disabled'}`} + > + {count > 0 ? formatNumber(count) : 'β€”'} + + {formatNumber(rowTotal)} +
Total + {formatNumber(colTotal)} + + {formatNumber(matrix.reduce((s, e) => s + e.count, 0))} +
+
+ )} +
+ )} +
+ ); +}