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:
SOC Analyst
2026-03-15 23:57:27 +01:00
parent 1455e04303
commit e2bc4a47cd
16 changed files with 3499 additions and 1 deletions

View File

@ -13,6 +13,7 @@ import os
from .config import settings from .config import settings
from .database import db from .database import db
from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints 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 # Configuration logging
logging.basicConfig( logging.basicConfig(
@ -74,6 +75,13 @@ app.include_router(incidents.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(reputation.router) app.include_router(reputation.router)
app.include_router(fingerprints.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 # Route pour servir le frontend

105
backend/routes/botnets.py Normal file
View 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))

View 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))

View 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
View 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))

View 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
View 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))

View 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))

View File

@ -15,6 +15,13 @@ import { BulkClassification } from './components/BulkClassification';
import { PivotView } from './components/PivotView'; import { PivotView } from './components/PivotView';
import { FingerprintsView } from './components/FingerprintsView'; import { FingerprintsView } from './components/FingerprintsView';
import { CampaignsView } from './components/CampaignsView'; 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'; import { useTheme } from './ThemeContext';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -72,7 +79,17 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) {
{ path: '/threat-intel', label: 'Threat Intel', icon: '📚', aliases: [] }, { 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 || location.pathname === link.path ||
link.aliases.some(a => location.pathname.startsWith(a)) || link.aliases.some(a => location.pathname.startsWith(a)) ||
(link.path !== '/' && location.pathname.startsWith(`${link.path}/`)); (link.path !== '/' && location.pathname.startsWith(`${link.path}/`));
@ -109,6 +126,25 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) {
))} ))}
</nav> </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 */} {/* Alert stats */}
{counts && ( {counts && (
<div className="mx-3 mt-5 bg-background-card rounded-lg p-3 space-y-2"> <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('/campaigns')) return 'Campagnes / Botnets';
if (p.startsWith('/pivot')) return 'Pivot / Corrélation'; if (p.startsWith('/pivot')) return 'Pivot / Corrélation';
if (p.startsWith('/bulk-classify')) return 'Classification en masse'; 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 ''; return '';
}; };
@ -334,6 +377,13 @@ export default function App() {
<Route path="/fingerprints" element={<FingerprintsView />} /> <Route path="/fingerprints" element={<FingerprintsView />} />
<Route path="/campaigns" element={<CampaignsView />} /> <Route path="/campaigns" element={<CampaignsView />} />
<Route path="/threat-intel" element={<ThreatIntelView />} /> <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" element={<DetectionsList />} />
<Route path="/detections/:type/:value" element={<DetailsView />} /> <Route path="/detections/:type/:value" element={<DetailsView />} />
<Route path="/investigate" element={<DetectionsList />} /> <Route path="/investigate" element={<DetectionsList />} />

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}