feat(phase2): Graph de corrélations, Timeline interactive, Threat Intel

🎯 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>
This commit is contained in:
SOC Analyst
2026-03-14 21:47:57 +01:00
parent 3b700e8be5
commit dc029c54ed
7 changed files with 1384 additions and 1 deletions

View File

@ -0,0 +1,376 @@
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>
);
}