feat: ajout de 7 nouveaux dashboards d'analyse avancée
- 🔥 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>
This commit is contained in:
@ -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
|
||||
|
||||
105
backend/routes/botnets.py
Normal file
105
backend/routes/botnets.py
Normal file
@ -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))
|
||||
107
backend/routes/bruteforce.py
Normal file
107
backend/routes/bruteforce.py
Normal file
@ -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))
|
||||
101
backend/routes/header_fingerprint.py
Normal file
101
backend/routes/header_fingerprint.py
Normal file
@ -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))
|
||||
145
backend/routes/heatmap.py
Normal file
145
backend/routes/heatmap.py
Normal file
@ -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))
|
||||
157
backend/routes/ml_features.py
Normal file
157
backend/routes/ml_features.py
Normal file
@ -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))
|
||||
101
backend/routes/rotation.py
Normal file
101
backend/routes/rotation.py
Normal file
@ -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))
|
||||
163
backend/routes/tcp_spoofing.py
Normal file
163
backend/routes/tcp_spoofing.py
Normal file
@ -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))
|
||||
@ -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 }) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Advanced analysis nav */}
|
||||
<nav className="px-3 pt-4 space-y-0.5">
|
||||
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider px-3 pb-1">Analyse Avancée</div>
|
||||
{advancedLinks.map(link => (
|
||||
<Link
|
||||
key={link.path}
|
||||
to={link.path}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm font-medium ${
|
||||
isActive(link)
|
||||
? 'bg-accent-primary text-white'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-background-card'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{link.icon}</span>
|
||||
<span className="flex-1">{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Alert stats */}
|
||||
{counts && (
|
||||
<div className="mx-3 mt-5 bg-background-card rounded-lg p-3 space-y-2">
|
||||
@ -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() {
|
||||
<Route path="/fingerprints" element={<FingerprintsView />} />
|
||||
<Route path="/campaigns" element={<CampaignsView />} />
|
||||
<Route path="/threat-intel" element={<ThreatIntelView />} />
|
||||
<Route path="/bruteforce" element={<BruteForceView />} />
|
||||
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
|
||||
<Route path="/headers" element={<HeaderFingerprintView />} />
|
||||
<Route path="/heatmap" element={<HeatmapView />} />
|
||||
<Route path="/botnets" element={<BotnetMapView />} />
|
||||
<Route path="/rotation" element={<RotationView />} />
|
||||
<Route path="/ml-features" element={<MLFeaturesView />} />
|
||||
<Route path="/detections" element={<DetectionsList />} />
|
||||
<Route path="/detections/:type/:value" element={<DetailsView />} />
|
||||
<Route path="/investigate" element={<DetectionsList />} />
|
||||
|
||||
316
frontend/src/components/BotnetMapView.tsx
Normal file
316
frontend/src/components/BotnetMapView.tsx
Normal file
@ -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 (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-xl font-bold truncate ${mono ? 'font-mono text-sm' : 'text-2xl'} ${accent ?? 'text-text-primary'}`} title={String(value)}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Botnet row with expandable countries ─────────────────────────────────────
|
||||
|
||||
function BotnetRow({
|
||||
item,
|
||||
onInvestigate,
|
||||
}: {
|
||||
item: BotnetItem;
|
||||
onInvestigate: (ja4: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [countries, setCountries] = useState<CountryEntry[]>([]);
|
||||
const [countriesLoading, setCountriesLoading] = useState(false);
|
||||
const [countriesError, setCountriesError] = useState<string | null>(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 (
|
||||
<>
|
||||
<tr
|
||||
className="border-b border-border hover:bg-background-card transition-colors cursor-pointer"
|
||||
onClick={toggle}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-accent-primary text-xs mr-2">{expanded ? '▾' : '▸'}</span>
|
||||
<span className="font-mono text-xs text-text-primary">{item.ja4 ? item.ja4.slice(0, 20) : '—'}…</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(item.unique_ips)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">
|
||||
<span className="flex items-center gap-1">🌍 {formatNumber(item.unique_countries)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{formatNumber(item.targeted_hosts)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 bg-background-card rounded-full h-1.5">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-accent-primary"
|
||||
style={{ width: `${Math.min(item.distribution_score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{Math.round(item.distribution_score)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onInvestigate(item.ja4); }}
|
||||
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
Investiguer JA4
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border bg-background-card">
|
||||
<td colSpan={7} className="px-6 py-4">
|
||||
{countriesLoading ? (
|
||||
<div className="flex items-center gap-2 text-text-secondary text-sm">
|
||||
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
Chargement des pays…
|
||||
</div>
|
||||
) : countriesError ? (
|
||||
<span className="text-threat-critical text-sm">⚠️ {countriesError}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{countries.map((c) => (
|
||||
<span
|
||||
key={c.country_code}
|
||||
title={`${c.country_code}: ${c.unique_ips} IPs, ${c.hits} hits`}
|
||||
className="inline-flex items-center gap-1 bg-background-secondary border border-border rounded-full px-2 py-1 text-xs text-text-primary"
|
||||
>
|
||||
{getCountryFlag(c.country_code)} {c.country_code}
|
||||
<span className="text-text-disabled">·</span>
|
||||
<span className="text-accent-primary">{formatNumber(c.unique_ips)} IPs</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BotnetMapView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [items, setItems] = useState<BotnetItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [summary, setSummary] = useState<BotnetSummary | null>(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||
const [summaryError, setSummaryError] = useState<string | null>(null);
|
||||
|
||||
const [sortField, setSortField] = useState<SortField>('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) => (
|
||||
<button
|
||||
onClick={() => setSortField(field)}
|
||||
className={`text-xs px-3 py-1.5 rounded transition-colors ${
|
||||
sortField === field
|
||||
? 'bg-accent-primary/20 text-accent-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🌍 Botnets Distribués</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Analyse de la distribution géographique des fingerprints JA4 pour détecter les botnets globaux.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
{summaryLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : summaryError ? (
|
||||
<ErrorMessage message={summaryError} />
|
||||
) : summary ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="Botnets globaux (>100 pays)" value={formatNumber(summary.total_global_botnets)} accent="text-threat-critical" />
|
||||
<StatCard label="Total IPs en botnets" value={formatNumber(summary.total_ips_in_botnets)} accent="text-threat-high" />
|
||||
<StatCard label="JA4 le plus répandu" value={summary.most_spread_ja4 || '—'} accent="text-accent-primary" mono />
|
||||
<StatCard label="JA4 avec le plus d'IPs" value={summary.most_ips_ja4 || '—'} accent="text-accent-primary" mono />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Sort controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-secondary text-sm">Trier par :</span>
|
||||
{sortButton('unique_ips', '🖥️ IPs')}
|
||||
{sortButton('unique_countries', '🌍 Pays')}
|
||||
{sortButton('targeted_hosts', '🎯 Hosts')}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<div className="p-4"><ErrorMessage message={error} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">JA4</th>
|
||||
<th className="px-4 py-3">IPs</th>
|
||||
<th className="px-4 py-3">Pays</th>
|
||||
<th className="px-4 py-3">Hosts ciblés</th>
|
||||
<th className="px-4 py-3">Score distribution</th>
|
||||
<th className="px-4 py-3">Classe</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((item) => (
|
||||
<BotnetRow
|
||||
key={item.ja4}
|
||||
item={item}
|
||||
onInvestigate={(ja4) => navigate(`/investigation/ja4/${ja4}`)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
361
frontend/src/components/BruteForceView.tsx
Normal file
361
frontend/src/components/BruteForceView.tsx
Normal file
@ -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 (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BruteForceView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('targets');
|
||||
|
||||
const [targets, setTargets] = useState<BruteForceTarget[]>([]);
|
||||
const [targetsTotal, setTargetsTotal] = useState(0);
|
||||
const [targetsLoading, setTargetsLoading] = useState(true);
|
||||
const [targetsError, setTargetsError] = useState<string | null>(null);
|
||||
|
||||
const [attackers, setAttackers] = useState<BruteForceAttacker[]>([]);
|
||||
const [attackersLoading, setAttackersLoading] = useState(false);
|
||||
const [attackersError, setAttackersError] = useState<string | null>(null);
|
||||
const [attackersLoaded, setAttackersLoaded] = useState(false);
|
||||
|
||||
const [timeline, setTimeline] = useState<TimelineHour[]>([]);
|
||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||
const [timelineError, setTimelineError] = useState<string | null>(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 (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🔥 Brute Force & Credential Stuffing</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Détection des attaques par force brute, credential stuffing et énumération de paramètres.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard label="Cibles détectées" value={formatNumber(targetsTotal)} accent="text-threat-high" />
|
||||
<StatCard
|
||||
label="IPs attaquantes"
|
||||
value={attackersLoaded ? formatNumber(attackers.length) : '—'}
|
||||
accent="text-threat-critical"
|
||||
/>
|
||||
<StatCard label="Total hits" value={formatNumber(totalHits)} accent="text-text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-accent-primary border-b-2 border-accent-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cibles tab */}
|
||||
{activeTab === 'targets' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{targetsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : targetsError ? (
|
||||
<div className="p-4"><ErrorMessage message={targetsError} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">Host</th>
|
||||
<th className="px-4 py-3">IPs distinctes</th>
|
||||
<th className="px-4 py-3">Total hits</th>
|
||||
<th className="px-4 py-3">Params combos</th>
|
||||
<th className="px-4 py-3">Type d'attaque</th>
|
||||
<th className="px-4 py-3">Top JA4</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((t) => (
|
||||
<tr key={t.host} className="border-b border-border hover:bg-background-card transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-text-primary text-xs">{t.host}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(t.unique_ips)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_hits)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_params)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{t.attack_type === 'credential_stuffing' ? (
|
||||
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-1 rounded-full">
|
||||
💳 Credential Stuffing
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-threat-high/20 text-threat-high text-xs px-2 py-1 rounded-full">
|
||||
🔍 Énumération
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(t.top_ja4 ?? []).slice(0, 3).map((ja4) => (
|
||||
<span key={ja4} className="font-mono text-xs bg-background-card px-1.5 py-0.5 rounded border border-border text-text-secondary">
|
||||
{ja4.slice(0, 12)}…
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${t.host}`)}
|
||||
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
Voir détails
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attaquants tab */}
|
||||
{activeTab === 'attackers' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{attackersLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : attackersError ? (
|
||||
<div className="p-4"><ErrorMessage message={attackersError} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">IP</th>
|
||||
<th className="px-4 py-3">Hosts ciblés</th>
|
||||
<th className="px-4 py-3">Hits</th>
|
||||
<th className="px-4 py-3">Params</th>
|
||||
<th className="px-4 py-3">JA4</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attackers.map((a) => (
|
||||
<tr key={a.ip} className="border-b border-border hover:bg-background-card transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-text-primary text-xs">{a.ip}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{a.distinct_hosts}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(a.total_hits)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(a.total_params)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-secondary">{a.ja4 ? a.ja4.slice(0, 16) : '—'}…</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${a.ip}`)}
|
||||
className="text-xs bg-threat-high/10 text-threat-high px-3 py-1 rounded hover:bg-threat-high/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline tab */}
|
||||
{activeTab === 'timeline' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
{timelineLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : timelineError ? (
|
||||
<ErrorMessage message={timelineError} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-text-primary font-semibold">Activité par heure</h2>
|
||||
{peakHour.hits > 0 && (
|
||||
<span className="text-xs text-text-secondary">
|
||||
Pic : <span className="text-threat-critical font-medium">{peakHour.hour}h</span> ({formatNumber(peakHour.hits)} hits)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end gap-1 h-48">
|
||||
{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 (
|
||||
<div key={i} className="flex flex-col items-center flex-1 gap-1">
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: '160px' }}>
|
||||
<div
|
||||
title={`${i}h: ${entry.hits} hits, ${entry.ips} IPs`}
|
||||
style={{ height: `${Math.max(pct, 1)}%` }}
|
||||
className={`w-full rounded-t transition-all ${
|
||||
isPeak
|
||||
? 'bg-threat-critical'
|
||||
: pct >= 70
|
||||
? 'bg-threat-high'
|
||||
: pct >= 30
|
||||
? 'bg-threat-medium'
|
||||
: 'bg-accent-primary/60'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-disabled text-xs">{i}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-4 mt-4 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-critical rounded-sm inline-block" /> Pic</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-high rounded-sm inline-block" /> Élevé (≥70%)</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-medium rounded-sm inline-block" /> Moyen (≥30%)</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-accent-primary/60 rounded-sm inline-block" /> Faible</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{activeTab === 'targets' && !targetsLoading && !targetsError && (
|
||||
<p className="text-text-secondary text-xs">{formatNumber(targetsTotal)} cible(s) détectée(s)</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
frontend/src/components/HeaderFingerprintView.tsx
Normal file
302
frontend/src/components/HeaderFingerprintView.tsx
Normal file
@ -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 (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cluster row with expandable IPs ─────────────────────────────────────────
|
||||
|
||||
function ClusterRow({
|
||||
cluster,
|
||||
onInvestigateIP,
|
||||
}: {
|
||||
cluster: HeaderCluster;
|
||||
onInvestigateIP: (ip: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [clusterIPs, setClusterIPs] = useState<ClusterIP[]>([]);
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsError, setIpsError] = useState<string | null>(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 (
|
||||
<>
|
||||
<tr
|
||||
className="border-b border-border hover:bg-background-card transition-colors cursor-pointer"
|
||||
onClick={toggle}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-accent-primary text-xs mr-2">{expanded ? '▾' : '▸'}</span>
|
||||
<span className="font-mono text-xs text-text-primary">{cluster.hash.slice(0, 16)}…</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(cluster.unique_ips)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-background-card rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${browserScoreColor(cluster.avg_browser_score)}`}
|
||||
style={{ width: `${cluster.avg_browser_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{Math.round(cluster.avg_browser_score)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-4 py-3 font-semibold text-sm ${mismatchColor(cluster.ua_ch_mismatch_pct)}`}>
|
||||
{Math.round(cluster.ua_ch_mismatch_pct)}%
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(cluster.top_sec_fetch_modes ?? []).slice(0, 3).map((mode) => (
|
||||
<span key={mode} className="text-xs bg-background-card border border-border px-1.5 py-0.5 rounded text-text-secondary">
|
||||
{mode}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border bg-background-card">
|
||||
<td colSpan={6} className="px-6 py-4">
|
||||
{ipsLoading ? (
|
||||
<div className="flex items-center gap-2 text-text-secondary text-sm">
|
||||
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
Chargement des IPs…
|
||||
</div>
|
||||
) : ipsError ? (
|
||||
<span className="text-threat-critical text-sm">⚠️ {ipsError}</span>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-64">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-text-secondary">
|
||||
<th className="text-left py-1 pr-4">IP</th>
|
||||
<th className="text-left py-1 pr-4">Browser Score</th>
|
||||
<th className="text-left py-1 pr-4">UA/CH Mismatch</th>
|
||||
<th className="text-left py-1 pr-4">Sec-Fetch Mode</th>
|
||||
<th className="text-left py-1 pr-4">Sec-Fetch Dest</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clusterIPs.map((ip) => (
|
||||
<tr key={ip.ip} className="border-t border-border/50">
|
||||
<td className="py-1.5 pr-4 font-mono text-text-primary">{ip.ip}</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
<span className={ip.browser_score >= 70 ? 'text-threat-low' : ip.browser_score >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
|
||||
{Math.round(ip.browser_score)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
{ip.ua_ch_mismatch ? (
|
||||
<span className="text-threat-critical">⚠️ Oui</span>
|
||||
) : (
|
||||
<span className="text-threat-low">✓ Non</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-text-secondary">{ip.sec_fetch_mode || '—'}</td>
|
||||
<td className="py-1.5 pr-4 text-text-secondary">{ip.sec_fetch_dest || '—'}</td>
|
||||
<td className="py-1.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onInvestigateIP(ip.ip); }}
|
||||
className="bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HeaderFingerprintView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [clusters, setClusters] = useState<HeaderCluster[]>([]);
|
||||
const [totalClusters, setTotalClusters] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">📡 Fingerprint HTTP Headers</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Clustering par ordre et composition des headers HTTP pour identifier les bots et fingerprints suspects.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard label="Total clusters" value={formatNumber(totalClusters)} accent="text-text-primary" />
|
||||
<StatCard label="Clusters suspects (UA/CH >50%)" value={formatNumber(suspiciousClusters)} accent="text-threat-critical" />
|
||||
<StatCard label="Clusters légitimes" value={formatNumber(legitimateClusters)} accent="text-threat-low" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<div className="p-4"><ErrorMessage message={error} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">Hash cluster</th>
|
||||
<th className="px-4 py-3">IPs</th>
|
||||
<th className="px-4 py-3">Browser Score</th>
|
||||
<th className="px-4 py-3">UA/CH Mismatch %</th>
|
||||
<th className="px-4 py-3">Classification</th>
|
||||
<th className="px-4 py-3">Sec-Fetch modes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clusters.map((cluster) => (
|
||||
<ClusterRow
|
||||
key={cluster.hash}
|
||||
cluster={cluster}
|
||||
onInvestigateIP={(ip) => navigate(`/investigation/${ip}`)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{!loading && !error && (
|
||||
<p className="text-text-secondary text-xs">{formatNumber(totalClusters)} cluster(s) détecté(s)</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/components/HeatmapView.tsx
Normal file
320
frontend/src/components/HeatmapView.tsx
Normal file
@ -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 (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mini sparkline for a host row
|
||||
function Sparkline({ data }: { data: number[] }) {
|
||||
const max = Math.max(...data, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-px" style={{ height: '24px', width: '96px' }}>
|
||||
{data.map((v, i) => {
|
||||
const pct = (v / max) * 100;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: `${Math.max(pct, 5)}%`, width: '4px' }}
|
||||
className={`rounded-sm ${pct >= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/50'}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HeatmapView() {
|
||||
const [hourly, setHourly] = useState<HourlyEntry[]>([]);
|
||||
const [hourlyLoading, setHourlyLoading] = useState(true);
|
||||
const [hourlyError, setHourlyError] = useState<string | null>(null);
|
||||
|
||||
const [topHosts, setTopHosts] = useState<TopHost[]>([]);
|
||||
const [topHostsLoading, setTopHostsLoading] = useState(true);
|
||||
const [topHostsError, setTopHostsError] = useState<string | null>(null);
|
||||
|
||||
const [matrixData, setMatrixData] = useState<HeatmapMatrix | null>(null);
|
||||
const [matrixLoading, setMatrixLoading] = useState(true);
|
||||
const [matrixError, setMatrixError] = useState<string | null>(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 (
|
||||
<div className="p-6 space-y-8 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">⏱️ Heatmap Temporelle d'Attaques</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Distribution horaire de l'activité malveillante par host cible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
label="Heure de pic"
|
||||
value={peakHour.hits > 0 ? `${peakHour.hour}h (${formatNumber(peakHour.hits)} hits)` : '—'}
|
||||
accent="text-threat-critical"
|
||||
/>
|
||||
<StatCard label="Total hits (24h)" value={formatNumber(totalHits)} accent="text-text-primary" />
|
||||
<StatCard label="Hosts ciblés" value={formatNumber(matrixData?.hosts.length ?? 0)} accent="text-threat-high" />
|
||||
</div>
|
||||
|
||||
{/* Section 1: Courbe horaire */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
<h2 className="text-text-primary font-semibold mb-4">Activité horaire — 24h</h2>
|
||||
{hourlyLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : hourlyError ? (
|
||||
<ErrorMessage message={hourlyError} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-end gap-1" style={{ height: '160px' }}>
|
||||
{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 (
|
||||
<div key={i} className="flex flex-col items-center flex-1 gap-1 h-full">
|
||||
<div className="w-full flex flex-col justify-end flex-1">
|
||||
<div
|
||||
title={`${i}h: ${entry.hits} hits`}
|
||||
style={{ height: `${Math.max(pct, 1)}%` }}
|
||||
className={`w-full rounded-t transition-all ${
|
||||
pct >= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/60'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-disabled text-xs">{i}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-critical rounded-sm inline-block" /> Élevé (≥70%)</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-medium rounded-sm inline-block" /> Moyen (≥30%)</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-accent-primary/60 rounded-sm inline-block" /> Faible</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Heatmap matrix */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
<h2 className="text-text-primary font-semibold mb-4">Heatmap Host × Heure</h2>
|
||||
{matrixLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : matrixError ? (
|
||||
<ErrorMessage message={matrixError} />
|
||||
) : !matrixData || displayHosts.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm">Aucune donnée disponible.</p>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<div className="inline-block min-w-full">
|
||||
{/* Hour headers */}
|
||||
<div className="flex" style={{ marginLeft: '180px' }}>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<div key={i} className="text-center text-xs text-text-disabled" style={{ width: '28px', minWidth: '28px' }}>
|
||||
{i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{displayHosts.map((host, rowIdx) => {
|
||||
const rowData = matrixData.matrix[rowIdx] ?? Array(24).fill(0);
|
||||
return (
|
||||
<div key={host} className="flex items-center" style={{ height: '24px', marginBottom: '2px' }}>
|
||||
<div className="text-xs text-text-secondary truncate text-right pr-2" style={{ width: '180px', minWidth: '180px' }} title={host}>
|
||||
{host}
|
||||
</div>
|
||||
{Array.from({ length: 24 }, (_, h) => {
|
||||
const val = rowData[h] ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
title={`${host} ${h}h: ${val} hits`}
|
||||
style={{
|
||||
width: '26px',
|
||||
minWidth: '26px',
|
||||
height: '20px',
|
||||
marginRight: '2px',
|
||||
borderRadius: '2px',
|
||||
...heatmapCellStyle(val, matrixMax),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-4 text-xs text-text-secondary items-center">
|
||||
<span>Intensité :</span>
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<span key={label} className="flex items-center gap-1">
|
||||
<span style={{ ...style, display: 'inline-block', width: '14px', height: '14px', borderRadius: '2px' }} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3: Top hosts table */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-text-primary font-semibold">Top Hosts ciblés</h2>
|
||||
</div>
|
||||
{topHostsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : topHostsError ? (
|
||||
<div className="p-4"><ErrorMessage message={topHostsError} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">Host</th>
|
||||
<th className="px-4 py-3">Total hits</th>
|
||||
<th className="px-4 py-3">IPs uniques</th>
|
||||
<th className="px-4 py-3">JA4 uniques</th>
|
||||
<th className="px-4 py-3">Activité 24h</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topHosts.map((h) => (
|
||||
<tr key={h.host} className="border-b border-border hover:bg-background-card transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-primary">{h.host}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(h.total_hits)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{formatNumber(h.unique_ips)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{formatNumber(h.unique_ja4s)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Sparkline data={h.hourly_hits ?? Array(24).fill(0)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
529
frontend/src/components/MLFeaturesView.tsx
Normal file
529
frontend/src/components/MLFeaturesView.tsx
Normal file
@ -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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="overflow-visible">
|
||||
{/* Background rings */}
|
||||
{rings.map((r) => {
|
||||
const pts = RADAR_AXES.map((_, i) => {
|
||||
const [x, y] = pointFor(i, (r / 100) * maxR);
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return (
|
||||
<polygon
|
||||
key={r}
|
||||
points={pts}
|
||||
fill="none"
|
||||
stroke="rgba(100,116,139,0.2)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Axis lines */}
|
||||
{RADAR_AXES.map((_, i) => {
|
||||
const [x, y] = pointFor(i, maxR);
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={cx} y1={cy}
|
||||
x2={x} y2={y}
|
||||
stroke="rgba(100,116,139,0.35)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Data polygon */}
|
||||
<polygon
|
||||
points={polygonPoints}
|
||||
fill="rgba(239,68,68,0.2)"
|
||||
stroke="rgba(239,68,68,0.85)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Data dots */}
|
||||
{dataPoints.map(([x, y], i) => (
|
||||
<circle key={i} cx={x} cy={y} r="3" fill="rgba(239,68,68,0.9)" />
|
||||
))}
|
||||
|
||||
{/* 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 (
|
||||
<text
|
||||
key={axis.key}
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor={anchor}
|
||||
fontSize="10"
|
||||
fill="rgba(148,163,184,0.9)"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{axis.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Percentage labels on vertical axis */}
|
||||
{rings.map((r) => {
|
||||
const [, y] = pointFor(0, (r / 100) * maxR);
|
||||
return (
|
||||
<text key={r} x={cx + 3} y={y} fontSize="8" fill="rgba(100,116,139,0.6)" dominantBaseline="middle">
|
||||
{r}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="relative">
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="overflow-visible"
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{xTicks.map((v) => (
|
||||
<line key={v} x1={toSvgX(v)} y1={padT} x2={toSvgX(v)} y2={H - padB} stroke="rgba(100,116,139,0.15)" strokeWidth="1" />
|
||||
))}
|
||||
{yTicks.map((v) => (
|
||||
<line key={v} x1={padL} y1={toSvgY(v)} x2={W - padR} y2={toSvgY(v)} stroke="rgba(100,116,139,0.15)" strokeWidth="1" />
|
||||
))}
|
||||
|
||||
{/* X axis */}
|
||||
<line x1={padL} y1={H - padB} x2={W - padR} y2={H - padB} stroke="rgba(100,116,139,0.4)" strokeWidth="1" />
|
||||
{xTicks.map((v) => (
|
||||
<text key={v} x={toSvgX(v)} y={H - padB + 12} textAnchor="middle" fontSize="9" fill="rgba(148,163,184,0.7)">{v}</text>
|
||||
))}
|
||||
<text x={(W - padL - padR) / 2 + padL} y={H - 2} textAnchor="middle" fontSize="10" fill="rgba(148,163,184,0.8)">Fuzzing Index →</text>
|
||||
|
||||
{/* Y axis */}
|
||||
<line x1={padL} y1={padT} x2={padL} y2={H - padB} stroke="rgba(100,116,139,0.4)" strokeWidth="1" />
|
||||
{yTicks.map((v) => (
|
||||
<text key={v} x={padL - 4} y={toSvgY(v)} textAnchor="end" fontSize="9" fill="rgba(148,163,184,0.7)" dominantBaseline="middle">{v.toFixed(2)}</text>
|
||||
))}
|
||||
|
||||
{/* 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 (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={3}
|
||||
fill={color}
|
||||
fillOpacity={0.75}
|
||||
stroke={color}
|
||||
strokeWidth="0.5"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={() => setTooltip({ ip: pt.ip, type: pt.attack_type, x, y })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<g>
|
||||
<rect
|
||||
x={Math.min(tooltip.x + 6, W - 120)}
|
||||
y={Math.max(tooltip.y - 28, padT)}
|
||||
width={110}
|
||||
height={28}
|
||||
rx={3}
|
||||
fill="rgba(15,23,42,0.95)"
|
||||
stroke="rgba(100,116,139,0.4)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={Math.min(tooltip.x + 11, W - 115)}
|
||||
y={Math.max(tooltip.y - 18, padT + 8)}
|
||||
fontSize="9"
|
||||
fill="white"
|
||||
>
|
||||
{tooltip.ip}
|
||||
</text>
|
||||
<text
|
||||
x={Math.min(tooltip.x + 11, W - 115)}
|
||||
y={Math.max(tooltip.y - 7, padT + 19)}
|
||||
fontSize="9"
|
||||
fill="rgba(148,163,184,0.9)"
|
||||
>
|
||||
{attackTypeEmoji(tooltip.type)} {tooltip.type}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function MLFeaturesView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [anomalies, setAnomalies] = useState<MLAnomaly[]>([]);
|
||||
const [anomaliesLoading, setAnomaliesLoading] = useState(true);
|
||||
const [anomaliesError, setAnomaliesError] = useState<string | null>(null);
|
||||
|
||||
const [scatter, setScatter] = useState<ScatterPoint[]>([]);
|
||||
const [scatterLoading, setScatterLoading] = useState(true);
|
||||
const [scatterError, setScatterError] = useState<string | null>(null);
|
||||
|
||||
const [selectedIP, setSelectedIP] = useState<string | null>(null);
|
||||
const [radarData, setRadarData] = useState<RadarData | null>(null);
|
||||
const [radarLoading, setRadarLoading] = useState(false);
|
||||
const [radarError, setRadarError] = useState<string | null>(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 (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🤖 Analyse Features ML</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Visualisation des features ML pour la détection d'anomalies comportementales.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main two-column layout */}
|
||||
<div className="flex gap-6 flex-col lg:flex-row">
|
||||
{/* Left: anomalies table */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h2 className="text-text-primary font-semibold text-sm">Top anomalies</h2>
|
||||
</div>
|
||||
{anomaliesLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : anomaliesError ? (
|
||||
<div className="p-4"><ErrorMessage message={anomaliesError} /></div>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-[500px]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-background-secondary z-10">
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-3 py-2">IP</th>
|
||||
<th className="px-3 py-2">Host</th>
|
||||
<th className="px-3 py-2">Hits</th>
|
||||
<th className="px-3 py-2">Fuzzing</th>
|
||||
<th className="px-3 py-2">Type</th>
|
||||
<th className="px-3 py-2">Signaux</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{anomalies.map((item) => (
|
||||
<tr
|
||||
key={item.ip}
|
||||
onClick={() => loadRadar(item.ip)}
|
||||
className={`border-b border-border cursor-pointer transition-colors ${
|
||||
selectedIP === item.ip
|
||||
? 'bg-accent-primary/10'
|
||||
: 'hover:bg-background-card'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-text-primary">{item.ip}</td>
|
||||
<td className="px-3 py-2 text-text-secondary max-w-[120px] truncate" title={item.host}>
|
||||
{item.host || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-primary">{formatNumber(item.hits)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(item.fuzzing_index)}`}>
|
||||
{Math.round(item.fuzzing_index)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span title={item.attack_type} className="text-sm">
|
||||
{attackTypeEmoji(item.attack_type)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex gap-0.5">
|
||||
{item.ua_ch_mismatch && <span title="UA/CH mismatch">⚠️</span>}
|
||||
{item.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
|
||||
{item.is_ua_rotating && <span title="UA rotatif">🔄</span>}
|
||||
{item.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Radar chart */}
|
||||
<div className="lg:w-80 xl:w-96">
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-4 h-full flex flex-col">
|
||||
<h2 className="text-text-primary font-semibold text-sm mb-3">
|
||||
Radar ML {selectedIP ? <span className="text-accent-primary font-mono text-xs">— {selectedIP}</span> : ''}
|
||||
</h2>
|
||||
{!selectedIP ? (
|
||||
<div className="flex-1 flex items-center justify-center text-text-disabled text-sm text-center">
|
||||
Cliquez sur une IP<br />pour afficher le radar
|
||||
</div>
|
||||
) : radarLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : radarError ? (
|
||||
<ErrorMessage message={radarError} />
|
||||
) : radarData ? (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<RadarChart data={radarData} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${selectedIP}`)}
|
||||
className="mt-3 w-full text-xs bg-accent-primary/10 text-accent-primary px-3 py-2 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
🔍 Investiguer {selectedIP}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scatter plot */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
<h2 className="text-text-primary font-semibold mb-4">Nuage de points — Fuzzing Index × Vélocité</h2>
|
||||
{scatterLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : scatterError ? (
|
||||
<ErrorMessage message={scatterError} />
|
||||
) : (
|
||||
<>
|
||||
<ScatterPlot points={scatter} />
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 mt-3 text-xs text-text-secondary">
|
||||
{['brute_force', 'flood', 'scraper', 'spoofing', 'scanner'].map((type) => (
|
||||
<span key={type} className="flex items-center gap-1.5">
|
||||
<span
|
||||
style={{ backgroundColor: attackTypeColor(type), display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%' }}
|
||||
/>
|
||||
{attackTypeEmoji(type)} {type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
370
frontend/src/components/RotationView.tsx
Normal file
370
frontend/src/components/RotationView.tsx
Normal file
@ -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 (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rotator row with expandable JA4 history ─────────────────────────────────
|
||||
|
||||
function RotatorRow({ item }: { item: JA4Rotator }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [history, setHistory] = useState<JA4HistoryEntry[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(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 (
|
||||
<>
|
||||
<tr
|
||||
className="border-b border-border hover:bg-background-card transition-colors cursor-pointer"
|
||||
onClick={toggle}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-accent-primary text-xs mr-2">{expanded ? '▾' : '▸'}</span>
|
||||
<span className="font-mono text-xs text-text-primary">{item.ip}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-semibold ${
|
||||
isHighRotation ? 'bg-threat-critical/20 text-threat-critical' : 'bg-threat-medium/20 text-threat-medium'
|
||||
}`}>
|
||||
{item.distinct_ja4_count} JA4
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(item.total_hits)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-background-card rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-threat-critical"
|
||||
style={{ width: `${Math.min(item.evasion_score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-threat-critical font-semibold">{Math.round(item.evasion_score)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border bg-background-card">
|
||||
<td colSpan={4} className="px-6 py-4">
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center gap-2 text-text-secondary text-sm">
|
||||
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
Chargement de l'historique…
|
||||
</div>
|
||||
) : historyError ? (
|
||||
<span className="text-threat-critical text-sm">⚠️ {historyError}</span>
|
||||
) : history.length === 0 ? (
|
||||
<span className="text-text-secondary text-sm">Aucun historique disponible.</span>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-text-secondary text-xs mb-2">Historique des JA4 utilisés :</p>
|
||||
{history.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono text-text-primary bg-background-secondary border border-border rounded px-2 py-0.5">
|
||||
{entry.ja4}
|
||||
</span>
|
||||
<span className="text-text-secondary">{formatNumber(entry.hits)} hits</span>
|
||||
<span className="text-text-disabled">{formatDate(entry.window_start)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function RotationView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('rotators');
|
||||
|
||||
const [rotators, setRotators] = useState<JA4Rotator[]>([]);
|
||||
const [rotatorsLoading, setRotatorsLoading] = useState(true);
|
||||
const [rotatorsError, setRotatorsError] = useState<string | null>(null);
|
||||
|
||||
const [persistent, setPersistent] = useState<PersistentThreat[]>([]);
|
||||
const [persistentLoading, setPersistentLoading] = useState(false);
|
||||
const [persistentError, setPersistentError] = useState<string | null>(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 (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🔄 Rotation JA4 & Persistance</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Détection des IPs qui changent de fingerprint pour contourner les détections, et des menaces persistantes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="IPs en rotation" value={formatNumber(rotators.length)} accent="text-threat-high" />
|
||||
<StatCard label="Score évasion max" value={Math.round(maxEvasion)} accent="text-threat-critical" />
|
||||
<StatCard label="IPs persistantes" value={persistentLoaded ? formatNumber(persistent.length) : '—'} accent="text-threat-medium" />
|
||||
<StatCard label="Score persistance max" value={persistentLoaded ? Math.round(maxPersistence) : '—'} accent="text-threat-critical" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-accent-primary border-b-2 border-accent-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rotateurs tab */}
|
||||
{activeTab === 'rotators' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{rotatorsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : rotatorsError ? (
|
||||
<div className="p-4"><ErrorMessage message={rotatorsError} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">IP</th>
|
||||
<th className="px-4 py-3">JA4 distincts</th>
|
||||
<th className="px-4 py-3">Total hits</th>
|
||||
<th className="px-4 py-3">Score d'évasion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rotators.map((item) => (
|
||||
<RotatorRow key={item.ip} item={item} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Persistantes tab */}
|
||||
{activeTab === 'persistent' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{persistentLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : persistentError ? (
|
||||
<div className="p-4"><ErrorMessage message={persistentError} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">IP</th>
|
||||
<th className="px-4 py-3">Récurrence</th>
|
||||
<th className="px-4 py-3">Score menace</th>
|
||||
<th className="px-4 py-3">Niveau</th>
|
||||
<th className="px-4 py-3">Première vue</th>
|
||||
<th className="px-4 py-3">Dernière vue</th>
|
||||
<th className="px-4 py-3">Score persistance</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{persistent.map((item) => {
|
||||
const badge = threatLevelBadge(item.worst_threat_level);
|
||||
return (
|
||||
<tr key={item.ip} className="border-b border-border hover:bg-background-card transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-primary">{item.ip}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">
|
||||
{item.recurrence}j
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary font-semibold">{Math.round(item.worst_score)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>
|
||||
{item.worst_threat_level?.toUpperCase() || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">{formatDate(item.first_seen)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">{formatDate(item.last_seen)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-background-card rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-threat-high"
|
||||
style={{ width: `${Math.min(item.persistence_score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{Math.round(item.persistence_score)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${item.ip}`)}
|
||||
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
frontend/src/components/TcpSpoofingView.tsx
Normal file
363
frontend/src/components/TcpSpoofingView.tsx
Normal file
@ -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 (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function TcpSpoofingView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('detections');
|
||||
|
||||
const [overview, setOverview] = useState<TcpSpoofingOverview | null>(null);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
|
||||
const [items, setItems] = useState<TcpSpoofingItem[]>([]);
|
||||
const [itemsLoading, setItemsLoading] = useState(true);
|
||||
const [itemsError, setItemsError] = useState<string | null>(null);
|
||||
|
||||
const [matrix, setMatrix] = useState<OsMatrixEntry[]>([]);
|
||||
const [matrixLoading, setMatrixLoading] = useState(false);
|
||||
const [matrixError, setMatrixError] = useState<string | null>(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 (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🧬 Spoofing TCP/OS</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Détection des incohérences entre TTL/fenêtre TCP et l'OS déclaré.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
{overviewLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : overviewError ? (
|
||||
<ErrorMessage message={overviewError} />
|
||||
) : overview ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="Total détections" value={formatNumber(overview.total_detections)} accent="text-threat-high" />
|
||||
<StatCard label="IPs uniques" value={formatNumber(overview.unique_ips)} accent="text-text-primary" />
|
||||
<StatCard label="TTL bas (<60)" value={formatNumber(overview.low_ttl_count)} accent="text-threat-medium" />
|
||||
<StatCard label="TTL zéro" value={formatNumber(overview.zero_ttl_count)} accent="text-threat-critical" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-accent-primary border-b-2 border-accent-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Détections tab */}
|
||||
{activeTab === 'detections' && (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrer par IP ou OS..."
|
||||
value={filterText}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{itemsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : itemsError ? (
|
||||
<div className="p-4"><ErrorMessage message={itemsError} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">IP</th>
|
||||
<th className="px-4 py-3">JA4</th>
|
||||
<th className="px-4 py-3">TTL observé</th>
|
||||
<th className="px-4 py-3">Fenêtre TCP</th>
|
||||
<th className="px-4 py-3">OS suspecté</th>
|
||||
<th className="px-4 py-3">OS déclaré</th>
|
||||
<th className="px-4 py-3">Spoof</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item) => (
|
||||
<tr key={item.ip} className="border-b border-border hover:bg-background-card transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-primary">{item.ip}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-secondary">
|
||||
{item.ja4 ? `${item.ja4.slice(0, 14)}…` : '—'}
|
||||
</td>
|
||||
<td className={`px-4 py-3 font-mono font-semibold ${ttlColor(item.tcp_ttl)}`}>
|
||||
{item.tcp_ttl}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">
|
||||
{formatNumber(item.tcp_window_size)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary text-xs">{item.suspected_os || '—'}</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">{item.declared_os || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.spoof_flag && (
|
||||
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-0.5 rounded-full">
|
||||
🚨 Spoof
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${item.ip}`)}
|
||||
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Matrice OS tab */}
|
||||
{activeTab === 'matrix' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
{matrixLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : matrixError ? (
|
||||
<ErrorMessage message={matrixError} />
|
||||
) : matrix.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm">Aucune donnée disponible.</p>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<h2 className="text-text-primary font-semibold mb-4">OS Suspecté × OS Déclaré</h2>
|
||||
<table className="text-xs border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-text-secondary text-left border border-border bg-background-card">
|
||||
Suspecté \ Déclaré
|
||||
</th>
|
||||
{declaredOSes.map((os) => (
|
||||
<th key={os} className="px-3 py-2 text-text-secondary text-center border border-border bg-background-card max-w-[80px]">
|
||||
<span className="block truncate w-20" title={os}>{os}</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-text-secondary text-center border border-border bg-background-card">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={sos}>
|
||||
<td className="px-3 py-2 text-text-primary border border-border bg-background-card font-medium max-w-[120px]">
|
||||
<span className="block truncate w-28" title={sos}>{sos}</span>
|
||||
</td>
|
||||
{rowEntries.map((count, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
className={`px-3 py-2 text-center border border-border font-mono ${matrixCellColor(count)} ${count > 0 ? 'text-text-primary' : 'text-text-disabled'}`}
|
||||
>
|
||||
{count > 0 ? formatNumber(count) : '—'}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-3 py-2 text-center border border-border font-semibold text-text-primary bg-background-card">
|
||||
{formatNumber(rowTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-text-secondary border border-border bg-background-card font-semibold">Total</td>
|
||||
{declaredOSes.map((dos) => {
|
||||
const colTotal = matrix
|
||||
.filter((e) => e.declared_os === dos)
|
||||
.reduce((s, e) => s + e.count, 0);
|
||||
return (
|
||||
<td key={dos} className="px-3 py-2 text-center border border-border font-semibold text-text-primary bg-background-card">
|
||||
{formatNumber(colTotal)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-3 py-2 text-center border border-border font-semibold text-accent-primary bg-background-card">
|
||||
{formatNumber(matrix.reduce((s, e) => s + e.count, 0))}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user