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:
SOC Analyst
2026-03-16 00:43:27 +01:00
parent 8032ebaab8
commit d4c3512572
11 changed files with 815 additions and 6 deletions

View File

@ -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

View File

@ -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):

View File

@ -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
] ]

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

View File

@ -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)}")

View File

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

View File

@ -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 {

View File

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

View File

@ -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">

View File

@ -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">

View File

@ -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 &lt; 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>
); );
} }