⚠️ {message}
diff --git a/frontend/src/components/HeatmapView.tsx b/frontend/src/components/HeatmapView.tsx
deleted file mode 100644
index 86d44c5..0000000
--- a/frontend/src/components/HeatmapView.tsx
+++ /dev/null
@@ -1,318 +0,0 @@
-import { useState, useEffect } from 'react';
-import { formatNumber } from '../utils/dateUtils';
-
-// ─── Types ────────────────────────────────────────────────────────────────────
-
-interface HourlyEntry {
- hour: number;
- hits: number;
- unique_ips: number;
- max_rps: number;
-}
-
-interface TopHost {
- host: string;
- total_hits: number;
- unique_ips: number;
- unique_ja4s: number;
- hourly_hits: number[];
-}
-
-interface HeatmapMatrix {
- hosts: string[];
- matrix: number[][];
-}
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-
-function heatmapCellStyle(value: number, maxValue: number): React.CSSProperties {
- if (maxValue === 0 || value === 0) return { backgroundColor: 'transparent' };
- const ratio = value / maxValue;
- if (ratio >= 0.75) return { backgroundColor: 'rgba(239, 68, 68, 0.85)' };
- if (ratio >= 0.5) return { backgroundColor: 'rgba(168, 85, 247, 0.7)' };
- if (ratio >= 0.25) return { backgroundColor: 'rgba(59, 130, 246, 0.6)' };
- if (ratio >= 0.05) return { backgroundColor: 'rgba(96, 165, 250, 0.35)' };
- return { backgroundColor: 'rgba(147, 197, 253, 0.15)' };
-}
-
-// ─── Sub-components ───────────────────────────────────────────────────────────
-
-function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-function LoadingSpinner() {
- return (
-
- );
-}
-
-function ErrorMessage({ message }: { message: string }) {
- return (
-
- ⚠️ {message}
-
- );
-}
-
-// Mini sparkline for a host row
-function Sparkline({ data }: { data: number[] }) {
- const max = Math.max(...data, 1);
- return (
-
- {data.map((v, i) => {
- const pct = (v / max) * 100;
- return (
-
= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/50'}`}
- />
- );
- })}
-
- );
-}
-
-// ─── Main Component ───────────────────────────────────────────────────────────
-
-export function HeatmapView() {
- const [hourly, setHourly] = useState
([]);
- const [hourlyLoading, setHourlyLoading] = useState(true);
- const [hourlyError, setHourlyError] = useState(null);
-
- const [topHosts, setTopHosts] = useState([]);
- const [topHostsLoading, setTopHostsLoading] = useState(true);
- const [topHostsError, setTopHostsError] = useState(null);
-
- const [matrixData, setMatrixData] = useState(null);
- const [matrixLoading, setMatrixLoading] = useState(true);
- const [matrixError, setMatrixError] = useState(null);
-
- useEffect(() => {
- const fetchHourly = async () => {
- try {
- const res = await fetch('/api/heatmap/hourly');
- if (!res.ok) throw new Error('Erreur chargement courbe horaire');
- const data: { hours: HourlyEntry[] } = await res.json();
- setHourly(data.hours ?? []);
- } catch (err) {
- setHourlyError(err instanceof Error ? err.message : 'Erreur inconnue');
- } finally {
- setHourlyLoading(false);
- }
- };
- const fetchTopHosts = async () => {
- try {
- const res = await fetch('/api/heatmap/top-hosts?limit=20');
- if (!res.ok) throw new Error('Erreur chargement top hosts');
- const data: { items: TopHost[] } = await res.json();
- setTopHosts(data.items ?? []);
- } catch (err) {
- setTopHostsError(err instanceof Error ? err.message : 'Erreur inconnue');
- } finally {
- setTopHostsLoading(false);
- }
- };
- const fetchMatrix = async () => {
- try {
- const res = await fetch('/api/heatmap/matrix');
- if (!res.ok) throw new Error('Erreur chargement heatmap matrix');
- const data: HeatmapMatrix = await res.json();
- setMatrixData(data);
- } catch (err) {
- setMatrixError(err instanceof Error ? err.message : 'Erreur inconnue');
- } finally {
- setMatrixLoading(false);
- }
- };
- fetchHourly();
- fetchTopHosts();
- fetchMatrix();
- }, []);
-
- const peakHour = hourly.reduce((best, h) => (h.hits > best.hits ? h : best), { hour: 0, hits: 0, unique_ips: 0, max_rps: 0 });
- const totalHits = hourly.reduce((s, h) => s + h.hits, 0);
- const maxHits = hourly.length > 0 ? Math.max(...hourly.map((h) => h.hits)) : 1;
-
- const matrixMax = matrixData
- ? Math.max(...matrixData.matrix.flatMap((row) => row), 1)
- : 1;
-
- const displayHosts = matrixData ? matrixData.hosts.slice(0, 15) : [];
-
- return (
-
- {/* Header */}
-
-
⏱️ Heatmap Temporelle d'Attaques
-
- Distribution horaire de l'activité malveillante par host cible.
-
-
-
- {/* Stat cards */}
-
- 0 ? `${peakHour.hour}h (${formatNumber(peakHour.hits)} hits)` : '—'}
- accent="text-threat-critical"
- />
-
-
-
-
- {/* Section 1: Courbe horaire */}
-
-
Activité horaire — 24h
- {hourlyLoading ? (
-
- ) : hourlyError ? (
-
- ) : (
- <>
-
- {Array.from({ length: 24 }, (_, i) => {
- const entry = hourly.find((h) => h.hour === i) ?? { hour: i, hits: 0, unique_ips: 0, max_rps: 0 };
- const pct = maxHits > 0 ? (entry.hits / maxHits) * 100 : 0;
- return (
-
-
-
= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/60'
- }`}
- />
-
-
{i}
-
- );
- })}
-
-
- Élevé (≥70%)
- Moyen (≥30%)
- Faible
-
- >
- )}
-
-
- {/* Section 2: Heatmap matrix */}
-
-
Heatmap Host × Heure
- {matrixLoading ? (
-
- ) : matrixError ? (
-
- ) : !matrixData || displayHosts.length === 0 ? (
-
Aucune donnée disponible.
- ) : (
-
-
- {/* Hour headers */}
-
- {Array.from({ length: 24 }, (_, i) => (
-
- {i}
-
- ))}
-
- {/* Rows */}
- {displayHosts.map((host, rowIdx) => {
- const rowData = matrixData.matrix[rowIdx] ?? Array(24).fill(0);
- return (
-
-
- {host}
-
- {Array.from({ length: 24 }, (_, h) => {
- const val = rowData[h] ?? 0;
- return (
-
- );
- })}
-
- );
- })}
-
-
- Intensité :
- {[
- { label: 'Nul', style: { backgroundColor: 'transparent', border: '1px solid rgba(255,255,255,0.1)' } },
- { label: 'Très faible', style: { backgroundColor: 'rgba(147, 197, 253, 0.15)' } },
- { label: 'Faible', style: { backgroundColor: 'rgba(96, 165, 250, 0.35)' } },
- { label: 'Moyen', style: { backgroundColor: 'rgba(59, 130, 246, 0.6)' } },
- { label: 'Élevé', style: { backgroundColor: 'rgba(168, 85, 247, 0.7)' } },
- { label: 'Critique', style: { backgroundColor: 'rgba(239, 68, 68, 0.85)' } },
- ].map(({ label, style }) => (
-
-
- {label}
-
- ))}
-
-
- )}
-
-
- {/* Section 3: Top hosts table */}
-
-
-
Top Hosts ciblés
-
- {topHostsLoading ? (
-
- ) : topHostsError ? (
-
- ) : (
-
-
-
- | Host |
- Total hits |
- IPs uniques |
- JA4 uniques |
- Activité 24h |
-
-
-
- {topHosts.map((h) => (
-
- | {h.host} |
- {formatNumber(h.total_hits)} |
- {formatNumber(h.unique_ips)} |
- {formatNumber(h.unique_ja4s)} |
-
-
- |
-
- ))}
-
-
- )}
-
-
- );
-}
diff --git a/frontend/src/components/IncidentsView.tsx b/frontend/src/components/IncidentsView.tsx
index 2188767..66405b1 100644
--- a/frontend/src/components/IncidentsView.tsx
+++ b/frontend/src/components/IncidentsView.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
+import { getCountryFlag } from '../utils/countryUtils';
interface IncidentCluster {
id: string;
@@ -123,9 +124,7 @@ export function IncidentsView() {
}
};
- const getCountryFlag = (code: string) => {
- return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
- };
+ ;
if (loading) {
return (
diff --git a/frontend/src/components/InvestigationPanel.tsx b/frontend/src/components/InvestigationPanel.tsx
deleted file mode 100644
index 2255cf9..0000000
--- a/frontend/src/components/InvestigationPanel.tsx
+++ /dev/null
@@ -1,351 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
-interface InvestigationPanelProps {
- entityType: 'ip' | 'ja4' | 'asn' | 'host';
- entityValue: string;
- onClose: () => void;
-}
-
-interface AttributeValue {
- value: string;
- count: number;
- percentage: number;
- first_seen?: string;
- last_seen?: string;
-}
-
-interface EntityData {
- type: string;
- value: string;
- total_detections: number;
- unique_ips: number;
- threat_level?: string;
- anomaly_score?: number;
- country_code?: string;
- asn_number?: string;
- user_agents?: { value: string; count: number }[];
- ja4s?: string[];
- hosts?: string[];
- attributes?: {
- user_agents?: AttributeValue[];
- ja4?: AttributeValue[];
- countries?: AttributeValue[];
- asns?: AttributeValue[];
- hosts?: AttributeValue[];
- };
-}
-
-export function InvestigationPanel({ entityType, entityValue, onClose }: InvestigationPanelProps) {
- const navigate = useNavigate();
- const [data, setData] = useState
(null);
- const [loading, setLoading] = useState(true);
- const [classifying, setClassifying] = useState(false);
- const [showAllUA, setShowAllUA] = useState(false);
-
- useEffect(() => {
- const fetchData = async () => {
- setLoading(true);
- try {
- const response = await fetch(`/api/variability/${entityType}/${encodeURIComponent(entityValue)}`);
- if (response.ok) {
- const result = await response.json();
- setData(result);
- }
- } catch (error) {
- console.error('Error fetching entity data:', error);
- } finally {
- setLoading(false);
- }
- };
-
- fetchData();
- }, [entityType, entityValue]);
-
- const getSeverityColor = (score?: number) => {
- if (!score) return 'bg-gray-500';
- if (score < -0.7) return 'bg-threat-critical';
- if (score < -0.3) return 'bg-threat-high';
- if (score < 0) return 'bg-threat-medium';
- return 'bg-threat-low';
- };
-
- const getSeverityLabel = (score?: number) => {
- if (!score) return 'Unknown';
- if (score < -0.7) return 'CRITICAL';
- if (score < -0.3) return 'HIGH';
- if (score < 0) return 'MEDIUM';
- return 'LOW';
- };
-
- const getCountryFlag = (code?: string) => {
- if (!code) return '';
- return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
- };
-
- const handleQuickClassify = async (label: string) => {
- setClassifying(true);
- try {
- await fetch('/api/analysis/classifications', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- [entityType]: entityValue,
- label,
- tags: ['quick-classification'],
- comment: 'Classification rapide depuis panel latéral',
- confidence: 0.7,
- analyst: 'soc_user'
- })
- });
- alert(`Classification sauvegardée: ${label}`);
- } catch (error) {
- alert('Erreur lors de la classification');
- } finally {
- setClassifying(false);
- }
- };
-
- return (
-
- {/* Backdrop */}
-
-
- {/* Panel */}
-
- {/* Header */}
-
-
-
-
-
-
-
-
- {entityType === 'ip' && '🌐'}
- {entityType === 'ja4' && '🔐'}
- {entityType === 'asn' && '🏢'}
- {entityType === 'host' && '🖥️'}
-
-
-
- {entityType.toUpperCase()}
-
-
- {entityValue}
-
-
-
-
-
- {/* Content */}
-
- {loading ? (
-
- Chargement...
-
- ) : data ? (
- <>
- {/* Quick Stats */}
-
-
-
-
-
- {/* Risk Score */}
-
-
Score de Risque Estimé
-
-
- {getSeverityLabel(data.anomaly_score)}
-
-
-
-
-
- {/* User-Agents */}
- {data.attributes?.user_agents && data.attributes.user_agents.length > 0 && (
-
-
- 🤖 User-Agents ({data.attributes.user_agents.length})
-
-
- {(showAllUA ? data.attributes.user_agents : data.attributes.user_agents.slice(0, 5)).map((ua: any, idx: number) => (
-
-
- {ua.value}
-
-
- {ua.count} détections • {ua.percentage.toFixed(1)}%
-
-
- ))}
- {data.attributes.user_agents.length > 5 && (
-
- )}
-
-
- )}
-
- {/* JA4 Fingerprints */}
- {data.attributes?.ja4 && data.attributes.ja4.length > 0 && (
-
-
- 🔐 JA4 Fingerprints ({data.attributes.ja4.length})
-
-
- {data.attributes.ja4.slice(0, 5).map((ja4: any, idx: number) => (
-
navigate(`/investigation/ja4/${encodeURIComponent(ja4.value)}`)}
- >
-
- {ja4.value}
-
-
- {ja4.count}
-
-
- ))}
-
-
- )}
-
- {/* Countries */}
- {data.attributes?.countries && data.attributes.countries.length > 0 && (
-
-
- 🌍 Pays ({data.attributes.countries.length})
-
-
- {data.attributes.countries.slice(0, 5).map((country: any, idx: number) => (
-
-
- {getCountryFlag(country.value)}
-
-
-
- {country.value}
-
-
- {country.percentage.toFixed(1)}%
-
-
-
- {country.count}
-
-
- ))}
-
-
- )}
-
- {/* Quick Classification */}
-
-
- ⚡ Classification Rapide
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : (
-
- Aucune donnée disponible
-
- )}
-
-
-
- );
-}
-
-// Stat Box Component
-function StatBox({ label, value }: { label: string; value: string }) {
- return (
-
- );
-}
diff --git a/frontend/src/components/MLFeaturesView.tsx b/frontend/src/components/MLFeaturesView.tsx
index 4f6c79c..e457abc 100644
--- a/frontend/src/components/MLFeaturesView.tsx
+++ b/frontend/src/components/MLFeaturesView.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
import { TIPS } from './ui/tooltips';
import { formatNumber } from '../utils/dateUtils';
+import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -80,15 +81,7 @@ function fuzzingBadgeClass(value: number): string {
// ─── Sub-components ───────────────────────────────────────────────────────────
-function LoadingSpinner() {
- return (
-
- );
-}
-
-function ErrorMessage({ message }: { message: string }) {
+: { message: string }) {
return (
⚠️ {message}
diff --git a/frontend/src/components/PivotView.tsx b/frontend/src/components/PivotView.tsx
index 4ecf728..4e31c04 100644
--- a/frontend/src/components/PivotView.tsx
+++ b/frontend/src/components/PivotView.tsx
@@ -14,6 +14,7 @@ import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
+import { getCountryFlag } from '../utils/countryUtils';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -67,12 +68,6 @@ function detectType(input: string): EntityType {
return 'ja4';
}
-function getCountryFlag(code: string): string {
- return (code || '').toUpperCase().replace(/./g, c =>
- String.fromCodePoint(c.charCodeAt(0) + 127397)
- );
-}
-
// ─── Component ────────────────────────────────────────────────────────────────
export function PivotView() {
diff --git a/frontend/src/components/RotationView.tsx b/frontend/src/components/RotationView.tsx
deleted file mode 100644
index ebb2799..0000000
--- a/frontend/src/components/RotationView.tsx
+++ /dev/null
@@ -1,590 +0,0 @@
-import { useState, useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { formatDateShort } { formatNumber; } from '../utils/dateUtils'
-
-// ─── Types ────────────────────────────────────────────────────────────────────
-
-interface JA4Rotator {
- ip: string;
- distinct_ja4_count: number;
- total_hits: number;
- evasion_score: number;
-}
-
-interface PersistentThreat {
- ip: string;
- recurrence: number;
- worst_score: number;
- worst_threat_level: string;
- first_seen: string;
- last_seen: string;
- persistence_score: number;
-}
-
-interface JA4HistoryEntry {
- ja4: string;
- hits: number;
- window_start: string;
-}
-
-interface SophisticationItem {
- ip: string;
- ja4_rotation_count: number;
- recurrence: number;
- bruteforce_hits: number;
- sophistication_score: number;
- tier: string;
-}
-
-interface ProactiveHuntItem {
- ip: string;
- recurrence: number;
- worst_score: number;
- worst_threat_level: string;
- first_seen: string;
- last_seen: string;
- days_active: number;
- risk_assessment: string;
-}
-
-type ActiveTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt';
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-
-function formatDate(iso: string): string {
- if (!iso) return '—';
- try {
- return formatDateShort(iso);
- } catch {
- return iso;
- }
-}
-
-function threatLevelBadge(level: string): { bg: string; text: string } {
- switch (level?.toLowerCase()) {
- case 'critical': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical' };
- case 'high': return { bg: 'bg-threat-high/20', text: 'text-threat-high' };
- case 'medium': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium' };
- case 'low': return { bg: 'bg-threat-low/20', text: 'text-threat-low' };
- default: return { bg: 'bg-background-card', text: 'text-text-secondary' };
- }
-}
-
-function tierBadge(tier: string): { bg: string; text: string } {
- switch (tier) {
- case 'APT-like': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical' };
- case 'Advanced': return { bg: 'bg-threat-high/20', text: 'text-threat-high' };
- case 'Automated': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium' };
- default: return { bg: 'bg-background-card', text: 'text-text-secondary' };
- }
-}
-
-// ─── Sub-components ───────────────────────────────────────────────────────────
-
-function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-function LoadingSpinner() {
- return (
-
- );
-}
-
-function ErrorMessage({ message }: { message: string }) {
- return (
-
- ⚠️ {message}
-
- );
-}
-
-// ─── Rotator row with expandable JA4 history ─────────────────────────────────
-
-function RotatorRow({ item }: { item: JA4Rotator }) {
- const [expanded, setExpanded] = useState(false);
- const [history, setHistory] = useState
([]);
- const [historyLoading, setHistoryLoading] = useState(false);
- const [historyError, setHistoryError] = useState(null);
- const [historyLoaded, setHistoryLoaded] = useState(false);
-
- const toggle = async () => {
- setExpanded((prev) => !prev);
- if (!historyLoaded && !expanded) {
- setHistoryLoading(true);
- try {
- const res = await fetch(`/api/rotation/ip/${encodeURIComponent(item.ip)}/ja4-history`);
- if (!res.ok) throw new Error('Erreur chargement historique JA4');
- const data: { ja4_history: JA4HistoryEntry[] } = await res.json();
- setHistory(data.ja4_history ?? []);
- setHistoryLoaded(true);
- } catch (err) {
- setHistoryError(err instanceof Error ? err.message : 'Erreur inconnue');
- } finally {
- setHistoryLoading(false);
- }
- }
- };
-
- const isHighRotation = item.distinct_ja4_count > 5;
-
- return (
- <>
-
- |
- {expanded ? '▾' : '▸'}
- {item.ip}
- |
-
-
- {item.distinct_ja4_count} JA4
-
- |
- {formatNumber(item.total_hits)} |
-
-
-
- {Math.round(item.evasion_score)}
-
- |
-
- {expanded && (
-
-
- {historyLoading ? (
-
-
- Chargement de l'historique…
-
- ) : historyError ? (
- ⚠️ {historyError}
- ) : history.length === 0 ? (
- Aucun historique disponible.
- ) : (
-
- Historique des JA4 utilisés :
- {history.map((entry, idx) => (
-
-
- {entry.ja4}
-
- {formatNumber(entry.hits)} hits
- {formatDate(entry.window_start)}
-
- ))}
-
- )}
- |
-
- )}
- >
- );
-}
-
-// ─── Main Component ───────────────────────────────────────────────────────────
-
-export function RotationView() {
- const navigate = useNavigate();
-
- const [activeTab, setActiveTab] = useState('rotators');
-
- const [rotators, setRotators] = useState([]);
- const [rotatorsLoading, setRotatorsLoading] = useState(true);
- const [rotatorsError, setRotatorsError] = useState(null);
-
- const [persistent, setPersistent] = useState([]);
- const [persistentLoading, setPersistentLoading] = useState(false);
- const [persistentError, setPersistentError] = useState(null);
- const [persistentLoaded, setPersistentLoaded] = useState(false);
-
- const [sophistication, setSophistication] = useState([]);
- const [sophisticationLoading, setSophisticationLoading] = useState(false);
- const [sophisticationError, setSophisticationError] = useState(null);
- const [sophisticationLoaded, setSophisticationLoaded] = useState(false);
-
- const [proactive, setProactive] = useState([]);
- const [proactiveLoading, setProactiveLoading] = useState(false);
- const [proactiveError, setProactiveError] = useState(null);
- const [proactiveLoaded, setProactiveLoaded] = useState(false);
-
- useEffect(() => {
- const fetchRotators = async () => {
- setRotatorsLoading(true);
- try {
- const res = await fetch('/api/rotation/ja4-rotators?limit=50');
- if (!res.ok) throw new Error('Erreur chargement des rotateurs');
- const data: { items: JA4Rotator[] } = await res.json();
- setRotators(data.items ?? []);
- } catch (err) {
- setRotatorsError(err instanceof Error ? err.message : 'Erreur inconnue');
- } finally {
- setRotatorsLoading(false);
- }
- };
- fetchRotators();
- }, []);
-
- const loadPersistent = async () => {
- if (persistentLoaded) return;
- setPersistentLoading(true);
- try {
- const res = await fetch('/api/rotation/persistent-threats?limit=100');
- if (!res.ok) throw new Error('Erreur chargement des menaces persistantes');
- const data: { items: PersistentThreat[] } = await res.json();
- const sorted = [...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score);
- setPersistent(sorted);
- setPersistentLoaded(true);
- } catch (err) {
- setPersistentError(err instanceof Error ? err.message : 'Erreur inconnue');
- } finally {
- setPersistentLoading(false);
- }
- };
-
- const loadSophistication = async () => {
- if (sophisticationLoaded) return;
- setSophisticationLoading(true);
- try {
- const res = await fetch('/api/rotation/sophistication?limit=50');
- if (!res.ok) throw new Error('Erreur chargement sophistication');
- const data: { items: SophisticationItem[] } = await res.json();
- setSophistication(data.items ?? []);
- setSophisticationLoaded(true);
- } catch (err) {
- setSophisticationError(err instanceof Error ? err.message : 'Erreur inconnue');
- } finally {
- setSophisticationLoading(false);
- }
- };
-
- const loadProactive = async () => {
- if (proactiveLoaded) return;
- setProactiveLoading(true);
- try {
- const res = await fetch('/api/rotation/proactive-hunt?min_recurrence=1&min_days=0&limit=50');
- if (!res.ok) throw new Error('Erreur chargement chasse proactive');
- const data: { items: ProactiveHuntItem[] } = await res.json();
- setProactive(data.items ?? []);
- setProactiveLoaded(true);
- } catch (err) {
- setProactiveError(err instanceof Error ? err.message : 'Erreur inconnue');
- } finally {
- setProactiveLoading(false);
- }
- };
-
- const handleTabChange = (tab: ActiveTab) => {
- setActiveTab(tab);
- if (tab === 'persistent') loadPersistent();
- if (tab === 'sophistication') loadSophistication();
- if (tab === 'hunt') loadProactive();
- };
-
- const maxEvasion = rotators.length > 0 ? Math.max(...rotators.map((r) => r.evasion_score)) : 0;
- const maxPersistence = persistent.length > 0 ? Math.max(...persistent.map((p) => p.persistence_score)) : 0;
-
- const tabs: { id: ActiveTab; label: string }[] = [
- { id: 'rotators', label: '🎭 Rotateurs JA4' },
- { id: 'persistent', label: '🕰️ Menaces Persistantes' },
- { id: 'sophistication', label: '🏆 Sophistication' },
- { id: 'hunt', label: '🕵️ Chasse proactive' },
- ];
-
- return (
-
- {/* Header */}
-
-
🔄 Rotation JA4 & Persistance
-
- Détection des IPs qui changent de fingerprint pour contourner les détections, et des menaces persistantes.
-
-
-
- {/* Stat cards */}
-
-
-
-
-
-
-
- {/* Tabs */}
-
- {tabs.map((tab) => (
-
- ))}
-
-
- {/* Rotateurs tab */}
- {activeTab === 'rotators' && (
-
- {rotatorsLoading ? (
-
- ) : rotatorsError ? (
-
- ) : (
-
-
-
- | IP |
- JA4 distincts |
- Total hits |
- Score d'évasion |
-
-
-
- {rotators.map((item) => (
-
- ))}
-
-
- )}
-
- )}
-
- {/* Persistantes tab */}
- {activeTab === 'persistent' && (
-
- {persistentLoading ? (
-
- ) : persistentError ? (
-
- ) : (
-
-
-
- | IP |
- Récurrence |
- Score menace |
- Niveau |
- Première vue |
- Dernière vue |
- Score persistance |
- |
-
-
-
- {persistent.map((item) => {
- const badge = threatLevelBadge(item.worst_threat_level);
- return (
-
- | {item.ip} |
-
-
- {item.recurrence}j
-
- |
- {Math.round(item.worst_score)} |
-
-
- {item.worst_threat_level?.toUpperCase() || '—'}
-
- |
- {formatDate(item.first_seen)} |
- {formatDate(item.last_seen)} |
-
-
-
- {Math.round(item.persistence_score)}
-
- |
-
-
- |
-
- );
- })}
-
-
- )}
-
- )}
-
- {/* Sophistication tab */}
- {activeTab === 'sophistication' && (
-
- {sophisticationLoading ? (
-
- ) : sophisticationError ? (
-
- ) : (
- <>
-
- Score de sophistication = rotation JA4 × 10 + récurrence × 20 + log(bruteforce+1) × 5
-
-
-
-
- | IP |
- Rotation JA4 |
- Récurrence |
- Hits bruteforce |
- Score sophistication |
- Tier |
- |
-
-
-
- {sophistication.map((item) => {
- const tb = tierBadge(item.tier);
- return (
-
- | {item.ip} |
-
-
- {item.ja4_rotation_count} JA4
-
- |
- {item.recurrence} |
- {formatNumber(item.bruteforce_hits)} |
-
-
-
-
- {item.sophistication_score}
-
-
- |
-
-
- {item.tier}
-
- |
-
-
- |
-
- );
- })}
-
-
- {sophistication.length === 0 && (
-
Aucune donnée de sophistication disponible.
- )}
- >
- )}
-
- )}
-
- {/* Chasse proactive tab */}
- {activeTab === 'hunt' && (
-
- {proactiveLoading ? (
-
- ) : proactiveError ? (
-
- ) : (
- <>
-
- IPs récurrentes volant sous le radar (score < 0.5) — persistantes mais non détectées comme critiques.
-
-
-
-
- | IP |
- Récurrence |
- Score max |
- Jours actifs |
- Timeline |
- Évaluation |
- |
-
-
-
- {proactive.map((item) => (
-
- | {item.ip} |
-
-
- {item.recurrence}×
-
- |
-
- {item.worst_score.toFixed(3)}
- |
- {item.days_active}j |
-
-
- Premier: {formatDate(item.first_seen)}
- Dernier: {formatDate(item.last_seen)}
-
- |
-
-
- {item.risk_assessment}
-
- |
-
-
- |
-
- ))}
-
-
- {proactive.length === 0 && (
-
Aucune IP sous le radar détectée avec ces critères.
- )}
- >
- )}
-
- )}
-
- );
-}
diff --git a/frontend/src/components/SearchModal.tsx b/frontend/src/components/SearchModal.tsx
deleted file mode 100644
index 51289d1..0000000
--- a/frontend/src/components/SearchModal.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
-
-interface SearchResult {
- type: 'ip' | 'ja4' | 'host' | 'asn';
- value: string;
- label: string;
- meta: string;
- url: string;
- investigation_url?: string;
-}
-
-const TYPE_ICON: Record = {
- ip: '🌐',
- ja4: '🔏',
- host: '🖥️',
- asn: '🏢',
-};
-
-const TYPE_LABEL: Record = {
- ip: 'IP',
- ja4: 'JA4',
- host: 'Host',
- asn: 'ASN',
-};
-
-interface SearchModalProps {
- open: boolean;
- onClose: () => void;
-}
-
-export default function SearchModal({ open, onClose }: SearchModalProps) {
- const navigate = useNavigate();
- const [query, setQuery] = useState('');
- const [results, setResults] = useState([]);
- const [loading, setLoading] = useState(false);
- const [selected, setSelected] = useState(0);
- const inputRef = useRef(null);
- const debounce = useRef | null>(null);
-
- // Focus input when modal opens
- useEffect(() => {
- if (open) {
- setQuery('');
- setResults([]);
- setSelected(0);
- setTimeout(() => inputRef.current?.focus(), 50);
- }
- }, [open]);
-
- const search = useCallback(async (q: string) => {
- if (q.length < 2) { setResults([]); return; }
- setLoading(true);
- try {
- const res = await fetch(`/api/search/quick?q=${encodeURIComponent(q)}`);
- if (!res.ok) return;
- const data = await res.json();
- setResults(data.results || []);
- setSelected(0);
- } catch {
- // ignore
- } finally {
- setLoading(false);
- }
- }, []);
-
- const handleChange = (e: React.ChangeEvent) => {
- const val = e.target.value;
- setQuery(val);
- if (debounce.current) clearTimeout(debounce.current);
- debounce.current = setTimeout(() => search(val), 200);
- };
-
- const go = (result: SearchResult, useInvestigation = false) => {
- const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
- navigate(url);
- onClose();
- };
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Escape') { onClose(); return; }
- if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, results.length - 1)); }
- if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)); }
- if (e.key === 'Enter' && results[selected]) {
- go(results[selected], e.metaKey || e.ctrlKey);
- }
- };
-
- if (!open) return null;
-
- return (
-
- {/* Backdrop */}
-
-
- {/* Modal */}
-
e.stopPropagation()}
- onKeyDown={handleKeyDown}
- >
- {/* Input */}
-
- 🔍
-
- {loading && (
- …
- )}
-
- Esc
-
-
-
- {/* Results */}
- {results.length > 0 && (
-
- {results.map((r, i) => (
- -
-
-
- ))}
-
- )}
-
- {/* Empty state */}
- {query.length >= 2 && !loading && results.length === 0 && (
-
- Aucun résultat pour "{query}"
-
- )}
-
- {/* Footer hints */}
-
- ↑↓ naviguer
- ↵ ouvrir
- ⌘↵ investigation
- Recherche sur les 24 dernières heures
-
-
-
- );
-}
diff --git a/frontend/src/components/SubnetInvestigation.tsx b/frontend/src/components/SubnetInvestigation.tsx
index ba79601..30514a6 100644
--- a/frontend/src/components/SubnetInvestigation.tsx
+++ b/frontend/src/components/SubnetInvestigation.tsx
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatDateShort } from '../utils/dateUtils';
+import { getCountryFlag } from '../utils/countryUtils';
interface SubnetIP {
ip: string;
@@ -62,9 +63,7 @@ export function SubnetInvestigation() {
fetchSubnet();
}, [formattedSubnet]);
- const getCountryFlag = (code: string) => {
- return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
- };
+ ;
const getThreatLevelColor = (level: string) => {
switch (level) {
@@ -76,11 +75,6 @@ export function SubnetInvestigation() {
}
};
- const formatDate = (dateString: string) => {
- if (!dateString) return '-';
- return formatDateShort(dateString);
- };
-
if (loading) {
return (
@@ -164,7 +158,7 @@ export function SubnetInvestigation() {
Période
- {formatDate(stats.first_seen)} – {formatDate(stats.last_seen)}
+ {formatDateShort(stats.first_seen)} – {formatDateShort(stats.last_seen)}
diff --git a/frontend/src/components/TcpSpoofingView.tsx b/frontend/src/components/TcpSpoofingView.tsx
index b324669..8199ed7 100644
--- a/frontend/src/components/TcpSpoofingView.tsx
+++ b/frontend/src/components/TcpSpoofingView.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
import { TIPS } from './ui/tooltips';
import { formatNumber } from '../utils/dateUtils';
+import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -109,15 +110,7 @@ function StatCard({ label, value, accent }: { label: string; value: string | num
);
}
-function LoadingSpinner() {
- return (
-
- );
-}
-
-function ErrorMessage({ message }: { message: string }) {
+: { message: string }) {
return (
⚠️ {message}
diff --git a/frontend/src/components/analysis/CorrelationSummary.tsx b/frontend/src/components/analysis/CorrelationSummary.tsx
index ca1074b..ac0a86c 100644
--- a/frontend/src/components/analysis/CorrelationSummary.tsx
+++ b/frontend/src/components/analysis/CorrelationSummary.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { PREDEFINED_TAGS } from '../../utils/classifications';
interface CorrelationIndicators {
subnet_ips_count: number;
@@ -22,26 +23,6 @@ interface CorrelationSummaryProps {
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
}
-const PREDEFINED_TAGS = [
- 'scraping',
- 'bot-network',
- 'scanner',
- 'bruteforce',
- 'data-exfil',
- 'ddos',
- 'spam',
- 'proxy',
- 'tor',
- 'vpn',
- 'hosting-asn',
- 'distributed',
- 'ja4-rotation',
- 'ua-rotation',
- 'country-cn',
- 'country-us',
- 'country-ru',
-];
-
export function CorrelationSummary({ ip, onClassify }: CorrelationSummaryProps) {
const [data, setData] = useState
(null);
const [loading, setLoading] = useState(true);
diff --git a/frontend/src/components/analysis/JA4CorrelationSummary.tsx b/frontend/src/components/analysis/JA4CorrelationSummary.tsx
index 5b7fdca..349c678 100644
--- a/frontend/src/components/analysis/JA4CorrelationSummary.tsx
+++ b/frontend/src/components/analysis/JA4CorrelationSummary.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
+import { PREDEFINED_TAGS_JA4 } from '../../utils/classifications';
interface CorrelationIndicators {
subnet_ips_count: number;
@@ -22,29 +23,6 @@ interface JA4CorrelationSummaryProps {
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
}
-const PREDEFINED_TAGS = [
- 'scraping',
- 'bot-network',
- 'scanner',
- 'bruteforce',
- 'data-exfil',
- 'ddos',
- 'spam',
- 'proxy',
- 'tor',
- 'vpn',
- 'hosting-asn',
- 'distributed',
- 'ja4-rotation',
- 'ua-rotation',
- 'country-cn',
- 'country-us',
- 'country-ru',
- 'known-bot',
- 'crawler',
- 'search-engine',
-];
-
export function JA4CorrelationSummary({ ja4, onClassify }: JA4CorrelationSummaryProps) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
@@ -309,7 +287,7 @@ export function JA4CorrelationSummary({ ja4, onClassify }: JA4CorrelationSummary
Tags
- {PREDEFINED_TAGS.map(tag => (
+ {PREDEFINED_TAGS_JA4.map(tag => (
toggleTag(tag)}
diff --git a/frontend/src/utils/classifications.ts b/frontend/src/utils/classifications.ts
new file mode 100644
index 0000000..85ffe6d
--- /dev/null
+++ b/frontend/src/utils/classifications.ts
@@ -0,0 +1,36 @@
+/**
+ * Tags prédéfinis pour la classification SOC.
+ *
+ * Utilisé par BulkClassification, CorrelationSummary, JA4CorrelationSummary.
+ * Ajouter de nouveaux tags ici pour les propager partout.
+ */
+export const PREDEFINED_TAGS: readonly string[] = [
+ 'scraping',
+ 'bot-network',
+ 'scanner',
+ 'bruteforce',
+ 'data-exfil',
+ 'ddos',
+ 'spam',
+ 'proxy',
+ 'tor',
+ 'vpn',
+ 'hosting-asn',
+ 'distributed',
+ 'ja4-rotation',
+ 'ua-rotation',
+ 'country-cn',
+ 'country-us',
+ 'country-ru',
+];
+
+/**
+ * Tags supplémentaires spécifiques aux fingerprints JA4.
+ * S'étend de PREDEFINED_TAGS.
+ */
+export const PREDEFINED_TAGS_JA4: readonly string[] = [
+ ...PREDEFINED_TAGS,
+ 'known-bot',
+ 'crawler',
+ 'search-engine',
+];