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

@ -14,7 +14,8 @@
"axios": "^1.6.0",
"recharts": "^2.10.0",
"@tanstack/react-table": "^8.11.0",
"date-fns": "^3.0.0"
"date-fns": "^3.0.0",
"reactflow": "^11.10.0"
},
"devDependencies": {
"@types/react": "^18.2.0",

View File

@ -7,6 +7,9 @@ import { JA4InvestigationView } from './components/JA4InvestigationView';
import { EntityInvestigationView } from './components/EntityInvestigationView';
import { IncidentsView } from './components/IncidentsView';
import { QuickSearch } from './components/QuickSearch';
import { ThreatIntelView } from './components/ThreatIntelView';
import { CorrelationGraph } from './components/CorrelationGraph';
import { InteractiveTimeline } from './components/InteractiveTimeline';
// Composant Dashboard
function Dashboard() {
@ -215,6 +218,7 @@ function Navigation() {
{ path: '/incidents', label: '🚨 Incidents' },
{ path: '/', label: '📊 Dashboard' },
{ path: '/detections', label: '📋 Détections' },
{ path: '/threat-intel', label: '📚 Threat Intel' },
];
return (
@ -255,12 +259,15 @@ export default function App() {
<main className="max-w-7xl mx-auto px-4 py-6">
<Routes>
<Route path="/incidents" element={<IncidentsView />} />
<Route path="/threat-intel" element={<ThreatIntelView />} />
<Route path="/" element={<Dashboard />} />
<Route path="/detections" element={<DetectionsList />} />
<Route path="/detections/:type/:value" element={<DetailsView />} />
<Route path="/investigation/:ip" element={<InvestigationView />} />
<Route path="/investigation/ja4/:ja4" element={<JA4InvestigationView />} />
<Route path="/entities/:type/:value" element={<EntityInvestigationView />} />
<Route path="/tools/correlation-graph/:ip" element={<CorrelationGraph ip={window.location.pathname.split('/').pop() || ''} height="600px" />} />
<Route path="/tools/timeline/:ip?" element={<InteractiveTimeline ip={window.location.pathname.split('/').pop()} height="400px" />} />
</Routes>
</main>
</div>

View 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>
);
}

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>
);
}

View File

@ -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} />

View 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>
);
}