From d4c35125728ba732afc78a60db099cddf18a925f Mon Sep 17 00:00:00 2001 From: SOC Analyst Date: Mon, 16 Mar 2026 00:43:27 +0100 Subject: [PATCH] =?UTF-8?q?feat:=206=20am=C3=A9liorations=20SOC=20?= =?UTF-8?q?=E2=80=94=20synth=C3=A8se=20IP,=20baseline,=20sophistication,?= =?UTF-8?q?=20chasse=20proactive,=20badge=20ASN,=202=20nouveaux=20onglets?= =?UTF-8?q?=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- backend/main.py | 3 +- backend/models.py | 2 + backend/routes/detections.py | 23 +- backend/routes/investigation_summary.py | 165 +++++++++++++ backend/routes/metrics.py | 53 ++++ backend/routes/rotation.py | 112 +++++++++ frontend/src/api/client.ts | 2 + frontend/src/components/DetectionsList.tsx | 25 ++ frontend/src/components/IncidentsView.tsx | 49 ++++ frontend/src/components/InvestigationView.tsx | 161 ++++++++++++- frontend/src/components/RotationView.tsx | 226 +++++++++++++++++- 11 files changed, 815 insertions(+), 6 deletions(-) create mode 100644 backend/routes/investigation_summary.py diff --git a/backend/main.py b/backend/main.py index 063a53a..ee3a3f5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,7 +13,7 @@ import os from .config import settings from .database import db from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints -from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features +from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features, investigation_summary # Configuration logging logging.basicConfig( @@ -82,6 +82,7 @@ app.include_router(heatmap.router) app.include_router(botnets.router) app.include_router(rotation.router) app.include_router(ml_features.router) +app.include_router(investigation_summary.router) # Route pour servir le frontend diff --git a/backend/models.py b/backend/models.py index a0000f6..f3397ad 100644 --- a/backend/models.py +++ b/backend/models.py @@ -75,6 +75,8 @@ class Detection(BaseModel): post_ratio: float reason: str client_headers: str = "" + asn_score: Optional[float] = None + asn_rep_label: str = "" class DetectionsListResponse(BaseModel): diff --git a/backend/routes/detections.py b/backend/routes/detections.py index 10183a1..71aafcb 100644 --- a/backend/routes/detections.py +++ b/backend/routes/detections.py @@ -97,8 +97,10 @@ async def get_detections( hit_velocity, fuzzing_index, post_ratio, - reason + reason, + ar.label AS asn_rep_label FROM ml_detected_anomalies + LEFT JOIN mabase_prod.asn_reputation ar ON ar.src_asn = toUInt32OrZero(asn_number) WHERE {where_clause} ORDER BY {sort_by} {sort_order} LIMIT %(limit)s OFFSET %(offset)s @@ -109,6 +111,21 @@ async def get_detections( 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 = [ Detection( detected_at=row[0], @@ -130,7 +147,9 @@ async def get_detections( hit_velocity=float(row[16]) if row[16] 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, - 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 ] diff --git a/backend/routes/investigation_summary.py b/backend/routes/investigation_summary.py new file mode 100644 index 0000000..46bad70 --- /dev/null +++ b/backend/routes/investigation_summary.py @@ -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)) diff --git a/backend/routes/metrics.py b/backend/routes/metrics.py index 0646fc4..660519e 100644 --- a/backend/routes/metrics.py +++ b/backend/routes/metrics.py @@ -120,3 +120,56 @@ async def get_threat_distribution(): except Exception as 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)}") diff --git a/backend/routes/rotation.py b/backend/routes/rotation.py index a792f0a..bcbff3b 100644 --- a/backend/routes/rotation.py +++ b/backend/routes/rotation.py @@ -1,6 +1,7 @@ """ Endpoints pour la détection de la rotation de fingerprints JA4 et des menaces persistantes """ +import math from fastapi import APIRouter, HTTPException, Query 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)} except Exception as 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)) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 19b0499..e6025ad 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -58,6 +58,8 @@ export interface Detection { post_ratio: number; reason: string; client_headers: string; + asn_score?: number | null; + asn_rep_label?: string; } export interface DetectionsListResponse { diff --git a/frontend/src/components/DetectionsList.tsx b/frontend/src/components/DetectionsList.tsx index e533ddc..e583801 100644 --- a/frontend/src/components/DetectionsList.tsx +++ b/frontend/src/components/DetectionsList.tsx @@ -451,6 +451,7 @@ export function DetectionsList() { {detection.asn_number && (
AS{detection.asn_number}
)} + ); } @@ -570,3 +571,27 @@ function getFlag(countryCode: string): string { const code = countryCode.toUpperCase(); 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 ( + + {display} + + ); +} diff --git a/frontend/src/components/IncidentsView.tsx b/frontend/src/components/IncidentsView.tsx index 0fd5600..1c20be8 100644 --- a/frontend/src/components/IncidentsView.tsx +++ b/frontend/src/components/IncidentsView.tsx @@ -30,10 +30,22 @@ interface MetricsSummary { 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() { const navigate = useNavigate(); const [clusters, setClusters] = useState([]); const [metrics, setMetrics] = useState(null); + const [baseline, setBaseline] = useState(null); const [loading, setLoading] = useState(true); const [selectedClusters, setSelectedClusters] = useState>(new Set()); @@ -47,6 +59,11 @@ export function IncidentsView() { setMetrics(metricsData.summary); } + const baselineResponse = await fetch('/api/metrics/baseline'); + if (baselineResponse.ok) { + setBaseline(await baselineResponse.json()); + } + const clustersResponse = await fetch('/api/incidents/clusters'); if (clustersResponse.ok) { const clustersData = await clustersResponse.json(); @@ -126,6 +143,38 @@ export function IncidentsView() { + {/* Baseline comparison */} + {baseline && ( +
+ {([ + { 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 ( +
+ {icon} +
+
{label}
+
{m.today.toLocaleString('fr-FR')}
+
hier: {m.yesterday.toLocaleString('fr-FR')}
+
+
+ {neutral ? '=' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} +
+
+ ); + })} +
+ )} + {/* Critical Metrics */} {metrics && (
diff --git a/frontend/src/components/InvestigationView.tsx b/frontend/src/components/InvestigationView.tsx index 718e455..c4183eb 100644 --- a/frontend/src/components/InvestigationView.tsx +++ b/frontend/src/components/InvestigationView.tsx @@ -8,7 +8,163 @@ import { CorrelationSummary } from './analysis/CorrelationSummary'; import { CorrelationGraph } from './CorrelationGraph'; 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 ( +
+ + + + {score} + + Risk Score +
+ ); +} + +function ActivityBadge({ active, label, color }: { active: boolean; label: string; color: string }) { + return ( +
+ {active ? '●' : '○'} + {label} +
+ ); +} + +function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) { + if (!data.length) return Pas d'activité 24h; + const max = Math.max(...data.map(d => d.hits), 1); + return ( +
+ {Array.from({ length: 24 }, (_, h) => { + const d = data.find(x => x.hour === h); + const pct = d ? (d.hits / max) * 100 : 0; + return ( +
+
0 ? 'bg-accent-primary' : 'bg-background-card'}`} + style={{ height: `${Math.max(pct, 2)}%` }} /> +
+ ); + })} +
+ ); +} + +function IPActivitySummary({ ip }: { ip: string }) { + const [data, setData] = useState(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 ( +
+ + + {open && ( +
+ {loading &&
Chargement des données multi-sources…
} + {!loading && !data &&
Données insuffisantes pour cette IP.
} + {data && ( +
+ {/* Risk + badges row */} +
+ +
+
+ 0} label={`ML: ${data.ml.total_detections} détections`} color="threat-critical" /> + + + + +
+ {/* Detail grid */} +
+ {data.ml.total_detections > 0 && ( +
+
ML Detection
+
{data.ml.threat_level || '—'} · {data.ml.attack_type || '—'}
+
Score: {data.ml.max_score} · {data.ml.distinct_ja4} JA4(s)
+
+ )} + {data.bruteforce.active && ( +
+
Brute Force
+
{data.bruteforce.total_hits.toLocaleString('fr-FR')} hits
+
+ {data.bruteforce.top_hosts[0] ?? '—'} +
+
+ )} + {data.tcp_spoofing.detected && ( +
+
TCP Spoofing
+
TTL {data.tcp_spoofing.tcp_ttl} → {data.tcp_spoofing.suspected_os}
+
UA déclare: {data.tcp_spoofing.declared_os}
+
+ )} + {data.persistence.persistent && ( +
+
Persistance
+
{data.persistence.recurrence}× sessions
+
{data.persistence.first_seen?.substring(0, 10)} → {data.persistence.last_seen?.substring(0, 10)}
+
+ )} +
+
+
+ {/* Mini timeline */} +
+
Activité dernières 24h
+ +
0h12h23h
+
+
+ )} +
+ )} +
+ ); +} interface CoherenceData { verdict: string; @@ -188,6 +344,9 @@ export function InvestigationView() {
+ {/* Ligne 0 : Synthèse multi-sources */} + + {/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
diff --git a/frontend/src/components/RotationView.tsx b/frontend/src/components/RotationView.tsx index 0cf0e32..5a789cb 100644 --- a/frontend/src/components/RotationView.tsx +++ b/frontend/src/components/RotationView.tsx @@ -26,7 +26,27 @@ interface JA4HistoryEntry { 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 ────────────────────────────────────────────────────────────────── @@ -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 ─────────────────────────────────────────────────────────── function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) { @@ -188,6 +217,16 @@ export function RotationView() { const [persistentError, setPersistentError] = useState(null); const [persistentLoaded, setPersistentLoaded] = useState(false); + const [sophistication, setSophistication] = useState([]); + const [sophisticationLoading, setSophisticationLoading] = useState(false); + const [sophisticationError, setSophisticationError] = useState(null); + const [sophisticationLoaded, setSophisticationLoaded] = useState(false); + + const [proactive, setProactive] = useState([]); + const [proactiveLoading, setProactiveLoading] = useState(false); + const [proactiveError, setProactiveError] = useState(null); + const [proactiveLoaded, setProactiveLoaded] = useState(false); + useEffect(() => { const fetchRotators = async () => { setRotatorsLoading(true); @@ -212,7 +251,6 @@ export function RotationView() { const res = await fetch('/api/rotation/persistent-threats?limit=100'); if (!res.ok) throw new Error('Erreur chargement des menaces persistantes'); const data: { items: PersistentThreat[] } = await res.json(); - // Sort by persistence_score DESC const sorted = [...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score); setPersistent(sorted); setPersistentLoaded(true); @@ -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) => { setActiveTab(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; @@ -234,6 +306,8 @@ export function RotationView() { const tabs: { id: ActiveTab; label: string }[] = [ { id: 'rotators', label: '🎭 Rotateurs JA4' }, { id: 'persistent', label: '🕰️ Menaces Persistantes' }, + { id: 'sophistication', label: '🏆 Sophistication' }, + { id: 'hunt', label: '🕵️ Chasse proactive' }, ]; return ( @@ -365,6 +439,154 @@ export function RotationView() { )}
)} + + {/* Sophistication tab */} + {activeTab === 'sophistication' && ( +
+ {sophisticationLoading ? ( + + ) : sophisticationError ? ( +
+ ) : ( + <> +
+ Score de sophistication = rotation JA4 × 10 + récurrence × 20 + log(bruteforce+1) × 5 +
+ + + + + + + + + + + + + + {sophistication.map((item) => { + const tb = tierBadge(item.tier); + return ( + + + + + + + + + + ); + })} + +
IPRotation JA4RécurrenceHits bruteforceScore sophisticationTier
{item.ip} + + {item.ja4_rotation_count} JA4 + + {item.recurrence}{formatNumber(item.bruteforce_hits)} +
+
+
+
+ + {item.sophistication_score} + +
+
+ + {item.tier} + + + +
+ {sophistication.length === 0 && ( +
Aucune donnée de sophistication disponible.
+ )} + + )} +
+ )} + + {/* Chasse proactive tab */} + {activeTab === 'hunt' && ( +
+ {proactiveLoading ? ( + + ) : proactiveError ? ( +
+ ) : ( + <> +
+ IPs récurrentes volant sous le radar (score < 0.5) — persistantes mais non détectées comme critiques. +
+ + + + + + + + + + + + + + {proactive.map((item) => ( + + + + + + + + + + ))} + +
IPRécurrenceScore maxJours actifsTimelineÉvaluation
{item.ip} + + {item.recurrence}× + + + {item.worst_score.toFixed(3)} + {item.days_active}j +
+
Premier: {formatDate(item.first_seen)}
+
Dernier: {formatDate(item.last_seen)}
+
+
+ + {item.risk_assessment} + + + +
+ {proactive.length === 0 && ( +
Aucune IP sous le radar détectée avec ces critères.
+ )} + + )} +
+ )}
); }