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 ? (
+
+ ) : (
+
+
+
+ | JA4 |
+ IPs |
+ Pays |
+ Hosts ciblés |
+ Score distribution |
+ Classe |
+ |
+
+
+
+ {sortedItems.map((item) => (
+ navigate(`/investigation/ja4/${ja4}`)}
+ />
+ ))}
+
+
+ )}
+
+
+ );
+}
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 ? (
+
+ ) : (
+
+
+
+ | Host |
+ IPs distinctes |
+ Total hits |
+ Params combos |
+ Type d'attaque |
+ Top JA4 |
+ |
+
+
+
+ {targets.map((t) => (
+
+ | {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 ? (
+
+ ) : (
+
+
+
+ | IP |
+ Hosts ciblés |
+ Hits |
+ Params |
+ JA4 |
+ |
+
+
+
+ {attackers.map((a) => (
+
+ | {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}
+ ) : (
+
+
+
+
+ | IP |
+ Browser Score |
+ UA/CH Mismatch |
+ Sec-Fetch Mode |
+ Sec-Fetch Dest |
+ |
+
+
+
+ {clusterIPs.map((ip) => (
+
+ | {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 ? (
+
+ ) : (
+
+
+
+ | Hash cluster |
+ IPs |
+ Browser Score |
+ UA/CH Mismatch % |
+ Classification |
+ Sec-Fetch modes |
+
+
+
+ {clusters.map((cluster) => (
+ navigate(`/investigation/${ip}`)}
+ />
+ ))}
+
+
+ )}
+
+ {!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 ? (
+
+ ) : (
+
+
+
+ | Host |
+ Total hits |
+ IPs uniques |
+ JA4 uniques |
+ Activité 24h |
+
+
+
+ {topHosts.map((h) => (
+
+ | {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 (
+
+ );
+}
+
+// ─── 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 (
+
+
+
+ );
+}
+
+// ─── 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 ? (
+
+ ) : (
+
+
+
+
+ | IP |
+ Host |
+ Hits |
+ Fuzzing |
+ Type |
+ Signaux |
+
+
+
+ {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'
+ }`}
+ >
+ | {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 ? (
+
+ ) : (
+
+
+
+ | IP |
+ JA4 distincts |
+ Total hits |
+ Score d'évasion |
+
+
+
+ {rotators.map((item) => (
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Persistantes tab */}
+ {activeTab === 'persistent' && (
+
+ {persistentLoading ? (
+
+ ) : persistentError ? (
+
+ ) : (
+
+
+
+ | IP |
+ Récurrence |
+ Score menace |
+ Niveau |
+ Première vue |
+ Dernière vue |
+ Score persistance |
+ |
+
+
+
+ {persistent.map((item) => {
+ const badge = threatLevelBadge(item.worst_threat_level);
+ return (
+
+ | {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 ? (
+
+ ) : (
+
+
+
+ | IP |
+ JA4 |
+ TTL observé |
+ Fenêtre TCP |
+ OS suspecté |
+ OS déclaré |
+ Spoof |
+ |
+
+
+
+ {filteredItems.map((item) => (
+
+ | {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é
+
+
+
+ |
+ Suspecté \ Déclaré
+ |
+ {declaredOSes.map((os) => (
+
+ {os}
+ |
+ ))}
+ Total |
+
+
+
+ {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 (
+
+ |
+ {sos}
+ |
+ {rowEntries.map((count, ci) => (
+ 0 ? 'text-text-primary' : 'text-text-disabled'}`}
+ >
+ {count > 0 ? formatNumber(count) : '—'}
+ |
+ ))}
+
+ {formatNumber(rowTotal)}
+ |
+
+ );
+ })}
+
+ | Total |
+ {declaredOSes.map((dos) => {
+ const colTotal = matrix
+ .filter((e) => e.declared_os === dos)
+ .reduce((s, e) => s + e.count, 0);
+ return (
+
+ {formatNumber(colTotal)}
+ |
+ );
+ })}
+
+ {formatNumber(matrix.reduce((s, e) => s + e.count, 0))}
+ |
+
+
+
+
+ )}
+
+ )}
+
+ );
+}