fix: correct CampaignsView, analysis.py IPv4 split, entities date filter

- CampaignsView: update ClusterData interface to match real API response
  (severity/unique_ips/score instead of threat_level/total_ips/confidence_range)
  Fix fetch to use data.items, rewrite ClusterCard and BehavioralTab
  Remove unused getClassificationColor and THREAT_ORDER constants
- analysis.py: fix IPv4Address object has no attribute 'split' on line 322
  Add str() conversion before calling .split('.')
- entities.py: fix Date vs DateTime comparison — log_date is a Date column,
  comparing against now()-INTERVAL HOUR caused yesterday's entries to be excluded
  Use toDate(now() - INTERVAL X HOUR) for correct Date-level comparison

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SOC Analyst
2026-03-15 23:10:35 +01:00
parent 8d35b91642
commit 1455e04303
50 changed files with 5442 additions and 7325 deletions

View File

@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
import { BrowserRouter, Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useEffect, useState, useCallback } from 'react';
import { DetectionsList } from './components/DetectionsList';
import { DetailsView } from './components/DetailsView';
import { InvestigationView } from './components/InvestigationView';
@ -10,65 +11,343 @@ import { ThreatIntelView } from './components/ThreatIntelView';
import { CorrelationGraph } from './components/CorrelationGraph';
import { InteractiveTimeline } from './components/InteractiveTimeline';
import { SubnetInvestigation } from './components/SubnetInvestigation';
import { BulkClassification } from './components/BulkClassification';
import { PivotView } from './components/PivotView';
import { FingerprintsView } from './components/FingerprintsView';
import { CampaignsView } from './components/CampaignsView';
import { useTheme } from './ThemeContext';
// Navigation
function Navigation() {
// ─── Types ────────────────────────────────────────────────────────────────────
interface AlertCounts {
critical: number;
high: number;
medium: number;
total: number;
}
interface RecentItem {
type: 'ip' | 'ja4' | 'subnet';
value: string;
ts: number;
}
// ─── Recent investigations (localStorage) ────────────────────────────────────
const RECENTS_KEY = 'soc_recent_investigations';
const MAX_RECENTS = 8;
function loadRecents(): RecentItem[] {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]');
} catch {
return [];
}
}
function saveRecent(item: Omit<RecentItem, 'ts'>) {
const all = loadRecents().filter(r => !(r.type === item.type && r.value === item.value));
all.unshift({ ...item, ts: Date.now() });
localStorage.setItem(RECENTS_KEY, JSON.stringify(all.slice(0, MAX_RECENTS)));
}
// ─── Sidebar ─────────────────────────────────────────────────────────────────
function Sidebar({ counts }: { counts: AlertCounts | null }) {
const location = useLocation();
const { theme, setTheme } = useTheme();
const [recents, setRecents] = useState<RecentItem[]>(loadRecents());
const links = [
{ path: '/', label: 'Incidents' },
{ path: '/threat-intel', label: 'Threat Intel' },
// Refresh recents when location changes
useEffect(() => {
setRecents(loadRecents());
}, [location.pathname]);
const navLinks = [
{ path: '/', label: 'Dashboard', icon: '📊', aliases: ['/incidents'] },
{ path: '/detections', label: 'Détections', icon: '🎯', aliases: ['/investigate'] },
{ path: '/campaigns', label: 'Campagnes / Botnets', icon: '🕸️', aliases: [] },
{ path: '/fingerprints', label: 'Fingerprints JA4', icon: '🔏', aliases: [] },
{ path: '/pivot', label: 'Pivot / Corrélation', icon: '🔗', aliases: [] },
{ path: '/threat-intel', label: 'Threat Intel', icon: '📚', aliases: [] },
];
const isActive = (link: typeof navLinks[0]) =>
location.pathname === link.path ||
link.aliases.some(a => location.pathname.startsWith(a)) ||
(link.path !== '/' && location.pathname.startsWith(`${link.path}/`));
const themeOptions: { value: typeof theme; icon: string; label: string }[] = [
{ value: 'dark', icon: '🌙', label: 'Sombre' },
{ value: 'light', icon: '☀️', label: 'Clair' },
{ value: 'auto', icon: '🔄', label: 'Auto' },
];
return (
<nav className="bg-background-secondary border-b border-background-card">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center h-16 gap-4">
<h1 className="text-xl font-bold text-text-primary">SOC Dashboard</h1>
<div className="flex gap-2">
{links.map(link => (
<aside className="fixed inset-y-0 left-0 w-56 bg-background-secondary border-r border-background-card flex flex-col z-30">
{/* Logo */}
<div className="h-14 flex items-center px-5 border-b border-background-card shrink-0">
<span className="text-lg font-bold text-text-primary">🛡 SOC</span>
<span className="ml-2 text-xs text-text-disabled font-mono bg-background-card px-1.5 py-0.5 rounded">v2</span>
</div>
{/* Main nav */}
<nav className="px-3 pt-4 space-y-0.5">
{navLinks.map(link => (
<Link
key={link.path}
to={link.path}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-sm font-medium ${
isActive(link)
? 'bg-accent-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-background-card'
}`}
>
<span className="text-base">{link.icon}</span>
<span className="flex-1">{link.label}</span>
</Link>
))}
</nav>
{/* Alert stats */}
{counts && (
<div className="mx-3 mt-5 bg-background-card rounded-lg p-3 space-y-2">
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider mb-2">Alertes 24h</div>
{counts.critical > 0 && (
<div className="flex justify-between items-center">
<span className="text-xs text-red-400 flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block animate-pulse" /> CRITICAL</span>
<span className="text-xs font-bold text-red-400 bg-red-500/20 px-1.5 py-0.5 rounded">{counts.critical}</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-xs text-orange-400 flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-orange-500 inline-block" /> HIGH</span>
<span className="text-xs font-bold text-orange-400 bg-orange-500/20 px-1.5 py-0.5 rounded">{counts.high}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-yellow-400 flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500 inline-block" /> MEDIUM</span>
<span className="text-xs font-bold text-yellow-400 bg-yellow-500/20 px-1.5 py-0.5 rounded">{counts.medium}</span>
</div>
<div className="border-t border-background-secondary pt-1.5 flex justify-between items-center mt-1">
<span className="text-xs text-text-secondary">Total détections</span>
<span className="text-xs font-bold text-text-primary">{counts.total.toLocaleString()}</span>
</div>
</div>
)}
{/* Recent investigations */}
{recents.length > 0 && (
<div className="mx-3 mt-4 flex-1 min-h-0 overflow-hidden">
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider px-1 mb-2">Récents</div>
<div className="space-y-0.5 overflow-y-auto max-h-44">
{recents.map((r, i) => (
<Link
key={link.path}
to={link.path}
className={`px-4 py-2 rounded-lg transition-colors ${
location.pathname === link.path
? 'bg-accent-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-background-card'
}`}
key={i}
to={r.type === 'ip' ? `/investigation/${r.value}` : r.type === 'ja4' ? `/investigation/ja4/${r.value}` : `/entities/subnet/${r.value}`}
className="flex items-center gap-2 px-2 py-1.5 rounded text-xs text-text-secondary hover:text-text-primary hover:bg-background-card transition-colors"
>
{link.label}
<span className="shrink-0 text-text-disabled">
{r.type === 'ip' ? '🌐' : r.type === 'ja4' ? '🔐' : '🔷'}
</span>
<span className="font-mono truncate">{r.value}</span>
</Link>
))}
</div>
<div className="ml-auto flex-1 max-w-xl">
<QuickSearch />
</div>
</div>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Theme toggle */}
<div className="mx-3 mb-3 bg-background-card rounded-lg p-2">
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider px-1 mb-2">Thème</div>
<div className="flex gap-1">
{themeOptions.map(opt => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
title={opt.label}
className={`flex-1 flex flex-col items-center gap-0.5 py-1.5 rounded transition-colors text-xs ${
theme === opt.value
? 'bg-accent-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-background-secondary'
}`}
>
<span>{opt.icon}</span>
<span className="text-[10px]">{opt.label}</span>
</button>
))}
</div>
</div>
</nav>
{/* Footer */}
<div className="px-4 py-3 border-t border-background-card shrink-0">
<div className="text-xs text-text-disabled">Analyste SOC</div>
</div>
</aside>
);
}
// App principale
// ─── Top header ───────────────────────────────────────────────────────────────
function TopHeader({ counts }: { counts: AlertCounts | null }) {
const location = useLocation();
const getBreadcrumb = () => {
const p = location.pathname;
if (p === '/' || p === '/incidents') return 'Dashboard';
if (p.startsWith('/investigation/ja4/')) return `JA4 · ${decodeURIComponent(p.split('/investigation/ja4/')[1] || '')}`;
if (p.startsWith('/investigation/')) return `IP · ${decodeURIComponent(p.split('/investigation/')[1] || '')}`;
if (p.startsWith('/detections/')) return `Détection · ${decodeURIComponent(p.split('/').pop() || '')}`;
if (p.startsWith('/detections')) return 'Détections';
if (p.startsWith('/entities/subnet/')) return `Subnet · ${decodeURIComponent(p.split('/entities/subnet/')[1] || '')}`;
if (p.startsWith('/entities/')) return `Entité · ${decodeURIComponent(p.split('/').pop() || '')}`;
if (p.startsWith('/fingerprints')) return 'Fingerprints JA4';
if (p.startsWith('/campaigns')) return 'Campagnes / Botnets';
if (p.startsWith('/pivot')) return 'Pivot / Corrélation';
if (p.startsWith('/bulk-classify')) return 'Classification en masse';
return '';
};
return (
<header className="fixed top-0 right-0 left-56 h-14 bg-background-secondary border-b border-background-card flex items-center gap-4 px-5 z-20">
{/* Breadcrumb */}
<div className="text-sm font-medium text-text-secondary shrink-0">{getBreadcrumb()}</div>
{/* Search */}
<div className="flex-1 max-w-xl">
<QuickSearch />
</div>
{/* Critical alert badge */}
{counts && counts.critical > 0 && (
<Link
to="/"
className="shrink-0 flex items-center gap-1.5 bg-red-500/20 border border-red-500/40 text-red-400 px-3 py-1 rounded-lg text-xs font-bold animate-pulse"
>
🔴 {counts.critical} CRITICAL
</Link>
)}
</header>
);
}
// ─── Route helpers ────────────────────────────────────────────────────────────
function CorrelationGraphRoute() {
const { ip } = useParams<{ ip: string }>();
return <CorrelationGraph ip={ip || ''} height="600px" />;
}
function TimelineRoute() {
const { ip } = useParams<{ ip?: string }>();
return <InteractiveTimeline ip={ip} height="400px" />;
}
function InvestigateRoute() {
const { type, value } = useParams<{ type?: string; value?: string }>();
if (!type || !value) return <Navigate to="/detections" replace />;
const decodedValue = decodeURIComponent(value);
if (type === 'ip') return <Navigate to={`/investigation/${encodeURIComponent(decodedValue)}`} replace />;
if (type === 'ja4') return <Navigate to={`/investigation/ja4/${encodeURIComponent(decodedValue)}`} replace />;
return <Navigate to={`/detections/${type}/${encodeURIComponent(decodedValue)}`} replace />;
}
function BulkClassificationRoute() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const ipsParam = searchParams.get('ips') || '';
const selectedIPs = ipsParam.split(',').map(ip => ip.trim()).filter(Boolean);
if (selectedIPs.length === 0) return <Navigate to="/" replace />;
return (
<BulkClassification
selectedIPs={selectedIPs}
onClose={() => navigate('/')}
onSuccess={() => navigate('/threat-intel')}
/>
);
}
// Track investigations for the recents list
function RouteTracker() {
const location = useLocation();
useEffect(() => {
const p = location.pathname;
if (p.startsWith('/investigation/ja4/')) {
saveRecent({ type: 'ja4', value: decodeURIComponent(p.split('/investigation/ja4/')[1] || '') });
} else if (p.startsWith('/investigation/')) {
saveRecent({ type: 'ip', value: decodeURIComponent(p.split('/investigation/')[1] || '') });
} else if (p.startsWith('/entities/subnet/')) {
saveRecent({ type: 'subnet', value: decodeURIComponent(p.split('/entities/subnet/')[1] || '') });
}
}, [location.pathname]);
return null;
}
// ─── App ──────────────────────────────────────────────────────────────────────
export default function App() {
const [counts, setCounts] = useState<AlertCounts | null>(null);
const fetchCounts = useCallback(async () => {
try {
const res = await fetch('/api/metrics');
if (res.ok) {
const data = await res.json();
const s = data.summary;
setCounts({
critical: s.critical_count ?? 0,
high: s.high_count ?? 0,
medium: s.medium_count ?? 0,
total: s.total_detections ?? 0,
});
}
} catch {
// silently ignore — metrics are informational
}
}, []);
useEffect(() => {
fetchCounts();
const id = setInterval(fetchCounts, 30_000);
return () => clearInterval(id);
}, [fetchCounts]);
return (
<BrowserRouter>
<div className="min-h-screen bg-background">
<Navigation />
<main className="max-w-7xl mx-auto px-4 py-6">
<Routes>
<Route path="/" element={<IncidentsView />} />
<Route path="/threat-intel" element={<ThreatIntelView />} />
<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/subnet/:subnet" element={<SubnetInvestigation />} />
<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>
<RouteTracker />
<div className="min-h-screen bg-background flex">
{/* Fixed sidebar */}
<Sidebar counts={counts} />
{/* Main area (offset by sidebar width) */}
<div className="flex-1 flex flex-col min-h-screen" style={{ marginLeft: '14rem' }}>
{/* Fixed top header */}
<TopHeader counts={counts} />
{/* Scrollable page content */}
<main className="flex-1 px-6 py-5 mt-14 overflow-auto">
<Routes>
<Route path="/" element={<IncidentsView />} />
<Route path="/incidents" element={<IncidentsView />} />
<Route path="/pivot" element={<PivotView />} />
<Route path="/fingerprints" element={<FingerprintsView />} />
<Route path="/campaigns" element={<CampaignsView />} />
<Route path="/threat-intel" element={<ThreatIntelView />} />
<Route path="/detections" element={<DetectionsList />} />
<Route path="/detections/:type/:value" element={<DetailsView />} />
<Route path="/investigate" element={<DetectionsList />} />
<Route path="/investigate/:type/:value" element={<InvestigateRoute />} />
<Route path="/investigation/ja4/:ja4" element={<JA4InvestigationView />} />
<Route path="/investigation/:ip" element={<InvestigationView />} />
<Route path="/entities/subnet/:subnet" element={<SubnetInvestigation />} />
<Route path="/entities/:type/:value" element={<EntityInvestigationView />} />
<Route path="/bulk-classify" element={<BulkClassificationRoute />} />
<Route path="/tools/correlation-graph/:ip" element={<CorrelationGraphRoute />} />
<Route path="/tools/timeline/:ip?" element={<TimelineRoute />} />
</Routes>
</main>
</div>
</div>
</BrowserRouter>
);

View File

@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from 'react';
export type Theme = 'dark' | 'light' | 'auto';
interface ThemeContextValue {
theme: Theme;
resolved: 'dark' | 'light';
setTheme: (t: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: 'dark',
resolved: 'dark',
setTheme: () => {},
});
const STORAGE_KEY = 'soc_theme';
function resolveTheme(theme: Theme): 'dark' | 'light' {
if (theme === 'auto') {
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
return theme;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
return stored ?? 'dark'; // SOC default: dark
});
const [resolved, setResolved] = useState<'dark' | 'light'>(() => resolveTheme(
(localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'dark'
));
const applyTheme = (t: Theme) => {
const r = resolveTheme(t);
setResolved(r);
document.documentElement.setAttribute('data-theme', r);
};
const setTheme = (t: Theme) => {
setThemeState(t);
localStorage.setItem(STORAGE_KEY, t);
applyTheme(t);
};
// Apply on mount
useEffect(() => {
applyTheme(theme);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Watch system preference changes when in 'auto' mode
useEffect(() => {
if (theme !== 'auto') return;
const mq = window.matchMedia('(prefers-color-scheme: light)');
const handler = () => applyTheme('auto');
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}

View File

@ -0,0 +1,793 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ClusterData {
id: string;
score: number;
severity: string;
total_detections: number;
unique_ips: number;
subnet: string;
sample_ip: string;
ja4: string;
primary_ua: string;
primary_target: string;
countries: { code: string; percentage: number }[];
asn: string;
first_seen: string;
last_seen: string;
trend: string;
trend_percentage: number;
}
interface SubnetIPEntry {
ip: string;
detections: number;
confidence: number;
ja4: string;
user_agent: string;
}
interface SubnetIPData {
subnet: string;
ips: SubnetIPEntry[];
}
interface JA4AttributeItem {
value: string;
count: number;
}
interface JA4AttributesResponse {
items: JA4AttributeItem[];
}
type ActiveTab = 'clusters' | 'ja4' | 'behavioral';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getThreatColors(level: string): { bg: string; text: string; border: string } {
switch (level) {
case 'critical': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', border: 'border-threat-critical' };
case 'high': return { bg: 'bg-threat-high/20', text: 'text-threat-high', border: 'border-threat-high' };
case 'medium': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium', border: 'border-threat-medium' };
case 'low': return { bg: 'bg-threat-low/20', text: 'text-threat-low', border: 'border-threat-low' };
default: return { bg: 'bg-background-card', text: 'text-text-secondary', border: 'border-border' };
}
}
function getThreatLabel(level: string): string {
switch (level) {
case 'critical': return '🔴 CRITIQUE';
case 'high': return '🟠 ÉLEVÉ';
case 'medium': return '🟡 MOYEN';
case 'low': return '🟢 FAIBLE';
default: return level.toUpperCase();
}
}
function getConfidenceTextColor(confidence: number): string {
if (confidence >= 0.8) return 'text-threat-critical';
if (confidence >= 0.6) return 'text-threat-high';
if (confidence >= 0.4) return 'text-threat-medium';
return 'text-threat-low';
}
function getJA4CountColor(count: number): string {
if (count >= 50) return 'bg-threat-critical/20 text-threat-critical';
if (count >= 20) return 'bg-threat-high/20 text-threat-high';
return 'bg-threat-medium/20 text-threat-medium';
}
function sortClusters(clusters: ClusterData[]): ClusterData[] {
const SEV_ORDER: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
return [...clusters].sort((a, b) => {
const levelDiff = (SEV_ORDER[a.severity] ?? 4) - (SEV_ORDER[b.severity] ?? 4);
return levelDiff !== 0 ? levelDiff : b.score - a.score;
});
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function CampaignsView() {
const navigate = useNavigate();
const [clusters, setClusters] = useState<ClusterData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [ja4Items, setJA4Items] = useState<JA4AttributeItem[]>([]);
const [ja4Loading, setJA4Loading] = useState(false);
const [ja4Error, setJA4Error] = useState<string | null>(null);
const [ja4Loaded, setJA4Loaded] = useState(false);
const [expandedSubnets, setExpandedSubnets] = useState<Set<string>>(new Set());
const [subnetIPs, setSubnetIPs] = useState<Map<string, SubnetIPEntry[]>>(new Map());
const [subnetLoading, setSubnetLoading] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<ActiveTab>('clusters');
const [minIPs, setMinIPs] = useState(3);
const [severityFilter, setSeverityFilter] = useState<string>('all');
// Fetch clusters on mount
useEffect(() => {
const fetchClusters = async () => {
setLoading(true);
try {
const response = await fetch('/api/incidents/clusters');
if (!response.ok) throw new Error('Erreur chargement des clusters');
const data: { items: ClusterData[] } = await response.json();
setClusters(sortClusters(data.items ?? []));
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
fetchClusters();
}, []);
// Lazy-load JA4 data on tab switch
const fetchJA4 = useCallback(async () => {
if (ja4Loaded) return;
setJA4Loading(true);
setJA4Error(null);
try {
const response = await fetch('/api/attributes/ja4?limit=100');
if (!response.ok) throw new Error('Erreur chargement des fingerprints JA4');
const data: JA4AttributesResponse = await response.json();
setJA4Items(data.items ?? []);
setJA4Loaded(true);
} catch (err) {
setJA4Error(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setJA4Loading(false);
}
}, [ja4Loaded]);
useEffect(() => {
if (activeTab === 'ja4') fetchJA4();
}, [activeTab, fetchJA4]);
// Toggle subnet expansion and fetch IPs on first expand
const toggleSubnet = useCallback(async (subnet: string) => {
const isCurrentlyExpanded = expandedSubnets.has(subnet);
setExpandedSubnets(prev => {
const next = new Set(prev);
isCurrentlyExpanded ? next.delete(subnet) : next.add(subnet);
return next;
});
if (isCurrentlyExpanded || subnetIPs.has(subnet)) return;
setSubnetLoading(prev => new Set(prev).add(subnet));
try {
const response = await fetch(`/api/entities/subnet/${encodeURIComponent(subnet)}`);
if (!response.ok) throw new Error('Erreur chargement IPs');
const data: SubnetIPData = await response.json();
setSubnetIPs(prev => new Map(prev).set(subnet, data.ips ?? []));
} catch {
setSubnetIPs(prev => new Map(prev).set(subnet, []));
} finally {
setSubnetLoading(prev => {
const next = new Set(prev);
next.delete(subnet);
return next;
});
}
}, [expandedSubnets, subnetIPs]);
const filteredClusters = clusters.filter(c => {
if (c.unique_ips < minIPs) return false;
if (severityFilter !== 'all' && c.severity.toLowerCase() !== severityFilter) return false;
return true;
});
const activeClusters = clusters.length;
const coordinatedIPs = clusters.reduce((sum, c) => sum + c.unique_ips, 0);
const criticalCampaigns = clusters.filter(c => c.severity === 'CRITICAL').length;
const ja4Campaigns = ja4Items.filter(j => j.count >= 5);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement des campagnes...</div>
</div>
);
}
if (error) {
return (
<div className="bg-threat-critical/10 border border-threat-critical rounded-lg p-6">
<div className="text-threat-critical mb-4">Erreur: {error}</div>
<button
onClick={() => window.location.reload()}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
>
Réessayer
</button>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* ── Row 1: Header + stat cards ── */}
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">🎯 Détection de Campagnes</h1>
<p className="text-text-secondary mt-1">
Identification de groupes d'IPs coordonnées partageant les mêmes fingerprints JA4, profils UA,
cibles et comportements temporels — indicateurs de botnets et campagnes organisées.
</p>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-text-secondary text-sm font-medium mb-2">Clusters actifs</div>
<div className="text-3xl font-bold text-text-primary">{activeClusters}</div>
<div className="text-text-disabled text-xs mt-1">sous-réseaux suspects</div>
</div>
<div className="bg-background-secondary rounded-lg p-6">
<div className="text-text-secondary text-sm font-medium mb-2">IPs coordinées</div>
<div className="text-3xl font-bold text-accent-primary">{coordinatedIPs.toLocaleString()}</div>
<div className="text-text-disabled text-xs mt-1">total dans tous les clusters</div>
</div>
<div className="bg-background-secondary rounded-lg p-6 border border-threat-critical/30">
<div className="text-text-secondary text-sm font-medium mb-2">Campagnes critiques</div>
<div className="text-3xl font-bold text-threat-critical">{criticalCampaigns}</div>
<div className="text-text-disabled text-xs mt-1">niveau critique · &gt;10 IPs</div>
</div>
</div>
</div>
{/* ── Row 2: Tabs + Filters ── */}
<div className="bg-background-secondary rounded-lg p-4">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex gap-1 bg-background-card rounded-lg p-1">
{(
[
{ id: 'clusters', label: 'Clusters réseau' },
{ id: 'ja4', label: 'Fingerprints JA4' },
{ id: 'behavioral', label: 'Analyse comportementale' },
] as const
).map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-2 px-4 rounded text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'bg-accent-primary text-white'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="flex gap-4 items-center">
<div className="flex items-center gap-2">
<label className="text-text-secondary text-sm whitespace-nowrap">IPs min :</label>
<input
type="range"
min={1}
max={20}
value={minIPs}
onChange={e => setMinIPs(parseInt(e.target.value))}
className="w-24 accent-blue-500"
/>
<span className="text-text-primary text-sm font-mono w-5 text-right">{minIPs}</span>
</div>
<select
value={severityFilter}
onChange={e => setSeverityFilter(e.target.value)}
className="bg-background-card text-text-primary text-sm rounded-lg px-3 py-2 border border-border focus:outline-none focus:border-accent-primary"
>
<option value="all">Tous niveaux</option>
<option value="critical">Critique</option>
<option value="high">Élevé</option>
<option value="medium">Moyen</option>
<option value="low">Faible</option>
</select>
</div>
</div>
</div>
{/* ── Tab Content ── */}
{activeTab === 'clusters' && (
<ClustersTab
clusters={filteredClusters}
expandedSubnets={expandedSubnets}
subnetIPs={subnetIPs}
subnetLoading={subnetLoading}
onToggleSubnet={toggleSubnet}
onNavigate={navigate}
/>
)}
{activeTab === 'ja4' && (
<JA4Tab
items={ja4Campaigns}
loading={ja4Loading}
error={ja4Error}
onNavigate={navigate}
/>
)}
{activeTab === 'behavioral' && (
<BehavioralTab clusters={clusters} />
)}
</div>
);
}
// ─── Tab: Clusters réseau ─────────────────────────────────────────────────────
interface ClustersTabProps {
clusters: ClusterData[];
expandedSubnets: Set<string>;
subnetIPs: Map<string, SubnetIPEntry[]>;
subnetLoading: Set<string>;
onToggleSubnet: (subnet: string) => void;
onNavigate: (path: string) => void;
}
function ClustersTab({
clusters,
expandedSubnets,
subnetIPs,
subnetLoading,
onToggleSubnet,
onNavigate,
}: ClustersTabProps) {
if (clusters.length === 0) {
return (
<div className="bg-background-secondary rounded-lg p-12 text-center">
<div className="text-4xl mb-4">🔍</div>
<div className="text-text-secondary">Aucun cluster correspondant aux filtres</div>
</div>
);
}
return (
<div className="space-y-4">
{clusters.map(cluster => (
<ClusterCard
key={cluster.subnet}
cluster={cluster}
expanded={expandedSubnets.has(cluster.subnet)}
ips={subnetIPs.get(cluster.subnet)}
loadingIPs={subnetLoading.has(cluster.subnet)}
onToggle={() => onToggleSubnet(cluster.subnet)}
onNavigate={onNavigate}
/>
))}
</div>
);
}
// ─── Cluster Card ─────────────────────────────────────────────────────────────
interface ClusterCardProps {
cluster: ClusterData;
expanded: boolean;
ips: SubnetIPEntry[] | undefined;
loadingIPs: boolean;
onToggle: () => void;
onNavigate: (path: string) => void;
}
function ClusterCard({ cluster, expanded, ips, loadingIPs, onToggle, onNavigate }: ClusterCardProps) {
const threatLevel = cluster.severity.toLowerCase();
const { bg, text, border } = getThreatColors(threatLevel);
const isHighRisk = cluster.score >= 70;
const scoreColor = getConfidenceTextColor(cluster.score / 100);
return (
<div className={`bg-background-secondary rounded-lg border ${border}/30 overflow-hidden`}>
{/* Coloured header strip */}
<div className={`px-4 py-3 flex flex-wrap items-center gap-2 ${bg} border-b ${border}/20`}>
<span className={`${text} font-bold text-sm`}>{getThreatLabel(threatLevel)}</span>
<span className="text-text-primary font-mono font-semibold">{cluster.subnet}</span>
{isHighRisk && (
<span className="bg-threat-critical/20 text-threat-critical px-2 py-0.5 rounded text-xs font-bold">
BOTNET
</span>
)}
{cluster.trend === 'new' && (
<span className="bg-accent-primary/20 text-accent-primary px-2 py-0.5 rounded text-xs font-medium">
NOUVEAU
</span>
)}
{cluster.trend === 'up' && (
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs font-medium">
↑ +{cluster.trend_percentage}%
</span>
)}
<span className="ml-auto text-text-secondary text-xs font-mono">
{cluster.id}
</span>
</div>
{/* Card body */}
<div className="p-4 space-y-4">
{/* Stats row */}
<div className="flex flex-wrap gap-6 text-sm">
<span className="text-text-secondary">
<span className="text-text-primary font-semibold">{cluster.unique_ips}</span> IP{cluster.unique_ips !== 1 ? 's' : ''}
</span>
<span className="text-text-secondary">
<span className="text-text-primary font-semibold">{cluster.total_detections.toLocaleString()}</span> détections
</span>
{cluster.asn && (
<span className="text-text-secondary">
ASN <span className="text-text-primary font-mono font-semibold">{cluster.asn}</span>
</span>
)}
{cluster.primary_target && (
<span className="text-text-secondary">
Cible : <span className="text-text-primary font-semibold">{cluster.primary_target}</span>
</span>
)}
</div>
{/* JA4 + UA */}
<div className="space-y-1.5">
{cluster.ja4 && (
<div className="flex items-center gap-2 text-xs">
<span className="text-text-disabled w-8">JA4</span>
<code className="font-mono text-text-secondary bg-background-card px-2 py-0.5 rounded truncate max-w-xs">
{cluster.ja4}
</code>
</div>
)}
{cluster.primary_ua && (
<div className="flex items-center gap-2 text-xs">
<span className="text-text-disabled w-8">UA</span>
<span className="text-text-secondary truncate max-w-xs" title={cluster.primary_ua}>
{cluster.primary_ua}
</span>
</div>
)}
</div>
{/* Countries */}
{cluster.countries?.length > 0 && (
<div className="flex flex-wrap gap-2">
{cluster.countries.map(c => (
<span key={c.code} className="bg-background-card text-text-secondary px-2 py-0.5 rounded text-xs">
{c.code} {c.percentage < 100 ? `${c.percentage}%` : ''}
</span>
))}
</div>
)}
{/* Score bar */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-text-secondary text-xs">Score menace</span>
<span className={`text-xs font-mono font-semibold ${scoreColor}`}>{cluster.score}/100</span>
</div>
<div className="relative h-2 bg-background-card rounded-full overflow-hidden">
<div
className="absolute h-full rounded-full bg-accent-primary/70 transition-all"
style={{ width: `${cluster.score}%` }}
/>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2 pt-1">
<button
onClick={() => onNavigate(`/investigation/${encodeURIComponent(cluster.sample_ip)}`)}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
>
🔍 Investiguer IP
</button>
<button
onClick={() => onNavigate(`/pivot?entities=${encodeURIComponent(cluster.sample_ip)}`)}
className="bg-background-card hover:bg-background-card/80 text-text-primary px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border"
>
⇄ Pivot IP
</button>
{cluster.ja4 && cluster.ja4 !== 'HTTP_CLEAR_TEXT' && (
<button
onClick={() => onNavigate(`/investigation/ja4/${encodeURIComponent(cluster.ja4)}`)}
className="bg-background-card hover:bg-background-card/80 text-text-secondary px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border"
>
🔏 Investiguer JA4
</button>
)}
<button
onClick={onToggle}
className="bg-background-card hover:bg-background-card/80 text-text-secondary px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border"
>
{expanded ? ' Masquer IPs' : ' Voir IPs'}
</button>
</div>
{/* Expanded IP list */}
{expanded && (
<div className="mt-2 bg-background-card rounded-lg overflow-hidden border border-border">
{loadingIPs ? (
<div className="flex items-center justify-center py-8">
<div className="text-text-secondary text-sm animate-pulse">Chargement des IPs...</div>
</div>
) : ips && ips.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-2 text-text-secondary font-medium text-xs">IP</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">JA4</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Détections</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Confiance</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs" />
</tr>
</thead>
<tbody>
{ips.map((entry, idx) => (
<tr
key={entry.ip}
className={`hover:bg-background-secondary/50 transition-colors ${
idx < ips.length - 1 ? 'border-b border-border/50' : ''
}`}
>
<td className="px-4 py-2 font-mono text-text-primary text-xs">{entry.ip}</td>
<td className="px-4 py-2">
<span
className="font-mono text-text-secondary text-xs block max-w-[160px] truncate"
title={entry.ja4}
>
{entry.ja4 || ''}
</span>
</td>
<td className="px-4 py-2 text-text-primary text-xs">
{entry.detections.toLocaleString()}
</td>
<td className="px-4 py-2">
<span className={`text-xs font-mono font-semibold ${getConfidenceTextColor(entry.confidence)}`}>
{Math.round(entry.confidence * 100)}%
</span>
</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => onNavigate(`/investigation/${encodeURIComponent(entry.ip)}`)}
className="text-accent-primary hover:text-accent-primary/80 text-xs transition-colors"
>
Investiguer →
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="px-4 py-6 text-text-disabled text-sm text-center">
Aucune IP disponible pour ce subnet
</div>
)}
</div>
)}
</div>
</div>
);
}
// ─── Tab: Fingerprints JA4 ───────────────────────────────────────────────────
interface JA4TabProps {
items: JA4AttributeItem[];
loading: boolean;
error: string | null;
onNavigate: (path: string) => void;
}
function JA4Tab({ items, loading, error, onNavigate }: JA4TabProps) {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement des fingerprints JA4...</div>
</div>
);
}
if (error) {
return (
<div className="bg-threat-critical/10 border border-threat-critical rounded-lg p-6">
<div className="text-threat-critical">Erreur : {error}</div>
</div>
);
}
if (items.length === 0) {
return (
<div className="bg-background-secondary rounded-lg p-12 text-center">
<div className="text-4xl mb-4">🔑</div>
<div className="text-text-secondary">Aucun fingerprint JA4 avec 5+ IPs détecté</div>
</div>
);
}
const sorted = [...items].sort((a, b) => b.count - a.count);
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
{sorted.map(item => (
<div
key={item.value}
className="bg-background-secondary rounded-lg p-4 border border-border hover:border-accent-primary/50 transition-colors"
>
<div className="flex items-start justify-between gap-3 mb-3">
<code className="font-mono text-text-primary text-xs break-all leading-relaxed">
{item.value}
</code>
<span className={`flex-shrink-0 px-2 py-0.5 rounded text-xs font-bold ${getJA4CountColor(item.count)}`}>
{item.count} IPs
</span>
</div>
<div className="flex gap-2">
<button
onClick={() => onNavigate(`/investigation/ja4/${encodeURIComponent(item.value)}`)}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
>
🔍 Investiguer JA4
</button>
<button
onClick={() => onNavigate('/fingerprints')}
className="bg-background-card hover:bg-background-card/80 text-text-secondary px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border"
>
📋 Voir fingerprint
</button>
</div>
</div>
))}
</div>
);
}
// ─── Tab: Analyse comportementale ─────────────────────────────────────────────
interface BehavioralTabProps {
clusters: ClusterData[];
}
function BehavioralTab({ clusters }: BehavioralTabProps) {
if (clusters.length === 0) {
return (
<div className="bg-background-secondary rounded-lg p-12 text-center">
<div className="text-4xl mb-4">📊</div>
<div className="text-text-secondary">Aucun cluster disponible pour l'analyse comportementale</div>
</div>
);
}
// Group by shared JA4 (multi-subnet same fingerprint = coordinated campaign)
const ja4Groups: Record<string, ClusterData[]> = {};
for (const cluster of clusters) {
if (!cluster.ja4 || cluster.ja4 === 'HTTP_CLEAR_TEXT') continue;
const key = cluster.ja4.slice(0, 20); // group by JA4 prefix
if (!ja4Groups[key]) ja4Groups[key] = [];
ja4Groups[key].push(cluster);
}
const groupsSorted = Object.entries(ja4Groups)
.filter(([, g]) => g.length >= 2)
.sort((a, b) => b[1].length - a[1].length);
return (
<div className="space-y-6">
{/* JA4-correlated clusters */}
{groupsSorted.length > 0 && (
<div className="bg-background-secondary rounded-lg p-4">
<h3 className="text-text-primary font-semibold mb-1">Clusters partageant le même JA4</h3>
<p className="text-text-secondary text-sm mb-4">
Subnets distincts utilisant le même fingerprint TLS indicateur fort de botnet centralisé.
</p>
<div className="space-y-3">
{groupsSorted.map(([ja4prefix, group]) => (
<div
key={ja4prefix}
className="rounded-lg p-3 border border-threat-high/40 bg-threat-high/5"
>
<div className="flex flex-wrap items-center gap-3 mb-2">
<code className="font-mono text-xs text-text-primary">{ja4prefix}</code>
<span className="text-text-secondary text-sm">
{group.length} subnet{group.length !== 1 ? 's' : ''}
</span>
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs font-medium">
Campagne probable
</span>
</div>
<div className="flex flex-wrap gap-2">
{group.map(c => {
const { bg, text } = getThreatColors(c.severity.toLowerCase());
return (
<span key={c.subnet} className={`px-2 py-0.5 rounded text-xs font-mono ${bg} ${text}`}>
{c.subnet}
</span>
);
})}
</div>
</div>
))}
</div>
</div>
)}
{/* Behavioral matrix */}
<div className="bg-background-secondary rounded-lg p-4">
<h3 className="text-text-primary font-semibold mb-4">Matrice de signaux comportementaux</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Subnet</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Score</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Tendance</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Niveau menace</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Pays</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs text-right">IPs</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs text-right">Détections</th>
</tr>
</thead>
<tbody>
{clusters.map((cluster, idx) => {
const { bg, text } = getThreatColors(cluster.severity.toLowerCase());
return (
<tr
key={cluster.subnet}
className={`hover:bg-background-card/50 transition-colors ${
idx < clusters.length - 1 ? 'border-b border-border/50' : ''
}`}
>
<td className="px-4 py-2 font-mono text-text-primary text-xs">{cluster.subnet}</td>
<td className="px-4 py-2">
<span className={`text-xs font-mono font-semibold ${getConfidenceTextColor(cluster.score / 100)}`}>
{cluster.score}
</span>
</td>
<td className="px-4 py-2">
{cluster.trend === 'up' ? (
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs">
+{cluster.trend_percentage}%
</span>
) : cluster.trend === 'new' ? (
<span className="bg-accent-primary/20 text-accent-primary px-2 py-0.5 rounded text-xs">
Nouveau
</span>
) : (
<span className="text-text-disabled text-xs">{cluster.trend}</span>
)}
</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${bg} ${text}`}>
{getThreatLabel(cluster.severity.toLowerCase())}
</span>
</td>
<td className="px-4 py-2">
{cluster.countries?.slice(0, 2).map(c => (
<span key={c.code} className="text-text-secondary text-xs mr-1">{c.code}</span>
))}
</td>
<td className="px-4 py-2 text-text-primary text-xs font-mono text-right">
{cluster.unique_ips}
</td>
<td className="px-4 py-2 text-text-primary text-xs font-mono text-right">
{cluster.total_detections.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -106,18 +106,21 @@ export function DetailsView() {
</div>
</div>
{/* Insights */}
{data.insights.length > 0 && (
<div className="space-y-2">
<h2 className="text-lg font-semibold text-text-primary">Insights</h2>
{data.insights.map((insight, i) => (
<InsightCard key={i} insight={insight} />
))}
</div>
)}
{/* Insights + Variabilité côte à côte */}
<div className="grid grid-cols-3 gap-6 items-start">
{data.insights.length > 0 && (
<div className="space-y-2">
<h2 className="text-lg font-semibold text-text-primary">Insights</h2>
{data.insights.map((insight, i) => (
<InsightCard key={i} insight={insight} />
))}
</div>
)}
{/* Variabilité */}
<VariabilityPanel attributes={data.attributes} />
<div className={data.insights.length > 0 ? 'col-span-2' : 'col-span-3'}>
<VariabilityPanel attributes={data.attributes} />
</div>
</div>
{/* Bouton retour */}
<div className="flex justify-center">

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDetections } from '../hooks/useDetections';
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
@ -13,6 +13,7 @@ interface ColumnConfig {
}
export function DetectionsList() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get('page') || '1');
@ -311,13 +312,13 @@ export function DetectionsList() {
</thead>
<tbody className="divide-y divide-background-card">
{processedData.items.map((detection) => (
<tr
key={`${detection.src_ip}-${detection.detected_at}-${groupByIP ? 'grouped' : 'individual'}`}
className="hover:bg-background-card/50 transition-colors cursor-pointer"
onClick={() => {
window.location.href = `/detections/ip/${encodeURIComponent(detection.src_ip)}`;
}}
>
<tr
key={`${detection.src_ip}-${detection.detected_at}-${groupByIP ? 'grouped' : 'individual'}`}
className="hover:bg-background-card/50 transition-colors cursor-pointer"
onClick={() => {
navigate(`/detections/ip/${encodeURIComponent(detection.src_ip)}`);
}}
>
{columns.filter(col => col.visible).map(col => {
if (col.key === 'ip_ja4') {
const detectionAny = detection as any;

View File

@ -39,6 +39,7 @@ export function EntityInvestigationView() {
const [data, setData] = useState<EntityInvestigationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAllUA, setShowAllUA] = useState(false);
useEffect(() => {
if (!type || !value) {
@ -90,10 +91,6 @@ export function EntityInvestigationView() {
return flags[code] || code;
};
const truncateUA = (ua: string, maxLength: number = 150) => {
if (ua.length <= maxLength) return ua;
return ua.substring(0, maxLength) + '...';
};
if (loading) {
return (
@ -227,10 +224,10 @@ export function EntityInvestigationView() {
<div className="bg-background-secondary rounded-lg p-6 mb-6">
<h3 className="text-lg font-medium text-text-primary mb-4">3. User-Agents</h3>
<div className="space-y-3">
{data.user_agents.slice(0, 10).map((ua, idx) => (
{(showAllUA ? data.user_agents : data.user_agents.slice(0, 10)).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="text-xs text-text-primary font-mono break-all">
{truncateUA(ua.value)}
<div className="text-xs text-text-primary font-mono break-all leading-relaxed">
{ua.value}
</div>
<div className="flex items-center gap-2">
<div className="text-text-secondary text-xs">{ua.count} requêtes</div>
@ -243,9 +240,12 @@ export function EntityInvestigationView() {
<div className="text-center text-text-secondary py-8">Aucun User-Agent</div>
)}
{data.user_agents.length > 10 && (
<div className="text-center text-text-secondary mt-4 text-sm">
+{data.user_agents.length - 10} autres User-Agents
</div>
<button
onClick={() => setShowAllUA(v => !v)}
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAllUA ? '↑ Réduire' : `↓ Voir les ${data.user_agents.length - 10} autres`}
</button>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { QuickSearch } from './QuickSearch';
interface IncidentCluster {
id: string;
@ -124,10 +123,7 @@ export function IncidentsView() {
<p className="text-text-secondary text-sm mt-1">
Surveillance en temps réel - 24 dernières heures
</p>
</div>
<div className="w-full md:w-auto">
<QuickSearch />
</div>
</div>
</div>
{/* Critical Metrics */}
@ -212,8 +208,10 @@ export function IncidentsView() {
</div>
)}
{/* Priority Incidents */}
<div>
{/* Main content: incidents list (2/3) + top threats table (1/3) */}
<div className="grid grid-cols-3 gap-6 items-start">
{/* Incidents list — 2/3 */}
<div className="col-span-2">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-text-primary">
Incidents Prioritaires
@ -367,41 +365,35 @@ export function IncidentsView() {
</div>
)}
</div>
</div>
</div>{/* end col-span-2 */}
{/* Top Active Threats */}
<div className="bg-background-secondary rounded-lg p-6">
<h2 className="text-xl font-semibold text-text-primary mb-4">
Top Menaces Actives
</h2>
<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">#</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">Type</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Score</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Pays</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">ASN</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Hits/s</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Tendance</th>
</tr>
</thead>
<tbody className="divide-y divide-background-card">
{clusters.slice(0, 10).map((cluster, index) => (
<tr
key={cluster.id}
className="hover:bg-background-card/50 transition-colors cursor-pointer"
onClick={() => navigate(`/investigation/${cluster.subnet?.split('/')[0] || ''}`)}
{/* Top threats sidebar — 1/3 */}
<div className="sticky top-4">
<div className="bg-background-secondary rounded-lg overflow-hidden">
<div className="p-4 border-b border-background-card">
<h3 className="text-base font-semibold text-text-primary">🔥 Top Menaces</h3>
</div>
<div className="divide-y divide-background-card">
{clusters.slice(0, 12).map((cluster, index) => (
<div
key={cluster.id}
className="px-4 py-3 flex items-center gap-3 hover:bg-background-card/50 transition-colors cursor-pointer"
onClick={() => navigate(`/investigation/${cluster.sample_ip || cluster.subnet?.split('/')[0] || ''}`)}
>
<td className="px-4 py-3 text-text-secondary">{index + 1}</td>
<td className="px-4 py-3 font-mono text-sm text-text-primary">
{cluster.subnet?.split('/')[0] || 'Unknown'}
</td>
<td className="px-4 py-3 text-sm text-text-secondary">IP</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-bold ${
<span className="text-text-disabled text-xs w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<div className="font-mono text-xs text-text-primary truncate">
{cluster.sample_ip || cluster.subnet?.split('/')[0] || 'Unknown'}
</div>
<div className="text-xs text-text-secondary flex gap-2 mt-0.5">
{cluster.countries[0] && (
<span>{getCountryFlag(cluster.countries[0].code)} {cluster.countries[0].code}</span>
)}
<span>AS{cluster.asn || '?'}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`px-1.5 py-0.5 rounded text-xs font-bold ${
cluster.score > 80 ? 'bg-red-500 text-white' :
cluster.score > 60 ? 'bg-orange-500 text-white' :
cluster.score > 40 ? 'bg-yellow-500 text-white' :
@ -409,33 +401,25 @@ export function IncidentsView() {
}`}>
{cluster.score}
</span>
</td>
<td className="px-4 py-3 text-text-primary">
{cluster.countries[0] && (
<>
{getCountryFlag(cluster.countries[0].code)} {cluster.countries[0].code}
</>
)}
</td>
<td className="px-4 py-3 text-sm text-text-primary">
AS{cluster.asn || '?'}
</td>
<td className="px-4 py-3 text-text-primary font-bold">
{Math.round(cluster.total_detections / 24) || 0}
</td>
<td className={`px-4 py-3 font-bold ${
cluster.trend === 'up' ? 'text-red-500' :
cluster.trend === 'down' ? 'text-green-500' :
'text-gray-400'
}`}>
{cluster.trend === 'up' ? '↑' : cluster.trend === 'down' ? '↓' : '→'} {cluster.trend_percentage}%
</td>
</tr>
<span className={`text-xs font-bold ${
cluster.trend === 'up' ? 'text-red-500' :
cluster.trend === 'down' ? 'text-green-500' :
'text-gray-400'
}`}>
{cluster.trend === 'up' ? '↑' : cluster.trend === 'down' ? '↓' : '→'}
</span>
</div>
</div>
))}
</tbody>
</table>
{clusters.length === 0 && (
<div className="px-4 py-8 text-center text-text-secondary text-sm">
Aucune menace active
</div>
)}
</div>
</div>
</div>
</div>
</div>{/* end grid */}
</div>
);
}

View File

@ -41,6 +41,7 @@ export function InvestigationPanel({ entityType, entityValue, onClose }: Investi
const [data, setData] = useState<EntityData | null>(null);
const [loading, setLoading] = useState(true);
const [classifying, setClassifying] = useState(false);
const [showAllUA, setShowAllUA] = useState(false);
useEffect(() => {
const fetchData = async () => {
@ -193,9 +194,9 @@ export function InvestigationPanel({ entityType, entityValue, onClose }: Investi
🤖 User-Agents ({data.attributes.user_agents.length})
</div>
<div className="space-y-2">
{data.attributes.user_agents.slice(0, 5).map((ua: any, idx: number) => (
{(showAllUA ? data.attributes.user_agents : data.attributes.user_agents.slice(0, 5)).map((ua: any, idx: number) => (
<div key={idx} className="bg-background-card rounded-lg p-3">
<div className="text-xs text-text-primary font-mono break-all">
<div className="text-xs text-text-primary font-mono break-all leading-relaxed">
{ua.value}
</div>
<div className="text-xs text-text-secondary mt-1">
@ -203,6 +204,14 @@ export function InvestigationPanel({ entityType, entityValue, onClose }: Investi
</div>
</div>
))}
{data.attributes.user_agents.length > 5 && (
<button
onClick={() => setShowAllUA(v => !v)}
className="w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAllUA ? '↑ Réduire' : `↓ Voir les ${data.attributes.user_agents.length - 5} autres`}
</button>
)}
</div>
</div>
)}

View File

@ -1,13 +1,156 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { SubnetAnalysis } from './analysis/SubnetAnalysis';
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';
import { ReputationPanel } from './ReputationPanel';
// ─── Spoofing Coherence Widget ─────────────────────────────────────────────
interface CoherenceData {
verdict: string;
spoofing_score: number;
explanation: string[];
indicators: {
ua_ch_mismatch_rate: number;
sni_mismatch_rate: number;
avg_browser_score: number;
distinct_ja4_count: number;
is_ua_rotating: boolean;
rare_ja4_rate: number;
};
fingerprints: { ja4_list: string[]; latest_ja4: string };
user_agents: { ua: string; count: number; type: string }[];
}
const VERDICT_STYLE: Record<string, { cls: string; icon: string; label: string }> = {
high_confidence_spoofing: { cls: 'bg-threat-critical/10 border-threat-critical/40 text-threat-critical', icon: '🎭', label: 'Spoofing haute confiance' },
suspicious_spoofing: { cls: 'bg-threat-high/10 border-threat-high/40 text-threat-high', icon: '⚠️', label: 'Spoofing suspect' },
known_bot_no_spoofing: { cls: 'bg-threat-medium/10 border-threat-medium/40 text-threat-medium', icon: '🤖', label: 'Bot connu (pas de spoofing)' },
legitimate_browser: { cls: 'bg-threat-low/10 border-threat-low/40 text-threat-low', icon: '✅', label: 'Navigateur légitime' },
inconclusive: { cls: 'bg-background-card border-background-card text-text-secondary', icon: '❓', label: 'Non concluant' },
};
function FingerprintCoherenceWidget({ ip }: { ip: string }) {
const navigate = useNavigate();
const [data, setData] = useState<CoherenceData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
setLoading(true);
setError(false);
fetch(`/api/fingerprints/ip/${encodeURIComponent(ip)}/coherence`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d) setData(d); else setError(true); })
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [ip]);
const vs = data ? (VERDICT_STYLE[data.verdict] ?? VERDICT_STYLE.inconclusive) : null;
return (
<div className="bg-background-secondary rounded-lg p-5">
<h3 className="text-base font-semibold text-text-primary mb-4">🎭 Cohérence JA4 / User-Agent</h3>
{loading && <div className="text-text-disabled text-sm">Analyse en cours</div>}
{error && <div className="text-text-disabled text-sm">Données insuffisantes pour cette IP</div>}
{data && vs && (
<div className="space-y-4">
{/* Verdict badge + score */}
<div className={`flex items-center gap-3 p-3 rounded-lg border ${vs.cls}`}>
<span className="text-2xl">{vs.icon}</span>
<div className="flex-1">
<div className="font-semibold text-sm">{vs.label}</div>
<div className="text-xs opacity-75 mt-0.5">
Score de spoofing: <strong>{data.spoofing_score}/100</strong>
</div>
</div>
<div className="w-24">
<div className="h-2 bg-white/20 rounded-full overflow-hidden">
<div
className={`h-2 rounded-full ${
data.spoofing_score >= 70 ? 'bg-threat-critical' :
data.spoofing_score >= 40 ? 'bg-threat-high' :
data.spoofing_score >= 20 ? 'bg-threat-medium' : 'bg-threat-low'
}`}
style={{ width: `${data.spoofing_score}%` }}
/>
</div>
</div>
</div>
{/* Explanation */}
<ul className="space-y-1">
{data.explanation.map((e, i) => (
<li key={i} className="text-xs text-text-secondary flex items-start gap-1.5">
<span className="text-text-disabled mt-0.5"></span> {e}
</li>
))}
</ul>
{/* Key indicators */}
<div className="grid grid-cols-2 gap-2">
{[
{ label: 'UA/CH mismatch', value: `${data.indicators.ua_ch_mismatch_rate}%`, warn: data.indicators.ua_ch_mismatch_rate > 20 },
{ label: 'Browser score', value: `${data.indicators.avg_browser_score}/100`, warn: data.indicators.avg_browser_score > 60 },
{ label: 'JA4 distincts', value: data.indicators.distinct_ja4_count, warn: data.indicators.distinct_ja4_count > 2 },
{ label: 'JA4 rares', value: `${data.indicators.rare_ja4_rate}%`, warn: data.indicators.rare_ja4_rate > 50 },
].map((ind) => (
<div key={ind.label} className={`rounded p-2 text-center ${ind.warn ? 'bg-threat-high/10' : 'bg-background-card'}`}>
<div className={`text-sm font-semibold ${ind.warn ? 'text-threat-high' : 'text-text-primary'}`}>{ind.value}</div>
<div className="text-xs text-text-disabled">{ind.label}</div>
</div>
))}
</div>
{/* Top UAs */}
{data.user_agents.length > 0 && (
<div>
<div className="text-xs text-text-disabled mb-1.5 font-medium uppercase tracking-wide">User-Agents observés</div>
<div className="space-y-1">
{data.user_agents.slice(0, 4).map((u, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${
u.type === 'bot' ? 'bg-threat-critical/20 text-threat-critical' :
u.type === 'browser' ? 'bg-accent-primary/20 text-accent-primary' :
'bg-background-card text-text-secondary'
}`}>{u.type}</span>
<span className="truncate text-text-secondary font-mono" title={u.ua}>
{u.ua.length > 45 ? u.ua.slice(0, 45) + '…' : u.ua}
</span>
</div>
))}
</div>
</div>
)}
{/* JA4 links */}
{data.fingerprints.ja4_list.length > 0 && (
<div>
<div className="text-xs text-text-disabled mb-1 font-medium uppercase tracking-wide">JA4 utilisés</div>
<div className="flex flex-wrap gap-1">
{data.fingerprints.ja4_list.map((j4) => (
<button
key={j4}
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(j4)}`)}
className="text-xs font-mono text-accent-primary hover:underline truncate max-w-[140px]"
title={j4}
>
{j4.length > 18 ? `${j4.slice(0, 9)}${j4.slice(-8)}` : j4}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
export function InvestigationView() {
const { ip } = useParams<{ ip: string }>();
const navigate = useNavigate();
@ -45,41 +188,47 @@ export function InvestigationView() {
</div>
</div>
{/* Panels d'analyse */}
<div className="space-y-6">
{/* NOUVEAU: Réputation IP */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP (Bases publiques)</h3>
<ReputationPanel ip={ip || ''} />
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
<div className="grid grid-cols-3 gap-6 items-start">
<div className="bg-background-secondary rounded-lg p-6 h-full">
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
<ReputationPanel ip={ip} />
</div>
{/* NOUVEAU: Graph de corrélations */}
<div className="bg-background-secondary rounded-lg p-6">
<div className="col-span-2 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" />
<CorrelationGraph ip={ip} height="600px" />
</div>
</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 */}
{/* Ligne 2 : Subnet / Country / JA4 (3 colonnes) */}
<div className="grid grid-cols-3 gap-6 items-start">
<SubnetAnalysis ip={ip} />
{/* Panel 2: Country (relatif à l'IP) */}
<CountryAnalysis ip={ip} />
{/* Panel 3: JA4 */}
<JA4Analysis ip={ip} />
</div>
{/* Panel 4: User-Agents */}
{/* Ligne 3 : User-Agents (1/2) + Classification (1/2) */}
<div className="grid grid-cols-2 gap-6 items-start">
<UserAgentAnalysis ip={ip} />
{/* Panel 5: Correlation Summary + Classification */}
<CorrelationSummary ip={ip} onClassify={handleClassify} />
</div>
{/* Ligne 4 : Cohérence JA4/UA (spoofing) */}
<div className="grid grid-cols-3 gap-6 items-start">
<FingerprintCoherenceWidget ip={ip} />
<div className="col-span-2 bg-background-secondary rounded-lg p-5">
<h3 className="text-base font-semibold text-text-primary mb-3">🔏 JA4 Légitimes (baseline)</h3>
<p className="text-xs text-text-secondary mb-3">
Comparez les fingerprints de cette IP avec la baseline des JA4 légitimes pour évaluer le risque de spoofing.
</p>
<button
onClick={() => navigate('/fingerprints?tab=spoofing')}
className="text-sm px-4 py-2 rounded-lg bg-accent-primary/20 text-accent-primary hover:bg-accent-primary/30 transition-colors"
>
🎭 Voir l'analyse de spoofing globale
</button>
</div>
</div>
</div>
);
}

View File

@ -139,10 +139,6 @@ export function JA4InvestigationView() {
}
};
const truncateUA = (ua: string, maxLength = 80) => {
if (ua.length <= maxLength) return ua;
return ua.substring(0, maxLength) + '...';
};
return (
<div className="space-y-6 animate-fade-in">
@ -180,155 +176,127 @@ export function JA4InvestigationView() {
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatBox
label="IPs Uniques"
value={data.unique_ips.toLocaleString()}
/>
<StatBox
label="Première détection"
value={formatDate(data.first_seen)}
/>
<StatBox
label="Dernière détection"
value={formatDate(data.last_seen)}
/>
<StatBox
label="User-Agents"
value={data.user_agents.length.toString()}
/>
<StatBox label="IPs Uniques" value={data.unique_ips.toLocaleString()} />
<StatBox label="Première détection" value={formatDate(data.first_seen)} />
<StatBox label="Dernière détection" value={formatDate(data.last_seen)} />
<StatBox label="User-Agents" value={data.user_agents.length.toString()} />
</div>
</div>
{/* Panel 1: Top IPs */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">1. TOP IPs (Utilisant ce JA4)</h3>
<div className="space-y-2">
{data.top_ips.length > 0 ? (
data.top_ips.map((ipData, idx) => (
<div
key={idx}
className="flex items-center justify-between bg-background-card rounded-lg p-3"
>
<div className="flex items-center gap-3">
<span className="text-text-secondary text-sm w-6">{idx + 1}.</span>
<button
onClick={() => navigate(`/investigation/${ipData.ip}`)}
className="font-mono text-sm text-accent-primary hover:text-accent-primary/80 transition-colors text-left"
>
{ipData.ip}
</button>
</div>
<div className="text-right">
<div className="text-text-primary font-medium">{ipData.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{ipData.percentage.toFixed(1)}%</div>
</div>
</div>
))
) : (
<div className="text-center text-text-secondary py-8">
Aucune IP trouvée
</div>
)}
</div>
{data.unique_ips > 10 && (
<p className="text-text-secondary text-sm mt-4 text-center">
... et {data.unique_ips - 10} autres IPs
</p>
)}
</div>
{/* Panel 2: Top Pays */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">2. TOP Pays</h3>
<div className="space-y-3">
{data.top_countries.map((country, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{getFlag(country.code)}</span>
<div className="text-text-primary font-medium text-sm">
{country.name} ({country.code})
{/* Ligne 2: Top IPs (gauche) | Top Pays + Top ASNs (droite empilés) */}
<div className="grid grid-cols-2 gap-6 items-start">
{/* Top IPs */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">📍 TOP IPs</h3>
<div className="space-y-2">
{data.top_ips.length > 0 ? (
data.top_ips.map((ipData, idx) => (
<div
key={idx}
className="flex items-center justify-between bg-background-card rounded-lg p-3"
>
<div className="flex items-center gap-3">
<span className="text-text-secondary text-sm w-6">{idx + 1}.</span>
<button
onClick={() => navigate(`/investigation/${ipData.ip}`)}
className="font-mono text-sm text-accent-primary hover:text-accent-primary/80 transition-colors text-left"
>
{ipData.ip}
</button>
</div>
<div className="text-right">
<div className="text-text-primary font-medium">{ipData.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{ipData.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{country.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
))
) : (
<div className="text-center text-text-secondary py-8">Aucune IP trouvée</div>
)}
{data.unique_ips > 10 && (
<p className="text-text-secondary text-sm mt-4 text-center">
... et {data.unique_ips - 10} autres IPs
</p>
)}
</div>
</div>
{/* Top Pays + Top ASNs empilés */}
<div className="space-y-6">
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 TOP Pays</h3>
<div className="space-y-3">
{data.top_countries.map((country, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-2xl">{getFlag(country.code)}</span>
<span className="text-text-primary font-medium text-sm">{country.name} ({country.code})</span>
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{country.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-accent-primary transition-all" style={{ width: `${Math.min(country.percentage, 100)}%` }} />
</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-accent-primary transition-all"
style={{ width: `${Math.min(country.percentage, 100)}%` }}
/>
</div>
))}
</div>
))}
</div>
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🏢 TOP ASNs</h3>
<div className="space-y-3">
{data.top_asns.map((asn, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium text-sm">{asn.asn} - {asn.org}</div>
<div className="text-right">
<div className="text-text-primary font-bold">{asn.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{asn.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-accent-primary transition-all" style={{ width: `${Math.min(asn.percentage, 100)}%` }} />
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Panel 3: Top ASN */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">3. TOP ASN</h3>
<div className="space-y-3">
{data.top_asns.map((asn, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium text-sm">
{asn.asn} - {asn.org}
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{asn.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{asn.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-accent-primary transition-all"
style={{ width: `${Math.min(asn.percentage, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Panel 4: Top Hosts */}
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">4. TOP Hosts Ciblés</h3>
<div className="space-y-3">
{data.top_hosts.map((host, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium text-sm truncate max-w-md">
{host.host}
</div>
<div className="text-right">
<div className="text-text-primary font-bold">{host.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{host.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-accent-primary transition-all"
style={{ width: `${Math.min(host.percentage, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Panel 5: User-Agents + Classification */}
<div className="space-y-6">
{/* Ligne 3: Top Hosts (gauche) | User-Agents (droite) */}
<div className="grid grid-cols-2 gap-6 items-start">
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">5. User-Agents</h3>
<h3 className="text-lg font-medium text-text-primary mb-4">🖥 TOP Hosts Ciblés</h3>
<div className="space-y-3">
{data.top_hosts.map((host, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium text-sm truncate max-w-xs">{host.host}</div>
<div className="text-right">
<div className="text-text-primary font-bold">{host.count.toLocaleString()}</div>
<div className="text-text-secondary text-xs">{host.percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-accent-primary transition-all" style={{ width: `${Math.min(host.percentage, 100)}%` }} />
</div>
</div>
))}
</div>
</div>
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🤖 User-Agents</h3>
<div className="space-y-3">
{data.user_agents.map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1">
{truncateUA(ua.ua)}
</div>
<div className="text-text-primary text-xs font-mono break-all flex-1 leading-relaxed">{ua.ua}</div>
{getClassificationBadge(ua.classification)}
</div>
<div className="flex items-center gap-2">
@ -338,16 +306,14 @@ export function JA4InvestigationView() {
</div>
))}
{data.user_agents.length === 0 && (
<div className="text-center text-text-secondary py-8">
Aucun User-Agent trouvé
</div>
<div className="text-center text-text-secondary py-8">Aucun User-Agent trouvé</div>
)}
</div>
</div>
{/* Classification JA4 */}
<JA4CorrelationSummary ja4={ja4 || ''} />
</div>
{/* Ligne 4: Classification JA4 (full width) */}
<JA4CorrelationSummary ja4={ja4 || ''} />
</div>
);
}

View File

@ -0,0 +1,367 @@
/**
* PivotView — Cross-entity correlation matrix
*
* SOC analysts add multiple IPs or JA4 fingerprints.
* The page fetches variability data for each entity and renders a
* comparison matrix highlighting shared values (correlations).
*
* Columns = entities added by the analyst
* Rows = attribute categories (JA4, User-Agent, Country, ASN, Subnet)
* ★ = value shared by 2+ entities → possible campaign link
*/
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
// ─── Types ────────────────────────────────────────────────────────────────────
type EntityType = 'ip' | 'ja4';
interface EntityCol {
id: string;
type: EntityType;
value: string;
loading: boolean;
error: string | null;
data: VariabilityData | null;
}
interface AttrItem {
value: string;
count: number;
percentage: number;
}
interface VariabilityData {
total_detections: number;
unique_ips?: number;
attributes: {
ja4?: AttrItem[];
user_agents?: AttrItem[];
countries?: AttrItem[];
asns?: AttrItem[];
hosts?: AttrItem[];
subnets?: AttrItem[];
};
}
type AttrKey = keyof VariabilityData['attributes'];
const ATTR_ROWS: { key: AttrKey; label: string; icon: string }[] = [
{ key: 'ja4', label: 'JA4 Fingerprint', icon: '🔐' },
{ key: 'user_agents', label: 'User-Agents', icon: '🤖' },
{ key: 'countries', label: 'Pays', icon: '🌍' },
{ key: 'asns', label: 'ASN', icon: '🏢' },
{ key: 'hosts', label: 'Hosts cibles', icon: '🖥️' },
{ key: 'subnets', label: 'Subnets', icon: '🔷' },
];
const MAX_VALUES_PER_CELL = 4;
function detectType(input: string): EntityType {
// JA4 fingerprints are ~36 chars containing letters, digits, underscores
// IP addresses match IPv4 pattern
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(input.trim())) return 'ip';
return 'ja4';
}
function getCountryFlag(code: string): string {
return (code || '').toUpperCase().replace(/./g, c =>
String.fromCodePoint(c.charCodeAt(0) + 127397)
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export function PivotView() {
const navigate = useNavigate();
const [cols, setCols] = useState<EntityCol[]>([]);
const [input, setInput] = useState('');
const fetchEntity = useCallback(async (col: EntityCol): Promise<EntityCol> => {
try {
const encoded = encodeURIComponent(col.value);
const url = col.type === 'ip'
? `/api/variability/ip/${encoded}`
: `/api/variability/ja4/${encoded}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: VariabilityData = await res.json();
return { ...col, loading: false, data };
} catch (e) {
return { ...col, loading: false, error: (e as Error).message };
}
}, []);
const addEntity = useCallback(async () => {
const val = input.trim();
if (!val) return;
// Support batch: comma-separated
const values = val.split(/[,\s]+/).map(v => v.trim()).filter(Boolean);
setInput('');
for (const v of values) {
const id = `${Date.now()}-${v}`;
const type = detectType(v);
const pending: EntityCol = { id, type, value: v, loading: true, error: null, data: null };
setCols(prev => {
if (prev.some(c => c.value === v)) return prev;
return [...prev, pending];
});
const resolved = await fetchEntity(pending);
setCols(prev => prev.map(c => c.id === id ? resolved : c));
}
}, [input, fetchEntity]);
const removeCol = (id: string) => setCols(prev => prev.filter(c => c.id !== id));
// Find shared values across all loaded cols for a given attribute key
const getSharedValues = (key: AttrKey): Set<string> => {
const loaded = cols.filter(c => c.data);
if (loaded.length < 2) return new Set();
const valueCounts = new Map<string, number>();
for (const col of loaded) {
const items = col.data?.attributes[key] ?? [];
for (const item of items.slice(0, 10)) {
valueCounts.set(item.value, (valueCounts.get(item.value) ?? 0) + 1);
}
}
const shared = new Set<string>();
valueCounts.forEach((count, val) => { if (count >= 2) shared.add(val); });
return shared;
};
const sharedByKey = Object.fromEntries(
ATTR_ROWS.map(r => [r.key, getSharedValues(r.key)])
) as Record<AttrKey, Set<string>>;
const totalCorrelations = ATTR_ROWS.reduce(
(sum, r) => sum + sharedByKey[r.key].size, 0
);
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary">🔗 Pivot Corrélation Multi-Entités</h1>
<p className="text-text-secondary text-sm mt-1">
Ajoutez des IPs ou JA4. Les valeurs partagées <span className="text-yellow-400 font-bold"></span> révèlent des campagnes coordonnées.
</p>
</div>
{/* Input bar */}
<div className="flex gap-3">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addEntity(); }}
placeholder="IP (ex: 1.2.3.4) ou JA4, séparés par des virgules…"
className="flex-1 bg-background-secondary border border-background-card rounded-lg px-4 py-2.5 text-text-primary placeholder-text-disabled font-mono text-sm focus:outline-none focus:border-accent-primary"
/>
<button
onClick={addEntity}
disabled={!input.trim()}
className="px-5 py-2.5 bg-accent-primary text-white rounded-lg text-sm font-medium hover:bg-accent-primary/80 disabled:opacity-40 transition-colors"
>
+ Ajouter
</button>
{cols.length > 0 && (
<button
onClick={() => setCols([])}
className="px-4 py-2.5 bg-background-card text-text-secondary rounded-lg text-sm hover:text-text-primary transition-colors"
>
Tout effacer
</button>
)}
</div>
{/* Empty state */}
{cols.length === 0 && (
<div className="bg-background-secondary rounded-xl p-12 text-center space-y-3">
<div className="text-5xl">🔗</div>
<div className="text-lg font-medium text-text-primary">Aucune entité ajoutée</div>
<div className="text-sm text-text-secondary max-w-md mx-auto">
Entrez plusieurs IPs ou fingerprints JA4 pour identifier des corrélations JA4 partagés, même pays d'origine, mêmes cibles, même ASN.
</div>
<div className="text-xs text-text-disabled pt-2">
Exemple : <span className="font-mono text-text-secondary">1.2.3.4, 5.6.7.8, 9.10.11.12</span>
</div>
</div>
)}
{/* Correlation summary badge */}
{cols.length >= 2 && cols.some(c => c.data) && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border ${
totalCorrelations > 0
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
: 'bg-background-secondary border-background-card text-text-secondary'
}`}>
<span className="text-xl">{totalCorrelations > 0 ? '' : ''}</span>
<div>
{totalCorrelations > 0 ? (
<>
<span className="font-bold">{totalCorrelations} corrélation{totalCorrelations > 1 ? 's' : ''} détectée{totalCorrelations > 1 ? 's' : ''}</span>
{' '}— attributs partagés par 2+ entités. Possible campagne coordonnée.
</>
) : (
'Aucune corrélation détectée entre les entités analysées.'
)}
</div>
</div>
)}
{/* Matrix */}
{cols.length > 0 && (
<div className="overflow-x-auto rounded-xl border border-background-card">
<table className="w-full min-w-max">
{/* Column headers */}
<thead>
<tr className="bg-background-secondary border-b border-background-card">
<th className="px-4 py-3 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider w-40">
Attribut
</th>
{cols.map(col => (
<th key={col.id} className="px-4 py-3 text-left w-56">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-text-disabled">
{col.type === 'ip' ? '🌐' : '🔐'}
</span>
<button
onClick={() => navigate(
col.type === 'ip'
? `/investigation/${col.value}`
: `/investigation/ja4/${col.value}`
)}
className="font-mono text-sm text-accent-primary hover:underline truncate max-w-[160px] text-left"
title={col.value}
>
{col.value.length > 20 ? col.value.slice(0, 20) + '' : col.value}
</button>
</div>
{col.data && (
<div className="text-xs text-text-disabled mt-0.5">
{col.data.total_detections.toLocaleString()} det.
{col.type === 'ja4' && col.data.unique_ips !== undefined && (
<> · {col.data.unique_ips} IPs</>
)}
</div>
)}
{col.loading && (
<div className="text-xs text-text-disabled mt-0.5 animate-pulse">Chargement…</div>
)}
{col.error && (
<div className="text-xs text-red-400 mt-0.5">⚠ {col.error}</div>
)}
</div>
<button
onClick={() => removeCol(col.id)}
className="text-text-disabled hover:text-red-400 transition-colors text-base leading-none shrink-0 mt-0.5"
title="Retirer"
>
×
</button>
</div>
</th>
))}
</tr>
</thead>
{/* Attribute rows */}
<tbody className="divide-y divide-background-card">
{ATTR_ROWS.map(row => {
const shared = sharedByKey[row.key];
const hasAnyData = cols.some(c => (c.data?.attributes[row.key]?.length ?? 0) > 0);
if (!hasAnyData && cols.every(c => c.data !== null && !c.loading)) return null;
return (
<tr key={row.key} className="hover:bg-background-card/20 transition-colors">
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-1.5">
<span>{row.icon}</span>
<span className="text-xs font-medium text-text-secondary">{row.label}</span>
</div>
{shared.size > 0 && (
<div className="mt-1">
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
★ {shared.size} commun{shared.size > 1 ? 's' : ''}
</span>
</div>
)}
</td>
{cols.map(col => {
const items = col.data?.attributes[row.key] ?? [];
return (
<td key={col.id} className="px-3 py-3 align-top">
{col.loading ? (
<div className="h-16 bg-background-card/30 rounded animate-pulse" />
) : items.length === 0 ? (
<span className="text-xs text-text-disabled">—</span>
) : (
<div className="space-y-1.5">
{items.slice(0, MAX_VALUES_PER_CELL).map((item, i) => {
const isShared = shared.has(item.value);
return (
<div
key={i}
className={`rounded px-2 py-1.5 text-xs ${
isShared
? 'bg-yellow-500/15 border border-yellow-500/30'
: 'bg-background-card/40'
}`}
>
<div className="flex items-start justify-between gap-1">
<div className={`font-mono break-all leading-tight ${
isShared ? 'text-yellow-300' : 'text-text-primary'
}`}>
{isShared && <span className="mr-1 text-yellow-400">★</span>}
{row.key === 'countries'
? `${getCountryFlag(item.value)} ${item.value}`
: item.value.length > 60
? item.value.slice(0, 60) + ''
: item.value}
</div>
</div>
<div className="text-text-disabled mt-0.5 flex gap-2">
<span>{item.count.toLocaleString()}</span>
<span>{item.percentage.toFixed(1)}%</span>
</div>
</div>
);
})}
{items.length > MAX_VALUES_PER_CELL && (
<div className="text-xs text-text-disabled px-2">
+{items.length - MAX_VALUES_PER_CELL} autres
</div>
)}
</div>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Legend */}
{cols.length >= 2 && (
<div className="flex items-center gap-4 text-xs text-text-disabled">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-yellow-500/20 border border-yellow-500/30 inline-block" />
Valeur partagée par 2+ entités
</span>
<span>|</span>
<span>Cliquer sur une entité Investigation complète</span>
</div>
)}
</div>
);
}

View File

@ -235,7 +235,7 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
</button>
<button
onClick={() => {
navigate('/investigate');
navigate('/detections');
setIsOpen(false);
}}
className="px-3 py-1.5 bg-accent-primary/20 text-accent-primary rounded text-xs hover:bg-accent-primary/30 transition-colors"

View File

@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { QuickSearch } from './QuickSearch';
interface SubnetIP {
ip: string;
@ -127,13 +126,10 @@ export function SubnetInvestigation() {
<p className="font-mono text-text-secondary">{subnet}</p>
</div>
</div>
<div className="w-full md:w-auto">
<QuickSearch />
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Stats Summary — 4 colonnes compact */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">Total IPs</div>
<div className="text-2xl font-bold text-text-primary">{stats.total_ips}</div>
@ -150,6 +146,10 @@ export function SubnetInvestigation() {
<div className="text-sm text-text-secondary mb-1">User-Agents Uniques</div>
<div className="text-2xl font-bold text-text-primary">{stats.unique_ua}</div>
</div>
</div>
{/* Infos secondaires — 4 colonnes */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">Hosts Uniques</div>
<div className="text-2xl font-bold text-text-primary">{stats.unique_hosts}</div>
@ -166,8 +166,8 @@ export function SubnetInvestigation() {
</div>
<div className="bg-background-card rounded-lg p-4">
<div className="text-sm text-text-secondary mb-1">Période</div>
<div className="text-sm text-text-primary">
{formatDate(stats.first_seen)} - {formatDate(stats.last_seen)}
<div className="text-sm text-text-primary font-medium">
{formatDate(stats.first_seen)} {formatDate(stats.last_seen)}
</div>
</div>
</div>

View File

@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { QuickSearch } from './QuickSearch';
interface Classification {
ip?: string;
@ -119,7 +118,6 @@ export function ThreatIntelView() {
Base de connaissances des classifications SOC
</p>
</div>
<QuickSearch />
</div>
{/* Statistics */}
@ -150,169 +148,140 @@ export function ThreatIntelView() {
/>
</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>
{/* Main content: sidebar filtres (1/4) + table (3/4) */}
<div className="grid grid-cols-4 gap-6 items-start">
{/* Sidebar filtres + tags */}
<div className="space-y-4">
<div className="bg-background-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-3">🔍 Recherche</h3>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="IP, JA4, tag, commentaire..."
className="w-full bg-background-card border border-background-card rounded-lg px-3 py-2 text-text-primary placeholder-text-secondary focus:outline-none focus:border-accent-primary text-sm"
/>
</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>
<div className="bg-background-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-3">🏷 Label</h3>
<div className="space-y-2">
{(['all', 'malicious', 'suspicious', 'legitimate'] as const).map(lbl => (
<button
key={lbl}
onClick={() => setFilterLabel(lbl)}
className={`w-full text-left px-3 py-1.5 rounded-lg text-sm transition-colors ${
filterLabel === lbl ? 'bg-accent-primary text-white' : 'text-text-secondary hover:text-text-primary hover:bg-background-card'
}`}
>
{lbl === 'all' ? '🔹 Tous' : lbl === 'malicious' ? '❌ Malicious' : lbl === 'suspicious' ? '⚠️ Suspicious' : '✅ Légitime'}
</button>
))}
</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 className="bg-background-secondary rounded-lg p-4">
<h3 className="text-sm font-semibold text-text-primary mb-3">🏷 Tags populaires</h3>
<div className="flex flex-wrap gap-1.5">
{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-2 py-1 rounded text-xs transition-colors ${
filterTag === tag ? 'bg-accent-primary text-white' : getTagColor(tag)
}`}
>
{tag} <span className="opacity-70">({count})</span>
</button>
);
})}
</div>
</div>
{(search || filterLabel !== 'all' || filterTag) && (
<div className="flex items-center justify-between">
<div className="text-xs text-text-secondary">{filteredClassifications.length} résultat(s)</div>
<button
onClick={() => { setSearch(''); setFilterLabel('all'); setFilterTag(''); }}
className="text-xs text-accent-primary hover:text-accent-primary/80"
>
Effacer
</button>
</div>
)}
</div>
{/* Table classifications (3/4) */}
<div className="col-span-3 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
<span className="ml-2 text-sm font-normal text-text-secondary">({filteredClassifications.length})</span>
</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>
</div>
);

View File

@ -53,37 +53,7 @@ export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
{/* User-Agents */}
{attributes.user_agents && attributes.user_agents.length > 0 && (
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">
User-Agents ({attributes.user_agents.length})
</h3>
<div className="space-y-3">
{attributes.user_agents.slice(0, 10).map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between">
<div className="text-text-primary font-medium truncate max-w-lg text-sm">
{item.value}
</div>
<div className="text-right">
<div className="text-text-primary font-medium">{item.count}</div>
<div className="text-text-secondary text-xs">{item.percentage?.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-threat-medium transition-all"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
{attributes.user_agents.length > 10 && (
<p className="text-text-secondary text-sm mt-4 text-center">
... et {attributes.user_agents.length - 10} autres (top 10 affiché)
</p>
)}
</div>
<UASection items={attributes.user_agents} />
)}
{/* Pays */}
@ -199,6 +169,50 @@ export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
);
}
// Composant UASection — jamais de troncature, expand/collapse
function UASection({ items }: { items: AttributeValue[] }) {
const [showAll, setShowAll] = useState(false);
const INITIAL = 5;
const displayed = showAll ? items : items.slice(0, INITIAL);
return (
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">
User-Agents ({items.length})
</h3>
<div className="space-y-3">
{displayed.map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-start justify-between gap-4">
<div className="text-text-primary font-medium text-xs font-mono break-all leading-relaxed flex-1">
{item.value}
</div>
<div className="text-right shrink-0">
<div className="text-text-primary font-medium">{item.count}</div>
<div className="text-text-secondary text-xs">{item.percentage?.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-threat-medium transition-all"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
{items.length > INITIAL && (
<button
onClick={() => setShowAll(v => !v)}
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAll ? '↑ Réduire' : `↓ Voir les ${items.length - INITIAL} autres`}
</button>
)}
</div>
);
}
// Composant AttributeSection
function AttributeSection({
title,
@ -284,7 +298,7 @@ function AttributeRow({
<div className="flex items-center justify-between">
<Link
to={getLink(value)}
className="text-text-primary hover:text-accent-primary transition-colors font-medium truncate max-w-md"
className="text-text-primary hover:text-accent-primary transition-colors font-medium break-all text-sm leading-relaxed flex-1"
>
{getValue(value)}
</Link>

View File

@ -22,6 +22,8 @@ export function UserAgentAnalysis({ ip }: UserAgentAnalysisProps) {
const [data, setData] = useState<UserAgentAnalysis | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showAllIpUA, setShowAllIpUA] = useState(false);
const [showAllJa4UA, setShowAllJa4UA] = useState(false);
useEffect(() => {
const fetchUserAgentAnalysis = async () => {
@ -60,20 +62,17 @@ export function UserAgentAnalysis({ ip }: UserAgentAnalysisProps) {
const getClassificationBadge = (classification: string) => {
switch (classification) {
case 'normal':
return <span className="bg-threat-low/20 text-threat-low px-2 py-0.5 rounded text-xs"> Normal</span>;
return <span className="bg-threat-low/20 text-threat-low px-2 py-0.5 rounded text-xs whitespace-nowrap"> Normal</span>;
case 'bot':
return <span className="bg-threat-medium/20 text-threat-medium px-2 py-0.5 rounded text-xs"> Bot</span>;
return <span className="bg-threat-medium/20 text-threat-medium px-2 py-0.5 rounded text-xs whitespace-nowrap"> Bot</span>;
case 'script':
return <span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs"> Script</span>;
return <span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs whitespace-nowrap"> Script</span>;
default:
return null;
}
};
const truncateUA = (ua: string, maxLength = 80) => {
if (ua.length <= maxLength) return ua;
return ua.substring(0, maxLength) + '...';
};
const INITIAL_COUNT = 5;
return (
<div className="bg-background-secondary rounded-lg p-6">
@ -86,18 +85,18 @@ export function UserAgentAnalysis({ ip }: UserAgentAnalysisProps) {
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* User-Agents pour cette IP */}
<div>
<div className="text-sm text-text-secondary mb-3">
User-Agents pour cette IP ({data.ip_user_agents.length})
</div>
<div className="space-y-2">
{data.ip_user_agents.slice(0, 5).map((ua, idx) => (
{(showAllIpUA ? data.ip_user_agents : data.ip_user_agents.slice(0, INITIAL_COUNT)).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1">
{truncateUA(ua.value)}
<div className="text-text-primary text-xs font-mono break-all flex-1 leading-relaxed">
{ua.value}
</div>
{getClassificationBadge(ua.classification)}
</div>
@ -111,6 +110,16 @@ export function UserAgentAnalysis({ ip }: UserAgentAnalysisProps) {
<div className="text-text-secondary text-sm">Aucun User-Agent trouvé</div>
)}
</div>
{data.ip_user_agents.length > INITIAL_COUNT && (
<button
onClick={() => setShowAllIpUA(v => !v)}
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAllIpUA
? '↑ Réduire'
: `↓ Voir les ${data.ip_user_agents.length - INITIAL_COUNT} autres`}
</button>
)}
</div>
{/* User-Agents pour le JA4 */}
@ -119,11 +128,11 @@ export function UserAgentAnalysis({ ip }: UserAgentAnalysisProps) {
User-Agents pour le JA4 (toutes IPs)
</div>
<div className="space-y-2">
{data.ja4_user_agents.slice(0, 5).map((ua, idx) => (
{(showAllJa4UA ? data.ja4_user_agents : data.ja4_user_agents.slice(0, INITIAL_COUNT)).map((ua, idx) => (
<div key={idx} className="bg-background-card rounded-lg p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-text-primary text-xs font-mono break-all flex-1">
{truncateUA(ua.value)}
<div className="text-text-primary text-xs font-mono break-all flex-1 leading-relaxed">
{ua.value}
</div>
{getClassificationBadge(ua.classification)}
</div>
@ -134,6 +143,16 @@ export function UserAgentAnalysis({ ip }: UserAgentAnalysisProps) {
</div>
))}
</div>
{data.ja4_user_agents.length > INITIAL_COUNT && (
<button
onClick={() => setShowAllJa4UA(v => !v)}
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAllJa4UA
? '↑ Réduire'
: `↓ Voir les ${data.ja4_user_agents.length - INITIAL_COUNT} autres`}
</button>
)}
</div>
</div>

View File

@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { ThemeProvider } from './ThemeContext'
import './styles/globals.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>,
)

View File

@ -2,63 +2,58 @@
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
/* ── Dark theme (default, SOC standard) ── */
:root,
[data-theme="dark"] {
color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--color-bg: 15 23 42; /* Slate 900 */
--color-bg-secondary: 30 41 59; /* Slate 800 */
--color-bg-card: 51 65 85; /* Slate 700 */
--color-text-primary: 248 250 252;/* Slate 50 */
--color-text-secondary:148 163 184;/* Slate 400 */
--color-text-disabled: 100 116 139;/* Slate 500 */
--scrollbar-track: #1e293b;
--scrollbar-thumb: #475569;
--scrollbar-thumb-hover: #64748b;
--border-color: rgba(148,163,184,0.12);
}
/* ── Light theme ── */
[data-theme="light"] {
color-scheme: light;
--color-bg: 241 245 249;/* Slate 100 */
--color-bg-secondary: 255 255 255;/* White */
--color-bg-card: 226 232 240;/* Slate 200 */
--color-text-primary: 15 23 42; /* Slate 900 */
--color-text-secondary:71 85 105; /* Slate 600 */
--color-text-disabled: 148 163 184;/* Slate 400 */
--scrollbar-track: #f1f5f9;
--scrollbar-thumb: #cbd5e1;
--scrollbar-thumb-hover: #94a3b8;
--border-color: rgba(15,23,42,0.1);
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar personnalisée */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
::-webkit-scrollbar-track {
background: #1E293B;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes pulse-red { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748B;
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.4s ease-out;
}
.animate-fade-in { animation: fadeIn 0.25s ease-in-out; }
.animate-slide-up { animation: slideUp 0.35s ease-out; }