diff --git a/backend/config.py b/backend/config.py index 2b01904..bf61b10 100644 --- a/backend/config.py +++ b/backend/config.py @@ -2,7 +2,6 @@ Configuration du Dashboard Bot Detector """ from pydantic_settings import BaseSettings -from typing import Optional class Settings(BaseSettings): @@ -12,20 +11,14 @@ class Settings(BaseSettings): CLICKHOUSE_DB: str = "mabase_prod" CLICKHOUSE_USER: str = "admin" CLICKHOUSE_PASSWORD: str = "" - + # API API_HOST: str = "0.0.0.0" API_PORT: int = 8000 - - # Frontend - FRONTEND_PORT: int = 3000 - + # CORS CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"] - - # Rate limiting - RATE_LIMIT_PER_MINUTE: int = 100 - + class Config: env_file = ".env" case_sensitive = True diff --git a/backend/database.py b/backend/database.py index c12cecf..81c26a4 100644 --- a/backend/database.py +++ b/backend/database.py @@ -40,11 +40,6 @@ class ClickHouseClient: client = self.connect() return client.query(query, params) - def query_df(self, query: str, params: Optional[dict] = None): - """Exécute une requête et retourne un DataFrame""" - client = self.connect() - return client.query_df(query, params) - def close(self): """Ferme la connexion""" if self._client: diff --git a/backend/main.py b/backend/main.py index 9afeffc..8d3f71b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -87,20 +87,25 @@ app.include_router(search.router) app.include_router(clustering.router) +# Chemin vers le fichier index.html du frontend (utilisé par serve_frontend et serve_spa) +_FRONTEND_INDEX = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html") + # Route pour servir le frontend @app.get("/") async def serve_frontend(): """Sert l'application React""" - frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html") - if os.path.exists(frontend_path): - return FileResponse(frontend_path) + if os.path.exists(_FRONTEND_INDEX): + return FileResponse(_FRONTEND_INDEX) return {"message": "Dashboard API - Frontend non construit. Voir /docs pour l'API."} # Servir les assets statiques -assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets") -if os.path.exists(assets_path): - app.mount("/assets", StaticFiles(directory=assets_path), name="assets") +_assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets") +if os.path.exists(_assets_path): + try: + app.mount("/assets", StaticFiles(directory=_assets_path), name="assets") + except Exception as _e: + logger.warning(f"Impossible de monter les assets statiques : {_e}") # Health check @@ -122,10 +127,9 @@ async def serve_spa(full_path: str): # Ne pas intercepter les routes API if full_path.startswith("api/"): raise HTTPException(status_code=404, detail="API endpoint not found") - - frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html") - if os.path.exists(frontend_path): - return FileResponse(frontend_path) + + if os.path.exists(_FRONTEND_INDEX): + return FileResponse(_FRONTEND_INDEX) return {"message": "Dashboard API - Frontend non construit"} diff --git a/backend/models.py b/backend/models.py index b146a4c..d62a9f9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,7 +1,7 @@ """ Modèles de données pour l'API """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from typing import Optional, List, Dict, Any from datetime import datetime from enum import Enum @@ -14,11 +14,6 @@ class ThreatLevel(str, Enum): LOW = "LOW" -class ModelName(str, Enum): - COMPLET = "Complet" - APPLICATIF = "Applicatif" - - # ───────────────────────────────────────────────────────────────────────────── # MÉTRIQUES # ───────────────────────────────────────────────────────────────────────────── @@ -169,33 +164,6 @@ class UserAgentsResponse(BaseModel): showing: int -# ───────────────────────────────────────────────────────────────────────────── -# COMPARAISON -# ───────────────────────────────────────────────────────────────────────────── - -class ComparisonMetric(BaseModel): - name: str - value1: Any - value2: Any - difference: str - trend: str # "better", "worse", "same" - - -class ComparisonEntity(BaseModel): - type: str - value: str - total_detections: int - unique_ips: int - avg_score: float - primary_threat: str - - -class ComparisonResponse(BaseModel): - entity1: ComparisonEntity - entity2: ComparisonEntity - metrics: List[ComparisonMetric] - - # ───────────────────────────────────────────────────────────────────────────── # CLASSIFICATIONS (SOC / ML) # ───────────────────────────────────────────────────────────────────────────── @@ -223,23 +191,13 @@ class ClassificationCreate(ClassificationBase): class Classification(ClassificationBase): """Classification complète avec métadonnées""" + model_config = ConfigDict(from_attributes=True) + created_at: datetime features: dict = Field(default_factory=dict) - - class Config: - from_attributes = True - - -class ClassificationStats(BaseModel): - """Statistiques de classification""" - label: str - total: int - unique_ips: int - avg_confidence: float class ClassificationsListResponse(BaseModel): - """Réponse pour la liste des classifications""" items: List[Classification] total: int diff --git a/backend/routes/analysis.py b/backend/routes/analysis.py index f4189d2..3accb71 100644 --- a/backend/routes/analysis.py +++ b/backend/routes/analysis.py @@ -1,9 +1,9 @@ """ Endpoints pour l'analyse de corrélations et la classification SOC """ +from collections import defaultdict from fastapi import APIRouter, HTTPException, Query from typing import Optional, List -from datetime import datetime import ipaddress import json @@ -17,6 +17,14 @@ from ..models import ( router = APIRouter(prefix="/api/analysis", tags=["analysis"]) +# Mapping code ISO → nom lisible (utilisé par analyze_ip_country et analyze_country) +_COUNTRY_NAMES: dict[str, str] = { + "CN": "China", "US": "United States", "DE": "Germany", + "FR": "France", "RU": "Russia", "GB": "United Kingdom", + "NL": "Netherlands", "IN": "India", "BR": "Brazil", + "JP": "Japan", "KR": "South Korea", "IT": "Italy", + "ES": "Spain", "CA": "Canada", "AU": "Australia" +} # ============================================================================= # ANALYSE SUBNET / ASN @@ -122,15 +130,6 @@ async def analyze_ip_country(ip: str): ip_country_code = ip_result.result_rows[0][0] asn_number = ip_result.result_rows[0][1] - # Noms des pays - country_names = { - "CN": "China", "US": "United States", "DE": "Germany", - "FR": "France", "RU": "Russia", "GB": "United Kingdom", - "NL": "Netherlands", "IN": "India", "BR": "Brazil", - "JP": "Japan", "KR": "South Korea", "IT": "Italy", - "ES": "Spain", "CA": "Canada", "AU": "Australia" - } - # Répartition des autres pays du même ASN asn_countries_query = """ SELECT @@ -150,7 +149,7 @@ async def analyze_ip_country(ip: str): asn_countries = [ { "code": row[0], - "name": country_names.get(row[0], row[0]), + "name": _COUNTRY_NAMES.get(row[0], row[0]), "count": row[1], "percentage": round((row[1] / total * 100), 2) if total > 0 else 0.0 } @@ -160,7 +159,7 @@ async def analyze_ip_country(ip: str): return { "ip_country": { "code": ip_country_code, - "name": country_names.get(ip_country_code, ip_country_code) + "name": _COUNTRY_NAMES.get(ip_country_code, ip_country_code) }, "asn_countries": asn_countries } @@ -196,19 +195,10 @@ async def analyze_country(days: int = Query(1, ge=1, le=30)): # Calculer le total pour le pourcentage total = sum(row[1] for row in top_result.result_rows) - # Noms des pays (mapping simple) - country_names = { - "CN": "China", "US": "United States", "DE": "Germany", - "FR": "France", "RU": "Russia", "GB": "United Kingdom", - "NL": "Netherlands", "IN": "India", "BR": "Brazil", - "JP": "Japan", "KR": "South Korea", "IT": "Italy", - "ES": "Spain", "CA": "Canada", "AU": "Australia" - } - top_countries = [ CountryData( code=row[0], - name=country_names.get(row[0], row[0]), + name=_COUNTRY_NAMES.get(row[0], row[0]), count=row[1], percentage=round((row[1] / total * 100), 2) if total > 0 else 0.0 ) @@ -311,7 +301,6 @@ async def analyze_ja4(ip: str): subnets_result = db.query(subnets_query, {"ja4": ja4}) # Grouper par subnet /24 - from collections import defaultdict subnet_counts = defaultdict(int) for row in subnets_result.result_rows: ip_addr = str(row[0]) @@ -439,24 +428,24 @@ async def get_classification_recommendation(ip: str): # Récupérer les analyses try: subnet_analysis = await analyze_subnet(ip) - except: + except Exception: subnet_analysis = None - + try: country_analysis = await analyze_country(1) - except: + except Exception: country_analysis = None - + try: ja4_analysis = await analyze_ja4(ip) - except: + except Exception: ja4_analysis = None - + try: ua_analysis = await analyze_user_agents(ip) - except: + except Exception: ua_analysis = None - + # Indicateurs par défaut indicators = CorrelationIndicators( subnet_ips_count=subnet_analysis.total_in_subnet if subnet_analysis else 0, diff --git a/backend/routes/audit.py b/backend/routes/audit.py index bd08012..3b47e43 100644 --- a/backend/routes/audit.py +++ b/backend/routes/audit.py @@ -1,12 +1,14 @@ """ Routes pour l'audit et les logs d'activité """ +import logging from fastapi import APIRouter, HTTPException, Query, Request -from typing import List, Optional -from datetime import datetime, timedelta +from typing import Optional +from datetime import datetime from ..database import db router = APIRouter(prefix="/api/audit", tags=["audit"]) +logger = logging.getLogger(__name__) @router.post("/logs") @@ -50,8 +52,8 @@ async def create_audit_log( try: db.query(insert_query, params) except Exception as e: - # Table might not exist yet, log warning - print(f"Warning: Could not insert audit log: {e}") + # La table peut ne pas encore exister — on logue mais on ne bloque pas l'appelant + logger.warning(f"Could not insert audit log: {e}") return { "status": "success", diff --git a/backend/routes/clustering.py b/backend/routes/clustering.py index 881ca19..a6d1388 100644 --- a/backend/routes/clustering.py +++ b/backend/routes/clustering.py @@ -6,22 +6,20 @@ Clustering d'IPs multi-métriques — WebGL / deck.gl backend. - Calcul en background thread + cache 30 min - Endpoints : /clusters, /status, /cluster/{id}/points """ -from __future__ import annotations - import math import time import logging import threading from collections import Counter from concurrent.futures import ThreadPoolExecutor -from typing import Optional, Any +from typing import Any import numpy as np from fastapi import APIRouter, HTTPException, Query from ..database import db from ..services.clustering_engine import ( - FEATURE_KEYS, FEATURE_NAMES, FEATURE_NORMS, N_FEATURES, + FEATURE_NAMES, build_feature_vector, kmeans_pp, pca_2d, compute_hulls, name_cluster, risk_score_from_centroid, standardize, risk_to_gradient_color, diff --git a/backend/routes/entities.py b/backend/routes/entities.py index de07b57..dba2969 100644 --- a/backend/routes/entities.py +++ b/backend/routes/entities.py @@ -2,9 +2,7 @@ Routes pour l'investigation d'entités (IP, JA4, User-Agent, Client-Header, Host, Path, Query-Param) """ from fastapi import APIRouter, HTTPException, Query -from typing import Optional, List, Dict, Any -from datetime import datetime -import json +from typing import Optional, List from ..database import db from ..models import ( @@ -16,18 +14,10 @@ from ..models import ( router = APIRouter(prefix="/api/entities", tags=["Entities"]) -db = db - -# Mapping des types d'entités -ENTITY_TYPES = { - 'ip': 'ip', - 'ja4': 'ja4', - 'user_agent': 'user_agent', - 'client_header': 'client_header', - 'host': 'host', - 'path': 'path', - 'query_param': 'query_param' -} +# Ensemble des types d'entités valides +VALID_ENTITY_TYPES = frozenset({ + 'ip', 'ja4', 'user_agent', 'client_header', 'host', 'path', 'query_param' +}) def get_entity_stats(entity_type: str, entity_value: str, hours: int = 24) -> Optional[EntityStats]: diff --git a/backend/routes/fingerprints.py b/backend/routes/fingerprints.py index 45fb548..c195c08 100644 --- a/backend/routes/fingerprints.py +++ b/backend/routes/fingerprints.py @@ -10,7 +10,6 @@ Objectifs: qui usurpent des UA de navigateurs légitimes """ from fastapi import APIRouter, HTTPException, Query -from typing import Optional import re from ..database import db diff --git a/backend/routes/incidents.py b/backend/routes/incidents.py index 081adc9..78f25b2 100644 --- a/backend/routes/incidents.py +++ b/backend/routes/incidents.py @@ -1,11 +1,11 @@ """ Routes pour la gestion des incidents clusterisés """ +import hashlib from fastapi import APIRouter, HTTPException, Query from typing import List, Optional -from datetime import datetime, timedelta +from datetime import datetime from ..database import db -from ..models import BaseModel router = APIRouter(prefix="/api/incidents", tags=["incidents"]) @@ -83,7 +83,6 @@ async def get_incident_clusters( # Collect sample IPs to fetch real UA and trend data in bulk sample_ips = [row[10] for row in result.result_rows if row[10]] - subnets_list = [row[0] for row in result.result_rows] # Fetch real primary UA per sample IP from view_dashboard_entities ua_by_ip: dict = {} @@ -182,7 +181,7 @@ async def get_incident_clusters( primary_ua = ua_by_ip.get(sample_ip, "") clusters.append({ - "id": f"INC-{datetime.now().strftime('%Y%m%d')}-{len(clusters)+1:03d}", + "id": f"INC-{hashlib.md5(subnet.encode()).hexdigest()[:8].upper()}", "score": risk_score, "severity": severity, "total_detections": row[1], @@ -213,22 +212,13 @@ async def get_incident_clusters( @router.get("/{cluster_id}") async def get_incident_details(cluster_id: str): """ - Récupère les détails d'un incident spécifique + Récupère les détails d'un incident spécifique. + Non encore implémenté — les détails par cluster seront disponibles dans une prochaine version. """ - try: - # Extraire le subnet du cluster_id (simplifié) - # Dans une implémentation réelle, on aurait une table de mapping - - return { - "id": cluster_id, - "details": "Implementation en cours", - "timeline": [], - "entities": [], - "classifications": [] - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}") + raise HTTPException( + status_code=501, + detail="Détails par incident non encore implémentés. Utilisez /api/incidents/clusters pour la liste." + ) @router.post("/{cluster_id}/classify") @@ -239,34 +229,38 @@ async def classify_incident( comment: str = "" ): """ - Classe un incident rapidement + Classe un incident rapidement. + Non encore implémenté — utilisez /api/analysis/{ip}/classify pour classifier une IP. """ - try: - # Implementation future - sauvegarde dans la table classifications - return { - "status": "success", - "cluster_id": cluster_id, - "label": label, - "tags": tags or [], - "comment": comment - } - - except Exception as e: - raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}") + raise HTTPException( + status_code=501, + detail="Classification par incident non encore implémentée. Utilisez /api/analysis/{ip}/classify." + ) @router.get("") async def list_incidents( status: str = Query("active", description="Statut des incidents"), - severity: str = Query(None, description="Filtrer par sévérité"), + severity: Optional[str] = Query(None, description="Filtrer par sévérité (LOW/MEDIUM/HIGH/CRITICAL)"), hours: int = Query(24, ge=1, le=168) ): """ - Liste tous les incidents avec filtres + Liste tous les incidents avec filtres. + Délègue à get_incident_clusters ; le filtre severity est appliqué post-requête. """ try: - # Redirige vers clusters pour l'instant - return await get_incident_clusters(hours=hours, limit=50) - + result = await get_incident_clusters(hours=hours, limit=100) + items = result["items"] + + if severity: + sev_upper = severity.upper() + items = [c for c in items if c.get("severity") == sev_upper] + + return { + "items": items, + "total": len(items), + "period_hours": hours, + } + except Exception as e: raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}") diff --git a/backend/routes/rotation.py b/backend/routes/rotation.py index c3fc660..a3633ea 100644 --- a/backend/routes/rotation.py +++ b/backend/routes/rotation.py @@ -1,7 +1,6 @@ """ 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 @@ -110,7 +109,7 @@ async def get_sophistication(limit: int = Query(50, ge=1, le=500)): try: sql = """ SELECT - replaceRegexpAll(toString(r.src_ip), '^::ffff:', '') AS ip, + r.ip, r.distinct_ja4_count, coalesce(rec.recurrence, 0) AS recurrence, coalesce(bf.bruteforce_hits, 0) AS bruteforce_hits, @@ -119,18 +118,26 @@ async def get_sophistication(limit: int = Query(50, ge=1, le=500)): + coalesce(rec.recurrence, 0) * 20 + least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5) ), 1) AS sophistication_score - FROM mabase_prod.view_host_ip_ja4_rotation r + FROM ( + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + distinct_ja4_count + FROM mabase_prod.view_host_ip_ja4_rotation + ) r LEFT JOIN ( - SELECT src_ip, count() AS recurrence + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + count() AS recurrence FROM mabase_prod.ml_detected_anomalies FINAL - GROUP BY src_ip - ) rec USING(src_ip) + GROUP BY ip + ) rec ON r.ip = rec.ip LEFT JOIN ( - SELECT replaceRegexpAll(toString(src_ip),'^::ffff:','') AS src_ip, - sum(hits) AS bruteforce_hits + SELECT + replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, + sum(hits) AS bruteforce_hits FROM mabase_prod.view_form_bruteforce_detected - GROUP BY src_ip - ) bf USING(src_ip) + GROUP BY ip + ) bf ON r.ip = bf.ip ORDER BY sophistication_score DESC LIMIT %(limit)s """ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c7c9f9f..c78dfb5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -367,7 +367,7 @@ function MainContent({ counts: _counts }: { counts: AlertCounts | null }) { } return ( -
+
} /> } /> diff --git a/frontend/src/components/CampaignsView.tsx b/frontend/src/components/CampaignsView.tsx index 5b9e81d..36dc10e 100644 --- a/frontend/src/components/CampaignsView.tsx +++ b/frontend/src/components/CampaignsView.tsx @@ -114,7 +114,7 @@ export function CampaignsView() { const [subnetLoading, setSubnetLoading] = useState>(new Set()); const [activeTab, setActiveTab] = useState('clusters'); - const [minIPs, setMinIPs] = useState(3); + const [minIPs, setMinIPs] = useState(1); const [severityFilter, setSeverityFilter] = useState('all'); // Fetch clusters on mount diff --git a/frontend/src/components/DetectionsList.tsx b/frontend/src/components/DetectionsList.tsx index 11adae0..682788a 100644 --- a/frontend/src/components/DetectionsList.tsx +++ b/frontend/src/components/DetectionsList.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useDetections } from '../hooks/useDetections'; import DataTable, { Column } from './ui/DataTable'; @@ -55,6 +55,14 @@ export function DetectionsList() { const scoreType = searchParams.get('score_type') || undefined; const [groupByIP, setGroupByIP] = useState(true); + const [threatDist, setThreatDist] = useState<{threat_level: string; count: number; percentage: number}[]>([]); + + useEffect(() => { + fetch('/api/metrics/threats') + .then(r => r.ok ? r.json() : null) + .then(d => { if (d?.items) setThreatDist(d.items); }) + .catch(() => null); + }, []); const { data, loading, error } = useDetections({ page, @@ -157,60 +165,36 @@ export function DetectionsList() { key: 'src_ip', label: col.label, sortable: true, - render: (_, row) => ( -
-
{row.src_ip}
- {groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? ( -
-
- {row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''} -
- {row.unique_ja4s.slice(0, 3).map((ja4, idx) => ( -
- {ja4} -
- ))} - {row.unique_ja4s.length > 3 && ( -
- +{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''} -
- )} + width: 'w-[220px] min-w-[180px]', + render: (_, row) => { + const ja4s = groupByIP && row.unique_ja4s?.length ? row.unique_ja4s : row.ja4 ? [row.ja4] : []; + const ja4Label = ja4s.length > 1 ? `${ja4s.length} JA4` : ja4s[0] ?? '—'; + return ( +
+
{row.src_ip}
+
+ {ja4Label}
- ) : ( -
- {row.ja4 || '-'} -
- )} -
- ), +
+ ); + }, }; case 'host': return { key: 'host', label: col.label, sortable: true, - render: (_, row) => - groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? ( -
-
- {row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''} -
- {row.unique_hosts.slice(0, 3).map((host, idx) => ( -
- {host} -
- ))} - {row.unique_hosts.length > 3 && ( -
- +{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''} -
- )} + width: 'w-[180px] min-w-[140px]', + render: (_, row) => { + const hosts = groupByIP && row.unique_hosts?.length ? row.unique_hosts : row.host ? [row.host] : []; + const primary = hosts[0] ?? '—'; + const extra = hosts.length > 1 ? ` +${hosts.length - 1}` : ''; + return ( +
+ {primary}{extra}
- ) : ( -
- {row.host || '-'} -
- ), + ); + }, }; case 'client_headers': return { @@ -257,24 +241,18 @@ export function DetectionsList() { ), sortable: false, + width: 'w-[140px]', render: (_, row) => { const name = row.anubis_bot_name; const action = row.anubis_bot_action; - const category = row.anubis_bot_category; if (!name) return ; const actionColor = - action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' : - action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' : - 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'; + action === 'ALLOW' ? 'text-green-400' : + action === 'DENY' ? 'text-red-400' : 'text-yellow-400'; return ( -
-
- {name} -
-
- {action && {action}} - {category && · {category}} -
+
+ {name} + {action && · {action}}
); }, @@ -330,13 +308,11 @@ export function DetectionsList() { key: 'asn_org', label: col.label, sortable: true, + width: 'w-[150px]', render: (_, row) => ( -
-
{row.asn_org || row.asn_number || '-'}
- {row.asn_number && ( -
AS{row.asn_number}
- )} - +
+ {row.asn_org || `AS${row.asn_number}` || '—'} + {row.asn_number && AS{row.asn_number}}
), }; @@ -358,34 +334,18 @@ export function DetectionsList() { key: 'detected_at', label: col.label, sortable: true, - render: (_, row) => - groupByIP && row.first_seen ? (() => { - const first = new Date(row.first_seen!); + width: 'w-[110px]', + render: (_, row) => { + if (groupByIP && row.first_seen) { const last = new Date(row.last_seen!); - const sameTime = first.getTime() === last.getTime(); - const fmt = (d: Date) => formatDate(d.toISOString()); - return sameTime ? ( -
{fmt(last)}
- ) : ( -
-
- Premier: {fmt(first)} -
-
- Dernier: {fmt(last)} -
-
- ); - })() : ( - <> -
- {formatDateOnly(row.detected_at)} -
-
- {formatTimeOnly(row.detected_at)} -
- - ), + return
{formatDate(last.toISOString())}
; + } + return ( +
+ {formatDateOnly(row.detected_at)} {formatTimeOnly(row.detected_at)} +
+ ); + }, }; default: return { key: col.key, label: col.label, sortable: col.sortable }; @@ -393,118 +353,143 @@ export function DetectionsList() { }); return ( -
- {/* En-tête */} -
-
-

Détections

-
- {data.items.length} - - {data.total} détections -
+
+ + {/* ── Barre unique : titre + pills + filtres + recherche ── */} +
+ + {/* Titre + compteur */} +
+ Détections + + {data.total.toLocaleString()} +
-
- {/* Toggle Grouper par IP */} +
+ + {/* Pills distribution */} + {threatDist.map(({ threat_level, count, percentage }) => { + const label = threat_level === 'KNOWN_BOT' ? '🤖 BOT' : + threat_level === 'ANUBIS_DENY' ? '🔴 RÈGLE' : + threat_level === 'HIGH' ? '⚠️ HIGH' : + threat_level === 'MEDIUM' ? '📊 MED' : + threat_level === 'CRITICAL' ? '🔥 CRIT' : threat_level; + const style = threat_level === 'KNOWN_BOT' ? 'bg-green-500/15 text-green-400 border-green-500/30 hover:bg-green-500/25' : + threat_level === 'ANUBIS_DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30 hover:bg-red-500/25' : + threat_level === 'HIGH' ? 'bg-orange-500/15 text-orange-400 border-orange-500/30 hover:bg-orange-500/25' : + threat_level === 'MEDIUM' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30 hover:bg-yellow-500/25' : + threat_level === 'CRITICAL' ? 'bg-red-700/15 text-red-300 border-red-700/30 hover:bg-red-700/25' : + 'bg-background-card text-text-secondary border-background-card'; + const filterVal = threat_level === 'KNOWN_BOT' ? 'BOT' : threat_level === 'ANUBIS_DENY' ? 'REGLE' : null; + const active = filterVal && scoreType === filterVal; + return ( + + ); + })} + +
+ + {/* Filtres select */} + + + + + {(modelName || scoreType || search || sortField !== 'detected_at') && ( + )} - {/* Sélecteur de colonnes */} -
- - - {showColumnSelector && ( -
-

Afficher les colonnes

- {columns.map(col => ( - - ))} -
- )} -
+ {/* Spacer */} +
- {/* Recherche */} -
- setSearchInput(e.target.value)} - placeholder="Rechercher IP, JA4, Host..." - className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-64" - /> - -
-
-
+ {/* Toggle grouper */} + - {/* Filtres */} -
-
- - - - - {(modelName || scoreType || search || sortField !== 'detected_at') && ( - + Colonnes ▾ + + {showColumnSelector && ( +
+ {columns.map(col => ( + + ))} +
)}
+ + {/* Recherche */} +
+ setSearchInput(e.target.value)} + placeholder="IP, JA4, Host..." + className="bg-background-card border border-background-card rounded px-2 py-1 text-xs text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-40" + /> + +
- {/* Tableau */} + {/* ── Tableau ── */}
data={processedData.items as DetectionRow[]} @@ -519,24 +504,24 @@ export function DetectionsList() { />
- {/* Pagination */} + {/* ── Pagination ── */} {data.total_pages > 1 && ( -
-

- Page {data.page} sur {data.total_pages} ({data.total} détections) +

+

+ Page {data.page}/{data.total_pages} · {data.total.toLocaleString()} détections

-
+
@@ -611,27 +596,3 @@ 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 00a445a..2188767 100644 --- a/frontend/src/components/IncidentsView.tsx +++ b/frontend/src/components/IncidentsView.tsx @@ -30,6 +30,8 @@ interface MetricsSummary { medium_count: number; low_count: number; unique_ips: number; + known_bots_count: number; + anomalies_count: number; } interface BaselineMetric { @@ -135,81 +137,114 @@ export function IncidentsView() { return (
- {/* Header with Quick Search */} + {/* Header */}

SOC Dashboard

-

- Surveillance en temps réel - 24 dernières heures -

-
+

Surveillance en temps réel · 24 dernières heures

+
- {/* Baseline comparison */} - {baseline && ( -
- {([ - { key: 'total_detections', label: 'Détections 24h', icon: '📊', tip: TIPS.total_detections_stat }, - { key: 'unique_ips', label: 'IPs uniques', icon: '🖥️', tip: TIPS.unique_ips_stat }, - { key: 'critical_alerts', label: 'Alertes CRITICAL', icon: '🔴', tip: TIPS.risk_critical }, - ] as { key: keyof BaselineData; label: string; icon: string; tip: string }[]).map(({ key, label, icon, tip }) => { - const m = baseline[key]; + {/* Stats unifiées — 6 cartes compact */} +
+ {/* Total détections avec comparaison hier */} +
navigate('/detections')} + > +
+ 📊 Total 24h +
+
+ {(metrics?.total_detections ?? 0).toLocaleString()} +
+ {baseline && (() => { + const m = baseline.total_detections; const up = m.pct_change > 0; const neutral = m.pct_change === 0; return ( -
- {icon} -
-
{label}
-
{m.today.toLocaleString(navigator.language || undefined)}
-
hier: {m.yesterday.toLocaleString(navigator.language || undefined)}
-
-
- {neutral ? '=' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} -
+
+ {neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
); - })} + })()}
- )} - {/* Critical Metrics */} - {metrics && ( -
- 0 ? 'Requiert action immédiate' : 'Aucune'} - color="bg-red-500/20" - trend={metrics.critical_count > 10 ? 'up' : 'stable'} - /> - - - + {/* IPs uniques */} +
navigate('/detections')} + > +
+ 🖥️ IPs uniques +
+
+ {(metrics?.unique_ips ?? 0).toLocaleString()} +
+ {baseline && (() => { + const m = baseline.unique_ips; + const up = m.pct_change > 0; + const neutral = m.pct_change === 0; + return ( +
+ {neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier +
+ ); + })()}
- )} + + {/* BOT connus */} +
navigate('/detections?score_type=BOT')} + > +
🤖 BOT nommés
+
+ {(metrics?.known_bots_count ?? 0).toLocaleString()} +
+
+ {metrics ? Math.round((metrics.known_bots_count / metrics.total_detections) * 100) : 0}% du total +
+
+ + {/* Anomalies ML */} +
navigate('/detections?score_type=SCORE')} + > +
🔬 Anomalies ML
+
+ {(metrics?.anomalies_count ?? 0).toLocaleString()} +
+
+ {metrics ? Math.round((metrics.anomalies_count / metrics.total_detections) * 100) : 0}% du total +
+
+ + {/* HIGH */} +
navigate('/detections?threat_level=HIGH')} + > +
⚠️ HIGH
+
+ {(metrics?.high_count ?? 0).toLocaleString()} +
+
Menaces élevées
+
+ + {/* MEDIUM */} +
navigate('/detections?threat_level=MEDIUM')} + > +
📊 MEDIUM
+
+ {(metrics?.medium_count ?? 0).toLocaleString()} +
+
Menaces moyennes
+
+
{/* Bulk Actions */} {selectedClusters.size > 0 && ( @@ -478,34 +513,6 @@ export function IncidentsView() { ); } -// Metric Card Component -function MetricCard({ - title, - value, - subtitle, - color, - trend -}: { - title: string; - value: string | number; - subtitle: string; - color: string; - trend: 'up' | 'down' | 'stable'; -}) { - return ( -
-
-

{title}

- - {trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'} - -
-

{value}

-

{subtitle}

-
- ); -} - // ─── Mini Heatmap ───────────────────────────────────────────────────────────── interface HeatmapHour { diff --git a/frontend/src/components/InvestigationView.tsx b/frontend/src/components/InvestigationView.tsx index 64ea776..04a801e 100644 --- a/frontend/src/components/InvestigationView.tsx +++ b/frontend/src/components/InvestigationView.tsx @@ -74,9 +74,9 @@ function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) { } function IPActivitySummary({ ip }: { ip: string }) { - const [data, setData] = useState(null); + const [open, setOpen] = useState(false); // fermée par défaut const [loading, setLoading] = useState(true); - const [open, setOpen] = useState(true); + const [data, setData] = useState(null); useEffect(() => { setLoading(true); @@ -327,7 +327,7 @@ function Metric({ label, value, accent }: { label: string; value: string; accent } function DetectionAttributesSection({ ip }: { ip: string }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(true); // ouvert par défaut const { data, loading } = useVariability('ip', ip); const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null; @@ -448,38 +448,59 @@ export function InvestigationView() {
+ {/* Navigation ancres inter-sections */} +
+ Aller à : + {[ + { id: 'section-attributs', label: '📡 Attributs' }, + { id: 'section-synthese', label: '🔎 Synthèse' }, + { id: 'section-reputation', label: '🌍 Réputation' }, + { id: 'section-correlations', label: '🕸️ Corrélations' }, + { id: 'section-geo', label: '🌐 Géo / JA4' }, + { id: 'section-classification', label: '🏷️ Classification' }, + ].map(({ id, label }) => ( + + {label} + + ))} +
+ {/* Attributs détectés (ex-DetailsView) */} - +
+ +
- {/* Ligne 0 : Synthèse multi-sources */} - + {/* Synthèse multi-sources */} +
+ +
- {/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */} -
+ {/* Réputation (1/3) + Graph de corrélations (2/3) */} +

🌍 Réputation IP

-
+

🕸️ Graph de Corrélations

- {/* Ligne 2 : Subnet / Country / JA4 (3 colonnes) */} -
+ {/* Subnet / Country / JA4 */} +
- {/* Ligne 3 : User-Agents (1/2) + Classification (1/2) */} -
+ {/* User-Agents (1/2) + Classification (1/2) */} +
- {/* Ligne 4 : Cohérence JA4/UA (spoofing) */} + {/* Cohérence JA4/UA (spoofing) */}
diff --git a/frontend/src/components/ThreatIntelView.tsx b/frontend/src/components/ThreatIntelView.tsx index 1c5df9c..b67b50d 100644 --- a/frontend/src/components/ThreatIntelView.tsx +++ b/frontend/src/components/ThreatIntelView.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { InfoTip } from './ui/Tooltip'; import { TIPS } from './ui/tooltips'; import { formatDateShort } from '../utils/dateUtils'; @@ -22,6 +23,7 @@ interface ClassificationStats { } export function ThreatIntelView() { + const navigate = useNavigate(); const [classifications, setClassifications] = useState([]); const [stats, setStats] = useState([]); const [loading, setLoading] = useState(true); @@ -232,20 +234,28 @@ export function ThreatIntelView() { Entité Label Tags + Commentaire Confiance Analyste - {filteredClassifications.slice(0, 50).map((classification, idx) => ( + {filteredClassifications.slice(0, 50).map((classification, idx) => { + const entity = classification.ip || classification.ja4; + const isIP = !!classification.ip; + return ( - + {formatDateShort(classification.created_at)} -
- {classification.ip || classification.ja4} -
+ @@ -254,17 +264,22 @@ export function ThreatIntelView() {
- {classification.tags.slice(0, 5).map((tag, tagIdx) => ( + {classification.tags.slice(0, 4).map((tag, tagIdx) => ( {tag} ))} - {classification.tags.length > 5 && ( - +{classification.tags.length - 5} + {classification.tags.length > 4 && ( + +{classification.tags.length - 4} )}
+ + + {(classification as any).comment || } + +
-
+
{(classification.confidence * 100).toFixed(0)}% @@ -272,7 +287,8 @@ export function ThreatIntelView() { {classification.analyst} - ))} + ); + })}