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:
@ -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>
|
||||
);
|
||||
|
||||
73
frontend/src/ThemeContext.tsx
Normal file
73
frontend/src/ThemeContext.tsx
Normal 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);
|
||||
}
|
||||
793
frontend/src/components/CampaignsView.tsx
Normal file
793
frontend/src/components/CampaignsView.tsx
Normal 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 · >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
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
1385
frontend/src/components/FingerprintsView.tsx
Normal file
1385
frontend/src/components/FingerprintsView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
367
frontend/src/components/PivotView.tsx
Normal file
367
frontend/src/components/PivotView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>,
|
||||
)
|
||||
|
||||
@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user