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:
265
frontend/src/components/CorrelationGraph.tsx
Normal file
265
frontend/src/components/CorrelationGraph.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface CorrelationGraphProps {
|
||||
ip: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps) {
|
||||
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCorrelationData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch data from multiple endpoints to build the graph
|
||||
const [variabilityResponse, subnetResponse] = await Promise.all([
|
||||
fetch(`/api/variability/ip/${encodeURIComponent(ip)}`),
|
||||
fetch(`/api/analysis/${encodeURIComponent(ip)}/subnet`),
|
||||
]);
|
||||
|
||||
const variability = await variabilityResponse.json().catch(() => null);
|
||||
const subnet = await subnetResponse.json().catch(() => null);
|
||||
|
||||
const newNodes: Node[] = [];
|
||||
const newEdges: Edge[] = [];
|
||||
|
||||
// Node IP (center)
|
||||
newNodes.push({
|
||||
id: 'ip',
|
||||
type: 'default',
|
||||
data: {
|
||||
label: (
|
||||
<div className="p-3 bg-blue-500/20 border border-blue-500 rounded-lg">
|
||||
<div className="text-xs text-blue-400 font-bold">IP SOURCE</div>
|
||||
<div className="text-sm text-white font-mono">{ip}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
position: { x: 400, y: 250 },
|
||||
style: { background: 'transparent', border: 'none', width: 200 },
|
||||
});
|
||||
|
||||
// Subnet node
|
||||
if (subnet?.subnet) {
|
||||
newNodes.push({
|
||||
id: 'subnet',
|
||||
type: 'default',
|
||||
data: {
|
||||
label: (
|
||||
<div className="p-3 bg-purple-500/20 border border-purple-500 rounded-lg">
|
||||
<div className="text-xs text-purple-400 font-bold">SUBNET /24</div>
|
||||
<div className="text-sm text-white font-mono">{subnet.subnet}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{subnet.total_in_subnet} IPs</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
position: { x: 50, y: 100 },
|
||||
style: { background: 'transparent', border: 'none', width: 200 },
|
||||
});
|
||||
newEdges.push({
|
||||
id: 'ip-subnet',
|
||||
source: 'ip',
|
||||
target: 'subnet',
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#8b5cf6', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#8b5cf6' },
|
||||
});
|
||||
}
|
||||
|
||||
// ASN node
|
||||
if (subnet?.asn_number) {
|
||||
newNodes.push({
|
||||
id: 'asn',
|
||||
type: 'default',
|
||||
data: {
|
||||
label: (
|
||||
<div className="p-3 bg-orange-500/20 border border-orange-500 rounded-lg">
|
||||
<div className="text-xs text-orange-400 font-bold">ASN</div>
|
||||
<div className="text-sm text-white">AS{subnet.asn_number}</div>
|
||||
<div className="text-xs text-gray-400 mt-1 truncate max-w-[150px]">{subnet.asn_org || 'Unknown'}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
position: { x: 50, y: 350 },
|
||||
style: { background: 'transparent', border: 'none', width: 200 },
|
||||
});
|
||||
newEdges.push({
|
||||
id: 'ip-asn',
|
||||
source: 'ip',
|
||||
target: 'asn',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#f97316', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#f97316' },
|
||||
});
|
||||
}
|
||||
|
||||
// JA4 nodes
|
||||
if (variability?.attributes?.ja4) {
|
||||
variability.attributes.ja4.slice(0, 5).forEach((ja4: any, idx: number) => {
|
||||
const ja4Id = `ja4-${idx}`;
|
||||
newNodes.push({
|
||||
id: ja4Id,
|
||||
type: 'default',
|
||||
data: {
|
||||
label: (
|
||||
<div className="p-3 bg-green-500/20 border border-green-500 rounded-lg">
|
||||
<div className="text-xs text-green-400 font-bold">🔐 JA4</div>
|
||||
<div className="text-xs text-white font-mono truncate max-w-[180px]">{ja4.value}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{ja4.count} détections</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
position: { x: 700, y: 50 + (idx * 100) },
|
||||
style: { background: 'transparent', border: 'none', width: 220 },
|
||||
});
|
||||
newEdges.push({
|
||||
id: `ip-ja4-${idx}`,
|
||||
source: 'ip',
|
||||
target: ja4Id,
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#22c55e', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#22c55e' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// User-Agent nodes
|
||||
if (variability?.attributes?.user_agents) {
|
||||
variability.attributes.user_agents.slice(0, 3).forEach((ua: any, idx: number) => {
|
||||
const uaId = `ua-${idx}`;
|
||||
newNodes.push({
|
||||
id: uaId,
|
||||
type: 'default',
|
||||
data: {
|
||||
label: (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg">
|
||||
<div className="text-xs text-red-400 font-bold">🤖 UA</div>
|
||||
<div className="text-xs text-white truncate max-w-[180px]">{ua.value}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{ua.percentage.toFixed(0)}%</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
position: { x: 700, y: 400 + (idx * 120) },
|
||||
style: { background: 'transparent', border: 'none', width: 220 },
|
||||
});
|
||||
newEdges.push({
|
||||
id: `ip-ua-${idx}`,
|
||||
source: 'ip',
|
||||
target: uaId,
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#ef4444', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Country node
|
||||
if (variability?.attributes?.countries && variability.attributes.countries.length > 0) {
|
||||
const country = variability.attributes.countries[0];
|
||||
newNodes.push({
|
||||
id: 'country',
|
||||
type: 'default',
|
||||
data: {
|
||||
label: (
|
||||
<div className="p-3 bg-yellow-500/20 border border-yellow-500 rounded-lg">
|
||||
<div className="text-xs text-yellow-400 font-bold">🌍 PAYS</div>
|
||||
<div className="text-lg">{getCountryFlag(country.value)}</div>
|
||||
<div className="text-sm text-white">{country.value}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{country.percentage.toFixed(0)}%</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
position: { x: 400, y: 500 },
|
||||
style: { background: 'transparent', border: 'none', width: 150 },
|
||||
});
|
||||
newEdges.push({
|
||||
id: 'ip-country',
|
||||
source: 'ip',
|
||||
target: 'country',
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#eab308', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: '#eab308' },
|
||||
});
|
||||
}
|
||||
|
||||
setGraphData({ nodes: newNodes, edges: newEdges });
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
} catch (error) {
|
||||
console.error('Error fetching correlation data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (ip) {
|
||||
fetchCorrelationData();
|
||||
}
|
||||
}, [ip, setNodes, setEdges]);
|
||||
|
||||
const getCountryFlag = (code: string) => {
|
||||
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ height }}>
|
||||
<div className="text-text-secondary">Chargement du graph...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (graphData.nodes.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">Aucune corrélation trouvée</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full border border-background-card rounded-lg overflow-hidden" style={{ height }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
fitView
|
||||
attributionPosition="bottom-right"
|
||||
className="bg-background-secondary"
|
||||
nodesDraggable={true}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={true}
|
||||
zoomOnScroll={true}
|
||||
panOnScroll={true}
|
||||
panOnDrag={true}
|
||||
>
|
||||
<Background color="#374151" gap={20} size={1} />
|
||||
<Controls className="bg-background-card border border-background-card rounded-lg" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
376
frontend/src/components/InteractiveTimeline.tsx
Normal file
376
frontend/src/components/InteractiveTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,8 @@ import { CountryAnalysis } from './analysis/CountryAnalysis';
|
||||
import { JA4Analysis } from './analysis/JA4Analysis';
|
||||
import { UserAgentAnalysis } from './analysis/UserAgentAnalysis';
|
||||
import { CorrelationSummary } from './analysis/CorrelationSummary';
|
||||
import { CorrelationGraph } from './CorrelationGraph';
|
||||
import { InteractiveTimeline } from './InteractiveTimeline';
|
||||
|
||||
export function InvestigationView() {
|
||||
const { ip } = useParams<{ ip: string }>();
|
||||
@ -44,6 +46,18 @@ export function InvestigationView() {
|
||||
|
||||
{/* Panels d'analyse */}
|
||||
<div className="space-y-6">
|
||||
{/* NOUVEAU: Graph de corrélations */}
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
|
||||
<CorrelationGraph ip={ip || ''} height="500px" />
|
||||
</div>
|
||||
|
||||
{/* NOUVEAU: Timeline interactive */}
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">📈 Timeline d'Activité</h3>
|
||||
<InteractiveTimeline ip={ip || ''} hours={24} height="350px" />
|
||||
</div>
|
||||
|
||||
{/* Panel 1: Subnet/ASN */}
|
||||
<SubnetAnalysis ip={ip} />
|
||||
|
||||
|
||||
340
frontend/src/components/ThreatIntelView.tsx
Normal file
340
frontend/src/components/ThreatIntelView.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { QuickSearch } from './QuickSearch';
|
||||
|
||||
interface Classification {
|
||||
ip?: string;
|
||||
ja4?: string;
|
||||
label: 'legitimate' | 'suspicious' | 'malicious';
|
||||
tags: string[];
|
||||
comment: string;
|
||||
confidence: number;
|
||||
analyst: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ClassificationStats {
|
||||
label: string;
|
||||
total: number;
|
||||
unique_ips: number;
|
||||
avg_confidence: number;
|
||||
}
|
||||
|
||||
export function ThreatIntelView() {
|
||||
const [classifications, setClassifications] = useState<Classification[]>([]);
|
||||
const [stats, setStats] = useState<ClassificationStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterLabel, setFilterLabel] = useState<string>('all');
|
||||
const [filterTag, setFilterTag] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchThreatIntel = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch classifications
|
||||
const classificationsResponse = await fetch('/api/analysis/classifications?page_size=100');
|
||||
if (classificationsResponse.ok) {
|
||||
const data = await classificationsResponse.json();
|
||||
setClassifications(data.items || []);
|
||||
}
|
||||
|
||||
// Fetch stats
|
||||
const statsResponse = await fetch('/api/analysis/classifications/stats');
|
||||
if (statsResponse.ok) {
|
||||
const data = await statsResponse.json();
|
||||
setStats(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching threat intel:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchThreatIntel();
|
||||
}, []);
|
||||
|
||||
const filteredClassifications = classifications.filter(c => {
|
||||
if (filterLabel !== 'all' && c.label !== filterLabel) return false;
|
||||
if (filterTag && !c.tags.includes(filterTag)) return false;
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
const ipMatch = c.ip?.toLowerCase().includes(searchLower);
|
||||
const ja4Match = c.ja4?.toLowerCase().includes(searchLower);
|
||||
const tagMatch = c.tags.some(t => t.toLowerCase().includes(searchLower));
|
||||
const commentMatch = c.comment.toLowerCase().includes(searchLower);
|
||||
if (!ipMatch && !ja4Match && !tagMatch && !commentMatch) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const allTags = Array.from(new Set(classifications.flatMap(c => c.tags)));
|
||||
|
||||
const getLabelIcon = (label: string) => {
|
||||
switch (label) {
|
||||
case 'legitimate': return '✅';
|
||||
case 'suspicious': return '⚠️';
|
||||
case 'malicious': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelColor = (label: string) => {
|
||||
switch (label) {
|
||||
case 'legitimate': return 'bg-threat-low/20 text-threat-low';
|
||||
case 'suspicious': return 'bg-threat-medium/20 text-threat-medium';
|
||||
case 'malicious': return 'bg-threat-high/20 text-threat-high';
|
||||
default: return 'bg-gray-500/20 text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getTagColor = (tag: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'scraping': 'bg-blue-500/20 text-blue-400',
|
||||
'bot-network': 'bg-red-500/20 text-red-400',
|
||||
'scanner': 'bg-orange-500/20 text-orange-400',
|
||||
'hosting-asn': 'bg-purple-500/20 text-purple-400',
|
||||
'distributed': 'bg-yellow-500/20 text-yellow-400',
|
||||
'ja4-rotation': 'bg-pink-500/20 text-pink-400',
|
||||
'ua-rotation': 'bg-cyan-500/20 text-cyan-400',
|
||||
};
|
||||
return colors[tag] || 'bg-gray-500/20 text-gray-400';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-secondary">Chargement de la Threat Intel...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">📚 Threat Intelligence</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
Base de connaissances des classifications SOC
|
||||
</p>
|
||||
</div>
|
||||
<QuickSearch />
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="🤖 Malicious"
|
||||
value={stats.find(s => s.label === 'malicious')?.total || 0}
|
||||
subtitle="Entités malveillantes"
|
||||
color="bg-threat-high/20"
|
||||
/>
|
||||
<StatCard
|
||||
title="⚠️ Suspicious"
|
||||
value={stats.find(s => s.label === 'suspicious')?.total || 0}
|
||||
subtitle="Entités suspectes"
|
||||
color="bg-threat-medium/20"
|
||||
/>
|
||||
<StatCard
|
||||
title="✅ Légitime"
|
||||
value={stats.find(s => s.label === 'legitimate')?.total || 0}
|
||||
subtitle="Entités légitimes"
|
||||
color="bg-threat-low/20"
|
||||
/>
|
||||
<StatCard
|
||||
title="📊 Total"
|
||||
value={classifications.length}
|
||||
subtitle="Classifications totales"
|
||||
color="bg-accent-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-background-secondary rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Rechercher IP, JA4, tag, commentaire..."
|
||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary placeholder-text-secondary focus:outline-none focus:border-accent-primary"
|
||||
/>
|
||||
|
||||
{/* Label Filter */}
|
||||
<select
|
||||
value={filterLabel}
|
||||
onChange={(e) => setFilterLabel(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"
|
||||
>
|
||||
<option value="all">Tous les labels</option>
|
||||
<option value="malicious">🤖 Malicious</option>
|
||||
<option value="suspicious">⚠️ Suspicious</option>
|
||||
<option value="legitimate">✅ Légitime</option>
|
||||
</select>
|
||||
|
||||
{/* Tag Filter */}
|
||||
<select
|
||||
value={filterTag}
|
||||
onChange={(e) => setFilterTag(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"
|
||||
>
|
||||
<option value="">Tous les tags</option>
|
||||
{allTags.map(tag => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(search || filterLabel !== 'all' || filterTag) && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-text-secondary">
|
||||
{filteredClassifications.length} résultat(s)
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setFilterLabel('all');
|
||||
setFilterTag('');
|
||||
}}
|
||||
className="text-sm text-accent-primary hover:text-accent-primary/80"
|
||||
>
|
||||
Effacer filtres
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Tags */}
|
||||
<div className="bg-background-secondary rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">🏷️ Tags Populaires (30j)</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.slice(0, 20).map(tag => {
|
||||
const count = classifications.filter(c => c.tags.includes(tag)).length;
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setFilterTag(filterTag === tag ? '' : tag)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
filterTag === tag
|
||||
? 'bg-accent-primary text-white'
|
||||
: getTagColor(tag)
|
||||
}`}
|
||||
>
|
||||
{tag} <span className="text-xs opacity-70">({count})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classifications Table */}
|
||||
<div className="bg-background-secondary rounded-lg overflow-hidden">
|
||||
<div className="p-4 border-b border-background-card">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
📋 Classifications Récentes
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-background-card">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Date</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">Tags</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Confiance</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Analyste</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-background-card">
|
||||
{filteredClassifications.slice(0, 50).map((classification, idx) => (
|
||||
<tr key={idx} className="hover:bg-background-card/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
{new Date(classification.created_at).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-mono text-sm text-text-primary">
|
||||
{classification.ip || classification.ja4}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
|
||||
{getLabelIcon(classification.label)} {classification.label.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{classification.tags.slice(0, 5).map((tag, tagIdx) => (
|
||||
<span
|
||||
key={tagIdx}
|
||||
className={`px-2 py-0.5 rounded text-xs ${getTagColor(tag)}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{classification.tags.length > 5 && (
|
||||
<span className="text-xs text-text-secondary">
|
||||
+{classification.tags.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-background-secondary rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-accent-primary"
|
||||
style={{ width: `${classification.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-primary font-bold">
|
||||
{(classification.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
{classification.analyst}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredClassifications.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-12">
|
||||
<div className="text-4xl mb-2">🔍</div>
|
||||
<div className="text-sm">Aucune classification trouvée</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
color
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
subtitle: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${color} rounded-lg p-6`}>
|
||||
<h3 className="text-text-secondary text-sm font-medium">{title}</h3>
|
||||
<p className="text-3xl font-bold text-text-primary mt-2">{value.toLocaleString()}</p>
|
||||
<p className="text-text-disabled text-xs mt-2">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user