feat: 6 améliorations SOC — synthèse IP, baseline, sophistication, chasse proactive, badge ASN, 2 nouveaux onglets rotation
- investigation_summary.py: nouveau endpoint GET /api/investigation/{ip}/summary
agrège 6 sources (ML, bruteforce, TCP spoofing, JA4 rotation, persistance, timeline 24h)
en un score de risque 0-100 avec signaux détaillés
- InvestigationView.tsx: widget IPActivitySummary avec jauge Risk Score SVG,
badges multi-sources et mini-timeline 24h barres
- metrics.py: endpoint GET /api/metrics/baseline — comparaison 24h vs hier
(total détections, IPs uniques, alertes CRITICAL) avec % de variation
- IncidentsView.tsx: widget baseline avec ▲▼ sur le dashboard principal
- rotation.py: endpoints /sophistication et /proactive-hunt
Score sophistication = JOIN 3 tables (rotation×10 + récurrence×20 + log(bf+1)×5)
Chasse proactive = IPs récurrentes sous le seuil ML (abs(score) < 0.5)
- RotationView.tsx: onglets 🏆 Sophistication et 🕵️ Chasse proactive
avec tier APT-like/Advanced/Automated/Basic et boutons investigation
- detections.py: LEFT JOIN asn_reputation, badge coloré rouge/orange/vert
selon label (bot/scanner → score 0.05, human → 0.9)
- models.py: ajout champs asn_score et asn_rep_label dans Detection
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -13,7 +13,7 @@ import os
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import db
|
from .database import db
|
||||||
from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints
|
from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints
|
||||||
from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features
|
from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features, investigation_summary
|
||||||
|
|
||||||
# Configuration logging
|
# Configuration logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -82,6 +82,7 @@ app.include_router(heatmap.router)
|
|||||||
app.include_router(botnets.router)
|
app.include_router(botnets.router)
|
||||||
app.include_router(rotation.router)
|
app.include_router(rotation.router)
|
||||||
app.include_router(ml_features.router)
|
app.include_router(ml_features.router)
|
||||||
|
app.include_router(investigation_summary.router)
|
||||||
|
|
||||||
|
|
||||||
# Route pour servir le frontend
|
# Route pour servir le frontend
|
||||||
|
|||||||
@ -75,6 +75,8 @@ class Detection(BaseModel):
|
|||||||
post_ratio: float
|
post_ratio: float
|
||||||
reason: str
|
reason: str
|
||||||
client_headers: str = ""
|
client_headers: str = ""
|
||||||
|
asn_score: Optional[float] = None
|
||||||
|
asn_rep_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DetectionsListResponse(BaseModel):
|
class DetectionsListResponse(BaseModel):
|
||||||
|
|||||||
@ -97,8 +97,10 @@ async def get_detections(
|
|||||||
hit_velocity,
|
hit_velocity,
|
||||||
fuzzing_index,
|
fuzzing_index,
|
||||||
post_ratio,
|
post_ratio,
|
||||||
reason
|
reason,
|
||||||
|
ar.label AS asn_rep_label
|
||||||
FROM ml_detected_anomalies
|
FROM ml_detected_anomalies
|
||||||
|
LEFT JOIN mabase_prod.asn_reputation ar ON ar.src_asn = toUInt32OrZero(asn_number)
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
ORDER BY {sort_by} {sort_order}
|
ORDER BY {sort_by} {sort_order}
|
||||||
LIMIT %(limit)s OFFSET %(offset)s
|
LIMIT %(limit)s OFFSET %(offset)s
|
||||||
@ -109,6 +111,21 @@ async def get_detections(
|
|||||||
|
|
||||||
result = db.query(main_query, params)
|
result = db.query(main_query, params)
|
||||||
|
|
||||||
|
def _label_to_score(label: str) -> float | None:
|
||||||
|
if not label:
|
||||||
|
return None
|
||||||
|
mapping = {
|
||||||
|
'human': 0.9,
|
||||||
|
'bot': 0.05,
|
||||||
|
'proxy': 0.25,
|
||||||
|
'vpn': 0.3,
|
||||||
|
'tor': 0.1,
|
||||||
|
'datacenter': 0.4,
|
||||||
|
'scanner': 0.05,
|
||||||
|
'malicious': 0.05,
|
||||||
|
}
|
||||||
|
return mapping.get(label.lower(), 0.5)
|
||||||
|
|
||||||
detections = [
|
detections = [
|
||||||
Detection(
|
Detection(
|
||||||
detected_at=row[0],
|
detected_at=row[0],
|
||||||
@ -130,7 +147,9 @@ async def get_detections(
|
|||||||
hit_velocity=float(row[16]) if row[16] else 0.0,
|
hit_velocity=float(row[16]) if row[16] else 0.0,
|
||||||
fuzzing_index=float(row[17]) if row[17] else 0.0,
|
fuzzing_index=float(row[17]) if row[17] else 0.0,
|
||||||
post_ratio=float(row[18]) if row[18] else 0.0,
|
post_ratio=float(row[18]) if row[18] else 0.0,
|
||||||
reason=row[19] or ""
|
reason=row[19] or "",
|
||||||
|
asn_rep_label=row[20] or "",
|
||||||
|
asn_score=_label_to_score(row[20] or ""),
|
||||||
)
|
)
|
||||||
for row in result.result_rows
|
for row in result.result_rows
|
||||||
]
|
]
|
||||||
|
|||||||
165
backend/routes/investigation_summary.py
Normal file
165
backend/routes/investigation_summary.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Endpoint d'investigation enrichie pour une IP donnée.
|
||||||
|
Agrège en une seule requête les données provenant de toutes les sources :
|
||||||
|
ml_detected_anomalies, view_form_bruteforce_detected, view_tcp_spoofing_detected,
|
||||||
|
agg_host_ip_ja4_1h (rotation JA4), view_ip_recurrence, view_ai_features_1h.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from ..database import db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/investigation", tags=["investigation"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{ip}/summary")
|
||||||
|
async def get_ip_full_summary(ip: str):
|
||||||
|
"""
|
||||||
|
Synthèse complète pour une IP : toutes les sources en un appel.
|
||||||
|
Normalise l'IP (accepte ::ffff:x.x.x.x ou x.x.x.x).
|
||||||
|
"""
|
||||||
|
clean_ip = ip.replace("::ffff:", "").strip()
|
||||||
|
try:
|
||||||
|
# ── 1. Score ML / features ─────────────────────────────────────────────
|
||||||
|
ml_sql = """
|
||||||
|
SELECT
|
||||||
|
max(abs(anomaly_score)) AS max_score,
|
||||||
|
any(threat_level) AS threat_level,
|
||||||
|
any(bot_name) AS bot_name,
|
||||||
|
count() AS total_detections,
|
||||||
|
uniq(host) AS distinct_hosts,
|
||||||
|
uniq(ja4) AS distinct_ja4
|
||||||
|
FROM mabase_prod.ml_detected_anomalies
|
||||||
|
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s
|
||||||
|
"""
|
||||||
|
ml_res = db.query(ml_sql, {"ip": clean_ip})
|
||||||
|
ml_row = ml_res.result_rows[0] if ml_res.result_rows else None
|
||||||
|
ml_data = {
|
||||||
|
"max_score": round(float(ml_row[0] or 0), 2) if ml_row else 0,
|
||||||
|
"threat_level": str(ml_row[1] or "") if ml_row else "",
|
||||||
|
"attack_type": str(ml_row[2] or "") if ml_row else "",
|
||||||
|
"total_detections": int(ml_row[3] or 0) if ml_row else 0,
|
||||||
|
"distinct_hosts": int(ml_row[4] or 0) if ml_row else 0,
|
||||||
|
"distinct_ja4": int(ml_row[5] or 0) if ml_row else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 2. Brute force ─────────────────────────────────────────────────────
|
||||||
|
bf_sql = """
|
||||||
|
SELECT
|
||||||
|
uniq(host) AS hosts_attacked,
|
||||||
|
sum(hits) AS total_hits,
|
||||||
|
sum(query_params_count) AS total_params,
|
||||||
|
groupArray(3)(host) AS top_hosts
|
||||||
|
FROM mabase_prod.view_form_bruteforce_detected
|
||||||
|
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s
|
||||||
|
"""
|
||||||
|
bf_res = db.query(bf_sql, {"ip": clean_ip})
|
||||||
|
bf_row = bf_res.result_rows[0] if bf_res.result_rows else None
|
||||||
|
bf_data = {
|
||||||
|
"active": bool(bf_row and int(bf_row[1] or 0) > 0),
|
||||||
|
"hosts_attacked": int(bf_row[0] or 0) if bf_row else 0,
|
||||||
|
"total_hits": int(bf_row[1] or 0) if bf_row else 0,
|
||||||
|
"total_params": int(bf_row[2] or 0) if bf_row else 0,
|
||||||
|
"top_hosts": [str(h) for h in (bf_row[3] or [])] if bf_row else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3. TCP spoofing ────────────────────────────────────────────────────
|
||||||
|
tcp_sql = """
|
||||||
|
SELECT tcp_ttl, first_ua
|
||||||
|
FROM mabase_prod.view_tcp_spoofing_detected
|
||||||
|
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s
|
||||||
|
AND tcp_ttl > 0
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
tcp_res = db.query(tcp_sql, {"ip": clean_ip})
|
||||||
|
tcp_data = {"detected": False, "tcp_ttl": None, "suspected_os": None}
|
||||||
|
if tcp_res.result_rows:
|
||||||
|
ttl = int(tcp_res.result_rows[0][0])
|
||||||
|
if 52 <= ttl <= 65:
|
||||||
|
sus_os = "Linux/Mac"
|
||||||
|
elif 110 <= ttl <= 135:
|
||||||
|
sus_os = "Windows"
|
||||||
|
else:
|
||||||
|
sus_os = "Unknown"
|
||||||
|
ua = str(tcp_res.result_rows[0][1] or "")
|
||||||
|
dec_os = "Windows" if "Windows" in ua else ("macOS" if "Mac OS X" in ua else "Linux/Android" if "Linux" in ua else "Unknown")
|
||||||
|
spoof = sus_os != "Unknown" and dec_os != "Unknown" and sus_os != dec_os
|
||||||
|
tcp_data = {
|
||||||
|
"detected": spoof,
|
||||||
|
"tcp_ttl": ttl,
|
||||||
|
"suspected_os": sus_os,
|
||||||
|
"declared_os": dec_os,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 4. JA4 rotation ────────────────────────────────────────────────────
|
||||||
|
rot_sql = """
|
||||||
|
SELECT distinct_ja4_count, total_hits
|
||||||
|
FROM mabase_prod.view_host_ip_ja4_rotation
|
||||||
|
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
rot_res = db.query(rot_sql, {"ip": clean_ip})
|
||||||
|
rot_data = {"rotating": False, "distinct_ja4_count": 0}
|
||||||
|
if rot_res.result_rows:
|
||||||
|
row = rot_res.result_rows[0]
|
||||||
|
cnt = int(row[0] or 0)
|
||||||
|
rot_data = {"rotating": cnt > 1, "distinct_ja4_count": cnt, "total_hits": int(row[1] or 0)}
|
||||||
|
|
||||||
|
# ── 5. Persistance ─────────────────────────────────────────────────────
|
||||||
|
pers_sql = """
|
||||||
|
SELECT recurrence, worst_score, worst_threat_level, first_seen, last_seen
|
||||||
|
FROM mabase_prod.view_ip_recurrence
|
||||||
|
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
pers_res = db.query(pers_sql, {"ip": clean_ip})
|
||||||
|
pers_data = {"persistent": False, "recurrence": 0}
|
||||||
|
if pers_res.result_rows:
|
||||||
|
row = pers_res.result_rows[0]
|
||||||
|
pers_data = {
|
||||||
|
"persistent": True,
|
||||||
|
"recurrence": int(row[0] or 0),
|
||||||
|
"worst_score": round(float(row[1] or 0), 2),
|
||||||
|
"worst_threat_level":str(row[2] or ""),
|
||||||
|
"first_seen": str(row[3]),
|
||||||
|
"last_seen": str(row[4]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 6. Timeline 24h ────────────────────────────────────────────────────
|
||||||
|
tl_sql = """
|
||||||
|
SELECT
|
||||||
|
toHour(window_start) AS hour,
|
||||||
|
sum(hits) AS hits,
|
||||||
|
groupUniqArray(3)(ja4) AS ja4s
|
||||||
|
FROM mabase_prod.agg_host_ip_ja4_1h
|
||||||
|
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(ip)s
|
||||||
|
AND window_start >= now() - INTERVAL 24 HOUR
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour ASC
|
||||||
|
"""
|
||||||
|
tl_res = db.query(tl_sql, {"ip": clean_ip})
|
||||||
|
timeline = [
|
||||||
|
{"hour": int(r[0]), "hits": int(r[1]), "ja4s": [str(j) for j in (r[2] or [])]}
|
||||||
|
for r in tl_res.result_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Global risk score (heuristic) ──────────────────────────────────────
|
||||||
|
risk = 0
|
||||||
|
risk += min(50, ml_data["max_score"] * 50)
|
||||||
|
if bf_data["active"]: risk += 20
|
||||||
|
if tcp_data["detected"]: risk += 15
|
||||||
|
if rot_data["rotating"]: risk += min(15, rot_data["distinct_ja4_count"] * 3)
|
||||||
|
if pers_data["persistent"]: risk += min(10, pers_data["recurrence"] * 2)
|
||||||
|
risk = min(100, round(risk))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ip": clean_ip,
|
||||||
|
"risk_score": risk,
|
||||||
|
"ml": ml_data,
|
||||||
|
"bruteforce": bf_data,
|
||||||
|
"tcp_spoofing":tcp_data,
|
||||||
|
"ja4_rotation":rot_data,
|
||||||
|
"persistence": pers_data,
|
||||||
|
"timeline_24h":timeline,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@ -120,3 +120,56 @@ async def get_threat_distribution():
|
|||||||
|
|
||||||
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)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/baseline")
|
||||||
|
async def get_metrics_baseline():
|
||||||
|
"""
|
||||||
|
Compare les métriques actuelles (24h) vs hier (24h-48h) pour afficher les tendances.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
countIf(detected_at >= now() - INTERVAL 24 HOUR) AS today_total,
|
||||||
|
countIf(detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_total,
|
||||||
|
uniqIf(src_ip, detected_at >= now() - INTERVAL 24 HOUR) AS today_ips,
|
||||||
|
uniqIf(src_ip, detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_ips,
|
||||||
|
countIf(threat_level = 'CRITICAL' AND detected_at >= now() - INTERVAL 24 HOUR) AS today_critical,
|
||||||
|
countIf(threat_level = 'CRITICAL' AND detected_at >= now() - INTERVAL 48 HOUR AND detected_at < now() - INTERVAL 24 HOUR) AS yesterday_critical
|
||||||
|
FROM ml_detected_anomalies
|
||||||
|
WHERE detected_at >= now() - INTERVAL 48 HOUR
|
||||||
|
"""
|
||||||
|
r = db.query(query)
|
||||||
|
row = r.result_rows[0] if r.result_rows else None
|
||||||
|
|
||||||
|
def pct_change(today: int, yesterday: int) -> float:
|
||||||
|
if yesterday == 0:
|
||||||
|
return 100.0 if today > 0 else 0.0
|
||||||
|
return round((today - yesterday) / yesterday * 100, 1)
|
||||||
|
|
||||||
|
today_total = int(row[0] or 0) if row else 0
|
||||||
|
yesterday_total = int(row[1] or 0) if row else 0
|
||||||
|
today_ips = int(row[2] or 0) if row else 0
|
||||||
|
yesterday_ips = int(row[3] or 0) if row else 0
|
||||||
|
today_crit = int(row[4] or 0) if row else 0
|
||||||
|
yesterday_crit = int(row[5] or 0) if row else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_detections": {
|
||||||
|
"today": today_total,
|
||||||
|
"yesterday": yesterday_total,
|
||||||
|
"pct_change": pct_change(today_total, yesterday_total),
|
||||||
|
},
|
||||||
|
"unique_ips": {
|
||||||
|
"today": today_ips,
|
||||||
|
"yesterday": yesterday_ips,
|
||||||
|
"pct_change": pct_change(today_ips, yesterday_ips),
|
||||||
|
},
|
||||||
|
"critical_alerts": {
|
||||||
|
"today": today_crit,
|
||||||
|
"yesterday": yesterday_crit,
|
||||||
|
"pct_change": pct_change(today_crit, yesterday_crit),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Erreur baseline: {str(e)}")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Endpoints pour la détection de la rotation de fingerprints JA4 et des menaces persistantes
|
Endpoints pour la détection de la rotation de fingerprints JA4 et des menaces persistantes
|
||||||
"""
|
"""
|
||||||
|
import math
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from ..database import db
|
from ..database import db
|
||||||
@ -99,3 +100,114 @@ async def get_ip_ja4_history(ip: str):
|
|||||||
return {"ip": ip, "ja4_history": items, "total": len(items)}
|
return {"ip": ip, "ja4_history": 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))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sophistication")
|
||||||
|
async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
|
||||||
|
"""Score de sophistication adversaire par IP (rotation JA4 + récurrence + bruteforce)."""
|
||||||
|
try:
|
||||||
|
# Separate queries merged in Python to avoid view JOIN issues
|
||||||
|
rot_result = db.query("""
|
||||||
|
SELECT
|
||||||
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||||
|
distinct_ja4_count
|
||||||
|
FROM mabase_prod.view_host_ip_ja4_rotation
|
||||||
|
""")
|
||||||
|
rotation_map = {str(row[0]): int(row[1]) for row in rot_result.result_rows}
|
||||||
|
|
||||||
|
rec_result = db.query("""
|
||||||
|
SELECT
|
||||||
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||||
|
recurrence
|
||||||
|
FROM mabase_prod.view_ip_recurrence
|
||||||
|
""")
|
||||||
|
recurrence_map = {str(row[0]): int(row[1]) for row in rec_result.result_rows}
|
||||||
|
|
||||||
|
bf_result = db.query("""
|
||||||
|
SELECT
|
||||||
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||||
|
sum(hits) AS total_hits
|
||||||
|
FROM mabase_prod.view_form_bruteforce_detected
|
||||||
|
GROUP BY ip
|
||||||
|
""")
|
||||||
|
bruteforce_map = {str(row[0]): int(row[1]) for row in bf_result.result_rows}
|
||||||
|
|
||||||
|
# Start from IPs that appear in rotation view (most evasive)
|
||||||
|
items = []
|
||||||
|
for ip, ja4_count in rotation_map.items():
|
||||||
|
recurrence = recurrence_map.get(ip, 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:
|
||||||
|
tier = "APT-like"
|
||||||
|
elif score > 50:
|
||||||
|
tier = "Advanced"
|
||||||
|
elif score > 20:
|
||||||
|
tier = "Automated"
|
||||||
|
else:
|
||||||
|
tier = "Basic"
|
||||||
|
items.append({
|
||||||
|
"ip": ip,
|
||||||
|
"ja4_rotation_count": ja4_count,
|
||||||
|
"recurrence": recurrence,
|
||||||
|
"bruteforce_hits": bf_hits,
|
||||||
|
"sophistication_score": round(score, 1),
|
||||||
|
"tier": tier,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.sort(key=lambda x: x["sophistication_score"], reverse=True)
|
||||||
|
items = items[:limit]
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/proactive-hunt")
|
||||||
|
async def get_proactive_hunt(
|
||||||
|
min_recurrence: int = Query(2, ge=1, description="Récurrence minimale"),
|
||||||
|
min_days: int = Query(2, ge=0, description="Jours d'activité minimum"),
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
):
|
||||||
|
"""IPs volant sous le radar : récurrentes mais sous le seuil de détection normal."""
|
||||||
|
try:
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||||
|
recurrence,
|
||||||
|
worst_score,
|
||||||
|
worst_threat_level,
|
||||||
|
first_seen,
|
||||||
|
last_seen,
|
||||||
|
dateDiff('day', first_seen, last_seen) AS days_active
|
||||||
|
FROM mabase_prod.view_ip_recurrence
|
||||||
|
WHERE recurrence >= %(min_recurrence)s
|
||||||
|
AND abs(worst_score) < 0.5
|
||||||
|
AND dateDiff('day', first_seen, last_seen) >= %(min_days)s
|
||||||
|
ORDER BY recurrence DESC, worst_score ASC
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
result = db.query(sql, {
|
||||||
|
"min_recurrence": min_recurrence,
|
||||||
|
"min_days": min_days,
|
||||||
|
"limit": limit,
|
||||||
|
})
|
||||||
|
items = []
|
||||||
|
for row in result.result_rows:
|
||||||
|
recurrence = int(row[1])
|
||||||
|
worst_score = float(row[2] or 0)
|
||||||
|
days_active = int(row[6] or 0)
|
||||||
|
ratio = recurrence / (worst_score + 0.1)
|
||||||
|
risk = "Évadeur potentiel" if ratio > 10 else "Persistant modéré"
|
||||||
|
items.append({
|
||||||
|
"ip": str(row[0]),
|
||||||
|
"recurrence": recurrence,
|
||||||
|
"worst_score": round(worst_score, 4),
|
||||||
|
"worst_threat_level": str(row[3] or ""),
|
||||||
|
"first_seen": str(row[4]),
|
||||||
|
"last_seen": str(row[5]),
|
||||||
|
"days_active": days_active,
|
||||||
|
"risk_assessment": risk,
|
||||||
|
})
|
||||||
|
return {"items": items, "total": len(items)}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@ -58,6 +58,8 @@ export interface Detection {
|
|||||||
post_ratio: number;
|
post_ratio: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
client_headers: string;
|
client_headers: string;
|
||||||
|
asn_score?: number | null;
|
||||||
|
asn_rep_label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetectionsListResponse {
|
export interface DetectionsListResponse {
|
||||||
|
|||||||
@ -451,6 +451,7 @@ export function DetectionsList() {
|
|||||||
{detection.asn_number && (
|
{detection.asn_number && (
|
||||||
<div className="text-xs text-text-secondary">AS{detection.asn_number}</div>
|
<div className="text-xs text-text-secondary">AS{detection.asn_number}</div>
|
||||||
)}
|
)}
|
||||||
|
<AsnRepBadge score={detection.asn_score} label={detection.asn_rep_label} />
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -570,3 +571,27 @@ function getFlag(countryCode: string): string {
|
|||||||
const code = countryCode.toUpperCase();
|
const code = countryCode.toUpperCase();
|
||||||
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Badge de réputation ASN
|
||||||
|
function AsnRepBadge({ score, label }: { score?: number | null; label?: string }) {
|
||||||
|
if (score == null) return null;
|
||||||
|
let bg: string;
|
||||||
|
let text: string;
|
||||||
|
let display: string;
|
||||||
|
if (score < 0.3) {
|
||||||
|
bg = 'bg-threat-critical/20';
|
||||||
|
text = 'text-threat-critical';
|
||||||
|
} else if (score < 0.6) {
|
||||||
|
bg = 'bg-threat-medium/20';
|
||||||
|
text = 'text-threat-medium';
|
||||||
|
} else {
|
||||||
|
bg = 'bg-threat-low/20';
|
||||||
|
text = 'text-threat-low';
|
||||||
|
}
|
||||||
|
display = label || (score < 0.3 ? 'malicious' : score < 0.6 ? 'suspect' : 'ok');
|
||||||
|
return (
|
||||||
|
<span className={`mt-1 inline-block text-xs px-1.5 py-0.5 rounded ${bg} ${text}`}>
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -30,10 +30,22 @@ interface MetricsSummary {
|
|||||||
unique_ips: number;
|
unique_ips: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BaselineMetric {
|
||||||
|
today: number;
|
||||||
|
yesterday: number;
|
||||||
|
pct_change: number;
|
||||||
|
}
|
||||||
|
interface BaselineData {
|
||||||
|
total_detections: BaselineMetric;
|
||||||
|
unique_ips: BaselineMetric;
|
||||||
|
critical_alerts: BaselineMetric;
|
||||||
|
}
|
||||||
|
|
||||||
export function IncidentsView() {
|
export function IncidentsView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [clusters, setClusters] = useState<IncidentCluster[]>([]);
|
const [clusters, setClusters] = useState<IncidentCluster[]>([]);
|
||||||
const [metrics, setMetrics] = useState<MetricsSummary | null>(null);
|
const [metrics, setMetrics] = useState<MetricsSummary | null>(null);
|
||||||
|
const [baseline, setBaseline] = useState<BaselineData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedClusters, setSelectedClusters] = useState<Set<string>>(new Set());
|
const [selectedClusters, setSelectedClusters] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
@ -47,6 +59,11 @@ export function IncidentsView() {
|
|||||||
setMetrics(metricsData.summary);
|
setMetrics(metricsData.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baselineResponse = await fetch('/api/metrics/baseline');
|
||||||
|
if (baselineResponse.ok) {
|
||||||
|
setBaseline(await baselineResponse.json());
|
||||||
|
}
|
||||||
|
|
||||||
const clustersResponse = await fetch('/api/incidents/clusters');
|
const clustersResponse = await fetch('/api/incidents/clusters');
|
||||||
if (clustersResponse.ok) {
|
if (clustersResponse.ok) {
|
||||||
const clustersData = await clustersResponse.json();
|
const clustersData = await clustersResponse.json();
|
||||||
@ -126,6 +143,38 @@ export function IncidentsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Baseline comparison */}
|
||||||
|
{baseline && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{([
|
||||||
|
{ key: 'total_detections', label: 'Détections 24h', icon: '📊' },
|
||||||
|
{ key: 'unique_ips', label: 'IPs uniques', icon: '🖥️' },
|
||||||
|
{ key: 'critical_alerts', label: 'Alertes CRITICAL', icon: '🔴' },
|
||||||
|
] as { key: keyof BaselineData; label: string; icon: string }[]).map(({ key, label, icon }) => {
|
||||||
|
const m = baseline[key];
|
||||||
|
const up = m.pct_change > 0;
|
||||||
|
const neutral = m.pct_change === 0;
|
||||||
|
return (
|
||||||
|
<div key={key} className="bg-background-card border border-border rounded-lg px-4 py-3 flex items-center gap-3">
|
||||||
|
<span className="text-xl">{icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-text-disabled uppercase tracking-wide">{label}</div>
|
||||||
|
<div className="text-xl font-bold text-text-primary">{m.today.toLocaleString('fr-FR')}</div>
|
||||||
|
<div className="text-xs text-text-secondary">hier: {m.yesterday.toLocaleString('fr-FR')}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm font-bold px-2 py-1 rounded ${
|
||||||
|
neutral ? 'text-text-disabled' :
|
||||||
|
up ? 'text-threat-critical bg-threat-critical/10' :
|
||||||
|
'text-threat-low bg-threat-low/10'
|
||||||
|
}`}>
|
||||||
|
{neutral ? '=' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Critical Metrics */}
|
{/* Critical Metrics */}
|
||||||
{metrics && (
|
{metrics && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
|||||||
@ -8,7 +8,163 @@ import { CorrelationSummary } from './analysis/CorrelationSummary';
|
|||||||
import { CorrelationGraph } from './CorrelationGraph';
|
import { CorrelationGraph } from './CorrelationGraph';
|
||||||
import { ReputationPanel } from './ReputationPanel';
|
import { ReputationPanel } from './ReputationPanel';
|
||||||
|
|
||||||
// ─── Spoofing Coherence Widget ─────────────────────────────────────────────
|
// ─── Multi-source Activity Summary Widget ─────────────────────────────────────
|
||||||
|
|
||||||
|
interface IPSummary {
|
||||||
|
ip: string;
|
||||||
|
risk_score: number;
|
||||||
|
ml: { max_score: number; threat_level: string; attack_type: string; total_detections: number; distinct_hosts: number; distinct_ja4: number };
|
||||||
|
bruteforce: { active: boolean; hosts_attacked: number; total_hits: number; total_params: number; top_hosts: string[] };
|
||||||
|
tcp_spoofing: { detected: boolean; tcp_ttl: number | null; suspected_os: string | null; declared_os: string | null };
|
||||||
|
ja4_rotation: { rotating: boolean; distinct_ja4_count: number; total_hits?: number };
|
||||||
|
persistence: { persistent: boolean; recurrence: number; worst_score?: number; worst_threat_level?: string; first_seen?: string; last_seen?: string };
|
||||||
|
timeline_24h: { hour: number; hits: number; ja4s: string[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskGauge({ score }: { score: number }) {
|
||||||
|
const color = score >= 75 ? '#ef4444' : score >= 50 ? '#f97316' : score >= 25 ? '#eab308' : '#22c55e';
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<svg width="80" height="80" viewBox="0 0 80 80">
|
||||||
|
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(100,116,139,0.2)" strokeWidth="8" />
|
||||||
|
<circle cx="40" cy="40" r="34" fill="none" stroke={color} strokeWidth="8"
|
||||||
|
strokeDasharray={`${(score / 100) * 213.6} 213.6`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 40 40)" />
|
||||||
|
<text x="40" y="44" textAnchor="middle" fontSize="18" fontWeight="bold" fill={color}>{score}</text>
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs text-text-secondary">Risk Score</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityBadge({ active, label, color }: { active: boolean; label: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium ${
|
||||||
|
active ? `border-${color}/40 bg-${color}/10 text-${color}` : 'border-border bg-background-card text-text-disabled'
|
||||||
|
}`}>
|
||||||
|
<span>{active ? '●' : '○'}</span>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
|
||||||
|
if (!data.length) return <span className="text-text-disabled text-xs">Pas d'activité 24h</span>;
|
||||||
|
const max = Math.max(...data.map(d => d.hits), 1);
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-0.5 h-8">
|
||||||
|
{Array.from({ length: 24 }, (_, h) => {
|
||||||
|
const d = data.find(x => x.hour === h);
|
||||||
|
const pct = d ? (d.hits / max) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={h} className="flex-1 flex flex-col justify-end" title={d ? `${h}h: ${d.hits} hits` : `${h}h: 0`}>
|
||||||
|
<div className={`w-full rounded-sm ${pct > 0 ? 'bg-accent-primary' : 'bg-background-card'}`}
|
||||||
|
style={{ height: `${Math.max(pct, 2)}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IPActivitySummary({ ip }: { ip: string }) {
|
||||||
|
const [data, setData] = useState<IPSummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/api/investigation/${encodeURIComponent(ip)}/summary`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(d => setData(d))
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [ip]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-4 hover:bg-background-card/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
🔎 Synthèse multi-sources
|
||||||
|
{data && <span className={`text-xs px-2 py-0.5 rounded-full font-bold ${
|
||||||
|
data.risk_score >= 75 ? 'bg-threat-critical/20 text-threat-critical' :
|
||||||
|
data.risk_score >= 50 ? 'bg-threat-high/20 text-threat-high' :
|
||||||
|
data.risk_score >= 25 ? 'bg-threat-medium/20 text-threat-medium' :
|
||||||
|
'bg-threat-low/20 text-threat-low'
|
||||||
|
}`}>Score: {data.risk_score}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-secondary">{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
{loading && <div className="text-text-disabled text-sm py-4">Chargement des données multi-sources…</div>}
|
||||||
|
{!loading && !data && <div className="text-text-disabled text-sm py-4">Données insuffisantes pour cette IP.</div>}
|
||||||
|
{data && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Risk + badges row */}
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
<RiskGauge score={data.risk_score} />
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ActivityBadge active={data.ml.total_detections > 0} label={`ML: ${data.ml.total_detections} détections`} color="threat-critical" />
|
||||||
|
<ActivityBadge active={data.bruteforce.active} label={`Brute Force: ${data.bruteforce.hosts_attacked} hosts`} color="threat-high" />
|
||||||
|
<ActivityBadge active={data.tcp_spoofing.detected} label={`TCP Spoof: TTL ${data.tcp_spoofing.tcp_ttl ?? '—'}`} color="threat-medium" />
|
||||||
|
<ActivityBadge active={data.ja4_rotation.rotating} label={`JA4 Rotation: ${data.ja4_rotation.distinct_ja4_count} signatures`} color="threat-medium" />
|
||||||
|
<ActivityBadge active={data.persistence.persistent} label={`Persistance: ${data.persistence.recurrence}x récurrences`} color="threat-high" />
|
||||||
|
</div>
|
||||||
|
{/* Detail grid */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||||
|
{data.ml.total_detections > 0 && (
|
||||||
|
<div className="bg-background-card rounded p-2">
|
||||||
|
<div className="text-text-disabled mb-1">ML Detection</div>
|
||||||
|
<div className="text-text-primary font-medium">{data.ml.threat_level || '—'} · {data.ml.attack_type || '—'}</div>
|
||||||
|
<div className="text-text-secondary">Score: {data.ml.max_score} · {data.ml.distinct_ja4} JA4(s)</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.bruteforce.active && (
|
||||||
|
<div className="bg-background-card rounded p-2">
|
||||||
|
<div className="text-text-disabled mb-1">Brute Force</div>
|
||||||
|
<div className="text-threat-high font-medium">{data.bruteforce.total_hits.toLocaleString('fr-FR')} hits</div>
|
||||||
|
<div className="text-text-secondary truncate" title={data.bruteforce.top_hosts.join(', ')}>
|
||||||
|
{data.bruteforce.top_hosts[0] ?? '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.tcp_spoofing.detected && (
|
||||||
|
<div className="bg-background-card rounded p-2">
|
||||||
|
<div className="text-text-disabled mb-1">TCP Spoofing</div>
|
||||||
|
<div className="text-threat-medium font-medium">TTL {data.tcp_spoofing.tcp_ttl} → {data.tcp_spoofing.suspected_os}</div>
|
||||||
|
<div className="text-text-secondary">UA déclare: {data.tcp_spoofing.declared_os}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.persistence.persistent && (
|
||||||
|
<div className="bg-background-card rounded p-2">
|
||||||
|
<div className="text-text-disabled mb-1">Persistance</div>
|
||||||
|
<div className="text-threat-high font-medium">{data.persistence.recurrence}× sessions</div>
|
||||||
|
<div className="text-text-secondary">{data.persistence.first_seen?.substring(0, 10)} → {data.persistence.last_seen?.substring(0, 10)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Mini timeline */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-text-disabled mb-1 font-medium uppercase tracking-wide">Activité dernières 24h</div>
|
||||||
|
<MiniTimeline data={data.timeline_24h} />
|
||||||
|
<div className="flex justify-between text-xs text-text-disabled mt-0.5"><span>0h</span><span>12h</span><span>23h</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface CoherenceData {
|
interface CoherenceData {
|
||||||
verdict: string;
|
verdict: string;
|
||||||
@ -188,6 +344,9 @@ export function InvestigationView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ligne 0 : Synthèse multi-sources */}
|
||||||
|
<IPActivitySummary ip={ip} />
|
||||||
|
|
||||||
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
|
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
|
||||||
<div className="grid grid-cols-3 gap-6 items-start">
|
<div className="grid grid-cols-3 gap-6 items-start">
|
||||||
<div className="bg-background-secondary rounded-lg p-6 h-full">
|
<div className="bg-background-secondary rounded-lg p-6 h-full">
|
||||||
|
|||||||
@ -26,7 +26,27 @@ interface JA4HistoryEntry {
|
|||||||
window_start: string;
|
window_start: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveTab = 'rotators' | 'persistent';
|
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 ActiveTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt';
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -53,6 +73,15 @@ function threatLevelBadge(level: string): { bg: string; text: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||||
@ -188,6 +217,16 @@ export function RotationView() {
|
|||||||
const [persistentError, setPersistentError] = useState<string | null>(null);
|
const [persistentError, setPersistentError] = useState<string | null>(null);
|
||||||
const [persistentLoaded, setPersistentLoaded] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const fetchRotators = async () => {
|
const fetchRotators = async () => {
|
||||||
setRotatorsLoading(true);
|
setRotatorsLoading(true);
|
||||||
@ -212,7 +251,6 @@ export function RotationView() {
|
|||||||
const res = await fetch('/api/rotation/persistent-threats?limit=100');
|
const res = await fetch('/api/rotation/persistent-threats?limit=100');
|
||||||
if (!res.ok) throw new Error('Erreur chargement des menaces persistantes');
|
if (!res.ok) throw new Error('Erreur chargement des menaces persistantes');
|
||||||
const data: { items: PersistentThreat[] } = await res.json();
|
const data: { items: PersistentThreat[] } = await res.json();
|
||||||
// Sort by persistence_score DESC
|
|
||||||
const sorted = [...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score);
|
const sorted = [...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score);
|
||||||
setPersistent(sorted);
|
setPersistent(sorted);
|
||||||
setPersistentLoaded(true);
|
setPersistentLoaded(true);
|
||||||
@ -223,9 +261,43 @@ export function RotationView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSophistication = async () => {
|
||||||
|
if (sophisticationLoaded) return;
|
||||||
|
setSophisticationLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/rotation/sophistication?limit=50');
|
||||||
|
if (!res.ok) throw new Error('Erreur chargement sophistication');
|
||||||
|
const data: { items: SophisticationItem[] } = await res.json();
|
||||||
|
setSophistication(data.items ?? []);
|
||||||
|
setSophisticationLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setSophisticationError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||||
|
} finally {
|
||||||
|
setSophisticationLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProactive = async () => {
|
||||||
|
if (proactiveLoaded) return;
|
||||||
|
setProactiveLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/rotation/proactive-hunt?min_recurrence=1&min_days=0&limit=50');
|
||||||
|
if (!res.ok) throw new Error('Erreur chargement chasse proactive');
|
||||||
|
const data: { items: ProactiveHuntItem[] } = await res.json();
|
||||||
|
setProactive(data.items ?? []);
|
||||||
|
setProactiveLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
setProactiveError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||||
|
} finally {
|
||||||
|
setProactiveLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTabChange = (tab: ActiveTab) => {
|
const handleTabChange = (tab: ActiveTab) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
if (tab === 'persistent') loadPersistent();
|
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 maxEvasion = rotators.length > 0 ? Math.max(...rotators.map((r) => r.evasion_score)) : 0;
|
||||||
@ -234,6 +306,8 @@ export function RotationView() {
|
|||||||
const tabs: { id: ActiveTab; label: string }[] = [
|
const tabs: { id: ActiveTab; label: string }[] = [
|
||||||
{ id: 'rotators', label: '🎭 Rotateurs JA4' },
|
{ id: 'rotators', label: '🎭 Rotateurs JA4' },
|
||||||
{ id: 'persistent', label: '🕰️ Menaces Persistantes' },
|
{ id: 'persistent', label: '🕰️ Menaces Persistantes' },
|
||||||
|
{ id: 'sophistication', label: '🏆 Sophistication' },
|
||||||
|
{ id: 'hunt', label: '🕵️ Chasse proactive' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -365,6 +439,154 @@ export function RotationView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sophistication tab */}
|
||||||
|
{activeTab === 'sophistication' && (
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
|
{sophisticationLoading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : sophisticationError ? (
|
||||||
|
<div className="p-4"><ErrorMessage message={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>
|
||||||
|
<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">Rotation JA4</th>
|
||||||
|
<th className="px-4 py-3">Récurrence</th>
|
||||||
|
<th className="px-4 py-3">Hits bruteforce</th>
|
||||||
|
<th className="px-4 py-3">Score sophistication</th>
|
||||||
|
<th className="px-4 py-3">Tier</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sophistication.map((item) => {
|
||||||
|
const tb = tierBadge(item.tier);
|
||||||
|
return (
|
||||||
|
<tr key={item.ip} className="border-b border-border hover:bg-background-card transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-text-primary">{item.ip}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="bg-threat-medium/10 text-threat-medium text-xs px-2 py-1 rounded-full">
|
||||||
|
{item.ja4_rotation_count} JA4
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-primary">{item.recurrence}</td>
|
||||||
|
<td className="px-4 py-3 text-text-primary">{formatNumber(item.bruteforce_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.sophistication_score, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-semibold text-threat-critical">
|
||||||
|
{item.sophistication_score}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${tb.bg} ${tb.text} font-semibold`}>
|
||||||
|
{item.tier}
|
||||||
|
</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>
|
||||||
|
{sophistication.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-text-secondary text-sm">Aucune donnée de sophistication disponible.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chasse proactive tab */}
|
||||||
|
{activeTab === 'hunt' && (
|
||||||
|
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||||
|
{proactiveLoading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : proactiveError ? (
|
||||||
|
<div className="p-4"><ErrorMessage message={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>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-text-secondary text-left">
|
||||||
|
<th className="px-4 py-3">IP</th>
|
||||||
|
<th className="px-4 py-3">Récurrence</th>
|
||||||
|
<th className="px-4 py-3">Score max</th>
|
||||||
|
<th className="px-4 py-3">Jours actifs</th>
|
||||||
|
<th className="px-4 py-3">Timeline</th>
|
||||||
|
<th className="px-4 py-3">Évaluation</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{proactive.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">
|
||||||
|
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">
|
||||||
|
{item.recurrence}×
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-threat-medium font-semibold">{item.worst_score.toFixed(3)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-text-primary font-medium">{item.days_active}j</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-xs text-text-secondary space-y-0.5">
|
||||||
|
<div><span className="text-text-disabled">Premier:</span> {formatDate(item.first_seen)}</div>
|
||||||
|
<div><span className="text-text-disabled">Dernier:</span> {formatDate(item.last_seen)}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-semibold ${
|
||||||
|
item.risk_assessment === 'Évadeur potentiel'
|
||||||
|
? 'bg-threat-critical/20 text-threat-critical'
|
||||||
|
: 'bg-threat-medium/20 text-threat-medium'
|
||||||
|
}`}>
|
||||||
|
{item.risk_assessment}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Lancer investigation
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user