refactor: UI improvements and code cleanup
Frontend: - DetectionsList: Simplify columns, improve truncation and display for IPs, hosts, bot info - IncidentsView: Replace metric cards with compact stat cards (unique IPs, known bots, ML anomalies, threat levels) - InvestigationView: Add section navigation anchors, reorganize layout with proper IDs - ThreatIntelView: Add navigation links to investigation pages, add comment column, improve table layout Backend: - Various route and model adjustments - Configuration updates Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@ -2,7 +2,6 @@
|
|||||||
Configuration du Dashboard Bot Detector
|
Configuration du Dashboard Bot Detector
|
||||||
"""
|
"""
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@ -17,15 +16,9 @@ class Settings(BaseSettings):
|
|||||||
API_HOST: str = "0.0.0.0"
|
API_HOST: str = "0.0.0.0"
|
||||||
API_PORT: int = 8000
|
API_PORT: int = 8000
|
||||||
|
|
||||||
# Frontend
|
|
||||||
FRONTEND_PORT: int = 3000
|
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
RATE_LIMIT_PER_MINUTE: int = 100
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
|||||||
@ -40,11 +40,6 @@ class ClickHouseClient:
|
|||||||
client = self.connect()
|
client = self.connect()
|
||||||
return client.query(query, params)
|
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):
|
def close(self):
|
||||||
"""Ferme la connexion"""
|
"""Ferme la connexion"""
|
||||||
if self._client:
|
if self._client:
|
||||||
|
|||||||
@ -87,20 +87,25 @@ app.include_router(search.router)
|
|||||||
app.include_router(clustering.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
|
# Route pour servir le frontend
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def serve_frontend():
|
async def serve_frontend():
|
||||||
"""Sert l'application React"""
|
"""Sert l'application React"""
|
||||||
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html")
|
if os.path.exists(_FRONTEND_INDEX):
|
||||||
if os.path.exists(frontend_path):
|
return FileResponse(_FRONTEND_INDEX)
|
||||||
return FileResponse(frontend_path)
|
|
||||||
return {"message": "Dashboard API - Frontend non construit. Voir /docs pour l'API."}
|
return {"message": "Dashboard API - Frontend non construit. Voir /docs pour l'API."}
|
||||||
|
|
||||||
|
|
||||||
# Servir les assets statiques
|
# Servir les assets statiques
|
||||||
assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets")
|
_assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets")
|
||||||
if os.path.exists(assets_path):
|
if os.path.exists(_assets_path):
|
||||||
app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
|
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
|
# Health check
|
||||||
@ -123,9 +128,8 @@ async def serve_spa(full_path: str):
|
|||||||
if full_path.startswith("api/"):
|
if full_path.startswith("api/"):
|
||||||
raise HTTPException(status_code=404, detail="API endpoint not found")
|
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_INDEX):
|
||||||
if os.path.exists(frontend_path):
|
return FileResponse(_FRONTEND_INDEX)
|
||||||
return FileResponse(frontend_path)
|
|
||||||
return {"message": "Dashboard API - Frontend non construit"}
|
return {"message": "Dashboard API - Frontend non construit"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Modèles de données pour l'API
|
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 typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@ -14,11 +14,6 @@ class ThreatLevel(str, Enum):
|
|||||||
LOW = "LOW"
|
LOW = "LOW"
|
||||||
|
|
||||||
|
|
||||||
class ModelName(str, Enum):
|
|
||||||
COMPLET = "Complet"
|
|
||||||
APPLICATIF = "Applicatif"
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# MÉTRIQUES
|
# MÉTRIQUES
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -169,33 +164,6 @@ class UserAgentsResponse(BaseModel):
|
|||||||
showing: int
|
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)
|
# CLASSIFICATIONS (SOC / ML)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -223,23 +191,13 @@ class ClassificationCreate(ClassificationBase):
|
|||||||
|
|
||||||
class Classification(ClassificationBase):
|
class Classification(ClassificationBase):
|
||||||
"""Classification complète avec métadonnées"""
|
"""Classification complète avec métadonnées"""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
features: dict = Field(default_factory=dict)
|
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):
|
class ClassificationsListResponse(BaseModel):
|
||||||
"""Réponse pour la liste des classifications"""
|
|
||||||
items: List[Classification]
|
items: List[Classification]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Endpoints pour l'analyse de corrélations et la classification SOC
|
Endpoints pour l'analyse de corrélations et la classification SOC
|
||||||
"""
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@ -17,6 +17,14 @@ from ..models import (
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
|
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
|
# ANALYSE SUBNET / ASN
|
||||||
@ -122,15 +130,6 @@ async def analyze_ip_country(ip: str):
|
|||||||
ip_country_code = ip_result.result_rows[0][0]
|
ip_country_code = ip_result.result_rows[0][0]
|
||||||
asn_number = ip_result.result_rows[0][1]
|
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
|
# Répartition des autres pays du même ASN
|
||||||
asn_countries_query = """
|
asn_countries_query = """
|
||||||
SELECT
|
SELECT
|
||||||
@ -150,7 +149,7 @@ async def analyze_ip_country(ip: str):
|
|||||||
asn_countries = [
|
asn_countries = [
|
||||||
{
|
{
|
||||||
"code": row[0],
|
"code": row[0],
|
||||||
"name": country_names.get(row[0], row[0]),
|
"name": _COUNTRY_NAMES.get(row[0], row[0]),
|
||||||
"count": row[1],
|
"count": row[1],
|
||||||
"percentage": round((row[1] / total * 100), 2) if total > 0 else 0.0
|
"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 {
|
return {
|
||||||
"ip_country": {
|
"ip_country": {
|
||||||
"code": ip_country_code,
|
"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
|
"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
|
# Calculer le total pour le pourcentage
|
||||||
total = sum(row[1] for row in top_result.result_rows)
|
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 = [
|
top_countries = [
|
||||||
CountryData(
|
CountryData(
|
||||||
code=row[0],
|
code=row[0],
|
||||||
name=country_names.get(row[0], row[0]),
|
name=_COUNTRY_NAMES.get(row[0], row[0]),
|
||||||
count=row[1],
|
count=row[1],
|
||||||
percentage=round((row[1] / total * 100), 2) if total > 0 else 0.0
|
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})
|
subnets_result = db.query(subnets_query, {"ja4": ja4})
|
||||||
|
|
||||||
# Grouper par subnet /24
|
# Grouper par subnet /24
|
||||||
from collections import defaultdict
|
|
||||||
subnet_counts = defaultdict(int)
|
subnet_counts = defaultdict(int)
|
||||||
for row in subnets_result.result_rows:
|
for row in subnets_result.result_rows:
|
||||||
ip_addr = str(row[0])
|
ip_addr = str(row[0])
|
||||||
@ -439,22 +428,22 @@ async def get_classification_recommendation(ip: str):
|
|||||||
# Récupérer les analyses
|
# Récupérer les analyses
|
||||||
try:
|
try:
|
||||||
subnet_analysis = await analyze_subnet(ip)
|
subnet_analysis = await analyze_subnet(ip)
|
||||||
except:
|
except Exception:
|
||||||
subnet_analysis = None
|
subnet_analysis = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
country_analysis = await analyze_country(1)
|
country_analysis = await analyze_country(1)
|
||||||
except:
|
except Exception:
|
||||||
country_analysis = None
|
country_analysis = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ja4_analysis = await analyze_ja4(ip)
|
ja4_analysis = await analyze_ja4(ip)
|
||||||
except:
|
except Exception:
|
||||||
ja4_analysis = None
|
ja4_analysis = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ua_analysis = await analyze_user_agents(ip)
|
ua_analysis = await analyze_user_agents(ip)
|
||||||
except:
|
except Exception:
|
||||||
ua_analysis = None
|
ua_analysis = None
|
||||||
|
|
||||||
# Indicateurs par défaut
|
# Indicateurs par défaut
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Routes pour l'audit et les logs d'activité
|
Routes pour l'audit et les logs d'activité
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from ..database import db
|
from ..database import db
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/audit", tags=["audit"])
|
router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logs")
|
@router.post("/logs")
|
||||||
@ -50,8 +52,8 @@ async def create_audit_log(
|
|||||||
try:
|
try:
|
||||||
db.query(insert_query, params)
|
db.query(insert_query, params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Table might not exist yet, log warning
|
# La table peut ne pas encore exister — on logue mais on ne bloque pas l'appelant
|
||||||
print(f"Warning: Could not insert audit log: {e}")
|
logger.warning(f"Could not insert audit log: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|||||||
@ -6,22 +6,20 @@ Clustering d'IPs multi-métriques — WebGL / deck.gl backend.
|
|||||||
- Calcul en background thread + cache 30 min
|
- Calcul en background thread + cache 30 min
|
||||||
- Endpoints : /clusters, /status, /cluster/{id}/points
|
- Endpoints : /clusters, /status, /cluster/{id}/points
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Optional, Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
from ..database import db
|
from ..database import db
|
||||||
from ..services.clustering_engine import (
|
from ..services.clustering_engine import (
|
||||||
FEATURE_KEYS, FEATURE_NAMES, FEATURE_NORMS, N_FEATURES,
|
FEATURE_NAMES,
|
||||||
build_feature_vector, kmeans_pp, pca_2d, compute_hulls,
|
build_feature_vector, kmeans_pp, pca_2d, compute_hulls,
|
||||||
name_cluster, risk_score_from_centroid, standardize,
|
name_cluster, risk_score_from_centroid, standardize,
|
||||||
risk_to_gradient_color,
|
risk_to_gradient_color,
|
||||||
|
|||||||
@ -2,9 +2,7 @@
|
|||||||
Routes pour l'investigation d'entités (IP, JA4, User-Agent, Client-Header, Host, Path, Query-Param)
|
Routes pour l'investigation d'entités (IP, JA4, User-Agent, Client-Header, Host, Path, Query-Param)
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
from ..database import db
|
from ..database import db
|
||||||
from ..models import (
|
from ..models import (
|
||||||
@ -16,18 +14,10 @@ from ..models import (
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/entities", tags=["Entities"])
|
router = APIRouter(prefix="/api/entities", tags=["Entities"])
|
||||||
|
|
||||||
db = db
|
# Ensemble des types d'entités valides
|
||||||
|
VALID_ENTITY_TYPES = frozenset({
|
||||||
# Mapping des types d'entités
|
'ip', 'ja4', 'user_agent', 'client_header', 'host', 'path', 'query_param'
|
||||||
ENTITY_TYPES = {
|
})
|
||||||
'ip': 'ip',
|
|
||||||
'ja4': 'ja4',
|
|
||||||
'user_agent': 'user_agent',
|
|
||||||
'client_header': 'client_header',
|
|
||||||
'host': 'host',
|
|
||||||
'path': 'path',
|
|
||||||
'query_param': 'query_param'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_entity_stats(entity_type: str, entity_value: str, hours: int = 24) -> Optional[EntityStats]:
|
def get_entity_stats(entity_type: str, entity_value: str, hours: int = 24) -> Optional[EntityStats]:
|
||||||
|
|||||||
@ -10,7 +10,6 @@ Objectifs:
|
|||||||
qui usurpent des UA de navigateurs légitimes
|
qui usurpent des UA de navigateurs légitimes
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import Optional
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from ..database import db
|
from ..database import db
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Routes pour la gestion des incidents clusterisés
|
Routes pour la gestion des incidents clusterisés
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from ..database import db
|
from ..database import db
|
||||||
from ..models import BaseModel
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/incidents", tags=["incidents"])
|
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
|
# 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]]
|
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
|
# Fetch real primary UA per sample IP from view_dashboard_entities
|
||||||
ua_by_ip: dict = {}
|
ua_by_ip: dict = {}
|
||||||
@ -182,7 +181,7 @@ async def get_incident_clusters(
|
|||||||
primary_ua = ua_by_ip.get(sample_ip, "")
|
primary_ua = ua_by_ip.get(sample_ip, "")
|
||||||
|
|
||||||
clusters.append({
|
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,
|
"score": risk_score,
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"total_detections": row[1],
|
"total_detections": row[1],
|
||||||
@ -213,22 +212,13 @@ async def get_incident_clusters(
|
|||||||
@router.get("/{cluster_id}")
|
@router.get("/{cluster_id}")
|
||||||
async def get_incident_details(cluster_id: str):
|
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:
|
raise HTTPException(
|
||||||
# Extraire le subnet du cluster_id (simplifié)
|
status_code=501,
|
||||||
# Dans une implémentation réelle, on aurait une table de mapping
|
detail="Détails par incident non encore implémentés. Utilisez /api/incidents/clusters pour la liste."
|
||||||
|
)
|
||||||
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)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{cluster_id}/classify")
|
@router.post("/{cluster_id}/classify")
|
||||||
@ -239,34 +229,38 @@ async def classify_incident(
|
|||||||
comment: str = ""
|
comment: str = ""
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Classe un incident rapidement
|
Classe un incident rapidement.
|
||||||
|
Non encore implémenté — utilisez /api/analysis/{ip}/classify pour classifier une IP.
|
||||||
"""
|
"""
|
||||||
try:
|
raise HTTPException(
|
||||||
# Implementation future - sauvegarde dans la table classifications
|
status_code=501,
|
||||||
return {
|
detail="Classification par incident non encore implémentée. Utilisez /api/analysis/{ip}/classify."
|
||||||
"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)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_incidents(
|
async def list_incidents(
|
||||||
status: str = Query("active", description="Statut des 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)
|
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:
|
try:
|
||||||
# Redirige vers clusters pour l'instant
|
result = await get_incident_clusters(hours=hours, limit=100)
|
||||||
return await get_incident_clusters(hours=hours, limit=50)
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
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
|
||||||
@ -110,7 +109,7 @@ async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
|
|||||||
try:
|
try:
|
||||||
sql = """
|
sql = """
|
||||||
SELECT
|
SELECT
|
||||||
replaceRegexpAll(toString(r.src_ip), '^::ffff:', '') AS ip,
|
r.ip,
|
||||||
r.distinct_ja4_count,
|
r.distinct_ja4_count,
|
||||||
coalesce(rec.recurrence, 0) AS recurrence,
|
coalesce(rec.recurrence, 0) AS recurrence,
|
||||||
coalesce(bf.bruteforce_hits, 0) AS bruteforce_hits,
|
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
|
+ coalesce(rec.recurrence, 0) * 20
|
||||||
+ least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5)
|
+ least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5)
|
||||||
), 1) AS sophistication_score
|
), 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 (
|
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
|
FROM mabase_prod.ml_detected_anomalies FINAL
|
||||||
GROUP BY src_ip
|
GROUP BY ip
|
||||||
) rec USING(src_ip)
|
) rec ON r.ip = rec.ip
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT replaceRegexpAll(toString(src_ip),'^::ffff:','') AS src_ip,
|
SELECT
|
||||||
|
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||||
sum(hits) AS bruteforce_hits
|
sum(hits) AS bruteforce_hits
|
||||||
FROM mabase_prod.view_form_bruteforce_detected
|
FROM mabase_prod.view_form_bruteforce_detected
|
||||||
GROUP BY src_ip
|
GROUP BY ip
|
||||||
) bf USING(src_ip)
|
) bf ON r.ip = bf.ip
|
||||||
ORDER BY sophistication_score DESC
|
ORDER BY sophistication_score DESC
|
||||||
LIMIT %(limit)s
|
LIMIT %(limit)s
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -367,7 +367,7 @@ function MainContent({ counts: _counts }: { counts: AlertCounts | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 px-6 py-5 mt-14 overflow-auto">
|
<main className="flex-1 px-4 py-3 mt-14 overflow-auto">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<IncidentsView />} />
|
<Route path="/" element={<IncidentsView />} />
|
||||||
<Route path="/incidents" element={<IncidentsView />} />
|
<Route path="/incidents" element={<IncidentsView />} />
|
||||||
|
|||||||
@ -114,7 +114,7 @@ export function CampaignsView() {
|
|||||||
const [subnetLoading, setSubnetLoading] = useState<Set<string>>(new Set());
|
const [subnetLoading, setSubnetLoading] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<ActiveTab>('clusters');
|
const [activeTab, setActiveTab] = useState<ActiveTab>('clusters');
|
||||||
const [minIPs, setMinIPs] = useState(3);
|
const [minIPs, setMinIPs] = useState(1);
|
||||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
||||||
|
|
||||||
// Fetch clusters on mount
|
// Fetch clusters on mount
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useDetections } from '../hooks/useDetections';
|
import { useDetections } from '../hooks/useDetections';
|
||||||
import DataTable, { Column } from './ui/DataTable';
|
import DataTable, { Column } from './ui/DataTable';
|
||||||
@ -55,6 +55,14 @@ export function DetectionsList() {
|
|||||||
const scoreType = searchParams.get('score_type') || undefined;
|
const scoreType = searchParams.get('score_type') || undefined;
|
||||||
|
|
||||||
const [groupByIP, setGroupByIP] = useState(true);
|
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({
|
const { data, loading, error } = useDetections({
|
||||||
page,
|
page,
|
||||||
@ -157,60 +165,36 @@ export function DetectionsList() {
|
|||||||
key: 'src_ip',
|
key: 'src_ip',
|
||||||
label: col.label,
|
label: col.label,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) => (
|
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 (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div>
|
<div className="font-mono text-sm text-text-primary whitespace-nowrap">{row.src_ip}</div>
|
||||||
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
|
<div className="font-mono text-xs text-text-disabled truncate max-w-[200px]" title={ja4s.join(' | ')}>
|
||||||
<div className="mt-1 space-y-1">
|
{ja4Label}
|
||||||
<div className="text-xs text-text-secondary font-medium">
|
|
||||||
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
|
|
||||||
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
|
||||||
{ja4}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
{row.unique_ja4s.length > 3 && (
|
},
|
||||||
<div className="font-mono text-xs text-text-disabled">
|
|
||||||
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
|
||||||
{row.ja4 || '-'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
case 'host':
|
case 'host':
|
||||||
return {
|
return {
|
||||||
key: 'host',
|
key: 'host',
|
||||||
label: col.label,
|
label: col.label,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) =>
|
width: 'w-[180px] min-w-[140px]',
|
||||||
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
|
render: (_, row) => {
|
||||||
<div className="space-y-1">
|
const hosts = groupByIP && row.unique_hosts?.length ? row.unique_hosts : row.host ? [row.host] : [];
|
||||||
<div className="text-xs text-text-secondary font-medium">
|
const primary = hosts[0] ?? '—';
|
||||||
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
|
const extra = hosts.length > 1 ? ` +${hosts.length - 1}` : '';
|
||||||
|
return (
|
||||||
|
<div className="truncate max-w-[175px] text-sm text-text-primary" title={hosts.join(', ')}>
|
||||||
|
{primary}<span className="text-text-disabled text-xs">{extra}</span>
|
||||||
</div>
|
</div>
|
||||||
{row.unique_hosts.slice(0, 3).map((host, idx) => (
|
);
|
||||||
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
},
|
||||||
{host}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{row.unique_hosts.length > 3 && (
|
|
||||||
<div className="text-xs text-text-disabled">
|
|
||||||
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
|
||||||
{row.host || '-'}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
case 'client_headers':
|
case 'client_headers':
|
||||||
return {
|
return {
|
||||||
@ -257,24 +241,18 @@ export function DetectionsList() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
sortable: false,
|
sortable: false,
|
||||||
|
width: 'w-[140px]',
|
||||||
render: (_, row) => {
|
render: (_, row) => {
|
||||||
const name = row.anubis_bot_name;
|
const name = row.anubis_bot_name;
|
||||||
const action = row.anubis_bot_action;
|
const action = row.anubis_bot_action;
|
||||||
const category = row.anubis_bot_category;
|
|
||||||
if (!name) return <span className="text-text-disabled text-xs">—</span>;
|
if (!name) return <span className="text-text-disabled text-xs">—</span>;
|
||||||
const actionColor =
|
const actionColor =
|
||||||
action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' :
|
action === 'ALLOW' ? 'text-green-400' :
|
||||||
action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' :
|
action === 'DENY' ? 'text-red-400' : 'text-yellow-400';
|
||||||
'bg-yellow-500/15 text-yellow-400 border-yellow-500/30';
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="truncate max-w-[135px]" title={`${name} · ${action}`}>
|
||||||
<div className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${actionColor}`}>
|
<span className={`text-xs font-medium ${actionColor}`}>{name}</span>
|
||||||
<span className="font-medium">{name}</span>
|
{action && <span className="text-[10px] text-text-disabled ml-1">· {action}</span>}
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{action && <span className="text-[10px] text-text-secondary">{action}</span>}
|
|
||||||
{category && <span className="text-[10px] text-text-disabled">· {category}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -330,13 +308,11 @@ export function DetectionsList() {
|
|||||||
key: 'asn_org',
|
key: 'asn_org',
|
||||||
label: col.label,
|
label: col.label,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
width: 'w-[150px]',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<div>
|
<div className="truncate max-w-[145px]" title={`${row.asn_org ?? ''} AS${row.asn_number ?? ''}`}>
|
||||||
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div>
|
<span className="text-sm text-text-primary">{row.asn_org || `AS${row.asn_number}` || '—'}</span>
|
||||||
{row.asn_number && (
|
{row.asn_number && <span className="text-xs text-text-disabled ml-1">AS{row.asn_number}</span>}
|
||||||
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
|
|
||||||
)}
|
|
||||||
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@ -358,34 +334,18 @@ export function DetectionsList() {
|
|||||||
key: 'detected_at',
|
key: 'detected_at',
|
||||||
label: col.label,
|
label: col.label,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (_, row) =>
|
width: 'w-[110px]',
|
||||||
groupByIP && row.first_seen ? (() => {
|
render: (_, row) => {
|
||||||
const first = new Date(row.first_seen!);
|
if (groupByIP && row.first_seen) {
|
||||||
const last = new Date(row.last_seen!);
|
const last = new Date(row.last_seen!);
|
||||||
const sameTime = first.getTime() === last.getTime();
|
return <div className="text-xs text-text-secondary whitespace-nowrap">{formatDate(last.toISOString())}</div>;
|
||||||
const fmt = (d: Date) => formatDate(d.toISOString());
|
}
|
||||||
return sameTime ? (
|
return (
|
||||||
<div className="text-xs text-text-secondary">{fmt(last)}</div>
|
<div className="text-xs text-text-secondary whitespace-nowrap">
|
||||||
) : (
|
{formatDateOnly(row.detected_at)} {formatTimeOnly(row.detected_at)}
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs text-text-secondary">
|
|
||||||
<span className="font-medium">Premier:</span> {fmt(first)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-secondary">
|
|
||||||
<span className="font-medium">Dernier:</span> {fmt(last)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})() : (
|
},
|
||||||
<>
|
|
||||||
<div className="text-sm text-text-primary">
|
|
||||||
{formatDateOnly(row.detected_at)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-secondary">
|
|
||||||
{formatTimeOnly(row.detected_at)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return { key: col.key, label: col.label, sortable: col.sortable };
|
return { key: col.key, label: col.label, sortable: col.sortable };
|
||||||
@ -393,88 +353,58 @@ export function DetectionsList() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in">
|
<div className="space-y-2 animate-fade-in">
|
||||||
{/* En-tête */}
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
{/* ── Barre unique : titre + pills + filtres + recherche ── */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center gap-2 bg-background-secondary rounded-lg px-3 py-2">
|
||||||
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
{/* Titre + compteur */}
|
||||||
<span>{data.items.length}</span>
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span>→</span>
|
<span className="font-semibold text-text-primary">Détections</span>
|
||||||
<span>{data.total} détections</span>
|
<span className="text-xs text-text-disabled bg-background-card rounded px-1.5 py-0.5">
|
||||||
</div>
|
{data.total.toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="w-px h-5 bg-background-card shrink-0" />
|
||||||
{/* 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 (
|
||||||
<button
|
<button
|
||||||
onClick={() => setGroupByIP(!groupByIP)}
|
key={threat_level}
|
||||||
className={`border rounded-lg px-4 py-2 text-sm transition-colors ${
|
onClick={() => {
|
||||||
groupByIP
|
if (filterVal) handleFilterChange('score_type', scoreType === filterVal ? '' : filterVal);
|
||||||
? 'bg-accent-primary text-white border-accent-primary'
|
else handleFilterChange('threat_level', threat_level);
|
||||||
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
}}
|
||||||
}`}
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium transition-colors ${style} ${active ? 'ring-1 ring-offset-1 ring-current' : ''}`}
|
||||||
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
|
|
||||||
>
|
>
|
||||||
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
|
{label} <span className="font-bold">{count.toLocaleString()}</span>
|
||||||
|
<span className="opacity-50">{percentage.toFixed(0)}%</span>
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Sélecteur de colonnes */}
|
<div className="w-px h-5 bg-background-card shrink-0" />
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
|
||||||
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-primary transition-colors"
|
|
||||||
>
|
|
||||||
Colonnes ▾
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showColumnSelector && (
|
{/* Filtres select */}
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-background-secondary border border-background-card rounded-lg shadow-lg z-10 p-2">
|
|
||||||
<p className="text-xs text-text-secondary mb-2 px-2">Afficher les colonnes</p>
|
|
||||||
{columns.map(col => (
|
|
||||||
<label
|
|
||||||
key={col.key}
|
|
||||||
className="flex items-center gap-2 px-2 py-1.5 hover:bg-background-card rounded cursor-pointer"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={col.visible}
|
|
||||||
onChange={() => toggleColumn(col.key)}
|
|
||||||
className="rounded bg-background-card border-background-card text-accent-primary"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-text-primary">{col.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recherche */}
|
|
||||||
<form onSubmit={handleSearch} className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Rechercher
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtres */}
|
|
||||||
<div className="bg-background-secondary rounded-lg p-4">
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
|
||||||
<select
|
<select
|
||||||
value={modelName || ''}
|
value={modelName || ''}
|
||||||
onChange={(e) => handleFilterChange('model_name', e.target.value)}
|
onChange={(e) => handleFilterChange('model_name', e.target.value)}
|
||||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
|
||||||
>
|
>
|
||||||
<option value="">Tous modèles</option>
|
<option value="">Tous modèles</option>
|
||||||
<option value="Complet">Complet</option>
|
<option value="Complet">Complet</option>
|
||||||
@ -484,27 +414,82 @@ export function DetectionsList() {
|
|||||||
<select
|
<select
|
||||||
value={scoreType || ''}
|
value={scoreType || ''}
|
||||||
onChange={(e) => handleFilterChange('score_type', e.target.value)}
|
onChange={(e) => handleFilterChange('score_type', e.target.value)}
|
||||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
|
||||||
>
|
>
|
||||||
<option value="">Tous types de score</option>
|
<option value="">Tous scores</option>
|
||||||
<option value="BOT">🟢 BOT seulement</option>
|
<option value="BOT">🟢 BOT</option>
|
||||||
<option value="REGLE">🔴 RÈGLE seulement</option>
|
<option value="REGLE">🔴 RÈGLE</option>
|
||||||
<option value="BOT_REGLE">BOT+RÈGLE</option>
|
<option value="BOT_REGLE">BOT+RÈGLE</option>
|
||||||
<option value="SCORE">Score numérique seulement</option>
|
<option value="SCORE">Score num.</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{(modelName || scoreType || search || sortField !== 'detected_at') && (
|
{(modelName || scoreType || search || sortField !== 'detected_at') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchParams({})}
|
onClick={() => setSearchParams({})}
|
||||||
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-secondary hover:text-text-primary transition-colors"
|
className="text-xs text-text-secondary hover:text-text-primary bg-background-card rounded px-2 py-1 border border-background-card transition-colors"
|
||||||
>
|
>
|
||||||
Effacer filtres
|
✕ Effacer
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Toggle grouper */}
|
||||||
|
<button
|
||||||
|
onClick={() => setGroupByIP(!groupByIP)}
|
||||||
|
className={`text-xs border rounded px-2 py-1 transition-colors shrink-0 ${
|
||||||
|
groupByIP ? 'bg-accent-primary text-white border-accent-primary' : 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
title={groupByIP ? 'Vue individuelle' : 'Vue groupée par IP'}
|
||||||
|
>
|
||||||
|
{groupByIP ? '⊞ Groupé' : '⊟ Individuel'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sélecteur colonnes */}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
||||||
|
className="text-xs bg-background-card hover:bg-background-card/80 border border-background-card rounded px-2 py-1 text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Colonnes ▾
|
||||||
|
</button>
|
||||||
|
{showColumnSelector && (
|
||||||
|
<div className="absolute right-0 mt-1 w-44 bg-background-secondary border border-background-card rounded-lg shadow-lg z-20 p-2">
|
||||||
|
{columns.map(col => (
|
||||||
|
<label key={col.key} className="flex items-center gap-2 px-2 py-1 hover:bg-background-card rounded cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.visible}
|
||||||
|
onChange={() => toggleColumn(col.key)}
|
||||||
|
className="rounded bg-background-card border-background-card text-accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-text-primary">{col.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tableau */}
|
{/* Recherche */}
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-1 shrink-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-accent-primary hover:bg-accent-primary/80 text-white text-xs px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
🔍
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tableau ── */}
|
||||||
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
||||||
<DataTable<DetectionRow>
|
<DataTable<DetectionRow>
|
||||||
data={processedData.items as DetectionRow[]}
|
data={processedData.items as DetectionRow[]}
|
||||||
@ -519,24 +504,24 @@ export function DetectionsList() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* ── Pagination ── */}
|
||||||
{data.total_pages > 1 && (
|
{data.total_pages > 1 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<p className="text-text-secondary text-sm">
|
<p className="text-text-secondary text-xs">
|
||||||
Page {data.page} sur {data.total_pages} ({data.total} détections)
|
Page {data.page}/{data.total_pages} · {data.total.toLocaleString()} détections
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(data.page - 1)}
|
onClick={() => handlePageChange(data.page - 1)}
|
||||||
disabled={data.page === 1}
|
disabled={data.page === 1}
|
||||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
|
||||||
>
|
>
|
||||||
← Précédent
|
← Précédent
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(data.page + 1)}
|
onClick={() => handlePageChange(data.page + 1)}
|
||||||
disabled={data.page === data.total_pages}
|
disabled={data.page === data.total_pages}
|
||||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
|
||||||
>
|
>
|
||||||
Suivant →
|
Suivant →
|
||||||
</button>
|
</button>
|
||||||
@ -611,27 +596,3 @@ function getFlag(countryCode: string): string {
|
|||||||
const code = countryCode.toUpperCase();
|
const code = countryCode.toUpperCase();
|
||||||
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Badge de réputation ASN
|
|
||||||
function AsnRepBadge({ score, label }: { score?: number | null; label?: string }) {
|
|
||||||
if (score == null) return null;
|
|
||||||
let bg: string;
|
|
||||||
let text: string;
|
|
||||||
let display: string;
|
|
||||||
if (score < 0.3) {
|
|
||||||
bg = 'bg-threat-critical/20';
|
|
||||||
text = 'text-threat-critical';
|
|
||||||
} else if (score < 0.6) {
|
|
||||||
bg = 'bg-threat-medium/20';
|
|
||||||
text = 'text-threat-medium';
|
|
||||||
} else {
|
|
||||||
bg = 'bg-threat-low/20';
|
|
||||||
text = 'text-threat-low';
|
|
||||||
}
|
|
||||||
display = label || (score < 0.3 ? 'malicious' : score < 0.6 ? 'suspect' : 'ok');
|
|
||||||
return (
|
|
||||||
<span className={`mt-1 inline-block text-xs px-1.5 py-0.5 rounded ${bg} ${text}`}>
|
|
||||||
{display}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -30,6 +30,8 @@ interface MetricsSummary {
|
|||||||
medium_count: number;
|
medium_count: number;
|
||||||
low_count: number;
|
low_count: number;
|
||||||
unique_ips: number;
|
unique_ips: number;
|
||||||
|
known_bots_count: number;
|
||||||
|
anomalies_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaselineMetric {
|
interface BaselineMetric {
|
||||||
@ -135,81 +137,114 @@ export function IncidentsView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* Header with Quick Search */}
|
{/* Header */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1>
|
<h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1>
|
||||||
<p className="text-text-secondary text-sm mt-1">
|
<p className="text-text-secondary text-sm mt-1">Surveillance en temps réel · 24 dernières heures</p>
|
||||||
Surveillance en temps réel - 24 dernières heures
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Baseline comparison */}
|
{/* Stats unifiées — 6 cartes compact */}
|
||||||
{baseline && (
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{/* Total détections avec comparaison hier */}
|
||||||
{([
|
<div
|
||||||
{ key: 'total_detections', label: 'Détections 24h', icon: '📊', tip: TIPS.total_detections_stat },
|
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
|
||||||
{ key: 'unique_ips', label: 'IPs uniques', icon: '🖥️', tip: TIPS.unique_ips_stat },
|
onClick={() => navigate('/detections')}
|
||||||
{ 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 }) => {
|
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
|
||||||
const m = baseline[key];
|
📊 Total 24h<InfoTip content={TIPS.total_detections_stat} />
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-text-primary">
|
||||||
|
{(metrics?.total_detections ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{baseline && (() => {
|
||||||
|
const m = baseline.total_detections;
|
||||||
const up = m.pct_change > 0;
|
const up = m.pct_change > 0;
|
||||||
const neutral = m.pct_change === 0;
|
const neutral = m.pct_change === 0;
|
||||||
return (
|
return (
|
||||||
<div key={key} className="bg-background-card border border-border rounded-lg px-4 py-3 flex items-center gap-3">
|
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
|
||||||
<span className="text-xl">{icon}</span>
|
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-xs text-text-disabled uppercase tracking-wide flex items-center gap-1">{label}<InfoTip content={tip} /></div>
|
|
||||||
<div className="text-xl font-bold text-text-primary">{m.today.toLocaleString(navigator.language || undefined)}</div>
|
|
||||||
<div className="text-xs text-text-secondary">hier: {m.yesterday.toLocaleString(navigator.language || undefined)}</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>
|
||||||
);
|
);
|
||||||
})}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Critical Metrics */}
|
{/* IPs uniques */}
|
||||||
{metrics && (
|
<div
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
|
||||||
<MetricCard
|
onClick={() => navigate('/detections')}
|
||||||
title="CRITICAL"
|
>
|
||||||
value={metrics.critical_count.toLocaleString()}
|
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
|
||||||
subtitle={metrics.critical_count > 0 ? 'Requiert action immédiate' : 'Aucune'}
|
🖥️ IPs uniques<InfoTip content={TIPS.unique_ips_stat} />
|
||||||
color="bg-red-500/20"
|
</div>
|
||||||
trend={metrics.critical_count > 10 ? 'up' : 'stable'}
|
<div className="text-xl font-bold text-text-primary">
|
||||||
/>
|
{(metrics?.unique_ips ?? 0).toLocaleString()}
|
||||||
<MetricCard
|
</div>
|
||||||
title="HIGH"
|
{baseline && (() => {
|
||||||
value={metrics.high_count.toLocaleString()}
|
const m = baseline.unique_ips;
|
||||||
subtitle="Menaces élevées"
|
const up = m.pct_change > 0;
|
||||||
color="bg-orange-500/20"
|
const neutral = m.pct_change === 0;
|
||||||
trend="stable"
|
return (
|
||||||
/>
|
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
|
||||||
<MetricCard
|
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
|
||||||
title="MEDIUM"
|
</div>
|
||||||
value={metrics.medium_count.toLocaleString()}
|
);
|
||||||
subtitle="Menaces moyennes"
|
})()}
|
||||||
color="bg-yellow-500/20"
|
</div>
|
||||||
trend="stable"
|
|
||||||
/>
|
{/* BOT connus */}
|
||||||
<MetricCard
|
<div
|
||||||
title="TOTAL"
|
className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-green-500/60 transition-colors"
|
||||||
value={metrics.total_detections.toLocaleString()}
|
onClick={() => navigate('/detections?score_type=BOT')}
|
||||||
subtitle={`${metrics.unique_ips.toLocaleString()} IPs uniques`}
|
>
|
||||||
color="bg-blue-500/20"
|
<div className="text-[10px] text-green-400/80 uppercase tracking-wide">🤖 BOT nommés</div>
|
||||||
trend="stable"
|
<div className="text-xl font-bold text-green-400">
|
||||||
/>
|
{(metrics?.known_bots_count ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-green-400/60">
|
||||||
|
{metrics ? Math.round((metrics.known_bots_count / metrics.total_detections) * 100) : 0}% du total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Anomalies ML */}
|
||||||
|
<div
|
||||||
|
className="bg-purple-500/10 border border-purple-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-purple-500/60 transition-colors"
|
||||||
|
onClick={() => navigate('/detections?score_type=SCORE')}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-purple-400/80 uppercase tracking-wide">🔬 Anomalies ML</div>
|
||||||
|
<div className="text-xl font-bold text-purple-400">
|
||||||
|
{(metrics?.anomalies_count ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-purple-400/60">
|
||||||
|
{metrics ? Math.round((metrics.anomalies_count / metrics.total_detections) * 100) : 0}% du total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HIGH */}
|
||||||
|
<div
|
||||||
|
className="bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-orange-500/60 transition-colors"
|
||||||
|
onClick={() => navigate('/detections?threat_level=HIGH')}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-orange-400/80 uppercase tracking-wide">⚠️ HIGH</div>
|
||||||
|
<div className="text-xl font-bold text-orange-400">
|
||||||
|
{(metrics?.high_count ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-orange-400/60">Menaces élevées</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MEDIUM */}
|
||||||
|
<div
|
||||||
|
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-yellow-500/60 transition-colors"
|
||||||
|
onClick={() => navigate('/detections?threat_level=MEDIUM')}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-yellow-400/80 uppercase tracking-wide">📊 MEDIUM</div>
|
||||||
|
<div className="text-xl font-bold text-yellow-400">
|
||||||
|
{(metrics?.medium_count ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-yellow-400/60">Menaces moyennes</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bulk Actions */}
|
{/* Bulk Actions */}
|
||||||
{selectedClusters.size > 0 && (
|
{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 (
|
|
||||||
<div className={`${color} rounded-lg p-6`}>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h3 className="text-text-secondary text-sm font-medium">{title}</h3>
|
|
||||||
<span className="text-lg">
|
|
||||||
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-3xl font-bold text-text-primary">{value}</p>
|
|
||||||
<p className="text-text-disabled text-xs mt-2">{subtitle}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
|
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface HeatmapHour {
|
interface HeatmapHour {
|
||||||
|
|||||||
@ -74,9 +74,9 @@ function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function IPActivitySummary({ ip }: { ip: string }) {
|
function IPActivitySummary({ ip }: { ip: string }) {
|
||||||
const [data, setData] = useState<IPSummary | null>(null);
|
const [open, setOpen] = useState(false); // fermée par défaut
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [open, setOpen] = useState(true);
|
const [data, setData] = useState<IPSummary | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -327,7 +327,7 @@ function Metric({ label, value, accent }: { label: string; value: string; accent
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DetectionAttributesSection({ ip }: { ip: string }) {
|
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 { data, loading } = useVariability('ip', ip);
|
||||||
|
|
||||||
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
|
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
|
||||||
@ -448,38 +448,59 @@ export function InvestigationView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation ancres inter-sections */}
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs font-medium sticky top-0 z-10 bg-background py-2">
|
||||||
|
<span className="text-text-disabled shrink-0">Aller à :</span>
|
||||||
|
{[
|
||||||
|
{ 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 }) => (
|
||||||
|
<a key={id} href={`#${id}`} className="shrink-0 px-3 py-1 rounded-full bg-background-card text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors">
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Attributs détectés (ex-DetailsView) */}
|
{/* Attributs détectés (ex-DetailsView) */}
|
||||||
|
<div id="section-attributs">
|
||||||
<DetectionAttributesSection ip={ip} />
|
<DetectionAttributesSection ip={ip} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Ligne 0 : Synthèse multi-sources */}
|
{/* Synthèse multi-sources */}
|
||||||
|
<div id="section-synthese">
|
||||||
<IPActivitySummary ip={ip} />
|
<IPActivitySummary ip={ip} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
|
{/* Réputation (1/3) + Graph de corrélations (2/3) */}
|
||||||
<div className="grid grid-cols-3 gap-6 items-start">
|
<div id="section-reputation" 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">
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
|
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
|
||||||
<ReputationPanel ip={ip} />
|
<ReputationPanel ip={ip} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 bg-background-secondary rounded-lg p-6">
|
<div id="section-correlations" className="col-span-2 bg-background-secondary rounded-lg p-6">
|
||||||
<h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
|
<h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
|
||||||
<CorrelationGraph ip={ip} height="600px" />
|
<CorrelationGraph ip={ip} height="600px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ligne 2 : Subnet / Country / JA4 (3 colonnes) */}
|
{/* Subnet / Country / JA4 */}
|
||||||
<div className="grid grid-cols-3 gap-6 items-start">
|
<div id="section-geo" className="grid grid-cols-3 gap-6 items-start">
|
||||||
<SubnetAnalysis ip={ip} />
|
<SubnetAnalysis ip={ip} />
|
||||||
<CountryAnalysis ip={ip} />
|
<CountryAnalysis ip={ip} />
|
||||||
<JA4Analysis ip={ip} />
|
<JA4Analysis ip={ip} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ligne 3 : User-Agents (1/2) + Classification (1/2) */}
|
{/* User-Agents (1/2) + Classification (1/2) */}
|
||||||
<div className="grid grid-cols-2 gap-6 items-start">
|
<div id="section-classification" className="grid grid-cols-2 gap-6 items-start">
|
||||||
<UserAgentAnalysis ip={ip} />
|
<UserAgentAnalysis ip={ip} />
|
||||||
<CorrelationSummary ip={ip} onClassify={handleClassify} />
|
<CorrelationSummary ip={ip} onClassify={handleClassify} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ligne 4 : Cohérence JA4/UA (spoofing) */}
|
{/* Cohérence JA4/UA (spoofing) */}
|
||||||
<div className="grid grid-cols-3 gap-6 items-start">
|
<div className="grid grid-cols-3 gap-6 items-start">
|
||||||
<FingerprintCoherenceWidget ip={ip} />
|
<FingerprintCoherenceWidget ip={ip} />
|
||||||
<div className="col-span-2 bg-background-secondary rounded-lg p-5">
|
<div className="col-span-2 bg-background-secondary rounded-lg p-5">
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { InfoTip } from './ui/Tooltip';
|
import { InfoTip } from './ui/Tooltip';
|
||||||
import { TIPS } from './ui/tooltips';
|
import { TIPS } from './ui/tooltips';
|
||||||
import { formatDateShort } from '../utils/dateUtils';
|
import { formatDateShort } from '../utils/dateUtils';
|
||||||
@ -22,6 +23,7 @@ interface ClassificationStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ThreatIntelView() {
|
export function ThreatIntelView() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [classifications, setClassifications] = useState<Classification[]>([]);
|
const [classifications, setClassifications] = useState<Classification[]>([]);
|
||||||
const [stats, setStats] = useState<ClassificationStats[]>([]);
|
const [stats, setStats] = useState<ClassificationStats[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -232,20 +234,28 @@ export function ThreatIntelView() {
|
|||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Entité</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Entité</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Label</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Label</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Tags</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Tags</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Commentaire</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Confiance<InfoTip content={TIPS.confiance} /></span></th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Confiance<InfoTip content={TIPS.confiance} /></span></th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Analyste</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Analyste</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-background-card">
|
<tbody className="divide-y divide-background-card">
|
||||||
{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 (
|
||||||
<tr key={idx} className="hover:bg-background-card/50 transition-colors">
|
<tr key={idx} className="hover:bg-background-card/50 transition-colors">
|
||||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
<td className="px-4 py-3 text-sm text-text-secondary whitespace-nowrap">
|
||||||
{formatDateShort(classification.created_at)}
|
{formatDateShort(classification.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-mono text-sm text-text-primary">
|
<button
|
||||||
{classification.ip || classification.ja4}
|
onClick={() => navigate(isIP ? `/investigation/${encodeURIComponent(entity!)}` : `/investigation/ja4/${encodeURIComponent(entity!)}`)}
|
||||||
</div>
|
className="font-mono text-sm text-accent-primary hover:underline text-left truncate max-w-[160px] block"
|
||||||
|
title={entity}
|
||||||
|
>
|
||||||
|
{entity}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
|
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
|
||||||
@ -254,17 +264,22 @@ export function ThreatIntelView() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{classification.tags.slice(0, 5).map((tag, tagIdx) => (
|
{classification.tags.slice(0, 4).map((tag, tagIdx) => (
|
||||||
<span key={tagIdx} className={`px-2 py-0.5 rounded text-xs ${getTagColor(tag)}`}>{tag}</span>
|
<span key={tagIdx} className={`px-2 py-0.5 rounded text-xs ${getTagColor(tag)}`}>{tag}</span>
|
||||||
))}
|
))}
|
||||||
{classification.tags.length > 5 && (
|
{classification.tags.length > 4 && (
|
||||||
<span className="text-xs text-text-secondary">+{classification.tags.length - 5}</span>
|
<span className="text-xs text-text-secondary">+{classification.tags.length - 4}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-text-secondary max-w-[200px]">
|
||||||
|
<span className="truncate block" title={(classification as any).comment || ''}>
|
||||||
|
{(classification as any).comment || <span className="text-text-disabled">—</span>}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 bg-background-secondary rounded-full h-2">
|
<div className="flex-1 bg-background-secondary rounded-full h-2 min-w-[60px]">
|
||||||
<div className="h-2 rounded-full bg-accent-primary" style={{ width: `${classification.confidence * 100}%` }} />
|
<div className="h-2 rounded-full bg-accent-primary" style={{ width: `${classification.confidence * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-text-primary font-bold">{(classification.confidence * 100).toFixed(0)}%</span>
|
<span className="text-xs text-text-primary font-bold">{(classification.confidence * 100).toFixed(0)}%</span>
|
||||||
@ -272,7 +287,8 @@ export function ThreatIntelView() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-text-secondary">{classification.analyst}</td>
|
<td className="px-4 py-3 text-sm text-text-secondary">{classification.analyst}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user