suite des maj
This commit is contained in:
@ -735,3 +735,97 @@ async def get_legitimate_ja4(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENDPOINT — Corrélation JA4 × ASN / Pays (C5)
|
||||||
|
# Détecte les JA4 fortement concentrés sur un seul ASN ou pays
|
||||||
|
# → signal de botnet ciblé ou d'infrastructure de test/attaque partagée
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/asn-correlation")
|
||||||
|
async def get_ja4_asn_correlation(
|
||||||
|
min_concentration: float = Query(0.7, ge=0.0, le=1.0, description="Seuil min de concentration ASN ou pays"),
|
||||||
|
min_ips: int = Query(5, ge=1, description="Nombre minimum d'IPs par JA4"),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Identifie les JA4 fingerprints fortement concentrés sur un seul ASN ou pays.
|
||||||
|
Un JA4 avec asn_concentration ≥ 0.7 signifie que ≥70% des IPs utilisant ce fingerprint
|
||||||
|
proviennent du même ASN → infrastructure de bot partagée ou datacenter suspect.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Two-pass: first aggregate per (ja4, asn) to get IP counts per ASN,
|
||||||
|
# then aggregate per ja4 to compute concentration ratio
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
ja4,
|
||||||
|
sum(ips_per_combo) AS unique_ips,
|
||||||
|
uniq(src_asn) AS unique_asns,
|
||||||
|
uniq(src_country_code) AS unique_countries,
|
||||||
|
toString(argMax(src_asn, ips_per_combo)) AS top_asn_number,
|
||||||
|
argMax(asn_name, ips_per_combo) AS top_asn_name,
|
||||||
|
argMax(src_country_code, country_ips) AS dominant_country,
|
||||||
|
sum(total_hits) AS total_hits,
|
||||||
|
round(max(ips_per_combo) / greatest(sum(ips_per_combo), 1), 3) AS asn_concentration,
|
||||||
|
round(max(country_ips) / greatest(sum(ips_per_combo), 1), 3) AS country_concentration
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
ja4,
|
||||||
|
src_asn,
|
||||||
|
src_country_code,
|
||||||
|
any(src_as_name) AS asn_name,
|
||||||
|
uniq(src_ip) AS ips_per_combo,
|
||||||
|
uniq(src_ip) AS country_ips,
|
||||||
|
sum(hits) AS total_hits
|
||||||
|
FROM mabase_prod.agg_host_ip_ja4_1h
|
||||||
|
WHERE window_start >= now() - INTERVAL 24 HOUR
|
||||||
|
AND ja4 != ''
|
||||||
|
GROUP BY ja4, src_asn, src_country_code
|
||||||
|
)
|
||||||
|
GROUP BY ja4
|
||||||
|
HAVING unique_ips >= %(min_ips)s
|
||||||
|
AND (asn_concentration >= %(min_conc)s OR country_concentration >= %(min_conc)s)
|
||||||
|
ORDER BY asn_concentration DESC, unique_ips DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
result = db.query(sql, {"min_ips": min_ips, "min_conc": min_concentration, "limit": limit})
|
||||||
|
items = []
|
||||||
|
for row in result.result_rows:
|
||||||
|
ja4 = str(row[0])
|
||||||
|
unique_ips = int(row[1])
|
||||||
|
unique_asns = int(row[2])
|
||||||
|
unique_countries = int(row[3])
|
||||||
|
top_asn_number = str(row[4] or "")
|
||||||
|
top_asn_name = str(row[5] or "")
|
||||||
|
dominant_country = str(row[6] or "")
|
||||||
|
total_hits = int(row[7] or 0)
|
||||||
|
asn_concentration = float(row[8] or 0)
|
||||||
|
country_concentration = float(row[9] or 0)
|
||||||
|
|
||||||
|
if asn_concentration >= 0.85:
|
||||||
|
corr_type, risk = "asn_monopoly", "high"
|
||||||
|
elif asn_concentration >= min_concentration:
|
||||||
|
corr_type, risk = "asn_dominant", "medium"
|
||||||
|
elif country_concentration >= min_concentration:
|
||||||
|
corr_type, risk = "geo_targeted", "medium"
|
||||||
|
else:
|
||||||
|
corr_type, risk = "distributed", "low"
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"ja4": ja4,
|
||||||
|
"unique_ips": unique_ips,
|
||||||
|
"unique_asns": unique_asns,
|
||||||
|
"unique_countries": unique_countries,
|
||||||
|
"top_asn_name": top_asn_name,
|
||||||
|
"top_asn_number": top_asn_number,
|
||||||
|
"dominant_country": dominant_country,
|
||||||
|
"total_hits": total_hits,
|
||||||
|
"asn_concentration": asn_concentration,
|
||||||
|
"country_concentration":country_concentration,
|
||||||
|
"correlation_type": corr_type,
|
||||||
|
"risk": risk,
|
||||||
|
})
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
|
||||||
|
|||||||
@ -23,37 +23,47 @@ def _attack_type(fuzzing_index: float, hit_velocity: float,
|
|||||||
|
|
||||||
@router.get("/top-anomalies")
|
@router.get("/top-anomalies")
|
||||||
async def get_top_anomalies(limit: int = Query(50, ge=1, le=500)):
|
async def get_top_anomalies(limit: int = Query(50, ge=1, le=500)):
|
||||||
"""Top IPs anomales déduplicées par IP (max fuzzing_index), triées par fuzzing_index DESC."""
|
"""Top IPs anomales (24h) — bypass view_ai_features_1h pour éviter les window functions.
|
||||||
|
Query directe sur agg_host_ip_ja4_1h + LEFT JOIN agg_header_fingerprint_1h.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT
|
SELECT
|
||||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
replaceRegexpAll(toString(a.src_ip), '^::ffff:', '') AS ip,
|
||||||
any(ja4) AS ja4,
|
any(a.ja4) AS ja4,
|
||||||
any(host) AS host,
|
any(a.host) AS host,
|
||||||
max(hits) AS hits,
|
sum(a.hits) AS hits,
|
||||||
max(fuzzing_index) AS max_fuzzing,
|
round(max(uniqMerge(a.uniq_query_params))
|
||||||
max(hit_velocity) AS hit_velocity,
|
/ greatest(max(uniqMerge(a.uniq_paths)), 1), 4) AS fuzzing_index,
|
||||||
max(temporal_entropy) AS temporal_entropy,
|
round(sum(a.hits)
|
||||||
max(is_fake_navigation) AS is_fake_navigation,
|
/ greatest(dateDiff('second', min(a.first_seen), max(a.last_seen)), 1), 2) AS hit_velocity,
|
||||||
max(ua_ch_mismatch) AS ua_ch_mismatch,
|
round(sum(a.count_head) / greatest(sum(a.hits), 1), 4) AS head_ratio,
|
||||||
max(sni_host_mismatch) AS sni_host_mismatch,
|
round(sum(a.count_no_sec_fetch) / greatest(sum(a.hits), 1), 4) AS sec_fetch_absence,
|
||||||
max(is_ua_rotating) AS is_ua_rotating,
|
round(sum(a.tls12_count) / greatest(sum(a.hits), 1), 4) AS tls12_ratio,
|
||||||
max(path_diversity_ratio) AS path_diversity_ratio,
|
round(sum(a.count_generic_accept) / greatest(sum(a.hits), 1), 4) AS generic_accept_ratio,
|
||||||
max(anomalous_payload_ratio) AS anomalous_payload_ratio,
|
any(a.src_country_code) AS country,
|
||||||
any(asn_label) AS asn_label,
|
any(a.src_as_name) AS asn_name,
|
||||||
any(bot_name) AS bot_name
|
max(h.ua_ch_mismatch) AS ua_ch_mismatch,
|
||||||
FROM mabase_prod.view_ai_features_1h
|
max(h.modern_browser_score) AS browser_score,
|
||||||
GROUP BY src_ip
|
dictGetOrDefault('mabase_prod.dict_asn_reputation', 'label', toUInt64(any(a.src_asn)), 'unknown') AS asn_label,
|
||||||
ORDER BY 5 DESC
|
coalesce(
|
||||||
|
nullIf(dictGetOrDefault('mabase_prod.dict_bot_ja4', 'bot_name', tuple(any(a.ja4)), ''), ''),
|
||||||
|
''
|
||||||
|
) AS bot_name
|
||||||
|
FROM mabase_prod.agg_host_ip_ja4_1h a
|
||||||
|
LEFT JOIN mabase_prod.agg_header_fingerprint_1h h
|
||||||
|
ON a.src_ip = h.src_ip AND a.window_start = h.window_start
|
||||||
|
WHERE a.window_start >= now() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY a.src_ip
|
||||||
|
ORDER BY fuzzing_index DESC
|
||||||
LIMIT %(limit)s
|
LIMIT %(limit)s
|
||||||
"""
|
"""
|
||||||
result = db.query(sql, {"limit": limit})
|
result = db.query(sql, {"limit": limit})
|
||||||
items = []
|
items = []
|
||||||
for row in result.result_rows:
|
for row in result.result_rows:
|
||||||
fuzzing = float(row[4] or 0)
|
fuzzing = float(row[4] or 0)
|
||||||
velocity = float(row[5] or 0)
|
velocity = float(row[5] or 0)
|
||||||
fake_nav = int(row[7] or 0)
|
ua_mm = int(row[12] or 0)
|
||||||
ua_mm = int(row[8] or 0)
|
|
||||||
items.append({
|
items.append({
|
||||||
"ip": str(row[0]),
|
"ip": str(row[0]),
|
||||||
"ja4": str(row[1]),
|
"ja4": str(row[1]),
|
||||||
@ -61,16 +71,17 @@ async def get_top_anomalies(limit: int = Query(50, ge=1, le=500)):
|
|||||||
"hits": int(row[3] or 0),
|
"hits": int(row[3] or 0),
|
||||||
"fuzzing_index": fuzzing,
|
"fuzzing_index": fuzzing,
|
||||||
"hit_velocity": velocity,
|
"hit_velocity": velocity,
|
||||||
"temporal_entropy": float(row[6] or 0),
|
"head_ratio": float(row[6] or 0),
|
||||||
"is_fake_navigation": fake_nav,
|
"sec_fetch_absence": float(row[7] or 0),
|
||||||
|
"tls12_ratio": float(row[8] or 0),
|
||||||
|
"generic_accept_ratio": float(row[9] or 0),
|
||||||
|
"country": str(row[10] or ""),
|
||||||
|
"asn_name": str(row[11] or ""),
|
||||||
"ua_ch_mismatch": ua_mm,
|
"ua_ch_mismatch": ua_mm,
|
||||||
"sni_host_mismatch": int(row[9] or 0),
|
"browser_score": int(row[13] or 0),
|
||||||
"is_ua_rotating": int(row[10] or 0),
|
"asn_label": str(row[14] or ""),
|
||||||
"path_diversity_ratio": float(row[11] or 0),
|
"bot_name": str(row[15] or ""),
|
||||||
"anomalous_payload_ratio":float(row[12] or 0),
|
"attack_type": _attack_type(fuzzing, velocity, 0, ua_mm),
|
||||||
"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}
|
return {"items": items}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -93,6 +104,7 @@ async def get_ip_radar(ip: str):
|
|||||||
avg(anomalous_payload_ratio) AS anomalous_payload_ratio
|
avg(anomalous_payload_ratio) AS anomalous_payload_ratio
|
||||||
FROM mabase_prod.view_ai_features_1h
|
FROM mabase_prod.view_ai_features_1h
|
||||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s
|
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s
|
||||||
|
AND window_start >= now() - INTERVAL 24 HOUR
|
||||||
"""
|
"""
|
||||||
result = db.query(sql, {"ip": ip})
|
result = db.query(sql, {"ip": ip})
|
||||||
if not result.result_rows:
|
if not result.result_rows:
|
||||||
@ -119,22 +131,264 @@ async def get_ip_radar(ip: str):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scatter")
|
@router.get("/score-distribution")
|
||||||
async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)):
|
async def get_score_distribution():
|
||||||
"""Points pour scatter plot (fuzzing_index × hit_velocity), dédupliqués par IP."""
|
"""
|
||||||
|
Distribution de TOUS les scores ML depuis ml_all_scores (3j).
|
||||||
|
Single query avec conditional aggregates pour éviter le double scan.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Single scan — global totals + per-model breakdown via GROUPING SETS
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
threat_level,
|
||||||
|
model_name,
|
||||||
|
count() AS total,
|
||||||
|
round(avg(anomaly_score), 4) AS avg_score,
|
||||||
|
round(min(anomaly_score), 4) AS min_score,
|
||||||
|
countIf(threat_level = 'NORMAL') AS normal_count,
|
||||||
|
countIf(threat_level NOT IN ('NORMAL','KNOWN_BOT')) AS anomaly_count,
|
||||||
|
countIf(threat_level = 'KNOWN_BOT') AS bot_count
|
||||||
|
FROM mabase_prod.ml_all_scores
|
||||||
|
WHERE detected_at >= now() - INTERVAL 3 DAY
|
||||||
|
GROUP BY threat_level, model_name
|
||||||
|
ORDER BY model_name, total DESC
|
||||||
|
"""
|
||||||
|
result = db.query(sql)
|
||||||
|
by_model: dict = {}
|
||||||
|
grand_total = 0
|
||||||
|
total_normal = total_anomaly = total_bot = 0
|
||||||
|
for row in result.result_rows:
|
||||||
|
level = str(row[0])
|
||||||
|
model = str(row[1])
|
||||||
|
total = int(row[2])
|
||||||
|
grand_total += total
|
||||||
|
total_normal += int(row[5] or 0)
|
||||||
|
total_anomaly += int(row[6] or 0)
|
||||||
|
total_bot += int(row[7] or 0)
|
||||||
|
if model not in by_model:
|
||||||
|
by_model[model] = []
|
||||||
|
by_model[model].append({
|
||||||
|
"threat_level": level,
|
||||||
|
"total": total,
|
||||||
|
"avg_score": float(row[3] or 0),
|
||||||
|
"min_score": float(row[4] or 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
grand_total = max(grand_total, 1)
|
||||||
|
return {
|
||||||
|
"by_model": by_model,
|
||||||
|
"totals": {
|
||||||
|
"normal": total_normal,
|
||||||
|
"anomaly": total_anomaly,
|
||||||
|
"known_bot": total_bot,
|
||||||
|
"grand_total": grand_total,
|
||||||
|
"normal_pct": round(total_normal / grand_total * 100, 1),
|
||||||
|
"anomaly_pct": round(total_anomaly / grand_total * 100, 1),
|
||||||
|
"bot_pct": round(total_bot / grand_total * 100, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/score-trends")
|
||||||
|
async def get_score_trends(hours: int = Query(72, ge=1, le=168)):
|
||||||
|
"""
|
||||||
|
Évolution temporelle des scores ML depuis ml_all_scores.
|
||||||
|
Retourne le score moyen et les counts par heure et par modèle.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT
|
SELECT
|
||||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
toStartOfHour(window_start) AS hour,
|
||||||
any(ja4) AS ja4,
|
model_name,
|
||||||
max(fuzzing_index) AS max_fuzzing,
|
countIf(threat_level = 'NORMAL') AS normal_count,
|
||||||
max(hit_velocity) AS hit_velocity,
|
countIf(threat_level IN ('LOW','MEDIUM','HIGH','CRITICAL')) AS anomaly_count,
|
||||||
max(hits) AS hits,
|
countIf(threat_level = 'KNOWN_BOT') AS bot_count,
|
||||||
max(is_fake_navigation) AS is_fake_navigation,
|
round(avgIf(anomaly_score, threat_level IN ('LOW','MEDIUM','HIGH','CRITICAL')), 4) AS avg_anomaly_score
|
||||||
max(ua_ch_mismatch) AS ua_ch_mismatch
|
FROM mabase_prod.ml_all_scores
|
||||||
FROM mabase_prod.view_ai_features_1h
|
WHERE window_start >= now() - INTERVAL %(hours)s HOUR
|
||||||
|
GROUP BY hour, model_name
|
||||||
|
ORDER BY hour ASC, model_name
|
||||||
|
"""
|
||||||
|
result = db.query(sql, {"hours": hours})
|
||||||
|
points = []
|
||||||
|
for row in result.result_rows:
|
||||||
|
points.append({
|
||||||
|
"hour": str(row[0]),
|
||||||
|
"model": str(row[1]),
|
||||||
|
"normal_count": int(row[2] or 0),
|
||||||
|
"anomaly_count": int(row[3] or 0),
|
||||||
|
"bot_count": int(row[4] or 0),
|
||||||
|
"avg_anomaly_score": float(row[5] or 0),
|
||||||
|
})
|
||||||
|
return {"points": points, "hours": hours}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/b-features")
|
||||||
|
async def get_b_features(limit: int = Query(50, ge=1, le=200)):
|
||||||
|
"""
|
||||||
|
Agrégation des B-features (HTTP pures) pour les top IPs anomales.
|
||||||
|
Source: agg_host_ip_ja4_1h (SimpleAggregateFunction columns).
|
||||||
|
Expose: head_ratio, sec_fetch_absence, tls12_ratio, generic_accept_ratio, http10_ratio.
|
||||||
|
Ces features sont calculées dans view_ai_features_1h mais jamais visualisées dans le dashboard.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sql = """
|
||||||
|
SELECT ip, ja4, country, asn_name, hits,
|
||||||
|
head_ratio, sec_fetch_absence, tls12_ratio, generic_accept_ratio, http10_ratio
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||||
|
any(ja4) AS ja4,
|
||||||
|
any(src_country_code) AS country,
|
||||||
|
any(src_as_name) AS asn_name,
|
||||||
|
sum(hits) AS hits,
|
||||||
|
round(sum(count_head) / greatest(sum(hits),1), 4) AS head_ratio,
|
||||||
|
round(sum(count_no_sec_fetch) / greatest(sum(hits),1), 4) AS sec_fetch_absence,
|
||||||
|
round(sum(tls12_count) / greatest(sum(hits),1), 4) AS tls12_ratio,
|
||||||
|
round(sum(count_generic_accept) / greatest(sum(hits),1), 4) AS generic_accept_ratio,
|
||||||
|
round(sum(count_http10) / greatest(sum(hits),1), 4) AS http10_ratio
|
||||||
|
FROM mabase_prod.agg_host_ip_ja4_1h
|
||||||
|
WHERE window_start >= now() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY src_ip
|
||||||
|
)
|
||||||
|
WHERE sec_fetch_absence > 0.5 OR generic_accept_ratio > 0.3
|
||||||
|
OR head_ratio > 0.1 OR tls12_ratio > 0.5
|
||||||
|
ORDER BY (head_ratio + sec_fetch_absence + generic_accept_ratio) DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
result = db.query(sql, {"limit": limit})
|
||||||
|
items = []
|
||||||
|
for row in result.result_rows:
|
||||||
|
items.append({
|
||||||
|
"ip": str(row[0]),
|
||||||
|
"ja4": str(row[1] or ""),
|
||||||
|
"country": str(row[2] or ""),
|
||||||
|
"asn_name": str(row[3] or ""),
|
||||||
|
"hits": int(row[4] or 0),
|
||||||
|
"head_ratio": float(row[5] or 0),
|
||||||
|
"sec_fetch_absence": float(row[6] or 0),
|
||||||
|
"tls12_ratio": float(row[7] or 0),
|
||||||
|
"generic_accept_ratio":float(row[8] or 0),
|
||||||
|
"http10_ratio": float(row[9] or 0),
|
||||||
|
})
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/campaigns")
|
||||||
|
async def get_ml_campaigns(hours: int = Query(24, ge=1, le=168), limit: int = Query(20, ge=1, le=100)):
|
||||||
|
"""
|
||||||
|
Groupes d'anomalies détectées par DBSCAN (campaign_id >= 0).
|
||||||
|
Si aucune campagne active, fallback sur clustering par /24 subnet + JA4 commun.
|
||||||
|
Utile pour détecter les botnets distribués sans état de campagne DBSCAN.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# First: check real campaigns
|
||||||
|
campaign_sql = """
|
||||||
|
SELECT
|
||||||
|
campaign_id,
|
||||||
|
count() AS total_detections,
|
||||||
|
uniq(src_ip) AS unique_ips,
|
||||||
|
any(threat_level) AS dominant_threat,
|
||||||
|
groupUniqArray(3)(threat_level) AS threat_levels,
|
||||||
|
groupUniqArray(3)(bot_name) AS bot_names,
|
||||||
|
min(detected_at) AS first_seen,
|
||||||
|
max(detected_at) AS last_seen
|
||||||
|
FROM mabase_prod.ml_detected_anomalies
|
||||||
|
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
|
||||||
|
AND campaign_id >= 0
|
||||||
|
GROUP BY campaign_id
|
||||||
|
ORDER BY total_detections DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
result = db.query(campaign_sql, {"hours": hours, "limit": limit})
|
||||||
|
campaigns = []
|
||||||
|
for row in result.result_rows:
|
||||||
|
campaigns.append({
|
||||||
|
"id": f"C{row[0]}",
|
||||||
|
"campaign_id": int(row[0]),
|
||||||
|
"total_detections": int(row[1]),
|
||||||
|
"unique_ips": int(row[2]),
|
||||||
|
"dominant_threat": str(row[3] or ""),
|
||||||
|
"threat_levels": list(row[4] or []),
|
||||||
|
"bot_names": list(row[5] or []),
|
||||||
|
"first_seen": str(row[6]),
|
||||||
|
"last_seen": str(row[7]),
|
||||||
|
"source": "dbscan",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Fallback: subnet-based clustering when DBSCAN has no campaigns
|
||||||
|
if not campaigns:
|
||||||
|
subnet_sql = """
|
||||||
|
SELECT
|
||||||
|
IPv4CIDRToRange(toIPv4(replaceRegexpAll(toString(src_ip),'^::ffff:','')), 24).1 AS subnet,
|
||||||
|
count() AS total_detections,
|
||||||
|
uniq(src_ip) AS unique_ips,
|
||||||
|
groupArray(3)(threat_level) AS threat_levels,
|
||||||
|
any(bot_name) AS bot_name,
|
||||||
|
any(ja4) AS sample_ja4,
|
||||||
|
min(detected_at) AS first_seen,
|
||||||
|
max(detected_at) AS last_seen
|
||||||
|
FROM mabase_prod.ml_detected_anomalies
|
||||||
|
WHERE detected_at >= now() - INTERVAL %(hours)s HOUR
|
||||||
|
AND threat_level IN ('HIGH','CRITICAL','MEDIUM')
|
||||||
|
GROUP BY subnet
|
||||||
|
HAVING unique_ips >= 3
|
||||||
|
ORDER BY total_detections DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
result2 = db.query(subnet_sql, {"hours": hours, "limit": limit})
|
||||||
|
for i, row in enumerate(result2.result_rows):
|
||||||
|
subnet_str = str(row[0]) + "/24"
|
||||||
|
campaigns.append({
|
||||||
|
"id": f"S{i+1:03d}",
|
||||||
|
"campaign_id": -1,
|
||||||
|
"subnet": subnet_str,
|
||||||
|
"total_detections": int(row[1]),
|
||||||
|
"unique_ips": int(row[2]),
|
||||||
|
"dominant_threat": str((row[3] or [""])[0]),
|
||||||
|
"threat_levels": list(row[3] or []),
|
||||||
|
"bot_names": [str(row[4] or "")],
|
||||||
|
"sample_ja4": str(row[5] or ""),
|
||||||
|
"first_seen": str(row[6]),
|
||||||
|
"last_seen": str(row[7]),
|
||||||
|
"source": "subnet_cluster",
|
||||||
|
})
|
||||||
|
|
||||||
|
dbscan_active = any(c["campaign_id"] >= 0 for c in campaigns)
|
||||||
|
return {
|
||||||
|
"campaigns": campaigns,
|
||||||
|
"total": len(campaigns),
|
||||||
|
"dbscan_active": dbscan_active,
|
||||||
|
"hours": hours,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scatter")
|
||||||
|
async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)):
|
||||||
|
"""Points scatter plot (fuzzing_index × hit_velocity) — bypass view_ai_features_1h."""
|
||||||
|
try:
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||||
|
any(ja4) AS ja4,
|
||||||
|
round(max(uniqMerge(uniq_query_params)) / greatest(max(uniqMerge(uniq_paths)), 1), 4) AS fuzzing_index,
|
||||||
|
round(sum(hits) / greatest(dateDiff('second', min(first_seen), max(last_seen)), 1), 2) AS hit_velocity,
|
||||||
|
sum(hits) AS hits,
|
||||||
|
round(sum(count_head) / greatest(sum(hits), 1), 4) AS head_ratio,
|
||||||
|
max(correlated_raw) AS correlated
|
||||||
|
FROM mabase_prod.agg_host_ip_ja4_1h
|
||||||
|
WHERE window_start >= now() - INTERVAL 24 HOUR
|
||||||
GROUP BY src_ip
|
GROUP BY src_ip
|
||||||
ORDER BY 3 DESC
|
ORDER BY fuzzing_index DESC
|
||||||
LIMIT %(limit)s
|
LIMIT %(limit)s
|
||||||
"""
|
"""
|
||||||
result = db.query(sql, {"limit": limit})
|
result = db.query(sql, {"limit": limit})
|
||||||
@ -142,15 +396,13 @@ async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)):
|
|||||||
for row in result.result_rows:
|
for row in result.result_rows:
|
||||||
fuzzing = float(row[2] or 0)
|
fuzzing = float(row[2] or 0)
|
||||||
velocity = float(row[3] or 0)
|
velocity = float(row[3] or 0)
|
||||||
fake_nav = int(row[5] or 0)
|
|
||||||
ua_mm = int(row[6] or 0)
|
|
||||||
points.append({
|
points.append({
|
||||||
"ip": str(row[0]),
|
"ip": str(row[0]),
|
||||||
"ja4": str(row[1]),
|
"ja4": str(row[1]),
|
||||||
"fuzzing_index":fuzzing,
|
"fuzzing_index":fuzzing,
|
||||||
"hit_velocity": velocity,
|
"hit_velocity": velocity,
|
||||||
"hits": int(row[4] or 0),
|
"hits": int(row[4] or 0),
|
||||||
"attack_type": _attack_type(fuzzing, velocity, fake_nav, ua_mm),
|
"attack_type": _attack_type(fuzzing, velocity, 0, 0),
|
||||||
})
|
})
|
||||||
return {"points": points}
|
return {"points": points}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -104,40 +104,40 @@ async def get_ip_ja4_history(ip: str):
|
|||||||
|
|
||||||
@router.get("/sophistication")
|
@router.get("/sophistication")
|
||||||
async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
|
async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
|
||||||
"""Score de sophistication adversaire par IP (rotation JA4 + récurrence + bruteforce)."""
|
"""Score de sophistication adversaire par IP (rotation JA4 + récurrence + bruteforce).
|
||||||
|
Single SQL JOIN query — aucun traitement Python sur 34K entrées.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Separate queries merged in Python to avoid view JOIN issues
|
sql = """
|
||||||
rot_result = db.query("""
|
SELECT
|
||||||
SELECT
|
replaceRegexpAll(toString(r.src_ip), '^::ffff:', '') AS ip,
|
||||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
r.distinct_ja4_count,
|
||||||
distinct_ja4_count
|
coalesce(rec.recurrence, 0) AS recurrence,
|
||||||
FROM mabase_prod.view_host_ip_ja4_rotation
|
coalesce(bf.bruteforce_hits, 0) AS bruteforce_hits,
|
||||||
""")
|
round(least(100.0,
|
||||||
rotation_map = {str(row[0]): int(row[1]) for row in rot_result.result_rows}
|
r.distinct_ja4_count * 10
|
||||||
|
+ coalesce(rec.recurrence, 0) * 20
|
||||||
rec_result = db.query("""
|
+ least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5)
|
||||||
SELECT
|
), 1) AS sophistication_score
|
||||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
FROM mabase_prod.view_host_ip_ja4_rotation r
|
||||||
recurrence
|
LEFT JOIN (
|
||||||
FROM mabase_prod.view_ip_recurrence
|
SELECT src_ip, count() AS recurrence
|
||||||
""")
|
FROM mabase_prod.ml_detected_anomalies FINAL
|
||||||
recurrence_map = {str(row[0]): int(row[1]) for row in rec_result.result_rows}
|
GROUP BY src_ip
|
||||||
|
) rec USING(src_ip)
|
||||||
bf_result = db.query("""
|
LEFT JOIN (
|
||||||
SELECT
|
SELECT replaceRegexpAll(toString(src_ip),'^::ffff:','') AS src_ip,
|
||||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
sum(hits) AS bruteforce_hits
|
||||||
sum(hits) AS total_hits
|
|
||||||
FROM mabase_prod.view_form_bruteforce_detected
|
FROM mabase_prod.view_form_bruteforce_detected
|
||||||
GROUP BY ip
|
GROUP BY src_ip
|
||||||
""")
|
) bf USING(src_ip)
|
||||||
bruteforce_map = {str(row[0]): int(row[1]) for row in bf_result.result_rows}
|
ORDER BY sophistication_score DESC
|
||||||
|
LIMIT %(limit)s
|
||||||
# Start from IPs that appear in rotation view (most evasive)
|
"""
|
||||||
|
result = db.query(sql, {"limit": limit})
|
||||||
items = []
|
items = []
|
||||||
for ip, ja4_count in rotation_map.items():
|
for row in result.result_rows:
|
||||||
recurrence = recurrence_map.get(ip, 0)
|
score = float(row[4] or 0)
|
||||||
bf_hits = bruteforce_map.get(ip, 0)
|
|
||||||
score = min(100.0, ja4_count * 10 + recurrence * 20 + min(30.0, math.log(bf_hits + 1) * 5))
|
|
||||||
if score > 80:
|
if score > 80:
|
||||||
tier = "APT-like"
|
tier = "APT-like"
|
||||||
elif score > 50:
|
elif score > 50:
|
||||||
@ -147,16 +147,13 @@ async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
|
|||||||
else:
|
else:
|
||||||
tier = "Basic"
|
tier = "Basic"
|
||||||
items.append({
|
items.append({
|
||||||
"ip": ip,
|
"ip": str(row[0]),
|
||||||
"ja4_rotation_count": ja4_count,
|
"ja4_rotation_count": int(row[1] or 0),
|
||||||
"recurrence": recurrence,
|
"recurrence": int(row[2] or 0),
|
||||||
"bruteforce_hits": bf_hits,
|
"bruteforce_hits": int(row[3] or 0),
|
||||||
"sophistication_score": round(score, 1),
|
"sophistication_score":score,
|
||||||
"tier": tier,
|
"tier": tier,
|
||||||
})
|
})
|
||||||
|
|
||||||
items.sort(key=lambda x: x["sophistication_score"], reverse=True)
|
|
||||||
items = items[:limit]
|
|
||||||
return {"items": items, "total": len(items)}
|
return {"items": items, "total": len(items)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@ -18,9 +18,6 @@ import { CampaignsView } from './components/CampaignsView';
|
|||||||
import { BruteForceView } from './components/BruteForceView';
|
import { BruteForceView } from './components/BruteForceView';
|
||||||
import { TcpSpoofingView } from './components/TcpSpoofingView';
|
import { TcpSpoofingView } from './components/TcpSpoofingView';
|
||||||
import { HeaderFingerprintView } from './components/HeaderFingerprintView';
|
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 { MLFeaturesView } from './components/MLFeaturesView';
|
||||||
import { useTheme } from './ThemeContext';
|
import { useTheme } from './ThemeContext';
|
||||||
|
|
||||||
@ -83,9 +80,6 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) {
|
|||||||
{ path: '/bruteforce', label: 'Brute Force', icon: '🔥', aliases: [] },
|
{ path: '/bruteforce', label: 'Brute Force', icon: '🔥', aliases: [] },
|
||||||
{ path: '/tcp-spoofing', label: 'TCP Spoofing', icon: '🧬', aliases: [] },
|
{ path: '/tcp-spoofing', label: 'TCP Spoofing', icon: '🧬', aliases: [] },
|
||||||
{ path: '/headers', label: 'Header Fingerprint', 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: [] },
|
{ path: '/ml-features', label: 'Features ML', icon: '🤖', aliases: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -245,9 +239,6 @@ function TopHeader({ counts }: { counts: AlertCounts | null }) {
|
|||||||
if (p.startsWith('/bruteforce')) return 'Brute Force & Credential Stuffing';
|
if (p.startsWith('/bruteforce')) return 'Brute Force & Credential Stuffing';
|
||||||
if (p.startsWith('/tcp-spoofing')) return 'Spoofing TCP/OS';
|
if (p.startsWith('/tcp-spoofing')) return 'Spoofing TCP/OS';
|
||||||
if (p.startsWith('/headers')) return 'Header Fingerprint Clustering';
|
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';
|
if (p.startsWith('/ml-features')) return 'Features ML / Radar';
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
@ -380,9 +371,9 @@ export default function App() {
|
|||||||
<Route path="/bruteforce" element={<BruteForceView />} />
|
<Route path="/bruteforce" element={<BruteForceView />} />
|
||||||
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
|
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
|
||||||
<Route path="/headers" element={<HeaderFingerprintView />} />
|
<Route path="/headers" element={<HeaderFingerprintView />} />
|
||||||
<Route path="/heatmap" element={<HeatmapView />} />
|
<Route path="/heatmap" element={<Navigate to="/" replace />} />
|
||||||
<Route path="/botnets" element={<BotnetMapView />} />
|
<Route path="/botnets" element={<Navigate to="/campaigns" replace />} />
|
||||||
<Route path="/rotation" element={<RotationView />} />
|
<Route path="/rotation" element={<Navigate to="/fingerprints" replace />} />
|
||||||
<Route path="/ml-features" element={<MLFeaturesView />} />
|
<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 />} />
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -61,6 +62,70 @@ function ErrorMessage({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Attackers DataTable ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AttackersTable({
|
||||||
|
attackers,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
attackers: BruteForceAttacker[];
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo((): Column<BruteForceAttacker>[] => [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP',
|
||||||
|
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'distinct_hosts', label: 'Hosts ciblés', align: 'right' },
|
||||||
|
{
|
||||||
|
key: 'total_hits',
|
||||||
|
label: 'Hits',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => formatNumber(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'total_params',
|
||||||
|
label: 'Params',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => formatNumber(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ja4',
|
||||||
|
label: 'JA4',
|
||||||
|
render: (v: string) => (
|
||||||
|
<span className="font-mono text-xs text-text-secondary">
|
||||||
|
{v ? `${v.slice(0, 16)}…` : '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_actions',
|
||||||
|
label: '',
|
||||||
|
sortable: false,
|
||||||
|
render: (_: unknown, row: BruteForceAttacker) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
data={attackers}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="ip"
|
||||||
|
defaultSortKey="total_hits"
|
||||||
|
emptyMessage="Aucun attaquant trouvé"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface HostAttacker { ip: string; total_hits: number; total_params: number; ja4: string; attack_type: string; }
|
interface HostAttacker { ip: string; total_hits: number; total_params: number; ja4: string; attack_type: string; }
|
||||||
@ -338,37 +403,7 @@ export function BruteForceView() {
|
|||||||
) : attackersError ? (
|
) : attackersError ? (
|
||||||
<div className="p-4"><ErrorMessage message={attackersError} /></div>
|
<div className="p-4"><ErrorMessage message={attackersError} /></div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<AttackersTable attackers={attackers} navigate={navigate} />
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
|
import ThreatBadge from './ui/ThreatBadge';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -44,7 +46,7 @@ interface JA4AttributesResponse {
|
|||||||
items: JA4AttributeItem[];
|
items: JA4AttributeItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveTab = 'clusters' | 'ja4' | 'behavioral';
|
type ActiveTab = 'clusters' | 'ja4' | 'behavioral' | 'botnets';
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -256,6 +258,7 @@ export function CampaignsView() {
|
|||||||
{ id: 'clusters', label: 'Clusters réseau' },
|
{ id: 'clusters', label: 'Clusters réseau' },
|
||||||
{ id: 'ja4', label: 'Fingerprints JA4' },
|
{ id: 'ja4', label: 'Fingerprints JA4' },
|
||||||
{ id: 'behavioral', label: 'Analyse comportementale' },
|
{ id: 'behavioral', label: 'Analyse comportementale' },
|
||||||
|
{ id: 'botnets', label: '🌍 Botnets Distribués' },
|
||||||
] as const
|
] as const
|
||||||
).map(tab => (
|
).map(tab => (
|
||||||
<button
|
<button
|
||||||
@ -324,6 +327,8 @@ export function CampaignsView() {
|
|||||||
{activeTab === 'behavioral' && (
|
{activeTab === 'behavioral' && (
|
||||||
<BehavioralTab clusters={clusters} />
|
<BehavioralTab clusters={clusters} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'botnets' && <BotnetTab />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -721,72 +726,345 @@ function BehavioralTab({ clusters }: BehavioralTabProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Behavioral matrix */}
|
{/* Behavioral matrix */}
|
||||||
<div className="bg-background-secondary rounded-lg p-4">
|
<div className="bg-background-secondary rounded-lg overflow-hidden">
|
||||||
<h3 className="text-text-primary font-semibold mb-4">Matrice de signaux comportementaux</h3>
|
<div className="px-4 py-2.5 border-b border-background-card">
|
||||||
<div className="overflow-x-auto">
|
<h3 className="text-text-primary font-semibold text-sm">Matrice de signaux comportementaux</h3>
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border text-left">
|
|
||||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Subnet</th>
|
|
||||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Score</th>
|
|
||||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Tendance</th>
|
|
||||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Niveau menace</th>
|
|
||||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Pays</th>
|
|
||||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs text-right">IPs</th>
|
|
||||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs text-right">Détections</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{clusters.map((cluster, idx) => {
|
|
||||||
const { bg, text } = getThreatColors(cluster.severity.toLowerCase());
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={cluster.subnet}
|
|
||||||
className={`hover:bg-background-card/50 transition-colors ${
|
|
||||||
idx < clusters.length - 1 ? 'border-b border-border/50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2 font-mono text-text-primary text-xs">{cluster.subnet}</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className={`text-xs font-mono font-semibold ${getConfidenceTextColor(cluster.score / 100)}`}>
|
|
||||||
{cluster.score}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{cluster.trend === 'up' ? (
|
|
||||||
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs">
|
|
||||||
↑ +{cluster.trend_percentage}%
|
|
||||||
</span>
|
|
||||||
) : cluster.trend === 'new' ? (
|
|
||||||
<span className="bg-accent-primary/20 text-accent-primary px-2 py-0.5 rounded text-xs">
|
|
||||||
Nouveau
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-text-disabled text-xs">{cluster.trend}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${bg} ${text}`}>
|
|
||||||
{getThreatLabel(cluster.severity.toLowerCase())}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{cluster.countries?.slice(0, 2).map(c => (
|
|
||||||
<span key={c.code} className="text-text-secondary text-xs mr-1">{c.code}</span>
|
|
||||||
))}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-text-primary text-xs font-mono text-right">
|
|
||||||
{cluster.unique_ips}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-text-primary text-xs font-mono text-right">
|
|
||||||
{cluster.total_detections.toLocaleString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<BehavioralMatrix clusters={clusters} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Behavioral Matrix DataTable ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface BehavioralRow {
|
||||||
|
subnet: string;
|
||||||
|
score: number;
|
||||||
|
severity: string;
|
||||||
|
trend: string;
|
||||||
|
trend_percentage: number;
|
||||||
|
unique_ips: number;
|
||||||
|
total_detections: number;
|
||||||
|
countries: { code: string; percentage: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function BehavioralMatrix({ clusters }: { clusters: ClusterData[] }) {
|
||||||
|
const columns = useMemo((): Column<BehavioralRow>[] => [
|
||||||
|
{
|
||||||
|
key: 'subnet',
|
||||||
|
label: 'Subnet',
|
||||||
|
render: (v: string) => <span className="font-mono text-text-primary text-xs">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
label: 'Score',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<span className={`text-xs font-mono font-semibold ${getConfidenceTextColor(v / 100)}`}>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'severity',
|
||||||
|
label: 'Niveau',
|
||||||
|
render: (v: string) => <ThreatBadge level={v} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'trend',
|
||||||
|
label: 'Tendance',
|
||||||
|
sortable: false,
|
||||||
|
render: (_: string, row: BehavioralRow) =>
|
||||||
|
row.trend === 'up' ? (
|
||||||
|
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs">
|
||||||
|
↑ +{row.trend_percentage}%
|
||||||
|
</span>
|
||||||
|
) : row.trend === 'new' ? (
|
||||||
|
<span className="bg-accent-primary/20 text-accent-primary px-2 py-0.5 rounded text-xs">
|
||||||
|
Nouveau
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-disabled text-xs">{row.trend}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'countries',
|
||||||
|
label: 'Pays',
|
||||||
|
sortable: false,
|
||||||
|
render: (_: unknown, row: BehavioralRow) => (
|
||||||
|
<span>
|
||||||
|
{row.countries?.slice(0, 2).map((c) => (
|
||||||
|
<span key={c.code} className="text-text-secondary text-xs mr-1">{c.code}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'unique_ips', label: 'IPs', align: 'right' },
|
||||||
|
{
|
||||||
|
key: 'total_detections',
|
||||||
|
label: 'Détections',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => formatNumber(v),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const rows: BehavioralRow[] = clusters;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
data={rows}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="subnet"
|
||||||
|
defaultSortKey="score"
|
||||||
|
emptyMessage="Aucun cluster disponible"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab: Botnets Distribués ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 BotnetCountryEntry {
|
||||||
|
country_code: string;
|
||||||
|
unique_ips: number;
|
||||||
|
hits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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_botnet': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🌐 Global' };
|
||||||
|
case 'regional_botnet': 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BotnetRow({ item, onInvestigate }: { item: BotnetItem; onInvestigate: (ja4: string) => void }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [countries, setCountries] = useState<BotnetCountryEntry[]>([]);
|
||||||
|
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`);
|
||||||
|
if (!res.ok) throw new Error('Erreur chargement des pays');
|
||||||
|
const data: { items: BotnetCountryEntry[] } = await res.json();
|
||||||
|
setCountries(data.items ?? []);
|
||||||
|
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" title={item.ja4}>
|
||||||
|
{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">🌍 {formatNumber(item.unique_countries)}</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BotnetTab() {
|
||||||
|
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<'unique_ips' | 'unique_countries' | 'targeted_hosts'>('unique_ips');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/botnets/ja4-spread')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement des botnets'))
|
||||||
|
.then((data: { items: BotnetItem[] }) => setItems(data.items ?? []))
|
||||||
|
.catch(err => setError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
|
fetch('/api/botnets/summary')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement du résumé'))
|
||||||
|
.then((data: BotnetSummary) => setSummary(data))
|
||||||
|
.catch(err => setSummaryError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setSummaryLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sortedItems = [...items].sort((a, b) => b[sortField] - a[sortField]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{summaryLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<div className="w-6 h-6 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : summaryError ? (
|
||||||
|
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical text-sm">⚠️ {summaryError}</div>
|
||||||
|
) : summary ? (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||||
|
<span className="text-text-secondary text-sm">Total Global Botnets</span>
|
||||||
|
<span className="text-2xl font-bold text-threat-critical">{formatNumber(summary.total_global_botnets)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||||
|
<span className="text-text-secondary text-sm">IPs impliquées</span>
|
||||||
|
<span className="text-2xl font-bold text-threat-high">{formatNumber(summary.total_ips_in_botnets)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||||
|
<span className="text-text-secondary text-sm">JA4 le + répandu</span>
|
||||||
|
<span className="font-mono text-xs font-bold text-accent-primary truncate" title={summary.most_spread_ja4}>{summary.most_spread_ja4 || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||||
|
<span className="text-text-secondary text-sm">IPs max par JA4</span>
|
||||||
|
<span className="font-mono text-xs font-bold text-accent-primary truncate" title={summary.most_ips_ja4}>{summary.most_ips_ja4 || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-text-secondary text-sm">Trier par :</span>
|
||||||
|
{(['unique_ips', 'unique_countries', 'targeted_hosts'] as const).map((field) => (
|
||||||
|
<button
|
||||||
|
key={field}
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{field === 'unique_ips' ? '🖥️ IPs' : field === 'unique_countries' ? '🌍 Pays' : '🎯 Hosts'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<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>
|
||||||
|
) : error ? (
|
||||||
|
<div className="p-4 bg-threat-critical/10 border border-threat-critical/30 text-threat-critical text-sm">⚠️ {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 distinctes</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/${encodeURIComponent(ja4)}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sortedItems.length === 0 && (
|
||||||
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-secondary">Aucun botnet détecté</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useDetections } from '../hooks/useDetections';
|
import { useDetections } from '../hooks/useDetections';
|
||||||
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
|
|
||||||
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
|
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
|
||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
@ -12,6 +13,28 @@ interface ColumnConfig {
|
|||||||
sortable: boolean;
|
sortable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DetectionRow {
|
||||||
|
src_ip: string;
|
||||||
|
ja4?: string;
|
||||||
|
host?: string;
|
||||||
|
client_headers?: string;
|
||||||
|
model_name: string;
|
||||||
|
anomaly_score: number;
|
||||||
|
hits?: number;
|
||||||
|
hit_velocity?: number;
|
||||||
|
asn_org?: string;
|
||||||
|
asn_number?: string | number;
|
||||||
|
asn_score?: number | null;
|
||||||
|
asn_rep_label?: string;
|
||||||
|
country_code?: string;
|
||||||
|
detected_at: string;
|
||||||
|
first_seen?: string;
|
||||||
|
last_seen?: string;
|
||||||
|
unique_ja4s?: string[];
|
||||||
|
unique_hosts?: string[];
|
||||||
|
unique_client_headers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function DetectionsList() {
|
export function DetectionsList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@ -72,26 +95,6 @@ export function DetectionsList() {
|
|||||||
setSearchParams(newParams);
|
setSearchParams(newParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
const newParams = new URLSearchParams(searchParams);
|
|
||||||
const currentSortField = newParams.get('sort_by') || 'detected_at';
|
|
||||||
const currentOrder = newParams.get('sort_order') || 'desc';
|
|
||||||
|
|
||||||
if (currentSortField === field) {
|
|
||||||
// Inverser l'ordre ou supprimer le tri
|
|
||||||
if (currentOrder === 'desc') {
|
|
||||||
newParams.set('sort_order', 'asc');
|
|
||||||
} else {
|
|
||||||
newParams.delete('sort_by');
|
|
||||||
newParams.delete('sort_order');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newParams.set('sort_by', field);
|
|
||||||
newParams.set('sort_order', 'desc');
|
|
||||||
}
|
|
||||||
setSearchParams(newParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleColumn = (key: string) => {
|
const toggleColumn = (key: string) => {
|
||||||
setColumns(cols => cols.map(col =>
|
setColumns(cols => cols.map(col =>
|
||||||
col.key === key ? { ...col, visible: !col.visible } : col
|
col.key === key ? { ...col, visible: !col.visible } : col
|
||||||
@ -104,20 +107,6 @@ export function DetectionsList() {
|
|||||||
setSearchParams(newParams);
|
setSearchParams(newParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSortIcon = (field: SortField) => {
|
|
||||||
if (sortField !== field) return '⇅';
|
|
||||||
return sortOrder === 'asc' ? '↑' : '↓';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Par défaut, trier par score croissant (scores négatifs en premier)
|
|
||||||
const getDefaultSortIcon = (field: SortField) => {
|
|
||||||
if (!searchParams.has('sort_by') && !searchParams.has('sort')) {
|
|
||||||
if (field === 'anomaly_score') return '↑';
|
|
||||||
return '⇅';
|
|
||||||
}
|
|
||||||
return getSortIcon(field);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -190,6 +179,208 @@ export function DetectionsList() {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Build DataTable columns from visible column configs
|
||||||
|
const tableColumns: Column<DetectionRow>[] = columns
|
||||||
|
.filter((col) => col.visible)
|
||||||
|
.map((col): Column<DetectionRow> => {
|
||||||
|
switch (col.key) {
|
||||||
|
case 'ip_ja4':
|
||||||
|
return {
|
||||||
|
key: 'src_ip',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div>
|
||||||
|
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
<div className="text-xs text-text-secondary font-medium">
|
||||||
|
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
|
||||||
|
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||||
|
{ja4}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{row.unique_ja4s.length > 3 && (
|
||||||
|
<div className="font-mono text-xs text-text-disabled">
|
||||||
|
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||||
|
{row.ja4 || '-'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'host':
|
||||||
|
return {
|
||||||
|
key: 'host',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
render: (_, row) =>
|
||||||
|
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-text-secondary font-medium">
|
||||||
|
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
{row.unique_hosts.slice(0, 3).map((host, idx) => (
|
||||||
|
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||||
|
{host}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{row.unique_hosts.length > 3 && (
|
||||||
|
<div className="text-xs text-text-disabled">
|
||||||
|
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||||
|
{row.host || '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'client_headers':
|
||||||
|
return {
|
||||||
|
key: 'client_headers',
|
||||||
|
label: col.label,
|
||||||
|
sortable: false,
|
||||||
|
render: (_, row) =>
|
||||||
|
groupByIP && row.unique_client_headers && row.unique_client_headers.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-text-secondary font-medium">
|
||||||
|
{row.unique_client_headers.length} Header{row.unique_client_headers.length > 1 ? 's' : ''} unique{row.unique_client_headers.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
{row.unique_client_headers.slice(0, 3).map((header, idx) => (
|
||||||
|
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{row.unique_client_headers.length > 3 && (
|
||||||
|
<div className="text-xs text-text-disabled">
|
||||||
|
+{row.unique_client_headers.length - 3} autre{row.unique_client_headers.length - 3 > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
||||||
|
{row.client_headers || '-'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'model_name':
|
||||||
|
return {
|
||||||
|
key: 'model_name',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
render: (_, row) => <ModelBadge model={row.model_name} />,
|
||||||
|
};
|
||||||
|
case 'anomaly_score':
|
||||||
|
return {
|
||||||
|
key: 'anomaly_score',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (_, row) => <ScoreBadge score={row.anomaly_score} />,
|
||||||
|
};
|
||||||
|
case 'hits':
|
||||||
|
return {
|
||||||
|
key: 'hits',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div className="text-sm text-text-primary font-medium">{row.hits ?? 0}</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'hit_velocity':
|
||||||
|
return {
|
||||||
|
key: 'hit_velocity',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
row.hit_velocity && row.hit_velocity > 10
|
||||||
|
? 'text-threat-high'
|
||||||
|
: row.hit_velocity && row.hit_velocity > 1
|
||||||
|
? 'text-threat-medium'
|
||||||
|
: 'text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.hit_velocity ? row.hit_velocity.toFixed(2) : '0.00'}
|
||||||
|
<span className="text-xs text-text-secondary ml-1">req/s</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'asn':
|
||||||
|
return {
|
||||||
|
key: 'asn_org',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
render: (_, row) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div>
|
||||||
|
{row.asn_number && (
|
||||||
|
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
|
||||||
|
)}
|
||||||
|
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'country':
|
||||||
|
return {
|
||||||
|
key: 'country_code',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.country_code ? (
|
||||||
|
<span className="text-lg">{getFlag(row.country_code)}</span>
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'detected_at':
|
||||||
|
return {
|
||||||
|
key: 'detected_at',
|
||||||
|
label: col.label,
|
||||||
|
sortable: true,
|
||||||
|
render: (_, row) =>
|
||||||
|
groupByIP && row.first_seen ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-text-secondary">
|
||||||
|
<span className="font-medium">Premier:</span>{' '}
|
||||||
|
{new Date(row.first_seen).toLocaleDateString('fr-FR')}{' '}
|
||||||
|
{new Date(row.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary">
|
||||||
|
<span className="font-medium">Dernier:</span>{' '}
|
||||||
|
{new Date(row.last_seen!).toLocaleDateString('fr-FR')}{' '}
|
||||||
|
{new Date(row.last_seen!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-text-primary">
|
||||||
|
{new Date(row.detected_at).toLocaleDateString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary">
|
||||||
|
{new Date(row.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { key: col.key, label: col.label, sortable: col.sortable };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in">
|
<div className="space-y-4 animate-fade-in">
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
@ -291,223 +482,15 @@ export function DetectionsList() {
|
|||||||
|
|
||||||
{/* Tableau */}
|
{/* Tableau */}
|
||||||
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
||||||
<table className="w-full">
|
<DataTable<DetectionRow>
|
||||||
<thead className="bg-background-card">
|
data={processedData.items as DetectionRow[]}
|
||||||
<tr>
|
columns={tableColumns}
|
||||||
{columns.filter(col => col.visible).map(col => (
|
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
|
||||||
<th
|
defaultSortKey="anomaly_score"
|
||||||
key={col.key}
|
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
|
||||||
className={`px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase ${col.sortable ? 'cursor-pointer hover:text-text-primary' : ''}`}
|
emptyMessage="Aucune détection trouvée"
|
||||||
onClick={() => col.sortable && handleSort(col.key as SortField)}
|
compact
|
||||||
>
|
/>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{col.label}
|
|
||||||
{col.sortable && (
|
|
||||||
<span className="text-text-disabled">{getDefaultSortIcon(col.key as SortField)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-background-card">
|
|
||||||
{processedData.items.map((detection) => (
|
|
||||||
<tr
|
|
||||||
key={`${detection.src_ip}-${detection.detected_at}-${groupByIP ? 'grouped' : 'individual'}`}
|
|
||||||
className="hover:bg-background-card/50 transition-colors cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
navigate(`/detections/ip/${encodeURIComponent(detection.src_ip)}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{columns.filter(col => col.visible).map(col => {
|
|
||||||
if (col.key === 'ip_ja4') {
|
|
||||||
const detectionAny = detection as any;
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
<div className="font-mono text-sm text-text-primary">{detection.src_ip}</div>
|
|
||||||
{groupByIP && detectionAny.unique_ja4s?.length > 0 ? (
|
|
||||||
<div className="mt-1 space-y-1">
|
|
||||||
<div className="text-xs text-text-secondary font-medium">
|
|
||||||
{detectionAny.unique_ja4s.length} JA4{detectionAny.unique_ja4s.length > 1 ? 's' : ''} unique{detectionAny.unique_ja4s.length > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
{detectionAny.unique_ja4s.slice(0, 3).map((ja4: string, idx: number) => (
|
|
||||||
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
|
||||||
{ja4}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{detectionAny.unique_ja4s.length > 3 && (
|
|
||||||
<div className="font-mono text-xs text-text-disabled">
|
|
||||||
+{detectionAny.unique_ja4s.length - 3} autre{detectionAny.unique_ja4s.length - 3 > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
|
||||||
{detection.ja4 || '-'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'host') {
|
|
||||||
const detectionAny = detection as any;
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
{groupByIP && detectionAny.unique_hosts?.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs text-text-secondary font-medium">
|
|
||||||
{detectionAny.unique_hosts.length} Host{detectionAny.unique_hosts.length > 1 ? 's' : ''} unique{detectionAny.unique_hosts.length > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
{detectionAny.unique_hosts.slice(0, 3).map((host: string, idx: number) => (
|
|
||||||
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
|
||||||
{host}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{detectionAny.unique_hosts.length > 3 && (
|
|
||||||
<div className="text-xs text-text-disabled">
|
|
||||||
+{detectionAny.unique_hosts.length - 3} autre{detectionAny.unique_hosts.length - 3 > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
|
||||||
{detection.host || '-'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'client_headers') {
|
|
||||||
const detectionAny = detection as any;
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
{groupByIP && detectionAny.unique_client_headers?.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs text-text-secondary font-medium">
|
|
||||||
{detectionAny.unique_client_headers.length} Header{detectionAny.unique_client_headers.length > 1 ? 's' : ''} unique{detectionAny.unique_client_headers.length > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
{detectionAny.unique_client_headers.slice(0, 3).map((header: string, idx: number) => (
|
|
||||||
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
|
||||||
{header}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{detectionAny.unique_client_headers.length > 3 && (
|
|
||||||
<div className="text-xs text-text-disabled">
|
|
||||||
+{detectionAny.unique_client_headers.length - 3} autre{detectionAny.unique_client_headers.length - 3 > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
|
||||||
{detection.client_headers || '-'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'model_name') {
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
<ModelBadge model={detection.model_name} />
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'anomaly_score') {
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
<ScoreBadge score={detection.anomaly_score} />
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'hits') {
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
<div className="text-sm text-text-primary font-medium">
|
|
||||||
{detection.hits || 0}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'hit_velocity') {
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
<div className={`text-sm font-medium ${
|
|
||||||
detection.hit_velocity && detection.hit_velocity > 10
|
|
||||||
? 'text-threat-high'
|
|
||||||
: detection.hit_velocity && detection.hit_velocity > 1
|
|
||||||
? 'text-threat-medium'
|
|
||||||
: 'text-text-primary'
|
|
||||||
}`}>
|
|
||||||
{detection.hit_velocity ? detection.hit_velocity.toFixed(2) : '0.00'}
|
|
||||||
<span className="text-xs text-text-secondary ml-1">req/s</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'asn') {
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
<div className="text-sm text-text-primary">{detection.asn_org || detection.asn_number || '-'}</div>
|
|
||||||
{detection.asn_number && (
|
|
||||||
<div className="text-xs text-text-secondary">AS{detection.asn_number}</div>
|
|
||||||
)}
|
|
||||||
<AsnRepBadge score={detection.asn_score} label={detection.asn_rep_label} />
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'country') {
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
{detection.country_code ? (
|
|
||||||
<span className="text-lg">{getFlag(detection.country_code)}</span>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === 'detected_at') {
|
|
||||||
const detectionAny = detection as any;
|
|
||||||
return (
|
|
||||||
<td key={col.key} className="px-4 py-3">
|
|
||||||
{groupByIP && detectionAny.first_seen ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs text-text-secondary">
|
|
||||||
<span className="font-medium">Premier:</span>{' '}
|
|
||||||
{new Date(detectionAny.first_seen).toLocaleDateString('fr-FR')}{' '}
|
|
||||||
{new Date(detectionAny.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-secondary">
|
|
||||||
<span className="font-medium">Dernier:</span>{' '}
|
|
||||||
{new Date(detectionAny.last_seen).toLocaleDateString('fr-FR')}{' '}
|
|
||||||
{new Date(detectionAny.last_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-sm text-text-primary">
|
|
||||||
{new Date(detection.detected_at).toLocaleDateString('fr-FR')}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-secondary">
|
|
||||||
{new Date(detection.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{data.items.length === 0 && (
|
|
||||||
<div className="text-center py-12 text-text-secondary">
|
|
||||||
Aucune détection trouvée
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, Fragment } from 'react';
|
import { useState, useEffect, useCallback, Fragment, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
|
import ThreatBadge from './ui/ThreatBadge';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -32,7 +34,8 @@ interface IPsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'ip_count' | 'detections' | 'botnet_score';
|
type SortField = 'ip_count' | 'detections' | 'botnet_score';
|
||||||
type ActiveTab = 'ja4' | 'spoofing' | 'ua_analysis';
|
type SortDir = 'asc' | 'desc';
|
||||||
|
type ActiveTab = 'ja4' | 'spoofing' | 'ua_analysis' | 'rotation';
|
||||||
|
|
||||||
// ─── Spoofing types ───────────────────────────────────────────────────────────
|
// ─── Spoofing types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -904,6 +907,11 @@ export function FingerprintsView() {
|
|||||||
const [expandedJa4, setExpandedJa4] = useState<string | null>(null);
|
const [expandedJa4, setExpandedJa4] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [sortField, setSortField] = useState<SortField>('ip_count');
|
const [sortField, setSortField] = useState<SortField>('ip_count');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
|
const handleColSort = (field: SortField) => {
|
||||||
|
if (field === sortField) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
else { setSortField(field); setSortDir('desc'); }
|
||||||
|
};
|
||||||
const [minIps, setMinIps] = useState(0);
|
const [minIps, setMinIps] = useState(0);
|
||||||
const [copiedJa4, setCopiedJa4] = useState<string | null>(null);
|
const [copiedJa4, setCopiedJa4] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -1053,24 +1061,25 @@ export function FingerprintsView() {
|
|||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const va = variabilityCache.get(a.value);
|
const va = variabilityCache.get(a.value);
|
||||||
const vb = variabilityCache.get(b.value);
|
const vb = variabilityCache.get(b.value);
|
||||||
|
let diff = 0;
|
||||||
if (sortField === 'ip_count') {
|
if (sortField === 'ip_count') {
|
||||||
const ia = va ? va.unique_ips : a.count;
|
const ia = va ? va.unique_ips : a.count;
|
||||||
const ib = vb ? vb.unique_ips : b.count;
|
const ib = vb ? vb.unique_ips : b.count;
|
||||||
return ib - ia;
|
diff = ib - ia;
|
||||||
}
|
} else if (sortField === 'detections') {
|
||||||
if (sortField === 'detections') {
|
|
||||||
const da = va ? va.total_detections : a.count;
|
const da = va ? va.total_detections : a.count;
|
||||||
const db = vb ? vb.total_detections : b.count;
|
const db = vb ? vb.total_detections : b.count;
|
||||||
return db - da;
|
diff = db - da;
|
||||||
|
} else {
|
||||||
|
const sa = va
|
||||||
|
? botnetScore(va.unique_ips, botUaPercentage(va.attributes.user_agents))
|
||||||
|
: 0;
|
||||||
|
const sb = vb
|
||||||
|
? botnetScore(vb.unique_ips, botUaPercentage(vb.attributes.user_agents))
|
||||||
|
: 0;
|
||||||
|
diff = sb - sa;
|
||||||
}
|
}
|
||||||
// botnet_score
|
return sortDir === 'desc' ? diff : -diff;
|
||||||
const sa = va
|
|
||||||
? botnetScore(va.unique_ips, botUaPercentage(va.attributes.user_agents))
|
|
||||||
: 0;
|
|
||||||
const sb = vb
|
|
||||||
? botnetScore(vb.unique_ips, botUaPercentage(vb.attributes.user_agents))
|
|
||||||
: 0;
|
|
||||||
return sb - sa;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Loading state ──
|
// ── Loading state ──
|
||||||
@ -1098,11 +1107,12 @@ export function FingerprintsView() {
|
|||||||
{ id: 'ja4', label: '🔏 JA4 Actifs', desc: 'Fingerprints TLS & IPs associées' },
|
{ id: 'ja4', label: '🔏 JA4 Actifs', desc: 'Fingerprints TLS & IPs associées' },
|
||||||
{ id: 'spoofing', label: '🎭 Spoofing JA4', desc: 'Détection spoofing navigateur' },
|
{ id: 'spoofing', label: '🎭 Spoofing JA4', desc: 'Détection spoofing navigateur' },
|
||||||
{ id: 'ua_analysis', label: '🧬 Analyse UA', desc: 'User-Agents & rotation' },
|
{ id: 'ua_analysis', label: '🧬 Analyse UA', desc: 'User-Agents & rotation' },
|
||||||
|
{ id: 'rotation', label: '🔄 Rotation JA4', desc: 'IPs changeant de fingerprint TLS' },
|
||||||
] as { id: ActiveTab; label: string; desc: string }[]).map((tab) => (
|
] as { id: ActiveTab; label: string; desc: string }[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px ${
|
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-accent-primary text-accent-primary bg-accent-primary/5'
|
? 'border-accent-primary text-accent-primary bg-accent-primary/5'
|
||||||
: 'border-transparent text-text-secondary hover:text-text-primary hover:bg-background-card'
|
: 'border-transparent text-text-secondary hover:text-text-primary hover:bg-background-card'
|
||||||
@ -1119,6 +1129,9 @@ export function FingerprintsView() {
|
|||||||
{/* ── UA Analysis tab ── */}
|
{/* ── UA Analysis tab ── */}
|
||||||
{activeTab === 'ua_analysis' && <UAAnalysisPanel />}
|
{activeTab === 'ua_analysis' && <UAAnalysisPanel />}
|
||||||
|
|
||||||
|
{/* ── Rotation tab ── */}
|
||||||
|
{activeTab === 'rotation' && <RotationTab />}
|
||||||
|
|
||||||
{/* ── JA4 tab (original content) ── */}
|
{/* ── JA4 tab (original content) ── */}
|
||||||
{activeTab === 'ja4' && (<>
|
{activeTab === 'ja4' && (<>
|
||||||
|
|
||||||
@ -1146,15 +1159,6 @@ export function FingerprintsView() {
|
|||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="flex-1 px-3 py-2 rounded-lg bg-background-secondary border border-background-card text-text-primary text-sm placeholder:text-text-disabled focus:outline-none focus:border-accent-primary"
|
className="flex-1 px-3 py-2 rounded-lg bg-background-secondary border border-background-card text-text-primary text-sm placeholder:text-text-disabled focus:outline-none focus:border-accent-primary"
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
value={sortField}
|
|
||||||
onChange={(e) => setSortField(e.target.value as SortField)}
|
|
||||||
className="px-3 py-2 rounded-lg bg-background-secondary border border-background-card text-text-primary text-sm focus:outline-none focus:border-accent-primary"
|
|
||||||
>
|
|
||||||
<option value="ip_count">Trier par IPs</option>
|
|
||||||
<option value="detections">Trier par détections</option>
|
|
||||||
<option value="botnet_score">Trier par score botnet</option>
|
|
||||||
</select>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-xs text-text-secondary whitespace-nowrap">IPs min :</label>
|
<label className="text-xs text-text-secondary whitespace-nowrap">IPs min :</label>
|
||||||
<input
|
<input
|
||||||
@ -1170,33 +1174,57 @@ export function FingerprintsView() {
|
|||||||
{/* ── Table ── */}
|
{/* ── Table ── */}
|
||||||
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-background-card">
|
<thead style={{ position: 'sticky', top: 0, zIndex: 10 }}>
|
||||||
<tr>
|
<tr className="bg-background-secondary">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||||
JA4
|
JA4
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th
|
||||||
IPs actives
|
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
|
||||||
|
onClick={() => handleColSort('ip_count')}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
IPs actives
|
||||||
|
{sortField === 'ip_count'
|
||||||
|
? <span className="text-accent-primary">{sortDir === 'desc' ? '↓' : '↑'}</span>
|
||||||
|
: <span className="text-text-disabled opacity-50">⇅</span>}
|
||||||
|
</span>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th
|
||||||
Détections
|
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
|
||||||
|
onClick={() => handleColSort('detections')}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
Détections
|
||||||
|
{sortField === 'detections'
|
||||||
|
? <span className="text-accent-primary">{sortDir === 'desc' ? '↓' : '↑'}</span>
|
||||||
|
: <span className="text-text-disabled opacity-50">⇅</span>}
|
||||||
|
</span>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th
|
||||||
Score botnet
|
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
|
||||||
|
onClick={() => handleColSort('botnet_score')}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
Score botnet
|
||||||
|
{sortField === 'botnet_score'
|
||||||
|
? <span className="text-accent-primary">{sortDir === 'desc' ? '↓' : '↑'}</span>
|
||||||
|
: <span className="text-text-disabled opacity-50">⇅</span>}
|
||||||
|
</span>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||||
Top pays
|
Top pays
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||||
Top ASN
|
Top ASN
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||||
% Bot UA
|
% Bot UA
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||||
Insights
|
Insights
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -1383,3 +1411,541 @@ export function FingerprintsView() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tab: Rotation JA4 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SophisticationItem {
|
||||||
|
ip: string;
|
||||||
|
ja4_rotation_count: number;
|
||||||
|
recurrence: number;
|
||||||
|
bruteforce_hits: number;
|
||||||
|
sophistication_score: number;
|
||||||
|
tier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProactiveHuntItem {
|
||||||
|
ip: string;
|
||||||
|
recurrence: number;
|
||||||
|
worst_score: number;
|
||||||
|
worst_threat_level: string;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
days_active: number;
|
||||||
|
risk_assessment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RotationSubTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt';
|
||||||
|
|
||||||
|
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 tierBadge(tier: string): { bg: string; text: string } {
|
||||||
|
switch (tier) {
|
||||||
|
case 'APT-like': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical' };
|
||||||
|
case 'Advanced': return { bg: 'bg-threat-high/20', text: 'text-threat-high' };
|
||||||
|
case 'Automated': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium' };
|
||||||
|
default: return { bg: 'bg-background-card', text: 'text-text-secondary' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: { ja4_history: JA4HistoryEntry[] } = await res.json();
|
||||||
|
setHistory(data.ja4_history ?? []);
|
||||||
|
setHistoryLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setHistoryError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ${item.distinct_ja4_count > 5 ? '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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rotation DataTable sub-components ───────────────────────────────────────
|
||||||
|
|
||||||
|
function PersistentTable({
|
||||||
|
data,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
data: PersistentThreat[];
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo((): Column<PersistentThreat>[] => [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP',
|
||||||
|
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recurrence',
|
||||||
|
label: 'Récurrence',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">{v}j</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'worst_score',
|
||||||
|
label: 'Score menace',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => <span className="text-text-primary font-semibold">{Math.round(v)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'worst_threat_level',
|
||||||
|
label: 'Niveau',
|
||||||
|
render: (v: string) => <ThreatBadge level={v} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'first_seen',
|
||||||
|
label: 'Première vue',
|
||||||
|
render: (v: string) => <span className="text-text-secondary text-xs">{formatDate(v)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'last_seen',
|
||||||
|
label: 'Dernière vue',
|
||||||
|
render: (v: string) => <span className="text-text-secondary text-xs">{formatDate(v)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'persistence_score',
|
||||||
|
label: 'Score persistance',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<div className="w-20 bg-background-card rounded-full h-2">
|
||||||
|
<div className="h-2 rounded-full bg-threat-high" style={{ width: `${Math.min(v, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-secondary">{Math.round(v)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_actions',
|
||||||
|
label: '',
|
||||||
|
sortable: false,
|
||||||
|
render: (_: unknown, row: PersistentThreat) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable data={data} columns={columns} rowKey="ip" defaultSortKey="persistence_score" compact />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SophisticationTable({
|
||||||
|
data,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
data: SophisticationItem[];
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo((): Column<SophisticationItem>[] => [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP',
|
||||||
|
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ja4_rotation_count',
|
||||||
|
label: 'Rotation JA4',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<span className="bg-threat-medium/10 text-threat-medium text-xs px-2 py-1 rounded-full">{v} JA4</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'recurrence', label: 'Récurrence', align: 'right' },
|
||||||
|
{
|
||||||
|
key: 'bruteforce_hits',
|
||||||
|
label: 'Hits bruteforce',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => formatNumber(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sophistication_score',
|
||||||
|
label: 'Score sophistication',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<div className="w-24 bg-background-card rounded-full h-2">
|
||||||
|
<div className="h-2 rounded-full bg-threat-critical" style={{ width: `${Math.min(v, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-threat-critical">{v}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tier',
|
||||||
|
label: 'Tier',
|
||||||
|
render: (v: string) => {
|
||||||
|
const tb = tierBadge(v);
|
||||||
|
return <span className={`text-xs px-2 py-1 rounded-full ${tb.bg} ${tb.text} font-semibold`}>{v}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_actions',
|
||||||
|
label: '',
|
||||||
|
sortable: false,
|
||||||
|
render: (_: unknown, row: SophisticationItem) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable data={data} columns={columns} rowKey="ip" defaultSortKey="sophistication_score" compact />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProactiveTable({
|
||||||
|
data,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
data: ProactiveHuntItem[];
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo((): Column<ProactiveHuntItem>[] => [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP',
|
||||||
|
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recurrence',
|
||||||
|
label: 'Récurrence',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">{v}×</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'worst_score',
|
||||||
|
label: 'Score max',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => <span className="text-threat-medium font-semibold">{v.toFixed(3)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'days_active',
|
||||||
|
label: 'Jours actifs',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => <span className="text-text-primary font-medium">{v}j</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'risk_assessment',
|
||||||
|
label: 'Évaluation',
|
||||||
|
render: (v: string) => (
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-semibold ${v === 'Évadeur potentiel' ? 'bg-threat-critical/20 text-threat-critical' : 'bg-threat-medium/20 text-threat-medium'}`}>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_actions',
|
||||||
|
label: '',
|
||||||
|
sortable: false,
|
||||||
|
render: (_: unknown, row: ProactiveHuntItem) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
|
||||||
|
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
Lancer investigation
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable data={data} columns={columns} rowKey="ip" defaultSortKey="days_active" compact />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RotationTab() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [activeSubTab, setActiveSubTab] = useState<RotationSubTab>('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);
|
||||||
|
|
||||||
|
const [sophistication, setSophistication] = useState<SophisticationItem[]>([]);
|
||||||
|
const [sophisticationLoading, setSophisticationLoading] = useState(false);
|
||||||
|
const [sophisticationError, setSophisticationError] = useState<string | null>(null);
|
||||||
|
const [sophisticationLoaded, setSophisticationLoaded] = useState(false);
|
||||||
|
|
||||||
|
const [proactive, setProactive] = useState<ProactiveHuntItem[]>([]);
|
||||||
|
const [proactiveLoading, setProactiveLoading] = useState(false);
|
||||||
|
const [proactiveError, setProactiveError] = useState<string | null>(null);
|
||||||
|
const [proactiveLoaded, setProactiveLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/rotation/ja4-rotators?limit=50')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement des rotateurs'))
|
||||||
|
.then((data: { items: JA4Rotator[] }) => setRotators(data.items ?? []))
|
||||||
|
.catch(err => setRotatorsError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setRotatorsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPersistent = () => {
|
||||||
|
if (persistentLoaded) return;
|
||||||
|
setPersistentLoading(true);
|
||||||
|
fetch('/api/rotation/persistent-threats?limit=100')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement menaces persistantes'))
|
||||||
|
.then((data: { items: PersistentThreat[] }) => {
|
||||||
|
setPersistent([...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score));
|
||||||
|
setPersistentLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(err => setPersistentError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setPersistentLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSophistication = () => {
|
||||||
|
if (sophisticationLoaded) return;
|
||||||
|
setSophisticationLoading(true);
|
||||||
|
fetch('/api/rotation/sophistication?limit=50')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement sophistication'))
|
||||||
|
.then((data: { items: SophisticationItem[] }) => {
|
||||||
|
setSophistication(data.items ?? []);
|
||||||
|
setSophisticationLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(err => setSophisticationError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setSophisticationLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProactive = () => {
|
||||||
|
if (proactiveLoaded) return;
|
||||||
|
setProactiveLoading(true);
|
||||||
|
fetch('/api/rotation/proactive-hunt?min_recurrence=1&min_days=0&limit=50')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement chasse proactive'))
|
||||||
|
.then((data: { items: ProactiveHuntItem[] }) => {
|
||||||
|
setProactive(data.items ?? []);
|
||||||
|
setProactiveLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(err => setProactiveError(err instanceof Error ? err.message : String(err)))
|
||||||
|
.finally(() => setProactiveLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubTabChange = (tab: RotationSubTab) => {
|
||||||
|
setActiveSubTab(tab);
|
||||||
|
if (tab === 'persistent') loadPersistent();
|
||||||
|
if (tab === 'sophistication') loadSophistication();
|
||||||
|
if (tab === 'hunt') loadProactive();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 subTabs: { id: RotationSubTab; label: string }[] = [
|
||||||
|
{ id: 'rotators', label: '🎭 Rotateurs JA4' },
|
||||||
|
{ id: 'persistent', label: '🕰️ Menaces Persistantes' },
|
||||||
|
{ id: 'sophistication', label: '🏆 Sophistication' },
|
||||||
|
{ id: 'hunt', label: '🕵️ Chasse proactive' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Spinner = () => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Sub-tabs */}
|
||||||
|
<div className="flex gap-2 border-b border-border">
|
||||||
|
{subTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => handleSubTabChange(tab.id)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activeSubTab === tab.id
|
||||||
|
? 'text-accent-primary border-b-2 border-accent-primary'
|
||||||
|
: 'text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rotateurs */}
|
||||||
|
{activeSubTab === 'rotators' && (
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
|
{rotatorsLoading ? <Spinner /> : rotatorsError ? (
|
||||||
|
<div className="p-4 text-threat-critical text-sm">⚠️ {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 */}
|
||||||
|
{activeSubTab === 'persistent' && (
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
|
{persistentLoading ? <Spinner /> : persistentError ? (
|
||||||
|
<div className="p-4 text-threat-critical text-sm">⚠️ {persistentError}</div>
|
||||||
|
) : (
|
||||||
|
<PersistentTable data={persistent} navigate={navigate} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sophistication */}
|
||||||
|
{activeSubTab === 'sophistication' && (
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
|
{sophisticationLoading ? <Spinner /> : sophisticationError ? (
|
||||||
|
<div className="p-4 text-threat-critical text-sm">⚠️ {sophisticationError}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b border-border text-text-secondary text-sm">Score de sophistication = rotation JA4 × 10 + récurrence × 20 + log(bruteforce+1) × 5</div>
|
||||||
|
<SophisticationTable data={sophistication} navigate={navigate} />
|
||||||
|
{sophistication.length === 0 && <div className="text-center py-8 text-text-secondary text-sm">Aucune donnée de sophistication disponible.</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chasse proactive */}
|
||||||
|
{activeSubTab === 'hunt' && (
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
|
{proactiveLoading ? <Spinner /> : proactiveError ? (
|
||||||
|
<div className="p-4 text-threat-critical text-sm">⚠️ {proactiveError}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b border-border text-text-secondary text-sm">IPs récurrentes volant sous le radar (score < 0.5) — persistantes mais non détectées comme critiques.</div>
|
||||||
|
<ProactiveTable data={proactive} navigate={navigate} />
|
||||||
|
{proactive.length === 0 && <div className="text-center py-8 text-text-secondary text-sm">Aucune IP sous le radar détectée avec ces critères.</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -65,14 +66,6 @@ function StatCard({ label, value, accent }: { label: string; value: string | num
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
function ErrorMessage({ message }: { message: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||||
@ -81,141 +74,6 @@ function ErrorMessage({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Cluster row with expandable IPs ─────────────────────────────────────────
|
|
||||||
|
|
||||||
function ClusterRow({
|
|
||||||
cluster,
|
|
||||||
onInvestigateIP,
|
|
||||||
}: {
|
|
||||||
cluster: HeaderCluster;
|
|
||||||
onInvestigateIP: (ip: string) => void;
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [clusterIPs, setClusterIPs] = useState<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: { items: ClusterIP[] } = await res.json();
|
|
||||||
setClusterIPs(data.items ?? []);
|
|
||||||
setIpsLoaded(true);
|
|
||||||
} catch (err) {
|
|
||||||
setIpsError(err instanceof Error ? err.message : 'Erreur inconnue');
|
|
||||||
} finally {
|
|
||||||
setIpsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const badge = classificationBadge(cluster.classification);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<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 ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function HeaderFingerprintView() {
|
export function HeaderFingerprintView() {
|
||||||
@ -226,6 +84,11 @@ export function HeaderFingerprintView() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [expandedHash, setExpandedHash] = useState<string | null>(null);
|
||||||
|
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
|
||||||
|
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
|
||||||
|
const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchClusters = async () => {
|
const fetchClusters = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -244,9 +107,157 @@ export function HeaderFingerprintView() {
|
|||||||
fetchClusters();
|
fetchClusters();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleCluster = async (hash: string) => {
|
||||||
|
if (expandedHash === hash) {
|
||||||
|
setExpandedHash(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedHash(hash);
|
||||||
|
if (clusterIPsMap[hash] !== undefined) return;
|
||||||
|
setLoadingHashes((prev) => new Set(prev).add(hash));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/headers/cluster/${hash}/ips?limit=50`);
|
||||||
|
if (!res.ok) throw new Error('Erreur chargement IPs');
|
||||||
|
const data: { items: ClusterIP[] } = await res.json();
|
||||||
|
setClusterIPsMap((prev) => ({ ...prev, [hash]: data.items ?? [] }));
|
||||||
|
} catch (err) {
|
||||||
|
setIpErrors((prev) => ({ ...prev, [hash]: err instanceof Error ? err.message : 'Erreur inconnue' }));
|
||||||
|
} finally {
|
||||||
|
setLoadingHashes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(hash);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length;
|
const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length;
|
||||||
const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length;
|
const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length;
|
||||||
|
|
||||||
|
const clusterColumns: Column<HeaderCluster>[] = [
|
||||||
|
{
|
||||||
|
key: 'hash',
|
||||||
|
label: 'Hash cluster',
|
||||||
|
sortable: true,
|
||||||
|
render: (_, row) => (
|
||||||
|
<span>
|
||||||
|
<span className="text-accent-primary text-xs mr-2">{expandedHash === row.hash ? '▾' : '▸'}</span>
|
||||||
|
<span className="font-mono text-xs text-text-primary">{row.hash.slice(0, 16)}…</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unique_ips',
|
||||||
|
label: 'IPs',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right',
|
||||||
|
render: (v) => <span className="text-text-primary">{formatNumber(v)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avg_browser_score',
|
||||||
|
label: 'Browser Score',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => (
|
||||||
|
<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(v)}`} style={{ width: `${v}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-secondary">{Math.round(v)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ua_ch_mismatch_pct',
|
||||||
|
label: 'UA/CH Mismatch %',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right',
|
||||||
|
render: (v) => (
|
||||||
|
<span className={`font-semibold text-sm ${mismatchColor(v)}`}>{Math.round(v)}%</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'classification',
|
||||||
|
label: 'Classification',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => {
|
||||||
|
const badge = classificationBadge(v);
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'top_sec_fetch_modes',
|
||||||
|
label: 'Sec-Fetch modes',
|
||||||
|
sortable: false,
|
||||||
|
render: (v) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(v ?? []).slice(0, 3).map((mode: string) => (
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ipColumns: Column<ClusterIP>[] = [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => <span className="font-mono text-text-primary">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'browser_score',
|
||||||
|
label: 'Browser Score',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right',
|
||||||
|
render: (v) => (
|
||||||
|
<span className={v >= 70 ? 'text-threat-low' : v >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
|
||||||
|
{Math.round(v)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ua_ch_mismatch',
|
||||||
|
label: 'UA/CH Mismatch',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) =>
|
||||||
|
v ? (
|
||||||
|
<span className="text-threat-critical">⚠️ Oui</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-threat-low">✓ Non</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sec_fetch_mode',
|
||||||
|
label: 'Sec-Fetch Mode',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sec_fetch_dest',
|
||||||
|
label: 'Sec-Fetch Dest',
|
||||||
|
sortable: true,
|
||||||
|
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '',
|
||||||
|
sortable: false,
|
||||||
|
render: (_, row) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
|
||||||
|
className="bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded hover:bg-accent-primary/20 transition-colors text-xs"
|
||||||
|
>
|
||||||
|
Investiguer
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 animate-fade-in">
|
<div className="p-6 space-y-6 animate-fade-in">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -264,36 +275,55 @@ export function HeaderFingerprintView() {
|
|||||||
<StatCard label="Clusters légitimes" value={formatNumber(legitimateClusters)} accent="text-threat-low" />
|
<StatCard label="Clusters légitimes" value={formatNumber(legitimateClusters)} accent="text-threat-low" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Clusters DataTable */}
|
||||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
{loading ? (
|
{error ? (
|
||||||
<LoadingSpinner />
|
|
||||||
) : error ? (
|
|
||||||
<div className="p-4"><ErrorMessage message={error} /></div>
|
<div className="p-4"><ErrorMessage message={error} /></div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<DataTable<HeaderCluster>
|
||||||
<thead>
|
data={clusters}
|
||||||
<tr className="border-b border-border text-text-secondary text-left">
|
columns={clusterColumns}
|
||||||
<th className="px-4 py-3">Hash cluster</th>
|
rowKey="hash"
|
||||||
<th className="px-4 py-3">IPs</th>
|
defaultSortKey="unique_ips"
|
||||||
<th className="px-4 py-3">Browser Score</th>
|
onRowClick={(row) => handleToggleCluster(row.hash)}
|
||||||
<th className="px-4 py-3">UA/CH Mismatch %</th>
|
loading={loading}
|
||||||
<th className="px-4 py-3">Classification</th>
|
emptyMessage="Aucun cluster détecté"
|
||||||
<th className="px-4 py-3">Sec-Fetch modes</th>
|
compact
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{clusters.map((cluster) => (
|
|
||||||
<ClusterRow
|
|
||||||
key={cluster.hash}
|
|
||||||
cluster={cluster}
|
|
||||||
onInvestigateIP={(ip) => navigate(`/investigation/${ip}`)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded IPs panel */}
|
||||||
|
{expandedHash && (
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-text-primary">
|
||||||
|
IPs du cluster{' '}
|
||||||
|
<span className="font-mono text-xs text-accent-primary">{expandedHash.slice(0, 16)}…</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedHash(null)}
|
||||||
|
className="text-text-secondary hover:text-text-primary text-xs"
|
||||||
|
>
|
||||||
|
✕ Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{ipErrors[expandedHash] ? (
|
||||||
|
<div className="p-4"><ErrorMessage message={ipErrors[expandedHash]} /></div>
|
||||||
|
) : (
|
||||||
|
<DataTable<ClusterIP>
|
||||||
|
data={clusterIPsMap[expandedHash] ?? []}
|
||||||
|
columns={ipColumns}
|
||||||
|
rowKey="ip"
|
||||||
|
defaultSortKey="browser_score"
|
||||||
|
loading={loadingHashes.has(expandedHash)}
|
||||||
|
emptyMessage="Aucune IP trouvée"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<p className="text-text-secondary text-xs">{formatNumber(totalClusters)} cluster(s) détecté(s)</p>
|
<p className="text-text-secondary text-xs">{formatNumber(totalClusters)} cluster(s) détecté(s)</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -469,6 +469,9 @@ export function IncidentsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>{/* end grid */}
|
</div>{/* end grid */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<MiniHeatmap />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -500,3 +503,57 @@ function MetricCard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HeatmapHour {
|
||||||
|
hour: number;
|
||||||
|
hits: number;
|
||||||
|
unique_ips: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniHeatmap() {
|
||||||
|
const [data, setData] = useState<HeatmapHour[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/heatmap/hourly')
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(d => { if (d) setData(d.hours ?? d.items ?? []); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
|
const maxHits = Math.max(...data.map(d => d.hits), 1);
|
||||||
|
|
||||||
|
const barColor = (hits: number) => {
|
||||||
|
const pct = (hits / maxHits) * 100;
|
||||||
|
if (pct >= 75) return 'bg-red-500/70';
|
||||||
|
if (pct >= 50) return 'bg-purple-500/60';
|
||||||
|
if (pct >= 25) return 'bg-blue-500/50';
|
||||||
|
if (pct >= 5) return 'bg-blue-400/30';
|
||||||
|
return 'bg-slate-700/30';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background-secondary border border-border rounded-lg p-4">
|
||||||
|
<div className="text-sm font-semibold text-text-primary mb-3">⏱️ Activité par heure (72h)</div>
|
||||||
|
<div className="flex items-end gap-px h-16">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={i} className="relative flex-1 flex flex-col items-center justify-end group">
|
||||||
|
<div
|
||||||
|
className={`w-full rounded-sm ${barColor(d.hits)}`}
|
||||||
|
style={{ height: `${Math.max((d.hits / maxHits) * 100, 2)}%` }}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:flex bg-background-card border border-border text-xs text-text-primary rounded px-2 py-1 whitespace-nowrap z-10 pointer-events-none">
|
||||||
|
{d.hits.toLocaleString()} hits — {d.unique_ips} IPs
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-text-disabled mt-0.5 leading-none">
|
||||||
|
{[0, 6, 12, 18].includes(d.hour) ? `${d.hour}h` : '\u00a0'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -321,6 +322,89 @@ function ScatterPlot({ points }: { points: ScatterPoint[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Anomalies DataTable ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AnomaliesTable({
|
||||||
|
anomalies,
|
||||||
|
selectedIP,
|
||||||
|
onRowClick,
|
||||||
|
}: {
|
||||||
|
anomalies: MLAnomaly[];
|
||||||
|
selectedIP: string | null;
|
||||||
|
onRowClick: (row: MLAnomaly) => void;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo((): Column<MLAnomaly>[] => [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP',
|
||||||
|
render: (v: string, row: MLAnomaly) => (
|
||||||
|
<span className={`font-mono text-xs ${selectedIP === row.ip ? 'text-accent-primary' : 'text-text-primary'}`}>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'host',
|
||||||
|
label: 'Host',
|
||||||
|
render: (v: string) => (
|
||||||
|
<span className="text-text-secondary max-w-[120px] truncate block" title={v}>
|
||||||
|
{v || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hits',
|
||||||
|
label: 'Hits',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => formatNumber(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fuzzing_index',
|
||||||
|
label: 'Fuzzing',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(v)}`}>
|
||||||
|
{Math.round(v)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'attack_type',
|
||||||
|
label: 'Type',
|
||||||
|
render: (v: string) => (
|
||||||
|
<span title={v} className="text-sm">{attackTypeEmoji(v)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_signals',
|
||||||
|
label: 'Signaux',
|
||||||
|
sortable: false,
|
||||||
|
render: (_: unknown, row: MLAnomaly) => (
|
||||||
|
<span className="flex gap-0.5">
|
||||||
|
{row.ua_ch_mismatch && <span title="UA/CH mismatch">⚠️</span>}
|
||||||
|
{row.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
|
||||||
|
{row.is_ua_rotating && <span title="UA rotatif">🔄</span>}
|
||||||
|
{row.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [selectedIP]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto max-h-[500px]">
|
||||||
|
<DataTable
|
||||||
|
data={anomalies}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="ip"
|
||||||
|
defaultSortKey="fuzzing_index"
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
emptyMessage="Aucune anomalie détectée"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function MLFeaturesView() {
|
export function MLFeaturesView() {
|
||||||
@ -412,57 +496,11 @@ export function MLFeaturesView() {
|
|||||||
) : anomaliesError ? (
|
) : anomaliesError ? (
|
||||||
<div className="p-4"><ErrorMessage message={anomaliesError} /></div>
|
<div className="p-4"><ErrorMessage message={anomaliesError} /></div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-auto max-h-[500px]">
|
<AnomaliesTable
|
||||||
<table className="w-full text-xs">
|
anomalies={anomalies}
|
||||||
<thead className="sticky top-0 bg-background-secondary z-10">
|
selectedIP={selectedIP}
|
||||||
<tr className="border-b border-border text-text-secondary text-left">
|
onRowClick={(row) => loadRadar(row.ip)}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -75,6 +76,94 @@ function ErrorMessage({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Detections DataTable ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TcpDetectionsTable({
|
||||||
|
items,
|
||||||
|
navigate,
|
||||||
|
}: {
|
||||||
|
items: TcpSpoofingItem[];
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo((): Column<TcpSpoofingItem>[] => [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP',
|
||||||
|
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ja4',
|
||||||
|
label: 'JA4',
|
||||||
|
render: (v: string) => (
|
||||||
|
<span className="font-mono text-xs text-text-secondary">
|
||||||
|
{v ? `${v.slice(0, 14)}…` : '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tcp_ttl',
|
||||||
|
label: 'TTL observé',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<span className={`font-mono font-semibold ${ttlColor(v)}`}>{v}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tcp_window_size',
|
||||||
|
label: 'Fenêtre TCP',
|
||||||
|
align: 'right',
|
||||||
|
render: (v: number) => (
|
||||||
|
<span className="text-text-secondary text-xs">{formatNumber(v)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'suspected_os',
|
||||||
|
label: 'OS suspecté',
|
||||||
|
render: (v: string) => <span className="text-text-primary text-xs">{v || '—'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'declared_os',
|
||||||
|
label: 'OS déclaré',
|
||||||
|
render: (v: string) => <span className="text-text-secondary text-xs">{v || '—'}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spoof_flag',
|
||||||
|
label: 'Spoof',
|
||||||
|
sortable: false,
|
||||||
|
render: (v: boolean) =>
|
||||||
|
v ? (
|
||||||
|
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-0.5 rounded-full">
|
||||||
|
🚨 Spoof
|
||||||
|
</span>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '_actions',
|
||||||
|
label: '',
|
||||||
|
sortable: false,
|
||||||
|
render: (_: unknown, row: TcpSpoofingItem) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
data={items}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="ip"
|
||||||
|
defaultSortKey="tcp_ttl"
|
||||||
|
emptyMessage="Aucune détection"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function TcpSpoofingView() {
|
export function TcpSpoofingView() {
|
||||||
@ -261,53 +350,7 @@ export function TcpSpoofingView() {
|
|||||||
) : itemsError ? (
|
) : itemsError ? (
|
||||||
<div className="p-4"><ErrorMessage message={itemsError} /></div>
|
<div className="p-4"><ErrorMessage message={itemsError} /></div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<TcpDetectionsTable items={filteredItems} navigate={navigate} />
|
||||||
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
26
frontend/src/components/ui/Card.tsx
Normal file
26
frontend/src/components/ui/Card.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
title?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Card({ title, actions, children, className = '' }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-background-secondary border border-background-card rounded-lg overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{(title || actions) && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-background-card">
|
||||||
|
{title && (
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
|
||||||
|
)}
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
frontend/src/components/ui/DataTable.tsx
Normal file
152
frontend/src/components/ui/DataTable.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSort, SortDir } from '../../hooks/useSort';
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
align?: 'left' | 'right' | 'center';
|
||||||
|
width?: string;
|
||||||
|
render?: (value: any, row: T) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
data: T[];
|
||||||
|
columns: Column<T>[];
|
||||||
|
defaultSortKey?: string;
|
||||||
|
defaultSortDir?: SortDir;
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
rowKey: keyof T | ((row: T) => string);
|
||||||
|
emptyMessage?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
maxHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataTable<T extends Record<string, any>>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
defaultSortKey,
|
||||||
|
defaultSortDir = 'desc',
|
||||||
|
onRowClick,
|
||||||
|
rowKey,
|
||||||
|
emptyMessage = 'Aucune donnée disponible',
|
||||||
|
loading = false,
|
||||||
|
className = '',
|
||||||
|
compact = false,
|
||||||
|
maxHeight,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
const firstSortableKey =
|
||||||
|
defaultSortKey ||
|
||||||
|
columns.find((c) => c.sortable !== false)?.key ||
|
||||||
|
columns[0]?.key ||
|
||||||
|
'id';
|
||||||
|
|
||||||
|
const { sorted, sortKey, sortDir, handleSort } = useSort<T>(
|
||||||
|
data,
|
||||||
|
firstSortableKey as keyof T,
|
||||||
|
defaultSortDir
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = compact ? 'px-3 py-1.5' : 'px-4 py-2.5';
|
||||||
|
|
||||||
|
const getRowKey = (row: T): string => {
|
||||||
|
if (typeof rowKey === 'function') return rowKey(row);
|
||||||
|
return String(row[rowKey as keyof T] ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const alignClass = (align?: 'left' | 'right' | 'center') => {
|
||||||
|
if (align === 'right') return 'text-right';
|
||||||
|
if (align === 'center') return 'text-center';
|
||||||
|
return 'text-left';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${maxHeight ? `${maxHeight} overflow-y-auto` : ''} ${className}`}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead style={{ position: 'sticky', top: 0, zIndex: 10 }}>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => {
|
||||||
|
const isSortable = col.sortable !== false;
|
||||||
|
const isActive = String(sortKey) === col.key;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={[
|
||||||
|
cell,
|
||||||
|
'text-xs font-semibold text-text-disabled uppercase tracking-wider',
|
||||||
|
'bg-background-secondary border-b border-background-card',
|
||||||
|
col.width ?? '',
|
||||||
|
alignClass(col.align),
|
||||||
|
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
|
||||||
|
].join(' ')}
|
||||||
|
onClick={isSortable ? () => handleSort(col.key as keyof T) : undefined}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{col.label}
|
||||||
|
{isSortable &&
|
||||||
|
(isActive ? (
|
||||||
|
<span className="text-accent-primary">
|
||||||
|
{sortDir === 'desc' ? '↓' : '↑'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-disabled opacity-50">⇅</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} className={`${cell} border-b border-background-card`}>
|
||||||
|
<div className="bg-background-card/50 rounded animate-pulse h-4" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center py-8 text-text-disabled text-sm"
|
||||||
|
>
|
||||||
|
{emptyMessage}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sorted.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={getRowKey(row)}
|
||||||
|
className={[
|
||||||
|
'border-b border-background-card transition-colors',
|
||||||
|
'hover:bg-background-card/50',
|
||||||
|
onRowClick ? 'cursor-pointer' : '',
|
||||||
|
].join(' ')}
|
||||||
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
>
|
||||||
|
{columns.map((col) => {
|
||||||
|
const value = row[col.key as keyof T];
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className={[cell, alignClass(col.align), col.className ?? ''].join(' ')}
|
||||||
|
>
|
||||||
|
{col.render ? col.render(value, row) : (value as React.ReactNode)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/src/components/ui/StatCard.tsx
Normal file
35
frontend/src/components/ui/StatCard.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Color = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'slate';
|
||||||
|
|
||||||
|
const COLOR_MAP: Record<Color, string> = {
|
||||||
|
red: 'text-red-400',
|
||||||
|
orange: 'text-orange-400',
|
||||||
|
yellow: 'text-yellow-400',
|
||||||
|
green: 'text-green-400',
|
||||||
|
blue: 'text-blue-400',
|
||||||
|
purple: 'text-purple-400',
|
||||||
|
slate: 'text-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
sub?: string;
|
||||||
|
color?: Color;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatCard({ label, value, sub, color, icon }: StatCardProps) {
|
||||||
|
const valueClass = color ? COLOR_MAP[color] : 'text-text-primary';
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="text-xs text-text-disabled uppercase tracking-wider flex items-center gap-1">
|
||||||
|
{icon && <span>{icon}</span>}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className={`text-xl font-bold ${valueClass}`}>{value}</div>
|
||||||
|
{sub && <div className="text-xs text-text-secondary mt-0.5">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/components/ui/ThreatBadge.tsx
Normal file
24
frontend/src/components/ui/ThreatBadge.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
type ThreatLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'NORMAL' | 'KNOWN_BOT';
|
||||||
|
|
||||||
|
const BADGE_STYLES: Record<ThreatLevel, string> = {
|
||||||
|
CRITICAL: 'bg-red-900/50 text-red-400 border border-red-800/50',
|
||||||
|
HIGH: 'bg-orange-900/50 text-orange-400 border border-orange-800/50',
|
||||||
|
MEDIUM: 'bg-yellow-900/50 text-yellow-400 border border-yellow-800/50',
|
||||||
|
LOW: 'bg-green-900/50 text-green-400 border border-green-800/50',
|
||||||
|
NORMAL: 'bg-slate-700/50 text-slate-400 border border-slate-600/50',
|
||||||
|
KNOWN_BOT: 'bg-purple-900/50 text-purple-400 border border-purple-800/50',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ThreatBadgeProps {
|
||||||
|
level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThreatBadge({ level }: ThreatBadgeProps) {
|
||||||
|
const key = (level?.toUpperCase() ?? 'NORMAL') as ThreatLevel;
|
||||||
|
const cls = BADGE_STYLES[key] ?? BADGE_STYLES.NORMAL;
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded font-medium uppercase ${cls}`}>
|
||||||
|
{level || 'NORMAL'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/hooks/useSort.ts
Normal file
42
frontend/src/hooks/useSort.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
export type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export function useSort<T extends Record<string, any>>(
|
||||||
|
data: T[],
|
||||||
|
defaultKey: keyof T,
|
||||||
|
defaultDir: SortDir = 'desc'
|
||||||
|
): {
|
||||||
|
sorted: T[];
|
||||||
|
sortKey: keyof T;
|
||||||
|
sortDir: SortDir;
|
||||||
|
handleSort: (key: keyof T) => void;
|
||||||
|
} {
|
||||||
|
const [sortKey, setSortKey] = useState<keyof T>(defaultKey);
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>(defaultDir);
|
||||||
|
|
||||||
|
const handleSort = (key: keyof T) => {
|
||||||
|
if (key === sortKey) {
|
||||||
|
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir('desc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
const av = a[sortKey];
|
||||||
|
const bv = b[sortKey];
|
||||||
|
let cmp = 0;
|
||||||
|
if (av == null && bv == null) cmp = 0;
|
||||||
|
else if (av == null) cmp = 1;
|
||||||
|
else if (bv == null) cmp = -1;
|
||||||
|
else if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv;
|
||||||
|
else cmp = String(av).localeCompare(String(bv));
|
||||||
|
return sortDir === 'desc' ? -cmp : cmp;
|
||||||
|
});
|
||||||
|
}, [data, sortKey, sortDir]);
|
||||||
|
|
||||||
|
return { sorted, sortKey, sortDir, handleSort };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user