🎯 NOUVELLES FONCTIONNALITÉS: • 🕸️ Graph de Corrélations (React Flow) - Visualisation des relations IP ↔ Subnet ↔ ASN ↔ JA4 ↔ UA ↔ Pays - Noeuds interactifs et déplaçables - Zoom et pan disponibles - Code couleur par type d'entité - Intégré dans /investigation/:ip • 📈 Timeline Interactive - Visualisation temporelle des détections - Détection automatique des pics et escalades - Zoom avant/arrière - Tooltips au survol - Click pour détails complets - Intégré dans /investigation/:ip • 📚 Threat Intelligence (/threat-intel) - Base de connaissances des classifications - Statistiques par label (Malicious/Suspicious/Légitime) - Filtres par label, tag, recherche texte - Tags populaires avec counts - Tableau des classifications récentes - Confiance affichée en barres de progression 🔧 COMPOSANTS CRÉÉS: • frontend/src/components/CorrelationGraph.tsx (266 lignes) - React Flow pour visualisation graphique - Fetch multi-endpoints pour données complètes • frontend/src/components/InteractiveTimeline.tsx (377 lignes) - Détection de patterns temporels - Zoom interactif - Modal de détails • frontend/src/components/ThreatIntelView.tsx (330 lignes) - Vue complète threat intelligence - Filtres multiples - Stats en temps réel 📦 DÉPENDANCES AJOUTÉES: • reactflow: ^11.10.0 - Graph de corrélations 🎨 UI/UX: • Navigation mise à jour avec lien Threat Intel • InvestigationView enrichie avec 2 nouveaux panels • Code couleur cohérent avec le thème SOC ✅ Build Docker: SUCCESS Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
import { useEffect, useState, useRef } from 'react';
|
||
import { format, parseISO } from 'date-fns';
|
||
import { fr } from 'date-fns/locale';
|
||
|
||
interface TimelineEvent {
|
||
timestamp: string;
|
||
type: 'detection' | 'escalation' | 'peak' | 'stabilization' | 'classification';
|
||
severity?: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||
count?: number;
|
||
description?: string;
|
||
ip?: string;
|
||
ja4?: string;
|
||
}
|
||
|
||
interface InteractiveTimelineProps {
|
||
ip?: string;
|
||
events?: TimelineEvent[];
|
||
hours?: number;
|
||
height?: string;
|
||
}
|
||
|
||
export function InteractiveTimeline({
|
||
ip,
|
||
events: propEvents,
|
||
hours = 24,
|
||
height = '300px'
|
||
}: InteractiveTimelineProps) {
|
||
const [events, setEvents] = useState<TimelineEvent[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [zoom, setZoom] = useState(1);
|
||
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
const fetchTimelineData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
if (ip) {
|
||
// Fetch detections for this IP to build timeline
|
||
const response = await fetch(`/api/detections?search=${encodeURIComponent(ip)}&page_size=100&sort_by=detected_at&sort_order=asc`);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
const timelineEvents = buildTimelineFromDetections(data.items);
|
||
setEvents(timelineEvents);
|
||
}
|
||
} else if (propEvents) {
|
||
setEvents(propEvents);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching timeline data:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchTimelineData();
|
||
}, [ip, propEvents]);
|
||
|
||
const buildTimelineFromDetections = (detections: any[]): TimelineEvent[] => {
|
||
if (!detections || detections.length === 0) return [];
|
||
|
||
const events: TimelineEvent[] = [];
|
||
|
||
// First detection
|
||
events.push({
|
||
timestamp: detections[0]?.detected_at,
|
||
type: 'detection',
|
||
severity: detections[0]?.threat_level,
|
||
count: 1,
|
||
description: 'Première détection',
|
||
ip: detections[0]?.src_ip,
|
||
});
|
||
|
||
// Group by time windows (5 minutes)
|
||
const timeWindows = new Map<string, any[]>();
|
||
detections.forEach((d: any) => {
|
||
const window = format(parseISO(d.detected_at), 'yyyy-MM-dd HH:mm', { locale: fr });
|
||
if (!timeWindows.has(window)) {
|
||
timeWindows.set(window, []);
|
||
}
|
||
timeWindows.get(window)!.push(d);
|
||
});
|
||
|
||
// Find peaks
|
||
let maxCount = 0;
|
||
timeWindows.forEach((items) => {
|
||
if (items.length > maxCount) {
|
||
maxCount = items.length;
|
||
}
|
||
if (items.length >= 10) {
|
||
events.push({
|
||
timestamp: items[0]?.detected_at,
|
||
type: 'peak',
|
||
severity: items[0]?.threat_level,
|
||
count: items.length,
|
||
description: `Pic d'activité: ${items.length} détections`,
|
||
});
|
||
}
|
||
});
|
||
|
||
// Escalation detection
|
||
const sortedWindows = Array.from(timeWindows.entries()).sort((a, b) =>
|
||
new Date(a[0]).getTime() - new Date(b[0]).getTime()
|
||
);
|
||
|
||
for (let i = 1; i < sortedWindows.length; i++) {
|
||
const prevCount = sortedWindows[i - 1][1].length;
|
||
const currCount = sortedWindows[i][1].length;
|
||
|
||
if (currCount > prevCount * 2 && currCount >= 5) {
|
||
events.push({
|
||
timestamp: sortedWindows[i][1][0]?.detected_at,
|
||
type: 'escalation',
|
||
severity: 'HIGH',
|
||
count: currCount,
|
||
description: `Escalade: ${prevCount} → ${currCount} détections`,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Last detection
|
||
if (detections.length > 1) {
|
||
events.push({
|
||
timestamp: detections[detections.length - 1]?.detected_at,
|
||
type: 'detection',
|
||
severity: detections[detections.length - 1]?.threat_level,
|
||
count: detections.length,
|
||
description: 'Dernière détection',
|
||
});
|
||
}
|
||
|
||
// Sort by timestamp
|
||
return events.sort((a, b) =>
|
||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||
);
|
||
};
|
||
|
||
const getEventTypeColor = (type: string) => {
|
||
switch (type) {
|
||
case 'detection': return 'bg-blue-500';
|
||
case 'escalation': return 'bg-orange-500';
|
||
case 'peak': return 'bg-red-500';
|
||
case 'stabilization': return 'bg-green-500';
|
||
case 'classification': return 'bg-purple-500';
|
||
default: return 'bg-gray-500';
|
||
}
|
||
};
|
||
|
||
const getEventTypeIcon = (type: string) => {
|
||
switch (type) {
|
||
case 'detection': return '🔍';
|
||
case 'escalation': return '📈';
|
||
case 'peak': return '🔥';
|
||
case 'stabilization': return '📉';
|
||
case 'classification': return '🏷️';
|
||
default: return '📍';
|
||
}
|
||
};
|
||
|
||
const getSeverityColor = (severity?: string) => {
|
||
switch (severity) {
|
||
case 'CRITICAL': return 'text-red-500';
|
||
case 'HIGH': return 'text-orange-500';
|
||
case 'MEDIUM': return 'text-yellow-500';
|
||
case 'LOW': return 'text-green-500';
|
||
default: return 'text-gray-400';
|
||
}
|
||
};
|
||
|
||
const visibleEvents = events.slice(
|
||
Math.max(0, Math.floor((events.length * (1 - zoom)) / 2)),
|
||
Math.min(events.length, Math.ceil(events.length * (1 + zoom) / 2))
|
||
);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center" style={{ height }}>
|
||
<div className="text-text-secondary">Chargement de la timeline...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (events.length === 0) {
|
||
return (
|
||
<div className="flex items-center justify-center" style={{ height }}>
|
||
<div className="text-text-secondary text-center">
|
||
<div className="text-4xl mb-2">📭</div>
|
||
<div className="text-sm">Aucun événement dans cette période</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div ref={containerRef} className="w-full" style={{ height }}>
|
||
{/* Controls */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="text-sm text-text-secondary">
|
||
{events.length} événements sur {hours}h
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}
|
||
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
|
||
>
|
||
− Zoom
|
||
</button>
|
||
<button
|
||
onClick={() => setZoom(1)}
|
||
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
|
||
>
|
||
100%
|
||
</button>
|
||
<button
|
||
onClick={() => setZoom(Math.min(2, zoom + 0.25))}
|
||
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
|
||
>
|
||
+ Zoom
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Timeline */}
|
||
<div className="relative overflow-x-auto">
|
||
<div className="min-w-full">
|
||
{/* Time axis */}
|
||
<div className="flex justify-between mb-4 text-xs text-text-secondary">
|
||
{events.length > 0 && (
|
||
<>
|
||
<span>{format(parseISO(events[0].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
|
||
<span>{format(parseISO(events[events.length - 1].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Events line */}
|
||
<div className="relative h-24 border-t-2 border-background-card">
|
||
{visibleEvents.map((event, idx) => {
|
||
const position = (idx / (visibleEvents.length - 1 || 1)) * 100;
|
||
return (
|
||
<button
|
||
key={idx}
|
||
onClick={() => setSelectedEvent(event)}
|
||
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
|
||
style={{ left: `${position}%` }}
|
||
>
|
||
<div className={`w-4 h-4 rounded-full ${getEventTypeColor(event.type)} border-2 border-background-secondary shadow-lg group-hover:scale-150 transition-transform`}>
|
||
<div className="text-xs text-center leading-3">
|
||
{getEventTypeIcon(event.type)}
|
||
</div>
|
||
</div>
|
||
{/* Tooltip */}
|
||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block">
|
||
<div className="bg-background-secondary border border-background-card rounded-lg p-2 shadow-xl whitespace-nowrap z-10">
|
||
<div className="text-xs text-text-primary font-bold">
|
||
{event.description}
|
||
</div>
|
||
<div className="text-xs text-text-secondary mt-1">
|
||
{format(parseISO(event.timestamp), 'dd/MM HH:mm:ss', { locale: fr })}
|
||
</div>
|
||
{event.count && (
|
||
<div className="text-xs text-text-secondary mt-1">
|
||
{event.count} détections
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Event cards */}
|
||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-64 overflow-y-auto">
|
||
{visibleEvents.slice(0, 12).map((event, idx) => (
|
||
<div
|
||
key={idx}
|
||
onClick={() => setSelectedEvent(event)}
|
||
className={`bg-background-card rounded-lg p-3 cursor-pointer hover:bg-background-card/80 transition-colors border-l-4 ${
|
||
event.severity === 'CRITICAL' ? 'border-threat-critical' :
|
||
event.severity === 'HIGH' ? 'border-threat-high' :
|
||
event.severity === 'MEDIUM' ? 'border-threat-medium' :
|
||
'border-threat-low'
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-lg">{getEventTypeIcon(event.type)}</span>
|
||
<div>
|
||
<div className="text-sm text-text-primary font-medium">
|
||
{event.description}
|
||
</div>
|
||
<div className="text-xs text-text-secondary mt-1">
|
||
{format(parseISO(event.timestamp), 'dd/MM HH:mm', { locale: fr })}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{event.count && (
|
||
<div className="text-xs text-text-primary font-bold bg-background-secondary px-2 py-1 rounded">
|
||
{event.count}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Selected Event Modal */}
|
||
{selectedEvent && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setSelectedEvent(null)}>
|
||
<div className="bg-background-secondary rounded-lg p-6 max-w-md mx-4" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-2xl">{getEventTypeIcon(selectedEvent.type)}</span>
|
||
<h3 className="text-lg font-bold text-text-primary">Détails de l'événement</h3>
|
||
</div>
|
||
<button onClick={() => setSelectedEvent(null)} className="text-text-secondary hover:text-text-primary">
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<div className="text-xs text-text-secondary">Type</div>
|
||
<div className="text-text-primary capitalize">{selectedEvent.type}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-text-secondary">Timestamp</div>
|
||
<div className="text-text-primary font-mono">
|
||
{format(parseISO(selectedEvent.timestamp), 'dd/MM/yyyy HH:mm:ss', { locale: fr })}
|
||
</div>
|
||
</div>
|
||
{selectedEvent.description && (
|
||
<div>
|
||
<div className="text-xs text-text-secondary">Description</div>
|
||
<div className="text-text-primary">{selectedEvent.description}</div>
|
||
</div>
|
||
)}
|
||
{selectedEvent.count && (
|
||
<div>
|
||
<div className="text-xs text-text-secondary">Nombre de détections</div>
|
||
<div className="text-text-primary font-bold">{selectedEvent.count}</div>
|
||
</div>
|
||
)}
|
||
{selectedEvent.severity && (
|
||
<div>
|
||
<div className="text-xs text-text-secondary">Sévérité</div>
|
||
<div className={`font-bold ${getSeverityColor(selectedEvent.severity)}`}>
|
||
{selectedEvent.severity}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{selectedEvent.ip && (
|
||
<div>
|
||
<div className="text-xs text-text-secondary">IP</div>
|
||
<div className="text-text-primary font-mono text-sm">{selectedEvent.ip}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-end">
|
||
<button
|
||
onClick={() => setSelectedEvent(null)}
|
||
className="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-accent-primary/80"
|
||
>
|
||
Fermer
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|