diff --git a/backend/routes/detections.py b/backend/routes/detections.py index 22e9097..00c00a3 100644 --- a/backend/routes/detections.py +++ b/backend/routes/detections.py @@ -8,6 +8,19 @@ from ..models import DetectionsListResponse, Detection router = APIRouter(prefix="/api/detections", tags=["detections"]) +# Mapping label ASN → score float (0 = très suspect, 1 = légitime) +_ASN_LABEL_SCORES: dict[str, float] = { + 'human': 0.9, 'bot': 0.05, 'proxy': 0.25, 'vpn': 0.3, + 'tor': 0.1, 'datacenter': 0.4, 'scanner': 0.05, 'malicious': 0.05, +} + + +def _label_to_score(label: str) -> float | None: + """Convertit un label de réputation ASN en score numérique.""" + if not label: + return None + return _ASN_LABEL_SCORES.get(label.lower(), 0.5) + @router.get("", response_model=DetectionsListResponse) async def get_detections( @@ -154,12 +167,6 @@ async def get_detections( params["offset"] = offset gresult = db.query(grouped_query, params) - def _label_to_score(label: str) -> float | None: - if not label: return None - mapping = {'human': 0.9, 'bot': 0.05, 'proxy': 0.25, 'vpn': 0.3, - 'tor': 0.1, 'datacenter': 0.4, 'scanner': 0.05, 'malicious': 0.05} - return mapping.get(label.lower(), 0.5) - detections = [] for row in gresult.result_rows: # row: src_ip, first_seen, last_seen, detection_count, unique_ja4s, unique_hosts, @@ -252,21 +259,6 @@ async def get_detections( params["offset"] = offset result = db.query(main_query, params) - - def _label_to_score(label: str) -> float | None: - if not label: - return None - mapping = { - 'human': 0.9, - 'bot': 0.05, - 'proxy': 0.25, - 'vpn': 0.3, - 'tor': 0.1, - 'datacenter': 0.4, - 'scanner': 0.05, - 'malicious': 0.05, - } - return mapping.get(label.lower(), 0.5) detections = [ Detection( diff --git a/backend/routes/entities.py b/backend/routes/entities.py index dba2969..dc7038c 100644 --- a/backend/routes/entities.py +++ b/backend/routes/entities.py @@ -39,7 +39,7 @@ def get_entity_stats(entity_type: str, entity_value: str, hours: int = 24) -> Op GROUP BY entity_type, entity_value """ - result = db.connect().query(query, { + result = db.query(query, { 'entity_type': entity_type, 'entity_value': entity_value, 'hours': hours @@ -73,7 +73,7 @@ def get_related_attributes(entity_type: str, entity_value: str, hours: int = 24) (SELECT groupUniqArrayArray(countries) FROM mabase_prod.view_dashboard_entities WHERE entity_type = %(entity_type)s AND entity_value = %(entity_value)s AND log_date >= toDate(now() - INTERVAL %(hours)s HOUR) AND notEmpty(countries)) as countries """ - result = db.connect().query(query, { + result = db.query(query, { 'entity_type': entity_type, 'entity_value': entity_value, 'hours': hours @@ -120,7 +120,7 @@ def get_array_values(entity_type: str, entity_value: str, array_field: str, hour ORDER BY count DESC """ - result = db.connect().query(query, { + result = db.query(query, { 'entity_type': entity_type, 'entity_value': entity_value, 'hours': hours @@ -318,6 +318,26 @@ async def get_subnet_investigation( raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}") +@router.get("/types") +async def get_entity_types(): + """ + Retourne la liste des types d'entités supportés. + NOTE: Cette route DOIT être déclarée avant /{entity_type}/... pour ne pas être masquée. + """ + return { + "entity_types": sorted(VALID_ENTITY_TYPES), + "descriptions": { + "ip": "Adresse IP source", + "ja4": "Fingerprint JA4 TLS", + "user_agent": "User-Agent HTTP", + "client_header": "Client Header", + "host": "Host HTTP", + "path": "Path URL", + "query_param": "Query Param" + } + } + + @router.get("/{entity_type}/{entity_value:path}", response_model=EntityInvestigation) async def get_entity_investigation( entity_type: str, @@ -340,10 +360,10 @@ async def get_entity_investigation( - Query-Params """ # Valider le type d'entité - if entity_type not in ENTITY_TYPES: + if entity_type not in VALID_ENTITY_TYPES: raise HTTPException( status_code=400, - detail=f"Type d'entité invalide. Types supportés: {', '.join(ENTITY_TYPES.keys())}" + detail=f"Type d'entité invalide. Types supportés: {', '.join(VALID_ENTITY_TYPES)}" ) # Stats générales @@ -385,10 +405,10 @@ async def get_entity_related( """ Récupère uniquement les attributs associés à une entité """ - if entity_type not in ENTITY_TYPES: + if entity_type not in VALID_ENTITY_TYPES: raise HTTPException( status_code=400, - detail=f"Type d'entité invalide. Types supportés: {', '.join(ENTITY_TYPES.keys())}" + detail=f"Type d'entité invalide. Types supportés: {', '.join(VALID_ENTITY_TYPES)}" ) related = get_related_attributes(entity_type, entity_value, hours) @@ -410,7 +430,7 @@ async def get_entity_user_agents( """ Récupère les User-Agents associés à une entité """ - if entity_type not in ENTITY_TYPES: + if entity_type not in VALID_ENTITY_TYPES: raise HTTPException(status_code=400, detail="Type d'entité invalide") user_agents = get_array_values(entity_type, entity_value, 'user_agents', hours) @@ -432,7 +452,7 @@ async def get_entity_client_headers( """ Récupère les Client-Headers associés à une entité """ - if entity_type not in ENTITY_TYPES: + if entity_type not in VALID_ENTITY_TYPES: raise HTTPException(status_code=400, detail="Type d'entité invalide") client_headers = get_array_values(entity_type, entity_value, 'client_headers', hours) @@ -454,7 +474,7 @@ async def get_entity_paths( """ Récupère les Paths associés à une entité """ - if entity_type not in ENTITY_TYPES: + if entity_type not in VALID_ENTITY_TYPES: raise HTTPException(status_code=400, detail="Type d'entité invalide") paths = get_array_values(entity_type, entity_value, 'paths', hours) @@ -476,7 +496,7 @@ async def get_entity_query_params( """ Récupère les Query-Params associés à une entité """ - if entity_type not in ENTITY_TYPES: + if entity_type not in VALID_ENTITY_TYPES: raise HTTPException(status_code=400, detail="Type d'entité invalide") query_params = get_array_values(entity_type, entity_value, 'query_params', hours) @@ -487,22 +507,3 @@ async def get_entity_query_params( "query_params": query_params, "total": len(query_params) } - - -@router.get("/types") -async def get_entity_types(): - """ - Retourne la liste des types d'entités supportés - """ - return { - "entity_types": list(ENTITY_TYPES.values()), - "descriptions": { - "ip": "Adresse IP source", - "ja4": "Fingerprint JA4 TLS", - "user_agent": "User-Agent HTTP", - "client_header": "Client Header", - "host": "Host HTTP", - "path": "Path URL", - "query_param": "Query Param" - } - } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c78dfb5..1f58e56 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { BrowserRouter, Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import { DetectionsList } from './components/DetectionsList'; import { DetailsView } from './components/DetailsView'; import { InvestigationView } from './components/InvestigationView'; @@ -21,6 +21,7 @@ import { HeaderFingerprintView } from './components/HeaderFingerprintView'; import { MLFeaturesView } from './components/MLFeaturesView'; import ClusteringView from './components/ClusteringView'; import { useTheme } from './ThemeContext'; +import { useMetrics } from './hooks/useMetrics'; import { Tooltip } from './components/ui/Tooltip'; import { TIPS } from './components/ui/tooltips'; @@ -403,31 +404,16 @@ function MainContent({ counts: _counts }: { counts: AlertCounts | null }) { // ─── App ────────────────────────────────────────────────────────────────────── export default function App() { - const [counts, setCounts] = useState(null); + const { data: metricsData } = useMetrics(); - const fetchCounts = useCallback(async () => { - try { - const res = await fetch('/api/metrics'); - if (res.ok) { - const data = await res.json(); - const s = data.summary; - setCounts({ - critical: s.critical_count ?? 0, - high: s.high_count ?? 0, - medium: s.medium_count ?? 0, - total: s.total_detections ?? 0, - }); + const counts = metricsData + ? { + critical: metricsData.summary.critical_count ?? 0, + high: metricsData.summary.high_count ?? 0, + medium: metricsData.summary.medium_count ?? 0, + total: metricsData.summary.total_detections ?? 0, } - } catch { - // silently ignore — metrics are informational - } - }, []); - - useEffect(() => { - fetchCounts(); - const id = setInterval(fetchCounts, 30_000); - return () => clearInterval(id); - }, [fetchCounts]); + : null; return ( diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 597d741..60a60c7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -147,11 +147,6 @@ export const detectionsApi = { }; export const variabilityApi = { - getVariability: (type: string, value: string) => + getVariability: (type: string, value: string) => api.get(`/variability/${type}/${encodeURIComponent(value)}`), }; - -export const attributesApi = { - getAttributes: (type: string, limit?: number) => - api.get(`/attributes/${type}`, { params: { limit } }), -}; diff --git a/frontend/src/components/BotnetMapView.tsx b/frontend/src/components/BotnetMapView.tsx index fa96f32..ecd9340 100644 --- a/frontend/src/components/BotnetMapView.tsx +++ b/frontend/src/components/BotnetMapView.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { formatNumber } from '../utils/dateUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -29,9 +30,6 @@ type SortField = 'unique_ips' | 'unique_countries' | 'targeted_hosts'; // ─── Helpers ────────────────────────────────────────────────────────────────── -function formatNumber(n: number): string { - return n.toLocaleString(navigator.language || undefined); -} function getCountryFlag(code: string): string { if (!code || code.length !== 2) return '🌐'; diff --git a/frontend/src/components/BruteForceView.tsx b/frontend/src/components/BruteForceView.tsx index b53c76e..e12d3c3 100644 --- a/frontend/src/components/BruteForceView.tsx +++ b/frontend/src/components/BruteForceView.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import DataTable, { Column } from './ui/DataTable'; import { InfoTip } from './ui/Tooltip'; import { TIPS } from './ui/tooltips'; +import { formatNumber } from '../utils/dateUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -33,9 +34,6 @@ type ActiveTab = 'targets' | 'attackers' | 'timeline'; // ─── Helpers ────────────────────────────────────────────────────────────────── -function formatNumber(n: number): string { - return n.toLocaleString(navigator.language || undefined); -} // ─── Sub-components ─────────────────────────────────────────────────────────── diff --git a/frontend/src/components/HeaderFingerprintView.tsx b/frontend/src/components/HeaderFingerprintView.tsx index 211b7da..f6a2e38 100644 --- a/frontend/src/components/HeaderFingerprintView.tsx +++ b/frontend/src/components/HeaderFingerprintView.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import DataTable, { Column } from './ui/DataTable'; import { TIPS } from './ui/tooltips'; +import { formatNumber } from '../utils/dateUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -27,9 +28,6 @@ interface ClusterIP { // ─── Helpers ────────────────────────────────────────────────────────────────── -function formatNumber(n: number): string { - return n.toLocaleString(navigator.language || undefined); -} function mismatchColor(pct: number): string { if (pct > 50) return 'text-threat-critical'; diff --git a/frontend/src/components/HeatmapView.tsx b/frontend/src/components/HeatmapView.tsx index 8f08bd4..86d44c5 100644 --- a/frontend/src/components/HeatmapView.tsx +++ b/frontend/src/components/HeatmapView.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { formatNumber } from '../utils/dateUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -24,9 +25,6 @@ interface HeatmapMatrix { // ─── Helpers ────────────────────────────────────────────────────────────────── -function formatNumber(n: number): string { - return n.toLocaleString(navigator.language || undefined); -} function heatmapCellStyle(value: number, maxValue: number): React.CSSProperties { if (maxValue === 0 || value === 0) return { backgroundColor: 'transparent' }; diff --git a/frontend/src/components/MLFeaturesView.tsx b/frontend/src/components/MLFeaturesView.tsx index c1b11ee..4f6c79c 100644 --- a/frontend/src/components/MLFeaturesView.tsx +++ b/frontend/src/components/MLFeaturesView.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import DataTable, { Column } from './ui/DataTable'; import { TIPS } from './ui/tooltips'; +import { formatNumber } from '../utils/dateUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -47,9 +48,6 @@ interface ScatterPoint { // ─── Helpers ────────────────────────────────────────────────────────────────── -function formatNumber(n: number): string { - return n.toLocaleString(navigator.language || undefined); -} function attackTypeEmoji(type: string): string { switch (type) { diff --git a/frontend/src/components/RotationView.tsx b/frontend/src/components/RotationView.tsx index 808963e..ebb2799 100644 --- a/frontend/src/components/RotationView.tsx +++ b/frontend/src/components/RotationView.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { formatDateShort } from '../utils/dateUtils'; +import { formatDateShort } { formatNumber; } from '../utils/dateUtils' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -51,9 +51,6 @@ type ActiveTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt'; // ─── Helpers ────────────────────────────────────────────────────────────────── -function formatNumber(n: number): string { - return n.toLocaleString(navigator.language || undefined); -} function formatDate(iso: string): string { if (!iso) return '—'; diff --git a/frontend/src/components/TcpSpoofingView.tsx b/frontend/src/components/TcpSpoofingView.tsx index 310ad45..b324669 100644 --- a/frontend/src/components/TcpSpoofingView.tsx +++ b/frontend/src/components/TcpSpoofingView.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import DataTable, { Column } from './ui/DataTable'; import { TIPS } from './ui/tooltips'; +import { formatNumber } from '../utils/dateUtils'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -51,9 +52,6 @@ type ActiveTab = 'detections' | 'matrix'; // ─── Helpers ────────────────────────────────────────────────────────────────── -function formatNumber(n: number): string { - return n.toLocaleString(navigator.language || undefined); -} function confidenceBar(conf: number): JSX.Element { const pct = Math.round(conf * 100); diff --git a/frontend/src/components/ui/Feedback.tsx b/frontend/src/components/ui/Feedback.tsx new file mode 100644 index 0000000..e4859c0 --- /dev/null +++ b/frontend/src/components/ui/Feedback.tsx @@ -0,0 +1,26 @@ +/** + * Composants UI réutilisables pour les états de chargement et d'erreur. + * Utiliser ces composants plutôt que de re-déclarer des versions locales. + */ + +/** Spinner centré — affiché pendant le chargement d'une section. */ +export function LoadingSpinner() { + return ( +
+
+
+ ); +} + +interface ErrorMessageProps { + message: string; +} + +/** Bandeau d'erreur rouge — affiché quand une requête échoue. */ +export function ErrorMessage({ message }: ErrorMessageProps) { + return ( +
+ ⚠️ {message} +
+ ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts index f30e551..63a9c09 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -16,23 +16,9 @@ export const CONFIG = { /** Clé localStorage pour la préférence de thème */ THEME_STORAGE_KEY: 'soc_theme', - // ── Pagination ──────────────────────────────────────────────────────────── - /** Taille de page par défaut pour les listes */ - DEFAULT_PAGE_SIZE: 50, - - /** Tailles de page disponibles */ - PAGE_SIZES: [25, 50, 100, 250] as const, - // ── Rafraîchissement ────────────────────────────────────────────────────── - /** Intervalle de rafraîchissement automatique des métriques (ms). 0 = désactivé */ - METRICS_REFRESH_MS: 0, - - // ── Détections ──────────────────────────────────────────────────────────── - /** Score d'anomalie en-dessous duquel une IP est considérée critique */ - CRITICAL_THRESHOLD: -0.5, - - /** Score d'anomalie en-dessous duquel une IP est considérée HIGH */ - HIGH_THRESHOLD: -0.3, + /** Intervalle de rafraîchissement automatique des métriques (ms). */ + METRICS_REFRESH_MS: 30_000, // ── Anubis ──────────────────────────────────────────────────────────────── /** @@ -49,8 +35,5 @@ export const CONFIG = { * WEIGH → trafic suspect — scoré par l'IsolationForest avec signal anubis_is_flagged=1 */ ANUBIS_RULES_URL: 'https://github.com/TecharoHQ/anubis/tree/main/data', - - // ── Application ─────────────────────────────────────────────────────────── - APP_NAME: 'JA4 SOC Dashboard', - APP_VERSION: '12', } as const; + diff --git a/frontend/src/hooks/useFetch.ts b/frontend/src/hooks/useFetch.ts new file mode 100644 index 0000000..81aea45 --- /dev/null +++ b/frontend/src/hooks/useFetch.ts @@ -0,0 +1,57 @@ +import { useState, useEffect } from 'react'; + +interface FetchState { + data: T | null; + loading: boolean; + error: string | null; +} + +/** + * Hook générique pour les appels fetch avec gestion loading/error. + * Annule automatiquement la requête si le composant est démonté + * ou si l'URL change avant que la réponse arrive. + * + * @param url URL relative ou absolue à appeler (typiquement "/api/...") + * @param deps Dépendances supplémentaires qui déclenchent un re-fetch + * (en plus de url). Équivalent au tableau de deps de useEffect. + * + * @example + * const { data, loading, error } = useFetch('/api/metrics'); + */ +export function useFetch(url: string, deps: unknown[] = []): FetchState { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + setLoading(true); + setError(null); + + fetch(url) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + return res.json() as Promise; + }) + .then((json) => { + if (!cancelled) { + setData(json); + setLoading(false); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Erreur inconnue'); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url, ...deps]); + + return { data, loading, error }; +} diff --git a/frontend/src/hooks/useMetrics.ts b/frontend/src/hooks/useMetrics.ts index 7e07e72..04d022d 100644 --- a/frontend/src/hooks/useMetrics.ts +++ b/frontend/src/hooks/useMetrics.ts @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { metricsApi, MetricsResponse } from '../api/client'; +import { CONFIG } from '../config'; export function useMetrics() { const [data, setData] = useState(null); @@ -19,9 +20,7 @@ export function useMetrics() { }; fetchMetrics(); - - // Rafraîchissement automatique toutes les 30 secondes - const interval = setInterval(fetchMetrics, 30000); + const interval = setInterval(fetchMetrics, CONFIG.METRICS_REFRESH_MS); return () => clearInterval(interval); }, []); diff --git a/frontend/src/utils/countryUtils.ts b/frontend/src/utils/countryUtils.ts new file mode 100644 index 0000000..57ad319 --- /dev/null +++ b/frontend/src/utils/countryUtils.ts @@ -0,0 +1,11 @@ +/** + * Convertit un code pays ISO 3166-1 alpha-2 en emoji drapeau. + * Utilise les Regional Indicator Symbols Unicode (U+1F1E6…U+1F1FF). + * Retourne 🌐 pour les codes invalides ou vides. + */ +export function getCountryFlag(code: string): string { + if (!code || code.length !== 2) return '🌐'; + return code + .toUpperCase() + .replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397)); +}