feat: ja4-platform monorepo — 5 services unified, tests & RPM builds standardized
Services: - ja4sentinel: TLS/JA4 fingerprint capture daemon (Go, libpcap) - logcorrelator: JA4 log correlation engine (Go, ClickHouse) - mod_reqin_log: Apache module (C, JSON request logging) - bot_detector: ML bot detection pipeline (Python) - dashboard: FastAPI/Streamlit analytics UI (Python) Shared libraries: - shared/go/ja4common: logger, config, shutdown, ipfilter (Go module) - shared/python/ja4_common: ClickHouseClient, ClickHouseSettings (Python package) - shared/clickhouse/: canonical SQL migrations (10 files) Build & packaging: - Unified 3-stage Dockerfile.package for Go RPMs (el8/el9/el10) - go.work workspace linking sentinel, correlator, ja4common - Makefile with test-all, build-all, rpm-* targets Fixes applied: - go.work: 1.21 → 1.24.6 (required by sentinel) - correlator Dockerfiles: golang:1.21 → golang:1.24 - replace directives in go.mod for ja4common local path - pyproject.toml: setuptools.backends → setuptools.build_meta - Removed static libpcap linking (unavailable on Rocky 9) - Fixed data races in output/writers_test.go (sync.Mutex + atomic.Int32) - Rewrote corrupted test files (logger_test.go × 2) Test coverage: - correlator: 67.1% total (unixsocket 80.5%, config 91.7%, app 83.3%, multi 87.7%, stdout 100%) - sentinel: all 10 packages pass (api, capture, config, fingerprint, ipfilter, logging, output, tlsparse) Documentation: - README.md + docs/ (architecture, development, 5 services, shared libs, DB schema & migrations) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
436
services/dashboard/frontend/src/App.tsx
Normal file
436
services/dashboard/frontend/src/App.tsx
Normal file
@ -0,0 +1,436 @@
|
||||
import { BrowserRouter, Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DetectionsList } from './components/DetectionsList';
|
||||
import { DetailsView } from './components/DetailsView';
|
||||
import { InvestigationView } from './components/InvestigationView';
|
||||
import { JA4InvestigationView } from './components/JA4InvestigationView';
|
||||
import { EntityInvestigationView } from './components/EntityInvestigationView';
|
||||
import { IncidentsView } from './components/IncidentsView';
|
||||
import { QuickSearch } from './components/QuickSearch';
|
||||
import { ThreatIntelView } from './components/ThreatIntelView';
|
||||
import { CorrelationGraph } from './components/CorrelationGraph';
|
||||
import { InteractiveTimeline } from './components/InteractiveTimeline';
|
||||
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 { BruteForceView } from './components/BruteForceView';
|
||||
import { TcpSpoofingView } from './components/TcpSpoofingView';
|
||||
import { HeaderFingerprintView } from './components/HeaderFingerprintView';
|
||||
import { MLFeaturesView } from './components/MLFeaturesView';
|
||||
import ClusteringView from './components/ClusteringView';
|
||||
import { useTheme } from './ThemeContext';
|
||||
import { useMetrics } from './hooks/useMetrics';
|
||||
import { Tooltip } from './components/ui/Tooltip';
|
||||
import { TIPS } from './components/ui/tooltips';
|
||||
|
||||
// ─── 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());
|
||||
|
||||
// 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 advancedLinks = [
|
||||
{ path: '/bruteforce', label: 'Brute Force', icon: '🔥', aliases: [] },
|
||||
{ path: '/tcp-spoofing', label: 'TCP Spoofing', icon: '🧬', aliases: [] },
|
||||
{ path: '/clustering', label: 'Clustering IPs', icon: '🔬', aliases: [] },
|
||||
{ path: '/headers', label: 'Header Fingerprint', icon: '📡', aliases: [] },
|
||||
{ path: '/ml-features', label: 'Features ML', icon: '🤖', aliases: [] },
|
||||
];
|
||||
|
||||
const isActive = (link: { path: string; aliases: string[] }) =>
|
||||
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 (
|
||||
<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>
|
||||
|
||||
{/* Advanced analysis nav */}
|
||||
<nav className="px-3 pt-4 space-y-0.5">
|
||||
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider px-3 pb-1">Analyse Avancée</div>
|
||||
{advancedLinks.map(link => (
|
||||
<Link
|
||||
key={link.path}
|
||||
to={link.path}
|
||||
className={`flex items-center gap-3 px-3 py-2 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">
|
||||
<Tooltip content={TIPS.alertes_24h}>
|
||||
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider mb-2 cursor-help">
|
||||
Alertes 24h ⓘ
|
||||
</div>
|
||||
</Tooltip>
|
||||
{counts.critical > 0 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<Tooltip content={TIPS.risk_critical}>
|
||||
<span className="text-xs text-red-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block animate-pulse" /> CRITICAL</span>
|
||||
</Tooltip>
|
||||
<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">
|
||||
<Tooltip content={TIPS.risk_high}>
|
||||
<span className="text-xs text-orange-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-orange-500 inline-block" /> HIGH</span>
|
||||
</Tooltip>
|
||||
<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">
|
||||
<Tooltip content={TIPS.risk_medium}>
|
||||
<span className="text-xs text-yellow-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500 inline-block" /> MEDIUM</span>
|
||||
</Tooltip>
|
||||
<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={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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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';
|
||||
if (p.startsWith('/bruteforce')) return 'Brute Force & Credential Stuffing';
|
||||
if (p.startsWith('/tcp-spoofing')) return 'Spoofing TCP/OS';
|
||||
if (p.startsWith('/clustering')) return 'Clustering IPs';
|
||||
if (p.startsWith('/headers')) return 'Header Fingerprint Clustering';
|
||||
if (p.startsWith('/ml-features')) return 'Features ML / Radar';
|
||||
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 />;
|
||||
}
|
||||
|
||||
/** Redirige /detections/ip/:ip → /investigation/:ip */
|
||||
function IpDetectionPageRedirect() {
|
||||
const { ip } = useParams<{ ip: string }>();
|
||||
return <Navigate to={`/investigation/${encodeURIComponent(ip || '')}`} replace />;
|
||||
}
|
||||
|
||||
/** Redirige /investigation/ip/:ip → /investigation/:ip */
|
||||
function IpInvestigationRedirect() {
|
||||
const { ip } = useParams<{ ip: string }>();
|
||||
return <Navigate to={`/investigation/${encodeURIComponent(ip || '')}`} 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/ip/')) {
|
||||
// Redirigé — ne pas sauvegarder l'alias (la route finale /investigation/:ip sera sauvegardée)
|
||||
} 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;
|
||||
}
|
||||
|
||||
// ─── MainContent : layout adaptatif selon la route ───────────────────────────
|
||||
// Les vues "canvas" ont besoin d'une hauteur fixe sans padding
|
||||
// pour que leurs colonnes scroll indépendamment.
|
||||
const FULLHEIGHT_ROUTES = ['/clustering'];
|
||||
|
||||
function MainContent({ counts: _counts }: { counts: AlertCounts | null }) {
|
||||
const location = useLocation();
|
||||
const isFullHeight = FULLHEIGHT_ROUTES.some(r => location.pathname.startsWith(r));
|
||||
|
||||
if (isFullHeight) {
|
||||
return (
|
||||
<main className="mt-14 overflow-hidden" style={{ height: 'calc(100vh - 3.5rem)' }}>
|
||||
<Routes>
|
||||
<Route path="/clustering" element={<ClusteringView />} />
|
||||
</Routes>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 px-4 py-3 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="/bruteforce" element={<BruteForceView />} />
|
||||
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
|
||||
<Route path="/headers" element={<HeaderFingerprintView />} />
|
||||
<Route path="/heatmap" element={<Navigate to="/" replace />} />
|
||||
<Route path="/botnets" element={<Navigate to="/campaigns" replace />} />
|
||||
<Route path="/rotation" element={<Navigate to="/fingerprints" replace />} />
|
||||
<Route path="/ml-features" element={<MLFeaturesView />} />
|
||||
<Route path="/detections" element={<DetectionsList />} />
|
||||
<Route path="/detections/ip/:ip" element={<IpDetectionPageRedirect />} />
|
||||
<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/:ip" element={<IpInvestigationRedirect />} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── App ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function App() {
|
||||
const { data: metricsData } = useMetrics();
|
||||
|
||||
const counts = metricsData
|
||||
? {
|
||||
critical: metricsData.summary.critical_count ?? 0,
|
||||
high: metricsData.summary.high_count ?? 0,
|
||||
medium: metricsData.summary.medium_count ?? 0,
|
||||
total: metricsData.summary.total_detections ?? 0,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<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} />
|
||||
|
||||
{/* Page content — full-height sans padding pour les vues canvas */}
|
||||
<MainContent counts={counts} />
|
||||
</div>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
74
services/dashboard/frontend/src/ThemeContext.tsx
Normal file
74
services/dashboard/frontend/src/ThemeContext.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { CONFIG } from './config';
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'auto';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
resolved: 'dark' | 'light';
|
||||
setTheme: (t: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: CONFIG.DEFAULT_THEME,
|
||||
resolved: 'dark',
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
const STORAGE_KEY = CONFIG.THEME_STORAGE_KEY;
|
||||
|
||||
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 ?? CONFIG.DEFAULT_THEME;
|
||||
});
|
||||
|
||||
const [resolved, setResolved] = useState<'dark' | 'light'>(() => resolveTheme(
|
||||
(localStorage.getItem(STORAGE_KEY) as Theme | null) ?? CONFIG.DEFAULT_THEME
|
||||
));
|
||||
|
||||
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);
|
||||
}
|
||||
152
services/dashboard/frontend/src/api/client.ts
Normal file
152
services/dashboard/frontend/src/api/client.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import axios from 'axios';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: CONFIG.API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Types
|
||||
export interface MetricsSummary {
|
||||
total_detections: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
known_bots_count: number;
|
||||
anomalies_count: number;
|
||||
unique_ips: number;
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
hour: string;
|
||||
total: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
summary: MetricsSummary;
|
||||
timeseries: TimeSeriesPoint[];
|
||||
threat_distribution: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface Detection {
|
||||
detected_at: string;
|
||||
src_ip: string;
|
||||
ja4: string;
|
||||
host: string;
|
||||
bot_name: string;
|
||||
anomaly_score: number;
|
||||
threat_level: string;
|
||||
model_name: string;
|
||||
recurrence: number;
|
||||
asn_number: string;
|
||||
asn_org: string;
|
||||
asn_detail: string;
|
||||
asn_domain: string;
|
||||
country_code: string;
|
||||
asn_label: string;
|
||||
hits: number;
|
||||
hit_velocity: number;
|
||||
fuzzing_index: number;
|
||||
post_ratio: number;
|
||||
reason: string;
|
||||
client_headers: string;
|
||||
asn_score?: number | null;
|
||||
asn_rep_label?: string;
|
||||
anubis_bot_name?: string;
|
||||
anubis_bot_action?: string;
|
||||
anubis_bot_category?: string;
|
||||
}
|
||||
|
||||
export interface DetectionsListResponse {
|
||||
items: Detection[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface AttributeValue {
|
||||
value: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
first_seen?: string;
|
||||
last_seen?: string;
|
||||
threat_levels?: Record<string, number>;
|
||||
unique_ips?: number;
|
||||
primary_threat?: string;
|
||||
}
|
||||
|
||||
export interface VariabilityAttributes {
|
||||
user_agents: AttributeValue[];
|
||||
ja4: AttributeValue[];
|
||||
countries: AttributeValue[];
|
||||
asns: AttributeValue[];
|
||||
hosts: AttributeValue[];
|
||||
threat_levels: AttributeValue[];
|
||||
model_names: AttributeValue[];
|
||||
}
|
||||
|
||||
export interface Insight {
|
||||
type: 'warning' | 'info' | 'success';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface VariabilityResponse {
|
||||
type: string;
|
||||
value: string;
|
||||
total_detections: number;
|
||||
unique_ips: number;
|
||||
date_range: {
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
};
|
||||
attributes: VariabilityAttributes;
|
||||
insights: Insight[];
|
||||
}
|
||||
|
||||
export interface AttributeListItem {
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AttributeListResponse {
|
||||
type: string;
|
||||
items: AttributeListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// API Functions
|
||||
export const metricsApi = {
|
||||
getMetrics: () => api.get<MetricsResponse>('/metrics'),
|
||||
getThreatDistribution: () => api.get('/metrics/threats'),
|
||||
};
|
||||
|
||||
export const detectionsApi = {
|
||||
getDetections: (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
threat_level?: string;
|
||||
model_name?: string;
|
||||
country_code?: string;
|
||||
asn_number?: string;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
group_by_ip?: boolean;
|
||||
score_type?: string;
|
||||
}) => api.get<DetectionsListResponse>('/detections', { params }),
|
||||
|
||||
getDetails: (id: string) => api.get(`/detections/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
export const variabilityApi = {
|
||||
getVariability: (type: string, value: string) =>
|
||||
api.get<VariabilityResponse>(`/variability/${type}/${encodeURIComponent(value)}`),
|
||||
};
|
||||
465
services/dashboard/frontend/src/components/BruteForceView.tsx
Normal file
465
services/dashboard/frontend/src/components/BruteForceView.tsx
Normal file
@ -0,0 +1,465 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BruteForceTarget {
|
||||
host: string;
|
||||
unique_ips: number;
|
||||
total_hits: number;
|
||||
total_params: number;
|
||||
attack_type: string;
|
||||
top_ja4s: string[];
|
||||
}
|
||||
|
||||
interface BruteForceAttacker {
|
||||
ip: string;
|
||||
distinct_hosts: number;
|
||||
total_hits: number;
|
||||
total_params: number;
|
||||
ja4: string;
|
||||
}
|
||||
|
||||
interface TimelineHour {
|
||||
hour: number;
|
||||
hits: number;
|
||||
ips: number;
|
||||
}
|
||||
|
||||
type ActiveTab = 'targets' | 'attackers' | 'timeline';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Attackers DataTable ─────────────────────────────────────────────────────
|
||||
|
||||
function AttackersTable({
|
||||
attackers,
|
||||
navigate,
|
||||
}: {
|
||||
attackers: BruteForceAttacker[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const columns = useMemo((): Column<BruteForceAttacker>[] => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||
},
|
||||
{ key: 'distinct_hosts', label: 'Hosts ciblés', align: 'right' },
|
||||
{
|
||||
key: 'total_hits',
|
||||
label: 'Hits',
|
||||
align: 'right',
|
||||
render: (v: number) => formatNumber(v),
|
||||
},
|
||||
{
|
||||
key: 'total_params',
|
||||
label: 'Params',
|
||||
tooltip: TIPS.params_combos,
|
||||
align: 'right',
|
||||
render: (v: number) => formatNumber(v),
|
||||
},
|
||||
{
|
||||
key: 'ja4',
|
||||
label: 'JA4',
|
||||
tooltip: TIPS.ja4,
|
||||
render: (v: string) => (
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{v ? `${v.slice(0, 16)}…` : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: BruteForceAttacker) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
|
||||
className="text-xs bg-threat-high/10 text-threat-high px-3 py-1 rounded hover:bg-threat-high/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
),
|
||||
},
|
||||
], [navigate]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={attackers}
|
||||
columns={columns}
|
||||
rowKey="ip"
|
||||
defaultSortKey="total_hits"
|
||||
emptyMessage="Aucun attaquant trouvé"
|
||||
compact
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface HostAttacker { ip: string; total_hits: number; total_params: number; ja4: string; attack_type: string; }
|
||||
|
||||
function TargetRow({ t, navigate }: { t: BruteForceTarget; navigate: (path: string) => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [hostAttackers, setHostAttackers] = useState<HostAttacker[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const toggle = async () => {
|
||||
setExpanded(prev => !prev);
|
||||
if (!loaded && !expanded) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/bruteforce/host/${encodeURIComponent(t.host)}/attackers?limit=20`);
|
||||
if (!res.ok) throw new Error('Erreur chargement');
|
||||
const data: { items: HostAttacker[] } = await res.json();
|
||||
setHostAttackers(data.items ?? []);
|
||||
setLoaded(true);
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-border hover:bg-background-card transition-colors cursor-pointer" onClick={toggle}>
|
||||
<td className="px-4 py-3 font-mono text-text-primary text-xs flex items-center gap-2">
|
||||
<span className="text-accent-primary">{expanded ? '▾' : '▸'}</span>
|
||||
{t.host}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(t.unique_ips)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_hits)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(t.total_params)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{t.attack_type === 'credential_stuffing' ? (
|
||||
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-1 rounded-full" title={TIPS.credential_stuffing}>💳 Credential Stuffing</span>
|
||||
) : (
|
||||
<span className="bg-threat-high/20 text-threat-high text-xs px-2 py-1 rounded-full" title={TIPS.enumeration}>🔍 Énumération</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(t.top_ja4s ?? []).slice(0, 2).map((ja4, i) => (
|
||||
<span key={i} className="font-mono text-xs bg-background-card px-1.5 py-0.5 rounded border border-border text-text-secondary">
|
||||
{ja4.slice(0, 12)}…
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border bg-background-card">
|
||||
<td colSpan={6} className="px-6 py-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-text-secondary text-sm py-2">
|
||||
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
Chargement des attaquants…
|
||||
</div>
|
||||
) : hostAttackers.length === 0 ? (
|
||||
<p className="text-text-disabled text-sm py-2">Aucun attaquant trouvé.</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-text-secondary text-xs mb-2 font-medium">
|
||||
Top {hostAttackers.length} IP attaquant <span className="text-accent-primary font-mono">{t.host}</span>
|
||||
</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-text-disabled border-b border-border">
|
||||
<th className="text-left py-1 pr-4">IP</th>
|
||||
<th className="text-left py-1 pr-4">Hits</th>
|
||||
<th className="text-left py-1 pr-4">Params</th>
|
||||
<th className="text-left py-1 pr-4">JA4</th>
|
||||
<th className="text-left py-1 pr-4">Type</th>
|
||||
<th className="text-left py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hostAttackers.map((a) => (
|
||||
<tr key={a.ip} className="border-b border-border/50 hover:bg-background-secondary transition-colors">
|
||||
<td className="py-1.5 pr-4 font-mono text-text-primary">{a.ip}</td>
|
||||
<td className="py-1.5 pr-4 text-text-primary">{formatNumber(a.total_hits)}</td>
|
||||
<td className="py-1.5 pr-4 text-text-secondary">{formatNumber(a.total_params)}</td>
|
||||
<td className="py-1.5 pr-4 font-mono text-text-secondary">{a.ja4 ? a.ja4.slice(0, 16) + '…' : '—'}</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
{a.attack_type === 'credential_stuffing'
|
||||
? <span className="text-threat-critical">💳</span>
|
||||
: <span className="text-threat-medium">🔍</span>
|
||||
}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${a.ip}`); }}
|
||||
className="text-accent-primary hover:underline text-xs"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BruteForceView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('targets');
|
||||
|
||||
const [targets, setTargets] = useState<BruteForceTarget[]>([]);
|
||||
const [targetsTotal, setTargetsTotal] = useState(0);
|
||||
const [targetsLoading, setTargetsLoading] = useState(true);
|
||||
const [targetsError, setTargetsError] = useState<string | null>(null);
|
||||
|
||||
const [attackers, setAttackers] = useState<BruteForceAttacker[]>([]);
|
||||
const [attackersLoading, setAttackersLoading] = useState(false);
|
||||
const [attackersError, setAttackersError] = useState<string | null>(null);
|
||||
const [attackersLoaded, setAttackersLoaded] = useState(false);
|
||||
|
||||
const [timeline, setTimeline] = useState<TimelineHour[]>([]);
|
||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||
const [timelineError, setTimelineError] = useState<string | null>(null);
|
||||
const [timelineLoaded, setTimelineLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTargets = async () => {
|
||||
setTargetsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/bruteforce/targets');
|
||||
if (!res.ok) throw new Error('Erreur chargement des cibles');
|
||||
const data: { items: BruteForceTarget[]; total: number } = await res.json();
|
||||
setTargets(data.items ?? []);
|
||||
setTargetsTotal(data.total ?? 0);
|
||||
} catch (err) {
|
||||
setTargetsError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setTargetsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTargets();
|
||||
}, []);
|
||||
|
||||
const loadAttackers = async () => {
|
||||
if (attackersLoaded) return;
|
||||
setAttackersLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/bruteforce/attackers?limit=50');
|
||||
if (!res.ok) throw new Error('Erreur chargement des attaquants');
|
||||
const data: { items: BruteForceAttacker[] } = await res.json();
|
||||
setAttackers(data.items ?? []);
|
||||
setAttackersLoaded(true);
|
||||
} catch (err) {
|
||||
setAttackersError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setAttackersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTimeline = async () => {
|
||||
if (timelineLoaded) return;
|
||||
setTimelineLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/bruteforce/timeline');
|
||||
if (!res.ok) throw new Error('Erreur chargement de la timeline');
|
||||
const data: { hours: TimelineHour[] } = await res.json();
|
||||
setTimeline(data.hours ?? []);
|
||||
setTimelineLoaded(true);
|
||||
} catch (err) {
|
||||
setTimelineError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setTimelineLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: ActiveTab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'attackers') loadAttackers();
|
||||
if (tab === 'timeline') loadTimeline();
|
||||
};
|
||||
|
||||
const totalHits = targets.reduce((s, t) => s + t.total_hits, 0);
|
||||
|
||||
const maxHits = timeline.length > 0 ? Math.max(...timeline.map((h) => h.hits)) : 1;
|
||||
const peakHour = timeline.reduce(
|
||||
(best, h) => (h.hits > best.hits ? h : best),
|
||||
{ hour: 0, hits: 0, ips: 0 }
|
||||
);
|
||||
|
||||
const tabs: { id: ActiveTab; label: string }[] = [
|
||||
{ id: 'targets', label: '🎯 Cibles' },
|
||||
{ id: 'attackers', label: '⚔️ Attaquants' },
|
||||
{ id: 'timeline', label: '⏱️ Timeline' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🔥 Brute Force & Credential Stuffing</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Détection des attaques par force brute, credential stuffing et énumération de paramètres.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard label="Cibles détectées" value={formatNumber(targetsTotal)} accent="text-threat-high" />
|
||||
<StatCard
|
||||
label="IPs attaquantes"
|
||||
value={attackersLoaded ? formatNumber(attackers.length) : '—'}
|
||||
accent="text-threat-critical"
|
||||
/>
|
||||
<StatCard label="Total hits" value={formatNumber(totalHits)} accent="text-text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-accent-primary border-b-2 border-accent-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cibles tab */}
|
||||
{activeTab === 'targets' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{targetsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : targetsError ? (
|
||||
<div className="p-4"><ErrorMessage message={targetsError} /></div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-4 py-3">Host (cliquer pour détails)</th>
|
||||
<th className="px-4 py-3">IPs distinctes</th>
|
||||
<th className="px-4 py-3">Total hits</th>
|
||||
<th className="px-4 py-3 whitespace-nowrap">
|
||||
Params combos
|
||||
<InfoTip content={TIPS.params_combos} />
|
||||
</th>
|
||||
<th className="px-4 py-3"><span className="flex items-center gap-1">Type d'attaque<InfoTip content={TIPS.attack_brute_force} /></span></th>
|
||||
<th className="px-4 py-3 whitespace-nowrap">
|
||||
Top JA4
|
||||
<InfoTip content={TIPS.ja4} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{targets.map((t) => (
|
||||
<TargetRow key={t.host} t={t} navigate={navigate} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attaquants tab */}
|
||||
{activeTab === 'attackers' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{attackersLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : attackersError ? (
|
||||
<div className="p-4"><ErrorMessage message={attackersError} /></div>
|
||||
) : (
|
||||
<AttackersTable attackers={attackers} navigate={navigate} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline tab */}
|
||||
{activeTab === 'timeline' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
{timelineLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : timelineError ? (
|
||||
<ErrorMessage message={timelineError} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-text-primary font-semibold">Activité par heure</h2>
|
||||
{peakHour.hits > 0 && (
|
||||
<span className="text-xs text-text-secondary">
|
||||
Pic : <span className="text-threat-critical font-medium">{peakHour.hour}h</span> ({formatNumber(peakHour.hits)} hits)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end gap-1 h-48">
|
||||
{Array.from({ length: 24 }, (_, i) => {
|
||||
const entry = timeline.find((h) => h.hour === i) ?? { hour: i, hits: 0, ips: 0 };
|
||||
const pct = maxHits > 0 ? (entry.hits / maxHits) * 100 : 0;
|
||||
const isPeak = entry.hour === peakHour.hour && entry.hits > 0;
|
||||
return (
|
||||
<div key={i} className="flex flex-col items-center flex-1 gap-1">
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: '160px' }}>
|
||||
<div
|
||||
title={`${i}h: ${entry.hits} hits, ${entry.ips} IPs`}
|
||||
style={{ height: `${Math.max(pct, 1)}%` }}
|
||||
className={`w-full rounded-t transition-all ${
|
||||
isPeak
|
||||
? 'bg-threat-critical'
|
||||
: pct >= 70
|
||||
? 'bg-threat-high'
|
||||
: pct >= 30
|
||||
? 'bg-threat-medium'
|
||||
: 'bg-accent-primary/60'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-disabled text-xs">{i}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-4 mt-4 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-critical rounded-sm inline-block" /> Pic</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-high rounded-sm inline-block" /> Élevé (≥70%)</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-medium rounded-sm inline-block" /> Moyen (≥30%)</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-accent-primary/60 rounded-sm inline-block" /> Faible</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{activeTab === 'targets' && !targetsLoading && !targetsError && (
|
||||
<p className="text-text-secondary text-xs">{formatNumber(targetsTotal)} cible(s) détectée(s)</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,295 @@
|
||||
import { useState } from 'react';
|
||||
import { PREDEFINED_TAGS } from '../utils/classifications';
|
||||
|
||||
interface BulkClassificationProps {
|
||||
selectedIPs: string[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function BulkClassification({ selectedIPs, onClose, onSuccess }: BulkClassificationProps) {
|
||||
const [selectedLabel, setSelectedLabel] = useState<string>('suspicious');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [comment, setComment] = useState('');
|
||||
const [confidence, setConfidence] = useState(0.7);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: selectedIPs.length });
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
const handleBulkClassify = async () => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
// Process in batches of 10
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < selectedIPs.length; i += batchSize) {
|
||||
const batch = selectedIPs.slice(i, i + batchSize);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(ip =>
|
||||
fetch('/api/analysis/classifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ip,
|
||||
label: selectedLabel,
|
||||
tags: selectedTags,
|
||||
comment: `${comment} (Classification en masse - ${selectedIPs.length} IPs)`,
|
||||
confidence,
|
||||
analyst: 'soc_user',
|
||||
bulk_operation: true,
|
||||
bulk_id: `bulk-${Date.now()}`
|
||||
})
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
setProgress({ current: Math.min(i + batchSize, selectedIPs.length), total: selectedIPs.length });
|
||||
}
|
||||
|
||||
// Log the bulk operation
|
||||
await fetch('/api/audit/logs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'BULK_CLASSIFICATION',
|
||||
entity_type: 'ip',
|
||||
entity_count: selectedIPs.length,
|
||||
details: {
|
||||
label: selectedLabel,
|
||||
tags: selectedTags,
|
||||
confidence
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Bulk classification error:', error);
|
||||
alert('Erreur lors de la classification en masse');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const csv = selectedIPs.map(ip =>
|
||||
`${ip},${selectedLabel},"${selectedTags.join(';')}",${confidence},"${comment}"`
|
||||
).join('\n');
|
||||
|
||||
const header = 'ip,label,tags,confidence,comment\n';
|
||||
const blob = new Blob([header + csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `bulk_classification_${Date.now()}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background-secondary rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">
|
||||
🏷️ Classification en Masse
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
{selectedIPs.length} IPs sélectionnées
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{processing && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-text-secondary">Progression</span>
|
||||
<span className="text-sm text-text-primary font-bold">
|
||||
{progress.current} / {progress.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-background-card rounded-full h-3">
|
||||
<div
|
||||
className="h-3 rounded-full bg-accent-primary transition-all"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification Label */}
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-semibold text-text-primary mb-3 block">
|
||||
Niveau de Menace
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedLabel('legitimate')}
|
||||
disabled={processing}
|
||||
className={`py-4 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'legitimate'
|
||||
? 'bg-threat-low text-white ring-2 ring-threat-low'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">✅</div>
|
||||
<div className="text-sm">Légitime</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedLabel('suspicious')}
|
||||
disabled={processing}
|
||||
className={`py-4 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'suspicious'
|
||||
? 'bg-threat-medium text-white ring-2 ring-threat-medium'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">⚠️</div>
|
||||
<div className="text-sm">Suspect</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedLabel('malicious')}
|
||||
disabled={processing}
|
||||
className={`py-4 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'malicious'
|
||||
? 'bg-threat-high text-white ring-2 ring-threat-high'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">❌</div>
|
||||
<div className="text-sm">Malveillant</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-semibold text-text-primary mb-3 block">
|
||||
Tags
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto p-2 bg-background-card rounded-lg">
|
||||
{PREDEFINED_TAGS.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
disabled={processing}
|
||||
className={`px-3 py-1.5 rounded text-xs transition-colors ${
|
||||
selectedTags.includes(tag)
|
||||
? 'bg-accent-primary text-white'
|
||||
: 'bg-background-secondary text-text-secondary hover:text-text-primary'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="mt-2 text-xs text-text-secondary">
|
||||
{selectedTags.length} tag(s) sélectionné(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confidence Slider */}
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-semibold text-text-primary mb-3 block">
|
||||
Confiance: {(confidence * 100).toFixed(0)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={confidence}
|
||||
onChange={(e) => setConfidence(parseFloat(e.target.value))}
|
||||
disabled={processing}
|
||||
className="w-full h-2 bg-background-card rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-text-secondary mt-1">
|
||||
<span>0%</span>
|
||||
<span>50%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-semibold text-text-primary mb-3 block">
|
||||
Commentaire
|
||||
</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
disabled={processing}
|
||||
placeholder="Notes d'analyse..."
|
||||
className="w-full bg-background-card border border-background-card rounded-lg p-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-background-card rounded-lg p-4 mb-6">
|
||||
<div className="text-sm font-semibold text-text-primary mb-2">
|
||||
📋 Résumé
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text-secondary">IPs:</span>{' '}
|
||||
<span className="text-text-primary font-bold">{selectedIPs.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Label:</span>{' '}
|
||||
<span className={`font-bold ${
|
||||
selectedLabel === 'legitimate' ? 'text-threat-low' :
|
||||
selectedLabel === 'suspicious' ? 'text-threat-medium' :
|
||||
'text-threat-high'
|
||||
}`}>
|
||||
{selectedLabel.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Tags:</span>{' '}
|
||||
<span className="text-text-primary">{selectedTags.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-secondary">Confiance:</span>{' '}
|
||||
<span className="text-text-primary">{(confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
disabled={processing}
|
||||
className="flex-1 py-3 px-4 bg-background-card text-text-primary rounded-lg font-medium hover:bg-background-card/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
📄 Export CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkClassify}
|
||||
disabled={processing || !selectedLabel}
|
||||
className="flex-1 py-3 px-4 bg-accent-primary text-white rounded-lg font-medium hover:bg-accent-primary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{processing ? '⏳ Traitement...' : `💾 Classifier ${selectedIPs.length} IPs`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1072
services/dashboard/frontend/src/components/CampaignsView.tsx
Normal file
1072
services/dashboard/frontend/src/components/CampaignsView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
734
services/dashboard/frontend/src/components/ClusteringView.tsx
Normal file
734
services/dashboard/frontend/src/components/ClusteringView.tsx
Normal file
@ -0,0 +1,734 @@
|
||||
/**
|
||||
* ClusteringView — Visualisation WebGL des clusters d'IPs via deck.gl
|
||||
*
|
||||
* Architecture LOD :
|
||||
* - Vue globale : PolygonLayer (hulls) + ScatterplotLayer (centroïdes)
|
||||
* - Sur sélection : ScatterplotLayer dense (toutes les IPs du cluster)
|
||||
* - Sidebar : profil radar, stats, liste IPs paginée
|
||||
*
|
||||
* Rendu WebGL via @deck.gl/react + OrthographicView
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import DeckGL from '@deck.gl/react';
|
||||
import { OrthographicView } from '@deck.gl/core';
|
||||
import { ScatterplotLayer, PolygonLayer, TextLayer, LineLayer } from '@deck.gl/layers';
|
||||
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import axios from 'axios';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RadarEntry { feature: string; value: number; }
|
||||
|
||||
interface ClusterNode {
|
||||
id: string;
|
||||
cluster_idx: number;
|
||||
label: string;
|
||||
pca_x: number;
|
||||
pca_y: number;
|
||||
radius: number;
|
||||
color: string;
|
||||
risk_score: number;
|
||||
ip_count: number;
|
||||
hit_count: number;
|
||||
mean_ttl: number;
|
||||
mean_mss: number;
|
||||
mean_velocity: number;
|
||||
mean_fuzzing: number;
|
||||
mean_headless: number;
|
||||
mean_ua_ch: number;
|
||||
top_threat: string;
|
||||
top_countries: string[];
|
||||
top_orgs: string[];
|
||||
sample_ips: string[];
|
||||
sample_ua: string;
|
||||
radar: RadarEntry[];
|
||||
hull: [number, number][];
|
||||
}
|
||||
|
||||
interface ClusterEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
interface ClusterStats {
|
||||
total_clusters: number;
|
||||
total_ips: number;
|
||||
total_hits: number;
|
||||
n_samples: number;
|
||||
k: number;
|
||||
elapsed_s: number;
|
||||
}
|
||||
|
||||
interface ClusterResult {
|
||||
status: string;
|
||||
nodes: ClusterNode[];
|
||||
edges: ClusterEdge[];
|
||||
stats: ClusterStats;
|
||||
feature_names: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface IPPoint { ip: string; ja4: string; pca_x: number; pca_y: number; risk: number; }
|
||||
interface IPDetail { ip: string; ja4: string; tcp_ttl: number; tcp_mss: number; hits: number; ua: string; avg_score: number; threat_level: string; country_code: string; asn_org: string; }
|
||||
|
||||
// ─── Coordonnées deck.gl ─────────────────────────────────────────────────────
|
||||
// PCA normalisé [0,1] → world [0, WORLD]
|
||||
const WORLD = 1000;
|
||||
|
||||
function toWorld(v: number): number { return v * WORLD; }
|
||||
|
||||
// Couleur hex → [r,g,b,a]
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return [r, g, b, alpha];
|
||||
}
|
||||
|
||||
// ─── Composant principal ──────────────────────────────────────────────────────
|
||||
|
||||
// Persistence des paramètres dans localStorage
|
||||
const LS_KEY = 'soc_clustering_params';
|
||||
function loadParams() {
|
||||
try {
|
||||
const s = localStorage.getItem(LS_KEY);
|
||||
if (s) return JSON.parse(s) as { k: number; hours: number; sensitivity: number };
|
||||
} catch { /* ignore */ }
|
||||
return { k: 20, hours: 24, sensitivity: 1.0 };
|
||||
}
|
||||
|
||||
export default function ClusteringView() {
|
||||
const init = loadParams();
|
||||
const [k, setK] = useState(init.k);
|
||||
const [hours, setHours] = useState(init.hours);
|
||||
const [sensitivity, setSensitivity] = useState(init.sensitivity);
|
||||
const [data, setData] = useState<ClusterResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [computing, setComputing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<ClusterNode | null>(null);
|
||||
const [clusterPoints, setClusterPoints] = useState<IPPoint[]>([]);
|
||||
const [ipDetails, setIpDetails] = useState<IPDetail[]>([]);
|
||||
const [ipPage, setIpPage] = useState(0);
|
||||
const [ipTotal, setIpTotal] = useState(0);
|
||||
const [showEdges, setShowEdges] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Viewport deck.gl — centré à [WORLD/2, WORLD/2]
|
||||
const [viewState, setViewState] = useState({
|
||||
target: [WORLD / 2, WORLD / 2, 0] as [number, number, number],
|
||||
zoom: -0.5, // montre légèrement plus que le monde [0,WORLD]
|
||||
minZoom: -3,
|
||||
maxZoom: 6,
|
||||
});
|
||||
|
||||
// ── Persistence des paramètres ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({ k, hours, sensitivity }));
|
||||
}, [k, hours, sensitivity]);
|
||||
|
||||
// ── Chargement / polling ─────────────────────────────────────────────────
|
||||
|
||||
const fetchClusters = useCallback(async (force = false) => {
|
||||
if (pollRef.current) { clearTimeout(pollRef.current); pollRef.current = null; }
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await axios.get<ClusterResult>('/api/clustering/clusters', {
|
||||
params: { k, hours, sensitivity, force },
|
||||
});
|
||||
if (res.data.status === 'computing' || res.data.status === 'idle') {
|
||||
setComputing(true);
|
||||
// Polling toutes les 3s
|
||||
pollRef.current = setTimeout(() => fetchClusters(), 3000);
|
||||
} else {
|
||||
setComputing(false);
|
||||
setData(res.data);
|
||||
// Fit viewport
|
||||
if (res.data.nodes?.length) {
|
||||
const xs = res.data.nodes.map(n => toWorld(n.pca_x));
|
||||
const ys = res.data.nodes.map(n => toWorld(n.pca_y));
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
const pad = 0.18;
|
||||
const fitW = (maxX - minX) * (1 + 2 * pad) || WORLD;
|
||||
const fitH = (maxY - minY) * (1 + 2 * pad) || WORLD;
|
||||
const canvasW = window.innerWidth - 288 - (selected ? 384 : 0);
|
||||
const canvasH = window.innerHeight - 60;
|
||||
setViewState(v => ({
|
||||
...v,
|
||||
target: [(minX + maxX) / 2, (minY + maxY) / 2, 0],
|
||||
zoom: Math.min(
|
||||
Math.log2(canvasW / fitW),
|
||||
Math.log2(canvasH / fitH),
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError((e as Error).message);
|
||||
setComputing(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [k, hours, sensitivity]); // sensitivity inclus pour éviter la stale closure
|
||||
|
||||
useEffect(() => {
|
||||
fetchClusters();
|
||||
return () => { if (pollRef.current) clearTimeout(pollRef.current); };
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// ── Drill-down : chargement des points du cluster sélectionné ───────────
|
||||
|
||||
const loadClusterPoints = useCallback(async (node: ClusterNode) => {
|
||||
try {
|
||||
const res = await axios.get<{ points: IPPoint[]; total: number }>(
|
||||
`/api/clustering/cluster/${node.id}/points`,
|
||||
{ params: { limit: 10000, offset: 0 } }
|
||||
);
|
||||
setClusterPoints(res.data.points);
|
||||
} catch { setClusterPoints([]); }
|
||||
}, []);
|
||||
|
||||
const loadClusterIPs = useCallback(async (node: ClusterNode, page = 0) => {
|
||||
try {
|
||||
const res = await axios.get<{ ips: IPDetail[]; total: number }>(
|
||||
`/api/clustering/cluster/${node.id}/ips`,
|
||||
{ params: { limit: 50, offset: page * 50 } }
|
||||
);
|
||||
setIpDetails(res.data.ips);
|
||||
setIpTotal(res.data.total);
|
||||
setIpPage(page);
|
||||
} catch { setIpDetails([]); }
|
||||
}, []);
|
||||
|
||||
const handleSelectCluster = useCallback((node: ClusterNode) => {
|
||||
setSelected(node);
|
||||
setClusterPoints([]);
|
||||
setIpDetails([]);
|
||||
loadClusterPoints(node);
|
||||
loadClusterIPs(node, 0);
|
||||
}, [loadClusterPoints, loadClusterIPs]);
|
||||
|
||||
// ── Layers deck.gl ─────────────────────────────────────────────────────
|
||||
|
||||
const layers = React.useMemo(() => {
|
||||
if (!data?.nodes) return [];
|
||||
const nodes = data.nodes;
|
||||
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]));
|
||||
|
||||
const layerList: object[] = [];
|
||||
|
||||
// 1. Hulls (enveloppes convexes) — toujours visibles
|
||||
const hullData = nodes
|
||||
.filter(n => n.hull && n.hull.length >= 3)
|
||||
.map(n => ({
|
||||
...n,
|
||||
polygon: n.hull.map(([x, y]) => [toWorld(x), toWorld(y)]),
|
||||
}));
|
||||
|
||||
layerList.push(new PolygonLayer({
|
||||
id: 'hulls',
|
||||
data: hullData,
|
||||
getPolygon: (d: typeof hullData[number]) => d.polygon,
|
||||
getFillColor: (d: typeof hullData[number]) => hexToRgba(d.color, d.id === selected?.id ? 55 : 28),
|
||||
getLineColor: (d: typeof hullData[number]) => hexToRgba(d.color, d.id === selected?.id ? 220 : 130),
|
||||
getLineWidth: (d: typeof hullData[number]) => d.id === selected?.id ? 3 : 1.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pickable: true,
|
||||
autoHighlight: true,
|
||||
highlightColor: [255, 255, 255, 30],
|
||||
onClick: ({ object }: { object?: typeof hullData[number] }) => {
|
||||
if (object) handleSelectCluster(object as ClusterNode);
|
||||
},
|
||||
updateTriggers: { getFillColor: [selected?.id], getLineColor: [selected?.id], getLineWidth: [selected?.id] },
|
||||
}));
|
||||
|
||||
// 2. Arêtes inter-clusters (optionnelles)
|
||||
if (showEdges && data.edges) {
|
||||
const edgeData = data.edges
|
||||
.map(e => {
|
||||
const s = nodeMap[e.source];
|
||||
const t = nodeMap[e.target];
|
||||
if (!s || !t) return null;
|
||||
return { source: [toWorld(s.pca_x), toWorld(s.pca_y)], target: [toWorld(t.pca_x), toWorld(t.pca_y)], sim: e.similarity };
|
||||
})
|
||||
.filter(Boolean) as { source: [number, number]; target: [number, number]; sim: number }[];
|
||||
|
||||
layerList.push(new LineLayer({
|
||||
id: 'edges',
|
||||
data: edgeData,
|
||||
getSourcePosition: d => d.source,
|
||||
getTargetPosition: d => d.target,
|
||||
getColor: [100, 100, 120, 80],
|
||||
getWidth: 1,
|
||||
widthUnits: 'pixels',
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. Points IPs du cluster sélectionné
|
||||
if (selected && clusterPoints.length > 0) {
|
||||
layerList.push(new ScatterplotLayer({
|
||||
id: 'ip-points',
|
||||
data: clusterPoints,
|
||||
getPosition: (d: IPPoint) => [toWorld(d.pca_x), toWorld(d.pca_y), 0],
|
||||
getRadius: 3,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: (d: IPPoint) => {
|
||||
const r = d.risk;
|
||||
if (r > 0.70) return [220, 38, 38, 200];
|
||||
if (r > 0.45) return [249, 115, 22, 200];
|
||||
if (r > 0.25) return [234, 179, 8, 200];
|
||||
return [34, 197, 94, 180];
|
||||
},
|
||||
pickable: false,
|
||||
updateTriggers: { getPosition: [clusterPoints.length] },
|
||||
}));
|
||||
}
|
||||
|
||||
// 4. Centroïdes (cercles de taille ∝ ip_count)
|
||||
layerList.push(new ScatterplotLayer({
|
||||
id: 'centroids',
|
||||
data: nodes,
|
||||
getPosition: (d: ClusterNode) => [toWorld(d.pca_x), toWorld(d.pca_y), 0],
|
||||
getRadius: (d: ClusterNode) => d.radius,
|
||||
radiusUnits: 'pixels',
|
||||
getFillColor: (d: ClusterNode) => hexToRgba(d.color, d.id === selected?.id ? 255 : 180),
|
||||
getLineColor: [255, 255, 255, 180],
|
||||
getLineWidth: (d: ClusterNode) => d.id === selected?.id ? 3 : 1,
|
||||
lineWidthUnits: 'pixels',
|
||||
stroked: true,
|
||||
filled: true,
|
||||
pickable: true,
|
||||
autoHighlight: true,
|
||||
highlightColor: [255, 255, 255, 60],
|
||||
onClick: ({ object }: { object?: ClusterNode }) => {
|
||||
if (object) handleSelectCluster(object);
|
||||
},
|
||||
updateTriggers: { getFillColor: [selected?.id], getLineWidth: [selected?.id] },
|
||||
}));
|
||||
|
||||
const stripNonAscii = (s: string) =>
|
||||
s.replace(/[\u{0080}-\u{FFFF}]/gu, c => {
|
||||
// Translitérations basiques pour la lisibilité
|
||||
const map: Record<string, string> = { é:'e',è:'e',ê:'e',ë:'e',à:'a',â:'a',ô:'o',ù:'u',û:'u',î:'i',ï:'i',ç:'c' };
|
||||
return map[c] ?? '';
|
||||
}).replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, '').trim();
|
||||
layerList.push(new TextLayer({
|
||||
id: 'labels',
|
||||
data: nodes,
|
||||
getPosition: (d: ClusterNode) => [toWorld(d.pca_x), toWorld(d.pca_y), 0],
|
||||
getText: (d: ClusterNode) => stripNonAscii(d.label),
|
||||
getSize: 12,
|
||||
sizeUnits: 'pixels',
|
||||
getColor: [255, 255, 255, 200],
|
||||
getAnchor: 'middle',
|
||||
getAlignmentBaseline: 'top',
|
||||
getPixelOffset: (d: ClusterNode) => [0, d.radius + 4],
|
||||
fontFamily: 'monospace',
|
||||
background: true,
|
||||
getBorderColor: [0, 0, 0, 0],
|
||||
backgroundPadding: [3, 1, 3, 1],
|
||||
getBackgroundColor: [15, 20, 30, 180],
|
||||
}));
|
||||
|
||||
return layerList;
|
||||
}, [data, selected, clusterPoints, showEdges, handleSelectCluster]);
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden bg-background text-text-primary">
|
||||
{/* ── Panneau gauche (scroll indépendant) ── */}
|
||||
<div className="flex flex-col w-72 flex-shrink-0 border-r border-gray-700 overflow-y-auto p-4 gap-4 z-10" style={{ height: '100%' }}>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">🔬 Clustering IPs</h2>
|
||||
<p className="text-xs text-text-secondary">Rendu WebGL · K-means++ sur toutes les IPs</p>
|
||||
</div>
|
||||
|
||||
{/* Paramètres */}
|
||||
<div className="bg-background-card rounded-lg p-3 space-y-3">
|
||||
{/* Sensibilité */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-text-secondary">
|
||||
<span className="flex items-center">
|
||||
Sensibilité
|
||||
<InfoTip content={TIPS.sensitivity} />
|
||||
</span>
|
||||
<span className="font-mono text-white">
|
||||
{sensitivity <= 0.5 ? 'Grossière' : sensitivity <= 1.0 ? 'Normale' : sensitivity <= 2.0 ? 'Fine' : sensitivity <= 3.5 ? 'Très fine' : sensitivity <= 4.5 ? 'Maximale' : 'Extrême'}
|
||||
{' '}(<span title={TIPS.k_actual}>{Math.round(k * sensitivity)} clusters effectifs</span>)
|
||||
</span>
|
||||
</div>
|
||||
<input type="range" min={0.5} max={5.0} step={0.5} value={sensitivity}
|
||||
onChange={e => setSensitivity(+e.target.value)}
|
||||
className="w-full accent-accent-primary" />
|
||||
<div className="flex justify-between text-xs text-text-disabled">
|
||||
<span>Grossière</span><span>Fine</span><span>Extrême</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* k avancé */}
|
||||
<details className="text-xs text-text-secondary">
|
||||
<summary className="cursor-pointer hover:text-white">Paramètres avancés</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<label className="block">
|
||||
<span className="flex items-center gap-1">
|
||||
Clusters de base (k)
|
||||
<InfoTip content={TIPS.k_base} />
|
||||
</span>
|
||||
<input type="range" min={4} max={100} value={k}
|
||||
onChange={e => setK(+e.target.value)}
|
||||
className="w-full mt-1 accent-accent-primary" />
|
||||
<span className="font-mono text-white">{k} → {Math.round(k * sensitivity)} clusters effectifs</span>
|
||||
</label>
|
||||
<label className="block">
|
||||
Fenêtre
|
||||
<select value={hours} onChange={e => setHours(+e.target.value)}
|
||||
className="w-full mt-1 bg-background border border-gray-600 rounded px-2 py-1">
|
||||
<option value={6}>6h</option>
|
||||
<option value={12}>12h</option>
|
||||
<option value={24}>24h</option>
|
||||
<option value={48}>48h</option>
|
||||
<option value={168}>7j</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" checked={showEdges} onChange={e => setShowEdges(e.target.checked)}
|
||||
className="accent-accent-primary" />
|
||||
<span className="flex items-center">
|
||||
Afficher les arêtes
|
||||
<InfoTip content={TIPS.show_edges} />
|
||||
</span>
|
||||
</label>
|
||||
<button onClick={() => fetchClusters(true)}
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-accent-primary text-white rounded text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{computing ? '⏳ Calcul en cours…' : loading ? '⏳ Chargement…' : '🔄 Recalculer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats globales */}
|
||||
{data?.stats && (
|
||||
<div className="bg-background-card rounded-lg p-3 space-y-1 text-xs">
|
||||
<div className="font-semibold text-sm mb-2">Résultats</div>
|
||||
<Stat label="Clusters" value={data.stats.total_clusters} tooltip={TIPS.k_actual} />
|
||||
<Stat label="IPs totales" value={data.stats.total_ips.toLocaleString()} tooltip={TIPS.pca_2d} />
|
||||
<Stat label="Hits totaux" value={data.stats.total_hits.toLocaleString()} tooltip={TIPS.total_hits} />
|
||||
<Stat label="Calcul" value={`${data.stats.elapsed_s}s`} tooltip={TIPS.calc_time} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message computing */}
|
||||
{computing && (
|
||||
<div className="bg-yellow-900/30 border border-yellow-600/40 rounded-lg p-3 text-xs text-yellow-300">
|
||||
⏳ Calcul en cours sur {data?.stats?.n_samples?.toLocaleString() ?? '…'} IPs…
|
||||
<br />Mise à jour automatique toutes les 3s
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/30 border border-red-600/40 rounded p-3 text-xs text-red-300">
|
||||
❌ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste clusters */}
|
||||
{data?.nodes && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary font-semibold uppercase tracking-wide">Clusters</div>
|
||||
{[...data.nodes]
|
||||
.sort((a, b) => b.risk_score - a.risk_score)
|
||||
.map(n => (
|
||||
<button key={n.id} onClick={() => handleSelectCluster(n)}
|
||||
className={`w-full text-left px-3 py-2 rounded text-xs flex items-center gap-2 transition-colors
|
||||
${selected?.id === n.id ? 'bg-accent-primary/20 ring-1 ring-accent-primary' : 'hover:bg-background-secondary'}`}>
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: n.color }} />
|
||||
<span className="flex-1 truncate">{n.label}</span>
|
||||
<span className="text-text-disabled">{n.ip_count.toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Canvas WebGL (deck.gl) ── */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
|
||||
{/* Animation de calcul — REMPLACE DeckGL (le canvas WebGL ignore z-index) */}
|
||||
{(computing || loading) ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background">
|
||||
{/* Noeuds pulsants animés */}
|
||||
<div className="relative w-56 h-56 mb-2">
|
||||
{/* Anneaux tournants */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-32 h-32 rounded-full border-2 border-accent-primary/20 border-t-accent-primary animate-spin" style={{ animationDuration: '1.4s' }} />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-20 h-20 rounded-full border-2 border-blue-500/20 border-b-blue-500/80 animate-spin" style={{ animationDuration: '2.1s', animationDirection: 'reverse' }} />
|
||||
</div>
|
||||
{/* Noeuds orbitaux représentant les clusters */}
|
||||
{([0,1,2,3,4,5,6,7] as const).map((i) => {
|
||||
const angle = (i / 8) * 2 * Math.PI;
|
||||
const x = 50 + 39 * Math.cos(angle);
|
||||
const y = 50 + 39 * Math.sin(angle);
|
||||
const colors = ['#dc2626','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6'];
|
||||
return (
|
||||
<div key={i} className="absolute w-3 h-3 rounded-full animate-ping"
|
||||
style={{
|
||||
left: `${x}%`, top: `${y}%`, transform: 'translate(-50%,-50%)',
|
||||
background: colors[i], opacity: 0.75,
|
||||
animationDelay: `${i * 0.18}s`, animationDuration: '1.6s',
|
||||
}} />
|
||||
);
|
||||
})}
|
||||
{/* Centre */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-2xl select-none animate-pulse">🔬</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg tracking-wide">Clustering en cours…</p>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
K-means++ · 30 features · {Math.round(k * sensitivity)} clusters · toutes les IPs
|
||||
</p>
|
||||
<p className="text-text-disabled text-xs mt-2 animate-pulse">Mise à jour automatique toutes les 3 secondes</p>
|
||||
</div>
|
||||
) : !data ? (
|
||||
/* État vide initial */
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-text-secondary">
|
||||
<span className="text-4xl">🔬</span>
|
||||
<span>Cliquez sur <strong className="text-white">Recalculer</strong> pour démarrer</span>
|
||||
</div>
|
||||
) : (
|
||||
/* Canvas WebGL — monté seulement quand il y a des données */
|
||||
<DeckGL
|
||||
views={new OrthographicView({ id: 'ortho', controller: true })}
|
||||
viewState={viewState}
|
||||
onViewStateChange={({ viewState: vs }) => setViewState(vs as typeof viewState)}
|
||||
layers={layers as any}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
controller={true}
|
||||
>
|
||||
{/* Légende overlay — gradient non-humanité */}
|
||||
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'none' }}>
|
||||
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1.5">
|
||||
<div className="text-white/50 text-[10px] uppercase tracking-wide">Non-humanité</div>
|
||||
{/* Barre de dégradé bleu → rouge */}
|
||||
<div className="relative w-28 h-3 rounded-full overflow-hidden"
|
||||
style={{ background: 'linear-gradient(to right, hsl(220,70%,58%), hsl(165,78%,53%), hsl(110,82%,52%), hsl(55,86%,52%), hsl(0,90%,48%)' }}>
|
||||
</div>
|
||||
<div className="flex justify-between w-28 text-[9px] text-white/50">
|
||||
<span>Humain</span>
|
||||
<span>Bot</span>
|
||||
</div>
|
||||
<div className="mt-0.5 pt-1 border-t border-white/10 text-white/40 text-[10px] cursor-help" title={TIPS.features_31}>
|
||||
30 features · PCA 2D ⓘ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tooltip zoom hint */}
|
||||
<div style={{ position: 'absolute', bottom: 16, right: selected ? 320 : 16, pointerEvents: 'none' }}>
|
||||
<div className="text-xs text-white/40">Scroll pour zoomer · Drag pour déplacer · Click sur un cluster</div>
|
||||
</div>
|
||||
</DeckGL>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Sidebar droite (sélection) ── */}
|
||||
{selected && (
|
||||
<ClusterSidebar
|
||||
node={selected}
|
||||
ipDetails={ipDetails}
|
||||
ipTotal={ipTotal}
|
||||
ipPage={ipPage}
|
||||
clusterPoints={clusterPoints}
|
||||
onClose={() => { setSelected(null); setClusterPoints([]); setIpDetails([]); }}
|
||||
onPageChange={(p) => loadClusterIPs(selected, p)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function Stat({ label, value, color, tooltip }: { label: string; value: string | number; color?: string; tooltip?: string }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-text-secondary flex items-center">
|
||||
{label}
|
||||
{tooltip && <InfoTip content={tooltip} />}
|
||||
</span>
|
||||
<span className={`font-mono font-semibold ${color ?? 'text-white'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sidebar détaillée ───────────────────────────────────────────────────────
|
||||
|
||||
function ClusterSidebar({ node, ipDetails, ipTotal, ipPage, clusterPoints, onClose, onPageChange }: {
|
||||
node: ClusterNode;
|
||||
ipDetails: IPDetail[];
|
||||
ipTotal: number;
|
||||
ipPage: number;
|
||||
clusterPoints: IPPoint[];
|
||||
onClose: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}) {
|
||||
const riskLabel = (r: number) =>
|
||||
r > 0.70 ? 'CRITICAL' : r > 0.45 ? 'HIGH' : r > 0.25 ? 'MEDIUM' : 'LOW';
|
||||
const riskClass = (r: number) =>
|
||||
r > 0.70 ? 'text-red-500' : r > 0.45 ? 'text-orange-500' : r > 0.25 ? 'text-yellow-400' : 'text-green-500';
|
||||
|
||||
const totalPages = Math.ceil(ipTotal / 50);
|
||||
|
||||
const exportCSV = () => {
|
||||
const header = 'IP,JA4,TTL,MSS,Hits,Score,Menace,Pays,ASN\n';
|
||||
const rows = ipDetails.map(ip =>
|
||||
[ip.ip, ip.ja4, ip.tcp_ttl, ip.tcp_mss, ip.hits, ip.avg_score, ip.threat_level, ip.country_code, ip.asn_org].join(',')
|
||||
).join('\n');
|
||||
const blob = new Blob([header + rows], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = `cluster_${node.id}.csv`; a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-96 flex-shrink-0 border-l border-gray-700 bg-background-secondary flex flex-col overflow-hidden" style={{ height: '100%' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
|
||||
<div>
|
||||
<div className="font-bold text-sm">{node.label}</div>
|
||||
<div className="text-xs text-text-secondary">{node.ip_count.toLocaleString()} IPs · {node.hit_count.toLocaleString()} hits</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-bold ${riskClass(node.risk_score)}`}>{riskLabel(node.risk_score)}</span>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-white text-lg leading-none">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{/* Score risque */}
|
||||
<div className="bg-background-card rounded-lg p-3">
|
||||
<div className="text-xs text-text-secondary mb-2 flex items-center">
|
||||
Score de risque
|
||||
<InfoTip content={TIPS.risk_score} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-3 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${node.risk_score * 100}%`, background: node.color }} />
|
||||
</div>
|
||||
<span className={`text-sm font-bold ${riskClass(node.risk_score)}`}>
|
||||
{(node.risk_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Radar chart */}
|
||||
{node.radar?.length > 0 && (
|
||||
<div className="bg-background-card rounded-lg p-3">
|
||||
<div className="text-xs text-text-secondary mb-2 flex items-center">
|
||||
Profil {node.radar?.length ?? 21} features
|
||||
<InfoTip content={TIPS.radar_profile} />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<RadarChart data={node.radar}>
|
||||
<PolarGrid stroke="#374151" />
|
||||
<PolarAngleAxis dataKey="feature" tick={{ fill: '#9ca3af', fontSize: 8 }} />
|
||||
<Radar dataKey="value" stroke={node.color} fill={node.color} fillOpacity={0.25} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1f2937', border: '1px solid #374151', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v: number) => [`${(v * 100).toFixed(1)}%`, '']}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TCP stack */}
|
||||
<div className="bg-background-card rounded-lg p-3 text-xs space-y-1">
|
||||
<div className="font-semibold mb-2">Stack TCP</div>
|
||||
<Stat label="TTL moyen" value={node.mean_ttl} tooltip={TIPS.mean_ttl} />
|
||||
<Stat label="MSS moyen" value={node.mean_mss} tooltip={TIPS.mean_mss} />
|
||||
<Stat label="Vélocité" value={node.mean_velocity?.toFixed ? `${node.mean_velocity.toFixed(2)} rps` : '-'} tooltip={TIPS.mean_velocity} />
|
||||
<Stat label="Headless" value={node.mean_headless ? `${(node.mean_headless * 100).toFixed(0)}%` : '-'} tooltip={TIPS.mean_headless} />
|
||||
<Stat label="UA-CH Mismatch" value={node.mean_ua_ch ? `${(node.mean_ua_ch * 100).toFixed(0)}%` : '-'} tooltip={TIPS.mean_ua_ch} />
|
||||
</div>
|
||||
|
||||
{/* Contexte */}
|
||||
{(node.top_countries?.length > 0 || node.top_orgs?.length > 0) && (
|
||||
<div className="bg-background-card rounded-lg p-3 text-xs space-y-2">
|
||||
<div className="font-semibold">Géographie & AS</div>
|
||||
{node.top_countries?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{node.top_countries.map(c => (
|
||||
<span key={c} className="bg-blue-900/40 border border-blue-700/40 rounded px-2 py-0.5">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{node.top_orgs?.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{node.top_orgs.map(o => (
|
||||
<div key={o} className="truncate text-text-secondary">{o}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IPs paginées */}
|
||||
<div className="bg-background-card rounded-lg p-3 text-xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold">IPs ({ipTotal.toLocaleString()})</span>
|
||||
<button onClick={exportCSV} className="text-accent-primary hover:underline text-xs">CSV ↓</button>
|
||||
</div>
|
||||
{ipDetails.length === 0 ? (
|
||||
<div className="text-text-disabled">Chargement…</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto font-mono">
|
||||
{ipDetails.map(ip => (
|
||||
<div key={ip.ip + ip.ja4} className="flex items-center gap-2 py-0.5 border-b border-gray-800/50">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
ip.avg_score > 0.45 ? 'bg-red-500' : ip.avg_score > 0.25 ? 'bg-orange-400' : 'bg-green-500'
|
||||
}`}
|
||||
/>
|
||||
<a href={`/investigation/ip/${ip.ip}`}
|
||||
className="text-blue-400 hover:underline flex-1 truncate">{ip.ip}</a>
|
||||
<span className="text-text-disabled">{ip.country_code}</span>
|
||||
<span className="text-text-disabled">{ip.hits}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<button onClick={() => onPageChange(ipPage - 1)} disabled={ipPage === 0}
|
||||
className="px-2 py-0.5 bg-gray-700 rounded disabled:opacity-30">←</button>
|
||||
<span className="text-text-disabled">{ipPage + 1} / {totalPages}</span>
|
||||
<button onClick={() => onPageChange(ipPage + 1)} disabled={ipPage >= totalPages - 1}
|
||||
className="px-2 py-0.5 bg-gray-700 rounded disabled:opacity-30">→</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Points info */}
|
||||
{clusterPoints.length > 0 && (
|
||||
<div className="text-xs text-text-secondary text-center pb-2">
|
||||
{clusterPoints.length.toLocaleString()} IPs affichées en WebGL
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
585
services/dashboard/frontend/src/components/CorrelationGraph.tsx
Normal file
585
services/dashboard/frontend/src/components/CorrelationGraph.tsx
Normal file
@ -0,0 +1,585 @@
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
Panel,
|
||||
useReactFlow,
|
||||
ReactFlowProvider,
|
||||
NodeTypes,
|
||||
Handle,
|
||||
Position,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { useEffect, useState, useCallback, memo } from 'react';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CorrelationGraphProps {
|
||||
ip: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
showSubnet: boolean;
|
||||
showASN: boolean;
|
||||
showJA4: boolean;
|
||||
showUA: boolean;
|
||||
showHost: boolean;
|
||||
showCountry: boolean;
|
||||
}
|
||||
|
||||
interface RawData {
|
||||
variability: any;
|
||||
subnet: any;
|
||||
entities: any;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function cleanIP(address: string): string {
|
||||
if (!address) return '';
|
||||
return address.replace(/^::ffff:/i, '');
|
||||
}
|
||||
|
||||
function classifyUA(ua: string): 'bot' | 'script' | 'normal' {
|
||||
const u = ua.toLowerCase();
|
||||
if (u.includes('bot') || u.includes('crawler') || u.includes('spider')) return 'bot';
|
||||
if (
|
||||
u.includes('python') ||
|
||||
u.includes('curl') ||
|
||||
u.includes('wget') ||
|
||||
u.includes('go-http') ||
|
||||
u.includes('java/') ||
|
||||
u.includes('axios')
|
||||
)
|
||||
return 'script';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
// ─── Custom node components (must be OUTSIDE the main component) ─────────────
|
||||
|
||||
const IPNode = memo(({ data }: { data: any }) => (
|
||||
<div className="px-4 py-3 bg-blue-700 border-2 border-blue-400 rounded-xl shadow-xl w-52 select-none">
|
||||
<Handle type="source" position={Position.Right} style={{ background: '#93c5fd' }} />
|
||||
<div className="text-xs text-blue-200 font-bold mb-1">🌐 IP SOURCE</div>
|
||||
<div className="text-sm text-white font-mono font-bold break-all">{data.label}</div>
|
||||
<div className="text-xs text-blue-200 mt-2">
|
||||
{(data.detections ?? 0).toLocaleString()} détections
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
IPNode.displayName = 'IPNode';
|
||||
|
||||
const SubnetNode = memo(({ data }: { data: any }) => (
|
||||
<div className="px-4 py-3 bg-purple-700 border-2 border-purple-400 rounded-xl shadow-xl w-52 select-none">
|
||||
<Handle type="target" position={Position.Left} style={{ background: '#d8b4fe' }} />
|
||||
<div className="text-xs text-purple-200 font-bold mb-1">🔷 SUBNET /24</div>
|
||||
<div className="text-sm text-white font-mono break-all">{data.label}</div>
|
||||
<div className="text-xs text-purple-200 mt-1">{data.ipsInSubnet ?? 0} IPs actives</div>
|
||||
</div>
|
||||
));
|
||||
SubnetNode.displayName = 'SubnetNode';
|
||||
|
||||
const ASNNode = memo(({ data }: { data: any }) => (
|
||||
<div className="px-4 py-3 bg-orange-700 border-2 border-orange-400 rounded-xl shadow-xl w-52 select-none">
|
||||
<Handle type="target" position={Position.Left} style={{ background: '#fdba74' }} />
|
||||
<div className="text-xs text-orange-200 font-bold mb-1">🏢 ASN</div>
|
||||
<div className="text-sm text-white font-bold">{data.label}</div>
|
||||
<div className="text-xs text-orange-200 truncate max-w-[180px] mt-0.5">{data.org}</div>
|
||||
<div className="text-xs text-orange-200 mt-0.5">
|
||||
{(data.totalInAsn ?? 0).toLocaleString()} IPs
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
ASNNode.displayName = 'ASNNode';
|
||||
|
||||
const CountryNode = memo(({ data }: { data: any }) => (
|
||||
<div className="px-4 py-3 bg-slate-600 border-2 border-slate-400 rounded-xl shadow-xl w-40 text-center select-none">
|
||||
<Handle type="target" position={Position.Left} style={{ background: '#cbd5e1' }} />
|
||||
<div className="text-xs text-slate-200 font-bold mb-1">🌍 PAYS</div>
|
||||
<div className="text-3xl leading-tight">{data.flag}</div>
|
||||
<div className="text-sm text-white font-bold mt-1">{data.label}</div>
|
||||
<div className="text-xs text-slate-200">
|
||||
{(data.percentage ?? 0).toFixed(0)}% · {(data.count ?? 0)} det.
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
CountryNode.displayName = 'CountryNode';
|
||||
|
||||
const JA4Node = memo(({ data }: { data: any }) => (
|
||||
<div className="px-4 py-3 bg-emerald-700 border-2 border-emerald-400 rounded-xl shadow-xl w-60 select-none">
|
||||
<Handle type="target" position={Position.Left} style={{ background: '#6ee7b7' }} />
|
||||
<div className="text-xs text-emerald-200 font-bold mb-1">🔐 JA4 Fingerprint</div>
|
||||
<div
|
||||
className="text-xs text-white font-mono break-all overflow-hidden"
|
||||
style={{ maxHeight: '3.5rem' }}
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
<div className="text-xs text-emerald-200 mt-1.5 flex gap-2">
|
||||
<span>{data.count} det.</span>
|
||||
<span>{(data.percentage ?? 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
JA4Node.displayName = 'JA4Node';
|
||||
|
||||
const UANode = memo(({ data }: { data: any }) => {
|
||||
const borderClass =
|
||||
data.classification === 'bot'
|
||||
? 'border-red-400'
|
||||
: data.classification === 'script'
|
||||
? 'border-yellow-400'
|
||||
: 'border-indigo-400';
|
||||
const badge =
|
||||
data.classification === 'bot'
|
||||
? '🔴 BOT'
|
||||
: data.classification === 'script'
|
||||
? '🟡 SCRIPT'
|
||||
: '🟢 Normal';
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-3 bg-rose-900 border-2 ${borderClass} rounded-xl shadow-xl w-64 select-none`}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} style={{ background: '#fca5a5' }} />
|
||||
<div className="text-xs text-rose-200 font-bold mb-1">
|
||||
🤖 User-Agent <span className="ml-1">{badge}</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs text-white font-mono break-all overflow-hidden leading-tight"
|
||||
style={{ maxHeight: '3.5rem' }}
|
||||
title={data.label}
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
<div className="text-xs text-rose-200 mt-1.5 flex gap-2">
|
||||
<span>{data.count} det.</span>
|
||||
<span>{(data.percentage ?? 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
UANode.displayName = 'UANode';
|
||||
|
||||
const HostNode = memo(({ data }: { data: any }) => (
|
||||
<div className="px-4 py-3 bg-amber-700 border-2 border-amber-400 rounded-xl shadow-xl w-52 select-none">
|
||||
<Handle type="target" position={Position.Left} style={{ background: '#fcd34d' }} />
|
||||
<div className="text-xs text-amber-200 font-bold mb-1">🖥️ Host cible</div>
|
||||
<div
|
||||
className="text-sm text-white font-mono break-all overflow-hidden"
|
||||
style={{ maxHeight: '3rem' }}
|
||||
title={data.label}
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
HostNode.displayName = 'HostNode';
|
||||
|
||||
// nodeTypes must be defined outside the component (stable reference)
|
||||
const nodeTypes: NodeTypes = {
|
||||
ipNode: IPNode,
|
||||
subnetNode: SubnetNode,
|
||||
asnNode: ASNNode,
|
||||
countryNode: CountryNode,
|
||||
ja4Node: JA4Node,
|
||||
uaNode: UANode,
|
||||
hostNode: HostNode,
|
||||
};
|
||||
|
||||
// ─── Layout builder ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildGraph(rawData: RawData, filters: FilterState): { nodes: Node[]; edges: Edge[] } {
|
||||
const { variability, subnet, entities } = rawData;
|
||||
const newNodes: Node[] = [];
|
||||
const newEdges: Edge[] = [];
|
||||
|
||||
const makeEdge = (
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
color: string,
|
||||
label: string
|
||||
): Edge => ({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
type: 'smoothstep',
|
||||
style: { stroke: color, strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color },
|
||||
label,
|
||||
labelStyle: { fill: color, fontWeight: 600, fontSize: 11 },
|
||||
labelBgStyle: { fill: '#1e293b', fillOpacity: 0.85 },
|
||||
labelBgPadding: [4, 2],
|
||||
});
|
||||
|
||||
// Center: IP
|
||||
newNodes.push({
|
||||
id: 'ip',
|
||||
type: 'ipNode',
|
||||
data: { label: cleanIP(variability?.value || ''), detections: variability?.total_detections },
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
// Inner ring (r=320): Subnet, ASN, Country — evenly spaced
|
||||
const innerItems: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
data: any;
|
||||
color: string;
|
||||
label: string;
|
||||
}> = [];
|
||||
|
||||
if (filters.showSubnet && subnet?.subnet) {
|
||||
innerItems.push({
|
||||
id: 'subnet',
|
||||
type: 'subnetNode',
|
||||
data: {
|
||||
label: cleanIP(subnet.subnet),
|
||||
ipsInSubnet: subnet.total_in_subnet,
|
||||
},
|
||||
color: '#a855f7',
|
||||
label: 'appartient à',
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.showASN && subnet?.asn_number) {
|
||||
innerItems.push({
|
||||
id: 'asn',
|
||||
type: 'asnNode',
|
||||
data: {
|
||||
label: `AS${subnet.asn_number}`,
|
||||
org: subnet.asn_org || 'Unknown',
|
||||
totalInAsn: subnet.total_in_asn,
|
||||
},
|
||||
color: '#f97316',
|
||||
label: 'hébergé par',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
filters.showCountry &&
|
||||
variability?.attributes?.countries?.length > 0
|
||||
) {
|
||||
const c = variability.attributes.countries[0];
|
||||
innerItems.push({
|
||||
id: 'country',
|
||||
type: 'countryNode',
|
||||
data: {
|
||||
label: c.value,
|
||||
flag: getCountryFlag(c.value),
|
||||
percentage: c.percentage,
|
||||
count: c.count,
|
||||
},
|
||||
color: '#eab308',
|
||||
label: 'localisé',
|
||||
});
|
||||
}
|
||||
|
||||
const r1 = 320;
|
||||
innerItems.forEach((item, idx) => {
|
||||
const angle = (2 * Math.PI * idx) / Math.max(innerItems.length, 1) - Math.PI / 2;
|
||||
const x = r1 * Math.cos(angle);
|
||||
const y = r1 * Math.sin(angle);
|
||||
newNodes.push({ id: item.id, type: item.type, data: item.data, position: { x, y } });
|
||||
newEdges.push(makeEdge(`ip-${item.id}`, 'ip', item.id, item.color, item.label));
|
||||
});
|
||||
|
||||
// Outer ring (r=640): JA4, UA, Host — evenly spaced, interleaved
|
||||
const outerItems: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
data: any;
|
||||
color: string;
|
||||
label: string;
|
||||
}> = [];
|
||||
|
||||
if (filters.showJA4 && variability?.attributes?.ja4) {
|
||||
variability.attributes.ja4.slice(0, 6).forEach((ja4: any, idx: number) => {
|
||||
outerItems.push({
|
||||
id: `ja4-${idx}`,
|
||||
type: 'ja4Node',
|
||||
data: { label: ja4.value, count: ja4.count, percentage: ja4.percentage },
|
||||
color: '#22c55e',
|
||||
label: 'JA4',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.showUA && variability?.attributes?.user_agents) {
|
||||
variability.attributes.user_agents.slice(0, 5).forEach((ua: any, idx: number) => {
|
||||
const classification = classifyUA(ua.value);
|
||||
outerItems.push({
|
||||
id: `ua-${idx}`,
|
||||
type: 'uaNode',
|
||||
data: { label: ua.value, count: ua.count, percentage: ua.percentage, classification },
|
||||
color:
|
||||
classification === 'bot'
|
||||
? '#ef4444'
|
||||
: classification === 'script'
|
||||
? '#f59e0b'
|
||||
: '#818cf8',
|
||||
label: 'User-Agent',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.showHost && entities?.related?.hosts) {
|
||||
(entities.related.hosts as string[]).slice(0, 5).forEach((host, idx) => {
|
||||
outerItems.push({
|
||||
id: `host-${idx}`,
|
||||
type: 'hostNode',
|
||||
data: { label: host },
|
||||
color: '#f59e0b',
|
||||
label: 'cible',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const r2 = 640;
|
||||
outerItems.forEach((item, idx) => {
|
||||
const angle = (2 * Math.PI * idx) / Math.max(outerItems.length, 1) - Math.PI / 2;
|
||||
const x = r2 * Math.cos(angle);
|
||||
const y = r2 * Math.sin(angle);
|
||||
newNodes.push({ id: item.id, type: item.type, data: item.data, position: { x, y } });
|
||||
newEdges.push(makeEdge(`ip-${item.id}`, 'ip', item.id, item.color, item.label));
|
||||
});
|
||||
|
||||
return { nodes: newNodes, edges: newEdges };
|
||||
}
|
||||
|
||||
// ─── Inner graph component (needs useReactFlow, must be child of ReactFlowProvider) ──
|
||||
|
||||
interface GraphInnerProps {
|
||||
rawData: RawData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
filters: FilterState;
|
||||
toggleFilter: (k: keyof FilterState) => void;
|
||||
height: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
function GraphInner({ rawData, loading, error, filters, toggleFilter, height, ip }: GraphInnerProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawData) return;
|
||||
const { nodes: n, edges: e } = buildGraph(rawData, filters);
|
||||
setNodes(n);
|
||||
setEdges(e);
|
||||
// fitView after React renders the new nodes
|
||||
setTimeout(() => fitView({ padding: 0.15, duration: 400 }), 60);
|
||||
}, [rawData, filters, setNodes, setEdges, fitView]);
|
||||
|
||||
const filterConfig: [keyof FilterState, string, string][] = [
|
||||
['showSubnet', 'Subnet', '#a855f7'],
|
||||
['showASN', 'ASN', '#f97316'],
|
||||
['showCountry', 'Pays', '#eab308'],
|
||||
['showJA4', 'JA4', '#22c55e'],
|
||||
['showUA', 'User-Agent', '#ef4444'],
|
||||
['showHost', 'Hosts', '#f59e0b'],
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center bg-background-secondary rounded-lg gap-3"
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="text-3xl animate-spin">⟳</div>
|
||||
<div className="text-sm text-text-secondary">Chargement du graphe de corrélations…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center bg-background-secondary rounded-lg gap-3"
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="text-3xl">⚠️</div>
|
||||
<div className="text-sm text-red-400">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center bg-background-secondary rounded-lg gap-3"
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="text-3xl">🕸️</div>
|
||||
<div className="text-sm text-text-secondary">Aucune corrélation trouvée pour {cleanIP(ip)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full border border-background-card rounded-lg overflow-hidden"
|
||||
style={{ height }}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
attributionPosition="bottom-right"
|
||||
className="bg-background-secondary"
|
||||
nodesDraggable
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
minZoom={0.05}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color="#334155" gap={24} size={1} />
|
||||
<Controls className="bg-background-card border border-background-card rounded-lg" />
|
||||
|
||||
{/* Filtres */}
|
||||
<Panel
|
||||
position="top-left"
|
||||
className="bg-background-secondary/95 border border-background-card rounded-lg p-3 shadow-lg"
|
||||
>
|
||||
<div className="text-xs font-bold text-text-primary mb-2">Filtres</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{filterConfig.map(([key, label, color]) => (
|
||||
<label key={key} className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters[key]}
|
||||
onChange={() => toggleFilter(key)}
|
||||
className="rounded"
|
||||
style={{ accentColor: color }}
|
||||
/>
|
||||
<span className="text-text-primary">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Légende */}
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="bg-background-secondary/95 border border-background-card rounded-lg p-3 shadow-lg"
|
||||
>
|
||||
<div className="text-xs font-bold text-text-primary mb-2">Légende</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
{[
|
||||
['bg-blue-700', 'IP Source'],
|
||||
['bg-purple-700', 'Subnet /24'],
|
||||
['bg-orange-700', 'ASN'],
|
||||
['bg-slate-600', 'Pays'],
|
||||
['bg-emerald-700', 'JA4'],
|
||||
['bg-rose-900', 'User-Agent'],
|
||||
['bg-amber-700', 'Host cible'],
|
||||
].map(([bg, lbl]) => (
|
||||
<div key={lbl} className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded ${bg} border border-white/20`} />
|
||||
<span className="text-text-secondary">{lbl}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2 pt-2 border-t border-background-card text-text-disabled space-y-0.5">
|
||||
<div>🔴 UA = Bot</div>
|
||||
<div>🟡 UA = Script</div>
|
||||
<div>🟢 UA = Normal</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Stats */}
|
||||
<Panel
|
||||
position="bottom-left"
|
||||
className="bg-background-secondary/95 border border-background-card rounded-lg px-3 py-2 shadow-lg text-xs text-text-secondary"
|
||||
>
|
||||
<span className="flex items-center gap-1">{nodes.length} nœuds · {edges.length} arêtes<InfoTip content={TIPS.correlation_node} /></span>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Exported component ───────────────────────────────────────────────────────
|
||||
|
||||
export function CorrelationGraph({ ip, height = '700px' }: CorrelationGraphProps) {
|
||||
const [rawData, setRawData] = useState<RawData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
showSubnet: true,
|
||||
showASN: true,
|
||||
showJA4: true,
|
||||
showUA: true,
|
||||
showHost: true,
|
||||
showCountry: true,
|
||||
});
|
||||
|
||||
// Fetch data only when IP changes — filters are applied client-side
|
||||
useEffect(() => {
|
||||
if (!ip) return;
|
||||
const cleaned = cleanIP(ip);
|
||||
let cancelled = false;
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [varRes, subnetRes, entitiesRes] = await Promise.all([
|
||||
fetch(`/api/variability/ip/${encodeURIComponent(cleaned)}`),
|
||||
fetch(`/api/analysis/${encodeURIComponent(cleaned)}/subnet`),
|
||||
fetch(`/api/entities/ip/${encodeURIComponent(cleaned)}`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const [variability, subnet, entities] = await Promise.all([
|
||||
varRes.ok ? varRes.json().catch(() => null) : null,
|
||||
subnetRes.ok ? subnetRes.json().catch(() => null) : null,
|
||||
entitiesRes.ok ? entitiesRes.json().catch(() => null) : null,
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setRawData({ variability, subnet, entities });
|
||||
} catch {
|
||||
if (!cancelled) setError('Erreur de chargement des données de corrélation');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [ip]);
|
||||
|
||||
const toggleFilter = useCallback((key: keyof FilterState) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<GraphInner
|
||||
ip={ip}
|
||||
height={height}
|
||||
rawData={rawData}
|
||||
loading={loading}
|
||||
error={error}
|
||||
filters={filters}
|
||||
toggleFilter={toggleFilter}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
153
services/dashboard/frontend/src/components/DetailsView.tsx
Normal file
153
services/dashboard/frontend/src/components/DetailsView.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useVariability } from '../hooks/useVariability';
|
||||
import { VariabilityPanel } from './VariabilityPanel';
|
||||
import { formatDateShort } from '../utils/dateUtils';
|
||||
|
||||
export function DetailsView() {
|
||||
const { type, value } = useParams<{ type: string; value: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, loading, error } = useVariability(type || '', value || '');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-text-secondary">
|
||||
Chargement…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-threat-critical_bg border border-threat-critical rounded-xl p-6">
|
||||
<p className="text-threat-critical font-semibold mb-4">Erreur : {error.message}</p>
|
||||
<button
|
||||
onClick={() => navigate('/detections')}
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
ip: 'IP',
|
||||
ja4: 'JA4',
|
||||
country: 'Pays',
|
||||
asn: 'ASN',
|
||||
host: 'Host',
|
||||
user_agent: 'User-Agent',
|
||||
};
|
||||
const typeLabel = typeLabels[type || ''] || type;
|
||||
const isIP = type === 'ip';
|
||||
const isJA4 = type === 'ja4';
|
||||
|
||||
const first = data.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
|
||||
const last = data.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
|
||||
const sameDate = first && last && first.getTime() === last.getTime();
|
||||
|
||||
const fmtDate = (d: Date) => formatDateShort(d.toISOString());
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<Link to="/" className="hover:text-text-primary">Dashboard</Link>
|
||||
<span>/</span>
|
||||
<Link to="/detections" className="hover:text-text-primary">Détections</Link>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{typeLabel}: {value}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header card */}
|
||||
<div className="bg-background-secondary rounded-xl p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
{/* Identité */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-1">{typeLabel}</p>
|
||||
<p className="text-lg font-mono font-bold text-text-primary break-all">{value}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isIP && (
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${encodeURIComponent(value!)}`)}
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
🔍 Investigation complète
|
||||
</button>
|
||||
)}
|
||||
{isJA4 && (
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value!)}`)}
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
🔍 Investigation JA4
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('/detections')}
|
||||
className="bg-background-card hover:bg-background-card/70 text-text-primary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métriques clés */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-5">
|
||||
<Metric label="Détections (24h)" value={data.total_detections.toLocaleString()} accent />
|
||||
{!isIP && (
|
||||
<Metric label="IPs uniques" value={data.unique_ips.toLocaleString()} />
|
||||
)}
|
||||
<Metric label="User-Agents" value={(data.attributes.user_agents?.length ?? 0).toString()} />
|
||||
{first && last && (
|
||||
sameDate ? (
|
||||
<Metric label="Détecté le" value={fmtDate(last)} />
|
||||
) : (
|
||||
<div className="bg-background-card rounded-xl p-3">
|
||||
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">Période</p>
|
||||
<p className="text-xs text-text-primary font-medium">{fmtDate(first)}</p>
|
||||
<p className="text-[10px] text-text-secondary">→ {fmtDate(last)}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
{data.insights.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.insights.map((ins, i) => {
|
||||
const s: Record<string, string> = {
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/40 text-yellow-400',
|
||||
info: 'bg-blue-500/10 border-blue-500/40 text-blue-400',
|
||||
success: 'bg-green-500/10 border-green-500/40 text-green-400',
|
||||
};
|
||||
return (
|
||||
<div key={i} className={`${s[ins.type] ?? s.info} border rounded-xl p-3 text-sm`}>
|
||||
{ins.message}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attributs */}
|
||||
<VariabilityPanel attributes={data.attributes} hideAssociatedIPs={isIP} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<div className="bg-background-card rounded-xl p-3">
|
||||
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={`text-xl font-bold ${accent ? 'text-accent-primary' : 'text-text-primary'}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
598
services/dashboard/frontend/src/components/DetectionsList.tsx
Normal file
598
services/dashboard/frontend/src/components/DetectionsList.tsx
Normal file
@ -0,0 +1,598 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useDetections } from '../hooks/useDetections';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDate, formatDateOnly, formatTimeOnly } from '../utils/dateUtils';
|
||||
|
||||
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
interface ColumnConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
visible: boolean;
|
||||
sortable: boolean;
|
||||
}
|
||||
|
||||
interface DetectionRow {
|
||||
src_ip: string;
|
||||
ja4?: string;
|
||||
host?: string;
|
||||
client_headers?: string;
|
||||
model_name: string;
|
||||
anomaly_score: number;
|
||||
threat_level?: string;
|
||||
bot_name?: string;
|
||||
hits?: number;
|
||||
hit_velocity?: number;
|
||||
asn_org?: string;
|
||||
asn_number?: string | number;
|
||||
asn_score?: number | null;
|
||||
asn_rep_label?: string;
|
||||
country_code?: string;
|
||||
detected_at: string;
|
||||
first_seen?: string;
|
||||
last_seen?: string;
|
||||
unique_ja4s?: string[];
|
||||
unique_hosts?: string[];
|
||||
unique_client_headers?: string[];
|
||||
anubis_bot_name?: string;
|
||||
anubis_bot_action?: string;
|
||||
anubis_bot_category?: string;
|
||||
}
|
||||
|
||||
export function DetectionsList() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const modelName = searchParams.get('model_name') || undefined;
|
||||
const search = searchParams.get('search') || undefined;
|
||||
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'detected_at') as SortField;
|
||||
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'desc') as SortOrder;
|
||||
const scoreType = searchParams.get('score_type') || undefined;
|
||||
|
||||
const [groupByIP, setGroupByIP] = useState(true);
|
||||
const [threatDist, setThreatDist] = useState<{threat_level: string; count: number; percentage: number}[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/metrics/threats')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.items) setThreatDist(d.items); })
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
const { data, loading, error } = useDetections({
|
||||
page,
|
||||
page_size: 25,
|
||||
model_name: modelName,
|
||||
search,
|
||||
sort_by: sortField,
|
||||
sort_order: sortOrder,
|
||||
group_by_ip: groupByIP,
|
||||
score_type: scoreType,
|
||||
});
|
||||
|
||||
const [searchInput, setSearchInput] = useState(search || '');
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
const [columns, setColumns] = useState<ColumnConfig[]>([
|
||||
{ key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true },
|
||||
{ key: 'host', label: 'Host', visible: true, sortable: true },
|
||||
{ key: 'client_headers', label: 'Client Headers', visible: false, sortable: false },
|
||||
{ key: 'model_name', label: 'Modèle', visible: true, sortable: true },
|
||||
{ key: 'anomaly_score', label: 'Score', visible: true, sortable: true },
|
||||
{ key: 'anubis', label: '🤖 Anubis', visible: true, sortable: false },
|
||||
{ key: 'hits', label: 'Hits', visible: true, sortable: true },
|
||||
{ key: 'hit_velocity', label: 'Velocity', visible: true, sortable: true },
|
||||
{ key: 'asn', label: 'ASN', visible: true, sortable: true },
|
||||
{ key: 'country', label: 'Pays', visible: true, sortable: true },
|
||||
{ key: 'detected_at', label: 'Date', visible: true, sortable: true },
|
||||
]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (searchInput.trim()) {
|
||||
newParams.set('search', searchInput.trim());
|
||||
} else {
|
||||
newParams.delete('search');
|
||||
}
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
newParams.set(key, value);
|
||||
} else {
|
||||
newParams.delete(key);
|
||||
}
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
setColumns(cols => cols.map(col =>
|
||||
col.key === key ? { ...col, visible: !col.visible } : col
|
||||
));
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('page', newPage.toString());
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleSort = (key: string, dir: 'asc' | 'desc') => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('sort_by', key);
|
||||
newParams.set('sort_order', dir);
|
||||
newParams.set('page', '1');
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-4">
|
||||
<p className="text-threat-critical">Erreur: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Backend handles grouping — data is already grouped when groupByIP=true
|
||||
const processedData = data;
|
||||
|
||||
// Build DataTable columns from visible column configs
|
||||
const tableColumns: Column<DetectionRow>[] = columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col): Column<DetectionRow> => {
|
||||
switch (col.key) {
|
||||
case 'ip_ja4':
|
||||
return {
|
||||
key: 'src_ip',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
width: 'w-[220px] min-w-[180px]',
|
||||
render: (_, row) => {
|
||||
const ja4s = groupByIP && row.unique_ja4s?.length ? row.unique_ja4s : row.ja4 ? [row.ja4] : [];
|
||||
const ja4Label = ja4s.length > 1 ? `${ja4s.length} JA4` : ja4s[0] ?? '—';
|
||||
return (
|
||||
<div>
|
||||
<div className="font-mono text-sm text-text-primary whitespace-nowrap">{row.src_ip}</div>
|
||||
<div className="font-mono text-xs text-text-disabled truncate max-w-[200px]" title={ja4s.join(' | ')}>
|
||||
{ja4Label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
case 'host':
|
||||
return {
|
||||
key: 'host',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
width: 'w-[180px] min-w-[140px]',
|
||||
render: (_, row) => {
|
||||
const hosts = groupByIP && row.unique_hosts?.length ? row.unique_hosts : row.host ? [row.host] : [];
|
||||
const primary = hosts[0] ?? '—';
|
||||
const extra = hosts.length > 1 ? ` +${hosts.length - 1}` : '';
|
||||
return (
|
||||
<div className="truncate max-w-[175px] text-sm text-text-primary" title={hosts.join(', ')}>
|
||||
{primary}<span className="text-text-disabled text-xs">{extra}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
case 'client_headers':
|
||||
return {
|
||||
key: 'client_headers',
|
||||
label: col.label,
|
||||
sortable: false,
|
||||
render: (_, row) =>
|
||||
groupByIP && row.unique_client_headers && row.unique_client_headers.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{row.unique_client_headers.length} Header{row.unique_client_headers.length > 1 ? 's' : ''} unique{row.unique_client_headers.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{row.unique_client_headers.slice(0, 3).map((header, idx) => (
|
||||
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
{row.unique_client_headers.length > 3 && (
|
||||
<div className="text-xs text-text-disabled">
|
||||
+{row.unique_client_headers.length - 3} autre{row.unique_client_headers.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
||||
{row.client_headers || '-'}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case 'model_name':
|
||||
return {
|
||||
key: 'model_name',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) => <ModelBadge model={row.model_name} />,
|
||||
};
|
||||
case 'anubis':
|
||||
return {
|
||||
key: 'anubis_bot_name',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
🤖 Anubis
|
||||
<InfoTip content={TIPS.anubis_identification} />
|
||||
</span>
|
||||
),
|
||||
sortable: false,
|
||||
width: 'w-[140px]',
|
||||
render: (_, row) => {
|
||||
const name = row.anubis_bot_name;
|
||||
const action = row.anubis_bot_action;
|
||||
if (!name) return <span className="text-text-disabled text-xs">—</span>;
|
||||
const actionColor =
|
||||
action === 'ALLOW' ? 'text-green-400' :
|
||||
action === 'DENY' ? 'text-red-400' : 'text-yellow-400';
|
||||
return (
|
||||
<div className="truncate max-w-[135px]" title={`${name} · ${action}`}>
|
||||
<span className={`text-xs font-medium ${actionColor}`}>{name}</span>
|
||||
{action && <span className="text-[10px] text-text-disabled ml-1">· {action}</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
case 'anomaly_score':
|
||||
return {
|
||||
key: 'anomaly_score',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
render: (_, row) => (
|
||||
<ScoreBadge
|
||||
score={row.anomaly_score}
|
||||
threatLevel={row.threat_level}
|
||||
botName={row.bot_name}
|
||||
anubisAction={row.anubis_bot_action}
|
||||
/>
|
||||
),
|
||||
};
|
||||
case 'hits':
|
||||
return {
|
||||
key: 'hits',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
render: (_, row) => (
|
||||
<div className="text-sm text-text-primary font-medium">{row.hits ?? 0}</div>
|
||||
),
|
||||
};
|
||||
case 'hit_velocity':
|
||||
return {
|
||||
key: 'hit_velocity',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
render: (_, row) => (
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
row.hit_velocity && row.hit_velocity > 10
|
||||
? 'text-threat-high'
|
||||
: row.hit_velocity && row.hit_velocity > 1
|
||||
? 'text-threat-medium'
|
||||
: 'text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{row.hit_velocity ? row.hit_velocity.toFixed(2) : '0.00'}
|
||||
<span className="text-xs text-text-secondary ml-1">req/s</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case 'asn':
|
||||
return {
|
||||
key: 'asn_org',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
width: 'w-[150px]',
|
||||
render: (_, row) => (
|
||||
<div className="truncate max-w-[145px]" title={`${row.asn_org ?? ''} AS${row.asn_number ?? ''}`}>
|
||||
<span className="text-sm text-text-primary">{row.asn_org || `AS${row.asn_number}` || '—'}</span>
|
||||
{row.asn_number && <span className="text-xs text-text-disabled ml-1">AS{row.asn_number}</span>}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case 'country':
|
||||
return {
|
||||
key: 'country_code',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
render: (_, row) =>
|
||||
row.country_code ? (
|
||||
<span className="text-lg">{getFlag(row.country_code)}</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
),
|
||||
};
|
||||
case 'detected_at':
|
||||
return {
|
||||
key: 'detected_at',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
width: 'w-[110px]',
|
||||
render: (_, row) => {
|
||||
if (groupByIP && row.first_seen) {
|
||||
const last = new Date(row.last_seen!);
|
||||
return <div className="text-xs text-text-secondary whitespace-nowrap">{formatDate(last.toISOString())}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="text-xs text-text-secondary whitespace-nowrap">
|
||||
{formatDateOnly(row.detected_at)} {formatTimeOnly(row.detected_at)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
default:
|
||||
return { key: col.key, label: col.label, sortable: col.sortable };
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2 animate-fade-in">
|
||||
|
||||
{/* ── Barre unique : titre + pills + filtres + recherche ── */}
|
||||
<div className="flex flex-wrap items-center gap-2 bg-background-secondary rounded-lg px-3 py-2">
|
||||
|
||||
{/* Titre + compteur */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="font-semibold text-text-primary">Détections</span>
|
||||
<span className="text-xs text-text-disabled bg-background-card rounded px-1.5 py-0.5">
|
||||
{data.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-background-card shrink-0" />
|
||||
|
||||
{/* Pills distribution */}
|
||||
{threatDist.map(({ threat_level, count, percentage }) => {
|
||||
const label = threat_level === 'KNOWN_BOT' ? '🤖 BOT' :
|
||||
threat_level === 'ANUBIS_DENY' ? '🔴 RÈGLE' :
|
||||
threat_level === 'HIGH' ? '⚠️ HIGH' :
|
||||
threat_level === 'MEDIUM' ? '📊 MED' :
|
||||
threat_level === 'CRITICAL' ? '🔥 CRIT' : threat_level;
|
||||
const style = threat_level === 'KNOWN_BOT' ? 'bg-green-500/15 text-green-400 border-green-500/30 hover:bg-green-500/25' :
|
||||
threat_level === 'ANUBIS_DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30 hover:bg-red-500/25' :
|
||||
threat_level === 'HIGH' ? 'bg-orange-500/15 text-orange-400 border-orange-500/30 hover:bg-orange-500/25' :
|
||||
threat_level === 'MEDIUM' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30 hover:bg-yellow-500/25' :
|
||||
threat_level === 'CRITICAL' ? 'bg-red-700/15 text-red-300 border-red-700/30 hover:bg-red-700/25' :
|
||||
'bg-background-card text-text-secondary border-background-card';
|
||||
const filterVal = threat_level === 'KNOWN_BOT' ? 'BOT' : threat_level === 'ANUBIS_DENY' ? 'REGLE' : null;
|
||||
const active = filterVal && scoreType === filterVal;
|
||||
return (
|
||||
<button
|
||||
key={threat_level}
|
||||
onClick={() => {
|
||||
if (filterVal) handleFilterChange('score_type', scoreType === filterVal ? '' : filterVal);
|
||||
else handleFilterChange('threat_level', threat_level);
|
||||
}}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium transition-colors ${style} ${active ? 'ring-1 ring-offset-1 ring-current' : ''}`}
|
||||
>
|
||||
{label} <span className="font-bold">{count.toLocaleString()}</span>
|
||||
<span className="opacity-50">{percentage.toFixed(0)}%</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="w-px h-5 bg-background-card shrink-0" />
|
||||
|
||||
{/* Filtres select */}
|
||||
<select
|
||||
value={modelName || ''}
|
||||
onChange={(e) => handleFilterChange('model_name', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
|
||||
>
|
||||
<option value="">Tous modèles</option>
|
||||
<option value="Complet">Complet</option>
|
||||
<option value="Applicatif">Applicatif</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={scoreType || ''}
|
||||
onChange={(e) => handleFilterChange('score_type', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
|
||||
>
|
||||
<option value="">Tous scores</option>
|
||||
<option value="BOT">🟢 BOT</option>
|
||||
<option value="REGLE">🔴 RÈGLE</option>
|
||||
<option value="BOT_REGLE">BOT+RÈGLE</option>
|
||||
<option value="SCORE">Score num.</option>
|
||||
</select>
|
||||
|
||||
{(modelName || scoreType || search || sortField !== 'detected_at') && (
|
||||
<button
|
||||
onClick={() => setSearchParams({})}
|
||||
className="text-xs text-text-secondary hover:text-text-primary bg-background-card rounded px-2 py-1 border border-background-card transition-colors"
|
||||
>
|
||||
✕ Effacer
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Toggle grouper */}
|
||||
<button
|
||||
onClick={() => setGroupByIP(!groupByIP)}
|
||||
className={`text-xs border rounded px-2 py-1 transition-colors shrink-0 ${
|
||||
groupByIP ? 'bg-accent-primary text-white border-accent-primary' : 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||
}`}
|
||||
title={groupByIP ? 'Vue individuelle' : 'Vue groupée par IP'}
|
||||
>
|
||||
{groupByIP ? '⊞ Groupé' : '⊟ Individuel'}
|
||||
</button>
|
||||
|
||||
{/* Sélecteur colonnes */}
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
||||
className="text-xs bg-background-card hover:bg-background-card/80 border border-background-card rounded px-2 py-1 text-text-primary transition-colors"
|
||||
>
|
||||
Colonnes ▾
|
||||
</button>
|
||||
{showColumnSelector && (
|
||||
<div className="absolute right-0 mt-1 w-44 bg-background-secondary border border-background-card rounded-lg shadow-lg z-20 p-2">
|
||||
{columns.map(col => (
|
||||
<label key={col.key} className="flex items-center gap-2 px-2 py-1 hover:bg-background-card rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.visible}
|
||||
onChange={() => toggleColumn(col.key)}
|
||||
className="rounded bg-background-card border-background-card text-accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-text-primary">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recherche */}
|
||||
<form onSubmit={handleSearch} className="flex gap-1 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="IP, JA4, Host..."
|
||||
className="bg-background-card border border-background-card rounded px-2 py-1 text-xs text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white text-xs px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ── Tableau ── */}
|
||||
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
||||
<DataTable<DetectionRow>
|
||||
data={processedData.items as DetectionRow[]}
|
||||
columns={tableColumns}
|
||||
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
|
||||
defaultSortKey={sortField}
|
||||
defaultSortDir={sortOrder}
|
||||
onSort={handleSort}
|
||||
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
|
||||
emptyMessage="Aucune détection trouvée"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{data.total_pages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-text-secondary text-xs">
|
||||
Page {data.page}/{data.total_pages} · {data.total.toLocaleString()} détections
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handlePageChange(data.page - 1)}
|
||||
disabled={data.page === 1}
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(data.page + 1)}
|
||||
disabled={data.page === data.total_pages}
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant ModelBadge
|
||||
function ModelBadge({ model }: { model: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
Complet: 'bg-accent-primary/20 text-accent-primary',
|
||||
Applicatif: 'bg-purple-500/20 text-purple-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`${styles[model] || 'bg-background-card'} px-2 py-1 rounded text-xs`}>
|
||||
{model}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant ScoreBadge
|
||||
// Les scores non-IF (ANUBIS_DENY, KNOWN_BOT) sont stockés comme sentinels
|
||||
// (-1.0 et 0.0) et doivent être affichés comme des badges textuels,
|
||||
// pas comme des scores numériques calculés par l'IsolationForest.
|
||||
function ScoreBadge({
|
||||
score,
|
||||
threatLevel,
|
||||
botName,
|
||||
anubisAction,
|
||||
}: {
|
||||
score: number;
|
||||
threatLevel?: string;
|
||||
botName?: string;
|
||||
anubisAction?: string;
|
||||
}) {
|
||||
// ANUBIS_DENY : menace identifiée par règle, pas par IF
|
||||
if (threatLevel === 'ANUBIS_DENY' || anubisAction === 'DENY') {
|
||||
return (
|
||||
<span className="inline-flex items-center text-xs px-1.5 py-0.5 rounded border bg-red-500/15 text-red-400 border-red-500/30 font-medium">
|
||||
RÈGLE
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// KNOWN_BOT : bot légitime identifié par dictionnaire ou Anubis ALLOW
|
||||
if (threatLevel === 'KNOWN_BOT' || (botName && botName !== '')) {
|
||||
return (
|
||||
<span className="inline-flex items-center text-xs px-1.5 py-0.5 rounded border bg-green-500/15 text-green-400 border-green-500/30 font-medium">
|
||||
BOT
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Score IF réel
|
||||
let color = 'text-threat-low';
|
||||
if (score < -0.3) color = 'text-threat-critical';
|
||||
else if (score < -0.15) color = 'text-threat-high';
|
||||
else if (score < -0.05) color = 'text-threat-medium';
|
||||
|
||||
return (
|
||||
<span className={`font-mono text-sm ${color}`}>
|
||||
{score.toFixed(3)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper pour les drapeaux
|
||||
function getFlag(countryCode: string): string {
|
||||
const code = countryCode.toUpperCase();
|
||||
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
}
|
||||
@ -0,0 +1,397 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDateOnly } from '../utils/dateUtils';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
interface EntityStats {
|
||||
entity_type: string;
|
||||
entity_value: string;
|
||||
total_requests: number;
|
||||
unique_ips: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface EntityRelatedAttributes {
|
||||
ips: string[];
|
||||
ja4s: string[];
|
||||
hosts: string[];
|
||||
asns: string[];
|
||||
countries: string[];
|
||||
}
|
||||
|
||||
interface AttributeValue {
|
||||
value: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface EntityInvestigationData {
|
||||
stats: EntityStats;
|
||||
related: EntityRelatedAttributes;
|
||||
user_agents: AttributeValue[];
|
||||
client_headers: AttributeValue[];
|
||||
paths: AttributeValue[];
|
||||
query_params: AttributeValue[];
|
||||
}
|
||||
|
||||
export function EntityInvestigationView() {
|
||||
const { type, value } = useParams<{ type: string; value: string }>();
|
||||
const navigate = useNavigate();
|
||||
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) {
|
||||
setError("Type ou valeur d'entité manquant");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchInvestigation = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/entities/${type}/${encodeURIComponent(value)}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Erreur chargement données');
|
||||
}
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchInvestigation();
|
||||
}, [type, value]);
|
||||
|
||||
const getEntityLabel = (entityType: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
ip: 'Adresse IP',
|
||||
ja4: 'Fingerprint JA4',
|
||||
user_agent: 'User-Agent',
|
||||
client_header: 'Client Header',
|
||||
host: 'Host',
|
||||
path: 'Path',
|
||||
query_param: 'Query Params'
|
||||
};
|
||||
return labels[entityType] || entityType;
|
||||
};
|
||||
|
||||
;
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background-primary">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background-primary">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-threat-high/10 border border-threat-high rounded-lg p-6 text-center">
|
||||
<div className="text-threat-high font-medium mb-2">Erreur</div>
|
||||
<div className="text-text-secondary">{error || 'Données non disponibles'}</div>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="mt-4 bg-accent-primary text-white px-6 py-2 rounded-lg hover:bg-accent-primary/80"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background-primary">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors mb-4"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-text-primary mb-2">
|
||||
Investigation: {getEntityLabel(data.stats.entity_type)}
|
||||
</h1>
|
||||
<div className="text-text-secondary font-mono text-sm break-all max-w-4xl">
|
||||
{data.stats.entity_value}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-text-secondary">
|
||||
<div>Requêtes: <span className="text-text-primary font-bold">{data.stats.total_requests.toLocaleString()}</span></div>
|
||||
<div>IPs Uniques: <span className="text-text-primary font-bold">{data.stats.unique_ips.toLocaleString()}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label="Total Requêtes"
|
||||
value={data.stats.total_requests.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
label="IPs Uniques"
|
||||
value={data.stats.unique_ips.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
label="Première Détection"
|
||||
value={formatDateOnly(data.stats.first_seen)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Dernière Détection"
|
||||
value={formatDateOnly(data.stats.last_seen)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Panel 1: IPs Associées */}
|
||||
<div className="bg-background-secondary rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">1. IPs Associées</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{data.related.ips.slice(0, 20).map((ip, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => navigate(`/investigation/${ip}`)}
|
||||
className="text-left px-3 py-2 bg-background-card rounded-lg text-sm text-text-primary hover:bg-background-card/80 transition-colors font-mono"
|
||||
>
|
||||
{ip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{data.related.ips.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucune IP associée</div>
|
||||
)}
|
||||
{data.related.ips.length > 20 && (
|
||||
<div className="text-center text-text-secondary mt-4 text-sm">
|
||||
+{data.related.ips.length - 20} autres IPs
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel 2: JA4 Fingerprints */}
|
||||
<div className="bg-background-secondary rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4"><span className="flex items-center gap-1">2. JA4 Fingerprints<InfoTip content={TIPS.ja4} /></span></h3>
|
||||
<div className="space-y-2">
|
||||
{data.related.ja4s.slice(0, 10).map((ja4, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-background-card rounded-lg p-3">
|
||||
<div className="font-mono text-sm text-text-primary break-all flex-1">
|
||||
{ja4}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(ja4)}`)}
|
||||
className="ml-4 text-xs bg-accent-primary text-white px-3 py-1 rounded hover:bg-accent-primary/80 whitespace-nowrap"
|
||||
>
|
||||
Investigation
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.related.ja4s.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun JA4 associé</div>
|
||||
)}
|
||||
{data.related.ja4s.length > 10 && (
|
||||
<div className="text-center text-text-secondary mt-4 text-sm">
|
||||
+{data.related.ja4s.length - 10} autres JA4
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel 3: User-Agents */}
|
||||
<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">
|
||||
{(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 leading-relaxed">
|
||||
{ua.value}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-text-secondary text-xs">{ua.count} requêtes</div>
|
||||
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.user_agents.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun User-Agent</div>
|
||||
)}
|
||||
{data.user_agents.length > 10 && (
|
||||
<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>
|
||||
|
||||
{/* Panel 4: Client Headers */}
|
||||
<div className="bg-background-secondary rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4"><span className="flex items-center gap-1">4. Client Headers<InfoTip content={TIPS.accept_encoding} /></span></h3>
|
||||
<div className="space-y-3">
|
||||
{data.client_headers.slice(0, 10).map((header, 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">
|
||||
{header.value}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-text-secondary text-xs">{header.count} requêtes</div>
|
||||
<div className="text-text-secondary text-xs">{header.percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.client_headers.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun Client Header</div>
|
||||
)}
|
||||
{data.client_headers.length > 10 && (
|
||||
<div className="text-center text-text-secondary mt-4 text-sm">
|
||||
+{data.client_headers.length - 10} autres Client Headers
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel 5: Hosts */}
|
||||
<div className="bg-background-secondary rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">5. Hosts Ciblés</h3>
|
||||
<div className="space-y-2">
|
||||
{data.related.hosts.slice(0, 15).map((host, idx) => (
|
||||
<div key={idx} className="bg-background-card rounded-lg p-3">
|
||||
<div className="text-sm text-text-primary break-all">{host}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.related.hosts.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun Host associé</div>
|
||||
)}
|
||||
{data.related.hosts.length > 15 && (
|
||||
<div className="text-center text-text-secondary mt-4 text-sm">
|
||||
+{data.related.hosts.length - 15} autres Hosts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel 6: Paths */}
|
||||
<div className="bg-background-secondary rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">6. Paths</h3>
|
||||
<div className="space-y-2">
|
||||
{data.paths.slice(0, 15).map((path, idx) => (
|
||||
<div key={idx} className="bg-background-card rounded-lg p-3">
|
||||
<div className="text-sm text-text-primary font-mono break-all">{path.value}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="text-text-secondary text-xs">{path.count} requêtes</div>
|
||||
<div className="text-text-secondary text-xs">{path.percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.paths.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun Path</div>
|
||||
)}
|
||||
{data.paths.length > 15 && (
|
||||
<div className="text-center text-text-secondary mt-4 text-sm">
|
||||
+{data.paths.length - 15} autres Paths
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel 7: Query Params */}
|
||||
<div className="bg-background-secondary rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">7. Query Params</h3>
|
||||
<div className="space-y-2">
|
||||
{data.query_params.slice(0, 15).map((qp, idx) => (
|
||||
<div key={idx} className="bg-background-card rounded-lg p-3">
|
||||
<div className="text-sm text-text-primary font-mono break-all">{qp.value}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="text-text-secondary text-xs">{qp.count} requêtes</div>
|
||||
<div className="text-text-secondary text-xs">{qp.percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.query_params.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun Query Param</div>
|
||||
)}
|
||||
{data.query_params.length > 15 && (
|
||||
<div className="text-center text-text-secondary mt-4 text-sm">
|
||||
+{data.query_params.length - 15} autres Query Params
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Panel 8: ASNs & Pays */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* ASNs */}
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4"><span className="flex items-center gap-1">ASNs<InfoTip content={TIPS.asn} /></span></h3>
|
||||
<div className="space-y-2">
|
||||
{data.related.asns.slice(0, 10).map((asn, idx) => (
|
||||
<div key={idx} className="bg-background-card rounded-lg p-3">
|
||||
<div className="text-sm text-text-primary">{asn}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.related.asns.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun ASN</div>
|
||||
)}
|
||||
{data.related.asns.length > 10 && (
|
||||
<div className="text-center text-text-secondary mt-4 text-sm">
|
||||
+{data.related.asns.length - 10} autres ASNs
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pays */}
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">Pays</h3>
|
||||
<div className="space-y-2">
|
||||
{data.related.countries.slice(0, 10).map((country, idx) => (
|
||||
<div key={idx} className="bg-background-card rounded-lg p-3 flex items-center gap-2">
|
||||
<span className="text-xl">{getCountryFlag(country)}</span>
|
||||
<span className="text-sm text-text-primary">{country}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.related.countries.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun pays</div>
|
||||
)}
|
||||
{data.related.countries.length > 10 && (
|
||||
<div className="text-center text-text-secondary mt-4 text-sm">
|
||||
+{data.related.countries.length - 10} autres pays
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-4">
|
||||
<div className="text-xs text-text-secondary mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1944
services/dashboard/frontend/src/components/FingerprintsView.tsx
Normal file
1944
services/dashboard/frontend/src/components/FingerprintsView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,333 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { ErrorMessage } from './ui/Feedback';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HeaderCluster {
|
||||
hash: string;
|
||||
unique_ips: number;
|
||||
avg_browser_score: number;
|
||||
ua_ch_mismatch_count: number;
|
||||
ua_ch_mismatch_pct: number;
|
||||
top_sec_fetch_modes: string[];
|
||||
has_cookie_pct: number;
|
||||
has_referer_pct: number;
|
||||
classification: string;
|
||||
}
|
||||
|
||||
interface ClusterIP {
|
||||
ip: string;
|
||||
browser_score: number;
|
||||
ua_ch_mismatch: boolean;
|
||||
sec_fetch_mode: string;
|
||||
sec_fetch_dest: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
function mismatchColor(pct: number): string {
|
||||
if (pct > 50) return 'text-threat-critical';
|
||||
if (pct > 10) return 'text-threat-medium';
|
||||
return 'text-threat-low';
|
||||
}
|
||||
|
||||
function browserScoreColor(score: number): string {
|
||||
if (score >= 70) return 'bg-threat-low';
|
||||
if (score >= 40) return 'bg-threat-medium';
|
||||
return 'bg-threat-critical';
|
||||
}
|
||||
|
||||
function classificationBadge(cls: string): { bg: string; text: string; label: string } {
|
||||
switch (cls) {
|
||||
case 'bot':
|
||||
return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🤖 Bot' };
|
||||
case 'suspicious':
|
||||
return { bg: 'bg-threat-high/20', text: 'text-threat-high', label: '⚠️ Suspect' };
|
||||
case 'legitimate':
|
||||
return { bg: 'bg-threat-low/20', text: 'text-threat-low', label: '✅ Légitime' };
|
||||
default:
|
||||
return { bg: 'bg-background-card', text: 'text-text-secondary', label: cls };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HeaderFingerprintView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [clusters, setClusters] = useState<HeaderCluster[]>([]);
|
||||
const [totalClusters, setTotalClusters] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [expandedHash, setExpandedHash] = useState<string | null>(null);
|
||||
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
|
||||
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
|
||||
const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
|
||||
const expandedPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchClusters = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/headers/clusters?limit=50');
|
||||
if (!res.ok) throw new Error('Erreur chargement des clusters');
|
||||
const data: { clusters: HeaderCluster[]; total_clusters: number } = await res.json();
|
||||
setClusters(data.clusters ?? []);
|
||||
setTotalClusters(data.total_clusters ?? 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchClusters();
|
||||
}, []);
|
||||
|
||||
const handleToggleCluster = async (hash: string) => {
|
||||
if (expandedHash === hash) {
|
||||
setExpandedHash(null);
|
||||
return;
|
||||
}
|
||||
setExpandedHash(hash);
|
||||
setTimeout(() => expandedPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
|
||||
if (clusterIPsMap[hash] !== undefined) return;
|
||||
setLoadingHashes((prev) => new Set(prev).add(hash));
|
||||
try {
|
||||
const res = await fetch(`/api/headers/cluster/${hash}/ips?limit=50`);
|
||||
if (!res.ok) throw new Error('Erreur chargement IPs');
|
||||
const data: { items: ClusterIP[] } = await res.json();
|
||||
setClusterIPsMap((prev) => ({ ...prev, [hash]: data.items ?? [] }));
|
||||
} catch (err) {
|
||||
setIpErrors((prev) => ({ ...prev, [hash]: err instanceof Error ? err.message : 'Erreur inconnue' }));
|
||||
} finally {
|
||||
setLoadingHashes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(hash);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length;
|
||||
const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length;
|
||||
|
||||
const clusterColumns: Column<HeaderCluster>[] = [
|
||||
{
|
||||
key: 'hash',
|
||||
label: 'Hash cluster',
|
||||
tooltip: TIPS.hash_cluster,
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span>
|
||||
<span className="text-accent-primary text-xs mr-2">{expandedHash === row.hash ? '▾' : '▸'}</span>
|
||||
<span className="font-mono text-xs text-text-primary">{row.hash.slice(0, 16)}…</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'unique_ips',
|
||||
label: 'IPs',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
render: (v) => <span className="text-text-primary">{formatNumber(v)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'avg_browser_score',
|
||||
label: 'Browser Score',
|
||||
tooltip: TIPS.browser_score,
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-background-card rounded-full h-2">
|
||||
<div className={`h-2 rounded-full ${browserScoreColor(v)}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{Math.round(v)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ua_ch_mismatch_pct',
|
||||
label: 'UA/CH Mismatch %',
|
||||
tooltip: TIPS.ua_mismatch,
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
render: (v) => (
|
||||
<span className={`font-semibold text-sm ${mismatchColor(v)}`}>{Math.round(v)}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'classification',
|
||||
label: 'Classification',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const badge = classificationBadge(v);
|
||||
return (
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'top_sec_fetch_modes',
|
||||
label: 'Sec-Fetch modes',
|
||||
tooltip: TIPS.sec_fetch,
|
||||
sortable: false,
|
||||
render: (v) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(v ?? []).slice(0, 3).map((mode: string) => (
|
||||
<span key={mode} className="text-xs bg-background-card border border-border px-1.5 py-0.5 rounded text-text-secondary">
|
||||
{mode}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const ipColumns: Column<ClusterIP>[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
sortable: true,
|
||||
render: (v) => <span className="font-mono text-text-primary">{v}</span>,
|
||||
},
|
||||
{
|
||||
key: 'browser_score',
|
||||
label: 'Browser Score',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
render: (v) => (
|
||||
<span className={v >= 70 ? 'text-threat-low' : v >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
|
||||
{Math.round(v)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ua_ch_mismatch',
|
||||
label: 'UA/CH Mismatch',
|
||||
sortable: true,
|
||||
render: (v) =>
|
||||
v ? (
|
||||
<span className="text-threat-critical">⚠️ Oui</span>
|
||||
) : (
|
||||
<span className="text-threat-low">✓ Non</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sec_fetch_mode',
|
||||
label: 'Sec-Fetch Mode',
|
||||
tooltip: TIPS.sec_fetch_dest,
|
||||
sortable: true,
|
||||
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'sec_fetch_dest',
|
||||
label: 'Sec-Fetch Dest',
|
||||
tooltip: TIPS.sec_fetch_dest,
|
||||
sortable: true,
|
||||
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
render: (_, row) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
|
||||
className="bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded hover:bg-accent-primary/20 transition-colors text-xs"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">📡 Fingerprint HTTP Headers</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Clustering par ordre et composition des headers HTTP pour identifier les bots et fingerprints suspects.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard label="Total clusters" value={formatNumber(totalClusters)} accent="text-text-primary" />
|
||||
<StatCard label="Clusters suspects (UA/CH >50%)" value={formatNumber(suspiciousClusters)} accent="text-threat-critical" />
|
||||
<StatCard label="Clusters légitimes" value={formatNumber(legitimateClusters)} accent="text-threat-low" />
|
||||
</div>
|
||||
|
||||
{/* Clusters DataTable */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{error ? (
|
||||
<div className="p-4"><ErrorMessage message={error} /></div>
|
||||
) : (
|
||||
<DataTable<HeaderCluster>
|
||||
data={clusters}
|
||||
columns={clusterColumns}
|
||||
rowKey="hash"
|
||||
defaultSortKey="unique_ips"
|
||||
onRowClick={(row) => handleToggleCluster(row.hash)}
|
||||
loading={loading}
|
||||
emptyMessage="Aucun cluster détecté"
|
||||
compact
|
||||
maxHeight="max-h-[480px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded IPs panel */}
|
||||
{expandedHash && (
|
||||
<div ref={expandedPanelRef} className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
IPs du cluster{' '}
|
||||
<span className="font-mono text-xs text-accent-primary">{expandedHash.slice(0, 16)}…</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setExpandedHash(null)}
|
||||
className="text-text-secondary hover:text-text-primary text-xs"
|
||||
>
|
||||
✕ Fermer
|
||||
</button>
|
||||
</div>
|
||||
{ipErrors[expandedHash] ? (
|
||||
<div className="p-4"><ErrorMessage message={ipErrors[expandedHash]} /></div>
|
||||
) : (
|
||||
<DataTable<ClusterIP>
|
||||
data={clusterIPsMap[expandedHash] ?? []}
|
||||
columns={ipColumns}
|
||||
rowKey="ip"
|
||||
defaultSortKey="browser_score"
|
||||
loading={loadingHashes.has(expandedHash)}
|
||||
emptyMessage="Aucune IP trouvée"
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<p className="text-text-secondary text-xs">{formatNumber(totalClusters)} cluster(s) détecté(s)</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
567
services/dashboard/frontend/src/components/IncidentsView.tsx
Normal file
567
services/dashboard/frontend/src/components/IncidentsView.tsx
Normal file
@ -0,0 +1,567 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
interface IncidentCluster {
|
||||
id: string;
|
||||
score: number;
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
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: 'up' | 'down' | 'stable';
|
||||
trend_percentage: number;
|
||||
hits_per_second?: number;
|
||||
}
|
||||
|
||||
interface MetricsSummary {
|
||||
total_detections: number;
|
||||
critical_count: number;
|
||||
high_count: number;
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
unique_ips: number;
|
||||
known_bots_count: number;
|
||||
anomalies_count: number;
|
||||
}
|
||||
|
||||
interface BaselineMetric {
|
||||
today: number;
|
||||
yesterday: number;
|
||||
pct_change: number;
|
||||
}
|
||||
interface BaselineData {
|
||||
total_detections: BaselineMetric;
|
||||
unique_ips: BaselineMetric;
|
||||
critical_alerts: BaselineMetric;
|
||||
}
|
||||
|
||||
export function IncidentsView() {
|
||||
const navigate = useNavigate();
|
||||
const [clusters, setClusters] = useState<IncidentCluster[]>([]);
|
||||
const [metrics, setMetrics] = useState<MetricsSummary | null>(null);
|
||||
const [baseline, setBaseline] = useState<BaselineData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedClusters, setSelectedClusters] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const fetchIncidents = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const metricsResponse = await fetch('/api/metrics');
|
||||
if (metricsResponse.ok) {
|
||||
const metricsData = await metricsResponse.json();
|
||||
setMetrics(metricsData.summary);
|
||||
}
|
||||
|
||||
const baselineResponse = await fetch('/api/metrics/baseline');
|
||||
if (baselineResponse.ok) {
|
||||
setBaseline(await baselineResponse.json());
|
||||
}
|
||||
|
||||
const clustersResponse = await fetch('/api/incidents/clusters');
|
||||
if (clustersResponse.ok) {
|
||||
const clustersData = await clustersResponse.json();
|
||||
setClusters(clustersData.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching incidents:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchIncidents();
|
||||
const interval = setInterval(fetchIncidents, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const toggleCluster = (id: string) => {
|
||||
const newSelected = new Set(selectedClusters);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id);
|
||||
} else {
|
||||
newSelected.add(id);
|
||||
}
|
||||
setSelectedClusters(newSelected);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (selectedClusters.size === clusters.length) {
|
||||
setSelectedClusters(new Set());
|
||||
} else {
|
||||
setSelectedClusters(new Set(clusters.map(c => c.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'CRITICAL': return 'border-red-500 bg-red-500/10';
|
||||
case 'HIGH': return 'border-orange-500 bg-orange-500/10';
|
||||
case 'MEDIUM': return 'border-yellow-500 bg-yellow-500/10';
|
||||
case 'LOW': return 'border-green-500 bg-green-500/10';
|
||||
default: return 'border-gray-500 bg-gray-500/10';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadgeColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'CRITICAL': return 'bg-red-500 text-white';
|
||||
case 'HIGH': return 'bg-orange-500 text-white';
|
||||
case 'MEDIUM': return 'bg-yellow-500 text-white';
|
||||
case 'LOW': return 'bg-green-500 text-white';
|
||||
default: return 'bg-gray-500 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">Surveillance en temps réel · 24 dernières heures</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats unifiées — 6 cartes compact */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{/* Total détections avec comparaison hier */}
|
||||
<div
|
||||
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
|
||||
onClick={() => navigate('/detections')}
|
||||
>
|
||||
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
|
||||
📊 Total 24h<InfoTip content={TIPS.total_detections_stat} />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-text-primary">
|
||||
{(metrics?.total_detections ?? 0).toLocaleString()}
|
||||
</div>
|
||||
{baseline && (() => {
|
||||
const m = baseline.total_detections;
|
||||
const up = m.pct_change > 0;
|
||||
const neutral = m.pct_change === 0;
|
||||
return (
|
||||
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
|
||||
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* IPs uniques */}
|
||||
<div
|
||||
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
|
||||
onClick={() => navigate('/detections')}
|
||||
>
|
||||
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
|
||||
🖥️ IPs uniques<InfoTip content={TIPS.unique_ips_stat} />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-text-primary">
|
||||
{(metrics?.unique_ips ?? 0).toLocaleString()}
|
||||
</div>
|
||||
{baseline && (() => {
|
||||
const m = baseline.unique_ips;
|
||||
const up = m.pct_change > 0;
|
||||
const neutral = m.pct_change === 0;
|
||||
return (
|
||||
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
|
||||
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* BOT connus */}
|
||||
<div
|
||||
className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-green-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?score_type=BOT')}
|
||||
>
|
||||
<div className="text-[10px] text-green-400/80 uppercase tracking-wide">🤖 BOT nommés</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
{(metrics?.known_bots_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-green-400/60">
|
||||
{metrics ? Math.round((metrics.known_bots_count / metrics.total_detections) * 100) : 0}% du total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anomalies ML */}
|
||||
<div
|
||||
className="bg-purple-500/10 border border-purple-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-purple-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?score_type=SCORE')}
|
||||
>
|
||||
<div className="text-[10px] text-purple-400/80 uppercase tracking-wide">🔬 Anomalies ML</div>
|
||||
<div className="text-xl font-bold text-purple-400">
|
||||
{(metrics?.anomalies_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-purple-400/60">
|
||||
{metrics ? Math.round((metrics.anomalies_count / metrics.total_detections) * 100) : 0}% du total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HIGH */}
|
||||
<div
|
||||
className="bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-orange-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?threat_level=HIGH')}
|
||||
>
|
||||
<div className="text-[10px] text-orange-400/80 uppercase tracking-wide">⚠️ HIGH</div>
|
||||
<div className="text-xl font-bold text-orange-400">
|
||||
{(metrics?.high_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-orange-400/60">Menaces élevées</div>
|
||||
</div>
|
||||
|
||||
{/* MEDIUM */}
|
||||
<div
|
||||
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-yellow-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?threat_level=MEDIUM')}
|
||||
>
|
||||
<div className="text-[10px] text-yellow-400/80 uppercase tracking-wide">📊 MEDIUM</div>
|
||||
<div className="text-xl font-bold text-yellow-400">
|
||||
{(metrics?.medium_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-yellow-400/60">Menaces moyennes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedClusters.size > 0 && (
|
||||
<div className="bg-blue-500/20 border border-blue-500 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-text-primary">
|
||||
<span className="font-bold">{selectedClusters.size}</span> incidents sélectionnés
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Bulk classification
|
||||
const ips = clusters
|
||||
.filter(c => selectedClusters.has(c.id))
|
||||
.flatMap(c => c.subnet ? [c.subnet.split('/')[0]] : []);
|
||||
navigate(`/bulk-classify?ips=${encodeURIComponent(ips.join(','))}`);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Classifier en masse
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Export selected
|
||||
const data = clusters.filter(c => selectedClusters.has(c.id));
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `export_incidents_${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}}
|
||||
className="px-4 py-2 bg-background-card text-text-primary rounded-lg text-sm hover:bg-background-card/80 transition-colors"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedClusters(new Set())}
|
||||
className="px-4 py-2 bg-background-card text-text-primary rounded-lg text-sm hover:bg-background-card/80 transition-colors"
|
||||
>
|
||||
Désélectionner
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
</h2>
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-sm text-accent-primary hover:text-accent-primary/80"
|
||||
>
|
||||
{selectedClusters.size === clusters.length ? 'Tout désélectionner' : 'Tout sélectionner'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{clusters.map((cluster) => (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className={`border-2 rounded-lg p-4 transition-all hover:shadow-lg ${getSeverityColor(cluster.severity)}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedClusters.has(cluster.id)}
|
||||
onChange={() => toggleCluster(cluster.id)}
|
||||
className="mt-1 w-4 h-4 rounded bg-background-card border-background-card text-accent-primary focus:ring-accent-primary"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span title={cluster.severity === 'CRITICAL' ? TIPS.risk_critical : cluster.severity === 'HIGH' ? TIPS.risk_high : cluster.severity === 'MEDIUM' ? TIPS.risk_medium : TIPS.risk_low} className={`px-2 py-1 rounded text-xs font-bold ${getSeverityBadgeColor(cluster.severity)}`}>
|
||||
{cluster.severity}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-text-primary">{cluster.id}</span>
|
||||
<span className="text-text-secondary">|</span>
|
||||
<span className="font-mono text-sm text-text-primary">{cluster.subnet || ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-text-primary">{cluster.score}/100</div>
|
||||
<div className="text-xs text-text-secondary flex items-center gap-1">Score de risque<InfoTip content={TIPS.risk_score_inv} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-3">
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1">IPs</div>
|
||||
<div className="text-text-primary font-bold">{cluster.unique_ips}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1">Détections</div>
|
||||
<div className="text-text-primary font-bold">{cluster.total_detections}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1">Pays</div>
|
||||
<div className="text-text-primary">
|
||||
{cluster.countries[0] && (
|
||||
<>
|
||||
{getCountryFlag(cluster.countries[0].code)} {cluster.countries[0].code}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">ASN<InfoTip content={TIPS.asn} /></div>
|
||||
<div className="text-text-primary">AS{cluster.asn || '?'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">Tendance<InfoTip content={TIPS.tendance} /></div>
|
||||
<div className={`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}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cluster.ja4 && (
|
||||
<div className="mb-3 p-2 bg-background-card rounded">
|
||||
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">JA4 Principal<InfoTip content={TIPS.ja4} /></div>
|
||||
<div className="font-mono text-xs text-text-primary break-all">{cluster.ja4}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${cluster.sample_ip || cluster.subnet?.split('/')[0] || ''}`)}
|
||||
className="px-3 py-1.5 bg-accent-primary text-white rounded text-sm hover:bg-accent-primary/80 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/entities/subnet/${encodeURIComponent((cluster.subnet || '').replace('/', '_'))}`)}
|
||||
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
|
||||
>
|
||||
Voir détails
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Quick classify
|
||||
navigate(`/bulk-classify?ips=${encodeURIComponent(cluster.sample_ip || cluster.subnet?.split('/')[0] || '')}`);
|
||||
}}
|
||||
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
|
||||
>
|
||||
Classifier
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Export STIX
|
||||
const stixData = {
|
||||
type: 'bundle',
|
||||
id: `bundle--${cluster.id}`,
|
||||
objects: [{
|
||||
type: 'indicator',
|
||||
id: `indicator--${cluster.id}`,
|
||||
pattern: `[ipv4-addr:value = '${cluster.subnet?.split('/')[0] || ''}'`,
|
||||
pattern_type: 'stix'
|
||||
}]
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(stixData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `stix_${cluster.id}_${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}}
|
||||
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
|
||||
>
|
||||
Export STIX
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{clusters.length === 0 && (
|
||||
<div className="bg-background-secondary rounded-lg p-12 text-center">
|
||||
<h3 className="text-xl font-semibold text-text-primary mb-2">
|
||||
Aucun incident actif
|
||||
</h3>
|
||||
<p className="text-text-secondary">
|
||||
Le système ne détecte aucun incident prioritaire en ce moment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>{/* end col-span-2 */}
|
||||
|
||||
{/* 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] || ''}`)}
|
||||
>
|
||||
<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' :
|
||||
'bg-green-500 text-white'
|
||||
}`}>
|
||||
{cluster.score}
|
||||
</span>
|
||||
<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>
|
||||
))}
|
||||
{clusters.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-text-secondary text-sm">
|
||||
Aucune menace active
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end grid */}
|
||||
<div className="mt-6">
|
||||
<MiniHeatmap />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface HeatmapHour {
|
||||
hour: number;
|
||||
hits: number;
|
||||
unique_ips: number;
|
||||
}
|
||||
|
||||
function MiniHeatmap() {
|
||||
const [data, setData] = useState<HeatmapHour[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/heatmap/hourly')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d) setData(d.hours ?? d.items ?? []); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const maxHits = Math.max(...data.map(d => d.hits), 1);
|
||||
|
||||
const barColor = (hits: number) => {
|
||||
const pct = (hits / maxHits) * 100;
|
||||
if (pct >= 75) return 'bg-red-500/70';
|
||||
if (pct >= 50) return 'bg-purple-500/60';
|
||||
if (pct >= 25) return 'bg-blue-500/50';
|
||||
if (pct >= 5) return 'bg-blue-400/30';
|
||||
return 'bg-slate-700/30';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary border border-border rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-text-primary mb-3">⏱️ Activité par heure (72h)</div>
|
||||
<div className="flex items-end gap-px h-16">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="relative flex-1 flex flex-col items-center justify-end group">
|
||||
<div
|
||||
className={`w-full rounded-sm ${barColor(d.hits)}`}
|
||||
style={{ height: `${Math.max((d.hits / maxHits) * 100, 2)}%` }}
|
||||
/>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:flex bg-background-card border border-border text-xs text-text-primary rounded px-2 py-1 whitespace-nowrap z-10 pointer-events-none">
|
||||
{d.hits.toLocaleString()} hits — {d.unique_ips} IPs
|
||||
</div>
|
||||
<div className="text-[9px] text-text-disabled mt-0.5 leading-none">
|
||||
{[0, 6, 12, 18].includes(d.hour) ? `${d.hour}h` : '\u00a0'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,376 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
|
||||
interface TimelineEvent {
|
||||
timestamp: string;
|
||||
type: 'detection' | 'escalation' | 'peak' | 'stabilization' | 'classification';
|
||||
severity?: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
count?: number;
|
||||
description?: string;
|
||||
ip?: string;
|
||||
ja4?: string;
|
||||
}
|
||||
|
||||
interface InteractiveTimelineProps {
|
||||
ip?: string;
|
||||
events?: TimelineEvent[];
|
||||
hours?: number;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export function InteractiveTimeline({
|
||||
ip,
|
||||
events: propEvents,
|
||||
hours = 24,
|
||||
height = '300px'
|
||||
}: InteractiveTimelineProps) {
|
||||
const [events, setEvents] = useState<TimelineEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTimelineData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (ip) {
|
||||
// Fetch detections for this IP to build timeline
|
||||
const response = await fetch(`/api/detections?search=${encodeURIComponent(ip)}&page_size=100&sort_by=detected_at&sort_order=asc`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const timelineEvents = buildTimelineFromDetections(data.items);
|
||||
setEvents(timelineEvents);
|
||||
}
|
||||
} else if (propEvents) {
|
||||
setEvents(propEvents);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching timeline data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTimelineData();
|
||||
}, [ip, propEvents]);
|
||||
|
||||
const buildTimelineFromDetections = (detections: any[]): TimelineEvent[] => {
|
||||
if (!detections || detections.length === 0) return [];
|
||||
|
||||
const events: TimelineEvent[] = [];
|
||||
|
||||
// First detection
|
||||
events.push({
|
||||
timestamp: detections[0]?.detected_at,
|
||||
type: 'detection',
|
||||
severity: detections[0]?.threat_level,
|
||||
count: 1,
|
||||
description: 'Première détection',
|
||||
ip: detections[0]?.src_ip,
|
||||
});
|
||||
|
||||
// Group by time windows (5 minutes)
|
||||
const timeWindows = new Map<string, any[]>();
|
||||
detections.forEach((d: any) => {
|
||||
const window = format(parseISO(d.detected_at), 'yyyy-MM-dd HH:mm', { locale: fr });
|
||||
if (!timeWindows.has(window)) {
|
||||
timeWindows.set(window, []);
|
||||
}
|
||||
timeWindows.get(window)!.push(d);
|
||||
});
|
||||
|
||||
// Find peaks
|
||||
let maxCount = 0;
|
||||
timeWindows.forEach((items) => {
|
||||
if (items.length > maxCount) {
|
||||
maxCount = items.length;
|
||||
}
|
||||
if (items.length >= 10) {
|
||||
events.push({
|
||||
timestamp: items[0]?.detected_at,
|
||||
type: 'peak',
|
||||
severity: items[0]?.threat_level,
|
||||
count: items.length,
|
||||
description: `Pic d'activité: ${items.length} détections`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Escalation detection
|
||||
const sortedWindows = Array.from(timeWindows.entries()).sort((a, b) =>
|
||||
new Date(a[0]).getTime() - new Date(b[0]).getTime()
|
||||
);
|
||||
|
||||
for (let i = 1; i < sortedWindows.length; i++) {
|
||||
const prevCount = sortedWindows[i - 1][1].length;
|
||||
const currCount = sortedWindows[i][1].length;
|
||||
|
||||
if (currCount > prevCount * 2 && currCount >= 5) {
|
||||
events.push({
|
||||
timestamp: sortedWindows[i][1][0]?.detected_at,
|
||||
type: 'escalation',
|
||||
severity: 'HIGH',
|
||||
count: currCount,
|
||||
description: `Escalade: ${prevCount} → ${currCount} détections`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Last detection
|
||||
if (detections.length > 1) {
|
||||
events.push({
|
||||
timestamp: detections[detections.length - 1]?.detected_at,
|
||||
type: 'detection',
|
||||
severity: detections[detections.length - 1]?.threat_level,
|
||||
count: detections.length,
|
||||
description: 'Dernière détection',
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
return events.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
};
|
||||
|
||||
const getEventTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'detection': return 'bg-blue-500';
|
||||
case 'escalation': return 'bg-orange-500';
|
||||
case 'peak': return 'bg-red-500';
|
||||
case 'stabilization': return 'bg-green-500';
|
||||
case 'classification': return 'bg-purple-500';
|
||||
default: return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getEventTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'detection': return '🔍';
|
||||
case 'escalation': return '📈';
|
||||
case 'peak': return '🔥';
|
||||
case 'stabilization': return '📉';
|
||||
case 'classification': return '🏷️';
|
||||
default: return '📍';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity?: string) => {
|
||||
switch (severity) {
|
||||
case 'CRITICAL': return 'text-red-500';
|
||||
case 'HIGH': return 'text-orange-500';
|
||||
case 'MEDIUM': return 'text-yellow-500';
|
||||
case 'LOW': return 'text-green-500';
|
||||
default: return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const visibleEvents = events.slice(
|
||||
Math.max(0, Math.floor((events.length * (1 - zoom)) / 2)),
|
||||
Math.min(events.length, Math.ceil(events.length * (1 + zoom) / 2))
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ height }}>
|
||||
<div className="text-text-secondary">Chargement de la timeline...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ height }}>
|
||||
<div className="text-text-secondary text-center">
|
||||
<div className="text-4xl mb-2">📭</div>
|
||||
<div className="text-sm">Aucun événement dans cette période</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full" style={{ height }}>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-text-secondary">
|
||||
{events.length} événements sur {hours}h
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}
|
||||
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
|
||||
>
|
||||
− Zoom
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(1)}
|
||||
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
|
||||
>
|
||||
100%
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(Math.min(2, zoom + 0.25))}
|
||||
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
|
||||
>
|
||||
+ Zoom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="relative overflow-x-auto">
|
||||
<div className="min-w-full">
|
||||
{/* Time axis */}
|
||||
<div className="flex justify-between mb-4 text-xs text-text-secondary">
|
||||
{events.length > 0 && (
|
||||
<>
|
||||
<span>{format(parseISO(events[0].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
|
||||
<span>{format(parseISO(events[events.length - 1].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events line */}
|
||||
<div className="relative h-24 border-t-2 border-background-card">
|
||||
{visibleEvents.map((event, idx) => {
|
||||
const position = (idx / (visibleEvents.length - 1 || 1)) * 100;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
|
||||
style={{ left: `${position}%` }}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded-full ${getEventTypeColor(event.type)} border-2 border-background-secondary shadow-lg group-hover:scale-150 transition-transform`}>
|
||||
<div className="text-xs text-center leading-3">
|
||||
{getEventTypeIcon(event.type)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block">
|
||||
<div className="bg-background-secondary border border-background-card rounded-lg p-2 shadow-xl whitespace-nowrap z-10">
|
||||
<div className="text-xs text-text-primary font-bold">
|
||||
{event.description}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{format(parseISO(event.timestamp), 'dd/MM HH:mm:ss', { locale: fr })}
|
||||
</div>
|
||||
{event.count && (
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{event.count} détections
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Event cards */}
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-64 overflow-y-auto">
|
||||
{visibleEvents.slice(0, 12).map((event, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
className={`bg-background-card rounded-lg p-3 cursor-pointer hover:bg-background-card/80 transition-colors border-l-4 ${
|
||||
event.severity === 'CRITICAL' ? 'border-threat-critical' :
|
||||
event.severity === 'HIGH' ? 'border-threat-high' :
|
||||
event.severity === 'MEDIUM' ? 'border-threat-medium' :
|
||||
'border-threat-low'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{getEventTypeIcon(event.type)}</span>
|
||||
<div>
|
||||
<div className="text-sm text-text-primary font-medium">
|
||||
{event.description}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{format(parseISO(event.timestamp), 'dd/MM HH:mm', { locale: fr })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{event.count && (
|
||||
<div className="text-xs text-text-primary font-bold bg-background-secondary px-2 py-1 rounded">
|
||||
{event.count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Event Modal */}
|
||||
{selectedEvent && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setSelectedEvent(null)}>
|
||||
<div className="bg-background-secondary rounded-lg p-6 max-w-md mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getEventTypeIcon(selectedEvent.type)}</span>
|
||||
<h3 className="text-lg font-bold text-text-primary">Détails de l'événement</h3>
|
||||
</div>
|
||||
<button onClick={() => setSelectedEvent(null)} className="text-text-secondary hover:text-text-primary">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary">Type</div>
|
||||
<div className="text-text-primary capitalize">{selectedEvent.type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary">Timestamp</div>
|
||||
<div className="text-text-primary font-mono">
|
||||
{format(parseISO(selectedEvent.timestamp), 'dd/MM/yyyy HH:mm:ss', { locale: fr })}
|
||||
</div>
|
||||
</div>
|
||||
{selectedEvent.description && (
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary">Description</div>
|
||||
<div className="text-text-primary">{selectedEvent.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent.count && (
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary">Nombre de détections</div>
|
||||
<div className="text-text-primary font-bold">{selectedEvent.count}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent.severity && (
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary">Sévérité</div>
|
||||
<div className={`font-bold ${getSeverityColor(selectedEvent.severity)}`}>
|
||||
{selectedEvent.severity}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent.ip && (
|
||||
<div>
|
||||
<div className="text-xs text-text-secondary">IP</div>
|
||||
<div className="text-text-primary font-mono text-sm">{selectedEvent.ip}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setSelectedEvent(null)}
|
||||
className="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-accent-primary/80"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
524
services/dashboard/frontend/src/components/InvestigationView.tsx
Normal file
524
services/dashboard/frontend/src/components/InvestigationView.tsx
Normal file
@ -0,0 +1,524 @@
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useVariability } from '../hooks/useVariability';
|
||||
import { VariabilityPanel } from './VariabilityPanel';
|
||||
import { formatDateShort } from '../utils/dateUtils';
|
||||
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 { ReputationPanel } from './ReputationPanel';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
|
||||
// ─── Multi-source Activity Summary Widget ─────────────────────────────────────
|
||||
|
||||
interface IPSummary {
|
||||
ip: string;
|
||||
risk_score: number;
|
||||
ml: { max_score: number; threat_level: string; attack_type: string; total_detections: number; distinct_hosts: number; distinct_ja4: number };
|
||||
bruteforce: { active: boolean; hosts_attacked: number; total_hits: number; total_params: number; top_hosts: string[] };
|
||||
tcp_spoofing: { detected: boolean; tcp_ttl: number | null; suspected_os: string | null; declared_os: string | null };
|
||||
ja4_rotation: { rotating: boolean; distinct_ja4_count: number; total_hits?: number };
|
||||
persistence: { persistent: boolean; recurrence: number; worst_score?: number; worst_threat_level?: string; first_seen?: string; last_seen?: string };
|
||||
timeline_24h: { hour: number; hits: number; ja4s: string[] }[];
|
||||
}
|
||||
|
||||
function RiskGauge({ score }: { score: number }) {
|
||||
const color = score >= 75 ? '#ef4444' : score >= 50 ? '#f97316' : score >= 25 ? '#eab308' : '#22c55e';
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<svg width="80" height="80" viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(100,116,139,0.2)" strokeWidth="8" />
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke={color} strokeWidth="8"
|
||||
strokeDasharray={`${(score / 100) * 213.6} 213.6`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)" />
|
||||
<text x="40" y="44" textAnchor="middle" fontSize="18" fontWeight="bold" fill={color}>{score}</text>
|
||||
</svg>
|
||||
<span className="text-xs text-text-secondary flex items-center">Risk Score<InfoTip content={TIPS.risk_score_inv} /></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityBadge({ active, label, color }: { active: boolean; label: string; color: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium ${
|
||||
active ? `border-${color}/40 bg-${color}/10 text-${color}` : 'border-border bg-background-card text-text-disabled'
|
||||
}`}>
|
||||
<span>{active ? '●' : '○'}</span>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
|
||||
if (!data.length) return <span className="text-text-disabled text-xs">Pas d'activité 24h</span>;
|
||||
const max = Math.max(...data.map(d => d.hits), 1);
|
||||
return (
|
||||
<div className="flex items-end gap-0.5 h-8">
|
||||
{Array.from({ length: 24 }, (_, h) => {
|
||||
const d = data.find(x => x.hour === h);
|
||||
const pct = d ? (d.hits / max) * 100 : 0;
|
||||
return (
|
||||
<div key={h} className="flex-1 flex flex-col justify-end" title={d ? `${h}h: ${d.hits} hits` : `${h}h: 0`}>
|
||||
<div className={`w-full rounded-sm ${pct > 0 ? 'bg-accent-primary' : 'bg-background-card'}`}
|
||||
style={{ height: `${Math.max(pct, 2)}%` }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IPActivitySummary({ ip }: { ip: string }) {
|
||||
const [open, setOpen] = useState(false); // fermée par défaut
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<IPSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/investigation/${encodeURIComponent(ip)}/summary`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => setData(d))
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false));
|
||||
}, [ip]);
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg border border-border">
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-background-card/50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-text-primary flex items-center gap-2">
|
||||
🔎 Synthèse multi-sources
|
||||
{data && <span className={`text-xs px-2 py-0.5 rounded-full font-bold ${
|
||||
data.risk_score >= 75 ? 'bg-threat-critical/20 text-threat-critical' :
|
||||
data.risk_score >= 50 ? 'bg-threat-high/20 text-threat-high' :
|
||||
data.risk_score >= 25 ? 'bg-threat-medium/20 text-threat-medium' :
|
||||
'bg-threat-low/20 text-threat-low'
|
||||
}`}>Score: {data.risk_score}</span>}
|
||||
</span>
|
||||
<span className="text-text-secondary">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-5 pb-5">
|
||||
{loading && <div className="text-text-disabled text-sm py-4">Chargement des données multi-sources…</div>}
|
||||
{!loading && !data && <div className="text-text-disabled text-sm py-4">Données insuffisantes pour cette IP.</div>}
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
{/* Risk + badges row */}
|
||||
<div className="flex items-start gap-6">
|
||||
<RiskGauge score={data.risk_score} />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ActivityBadge active={data.ml.total_detections > 0} label={`ML: ${data.ml.total_detections} détections`} color="threat-critical" />
|
||||
<ActivityBadge active={data.bruteforce.active} label={`Brute Force: ${data.bruteforce.hosts_attacked} hosts`} color="threat-high" />
|
||||
<span title={TIPS.spoof_verdict}><ActivityBadge active={data.tcp_spoofing.detected} label={`TCP Spoof: TTL ${data.tcp_spoofing.tcp_ttl ?? '—'}`} color="threat-medium" /></span>
|
||||
<span title={TIPS.ja4_rotation}><ActivityBadge active={data.ja4_rotation.rotating} label={`JA4 Rotation: ${data.ja4_rotation.distinct_ja4_count} signatures`} color="threat-medium" /></span>
|
||||
<span title={TIPS.persistence}><ActivityBadge active={data.persistence.persistent} label={`Persistance: ${data.persistence.recurrence}x récurrences`} color="threat-high" /></span>
|
||||
</div>
|
||||
{/* Detail grid */}
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
{data.ml.total_detections > 0 && (
|
||||
<div className="bg-background-card rounded p-2">
|
||||
<div className="text-text-disabled mb-1">ML Detection</div>
|
||||
<div className="text-text-primary font-medium">{data.ml.threat_level || '—'} · {data.ml.attack_type || '—'}</div>
|
||||
<div className="text-text-secondary">Score: {data.ml.max_score} · {data.ml.distinct_ja4} JA4(s)</div>
|
||||
</div>
|
||||
)}
|
||||
{data.bruteforce.active && (
|
||||
<div className="bg-background-card rounded p-2">
|
||||
<div className="text-text-disabled mb-1">Brute Force</div>
|
||||
<div className="text-threat-high font-medium">{data.bruteforce.total_hits.toLocaleString(navigator.language || undefined)} hits</div>
|
||||
<div className="text-text-secondary truncate" title={data.bruteforce.top_hosts.join(', ')}>
|
||||
{data.bruteforce.top_hosts[0] ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.tcp_spoofing.detected && (
|
||||
<div className="bg-background-card rounded p-2">
|
||||
<div className="text-text-disabled mb-1" title={TIPS.spoof_verdict}>TCP Spoofing</div>
|
||||
<div className="text-threat-medium font-medium">TTL {data.tcp_spoofing.tcp_ttl} → {data.tcp_spoofing.suspected_os}</div>
|
||||
<div className="text-text-secondary">UA déclare: {data.tcp_spoofing.declared_os}</div>
|
||||
</div>
|
||||
)}
|
||||
{data.persistence.persistent && (
|
||||
<div className="bg-background-card rounded p-2">
|
||||
<div className="text-text-disabled mb-1">Persistance</div>
|
||||
<div className="text-threat-high font-medium">{data.persistence.recurrence}× sessions</div>
|
||||
<div className="text-text-secondary">{data.persistence.first_seen?.substring(0, 10)} → {data.persistence.last_seen?.substring(0, 10)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mini timeline */}
|
||||
<div>
|
||||
<div className="text-xs text-text-disabled mb-1 font-medium uppercase tracking-wide">Activité dernières 24h</div>
|
||||
<MiniTimeline data={data.timeline_24h} />
|
||||
<div className="flex justify-between text-xs text-text-disabled mt-0.5"><span>0h</span><span>12h</span><span>23h</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 flex items-center gap-1">
|
||||
Score de spoofing: <strong>{data.spoofing_score}/100</strong><InfoTip content={TIPS.spoofing_score} />
|
||||
</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', tip: TIPS.ua_mismatch, value: `${data.indicators.ua_ch_mismatch_rate}%`, warn: data.indicators.ua_ch_mismatch_rate > 20 },
|
||||
{ label: 'Browser score', tip: TIPS.browser_score, value: `${data.indicators.avg_browser_score}/100`, warn: data.indicators.avg_browser_score > 60 },
|
||||
{ label: 'JA4 distincts', tip: TIPS.ja4_distinct, value: data.indicators.distinct_ja4_count, warn: data.indicators.distinct_ja4_count > 2 },
|
||||
{ label: 'JA4 rares %', tip: TIPS.ja4_rare_pct, 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 flex items-center justify-center gap-0.5">
|
||||
{ind.label}
|
||||
<InfoTip content={ind.tip} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section "Attributs détectés" (données de variabilité, ex-DetailsView) ───
|
||||
|
||||
function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<div className="bg-background-card rounded-xl p-3">
|
||||
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={`text-xl font-bold ${accent ? 'text-accent-primary' : 'text-text-primary'}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionAttributesSection({ ip }: { ip: string }) {
|
||||
const [open, setOpen] = useState(true); // ouvert par défaut
|
||||
const { data, loading } = useVariability('ip', ip);
|
||||
|
||||
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
|
||||
const last = data?.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
|
||||
const sameDate = first && last && first.getTime() === last.getTime();
|
||||
const fmt = (d: Date) => formatDateShort(d.toISOString());
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg border border-border">
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-background-card/50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-text-primary flex items-center gap-2">
|
||||
📋 Attributs détectés
|
||||
{data && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-primary/20 text-accent-primary font-normal">
|
||||
{data.total_detections} détections · {data.attributes.user_agents?.length ?? 0} UA · {data.attributes.ja4?.length ?? 0} JA4
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-text-secondary">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{loading && <div className="text-text-disabled text-sm py-4">Chargement…</div>}
|
||||
{data && (
|
||||
<>
|
||||
{/* Métriques */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Metric label="Détections (24h)" value={data.total_detections.toLocaleString()} accent />
|
||||
<Metric label="User-Agents" value={(data.attributes.user_agents?.length ?? 0).toString()} />
|
||||
{first && last && (
|
||||
sameDate ? (
|
||||
<Metric label="Détecté le" value={fmt(last!)} />
|
||||
) : (
|
||||
<div className="bg-background-card rounded-xl p-3 col-span-2">
|
||||
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">Période</p>
|
||||
<p className="text-xs text-text-primary font-medium">{fmt(first)}</p>
|
||||
<p className="text-[10px] text-text-secondary">→ {fmt(last!)}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
{data.insights.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.insights.map((ins, i) => {
|
||||
const s: Record<string, string> = {
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/40 text-yellow-400',
|
||||
info: 'bg-blue-500/10 border-blue-500/40 text-blue-400',
|
||||
success: 'bg-green-500/10 border-green-500/40 text-green-400',
|
||||
};
|
||||
return (
|
||||
<div key={i} className={`${s[ins.type] ?? s.info} border rounded-xl p-3 text-sm`}>
|
||||
{ins.message}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attributs (JA4, hosts, ASN, pays, UA…) */}
|
||||
<VariabilityPanel attributes={data.attributes} hideAssociatedIPs />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvestigationView() {
|
||||
const { ip } = useParams<{ ip: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!ip) {
|
||||
return (
|
||||
<div className="text-center text-text-secondary py-12">
|
||||
IP non spécifiée
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClassify = (label: string, tags: string[], comment: string, confidence: number) => {
|
||||
// Callback optionnel après classification
|
||||
console.log('IP classifiée:', { ip, label, tags, comment, confidence });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<Link to="/" className="hover:text-text-primary">Dashboard</Link>
|
||||
<span>/</span>
|
||||
<Link to="/detections" className="hover:text-text-primary">Détections</Link>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary font-mono">{ip}</span>
|
||||
</nav>
|
||||
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Investigation: {ip}</h1>
|
||||
</div>
|
||||
<div className="text-text-secondary text-sm">
|
||||
Analyse de corrélations pour classification SOC
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation ancres inter-sections */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs font-medium sticky top-0 z-10 bg-background py-2">
|
||||
<span className="text-text-disabled shrink-0">Aller à :</span>
|
||||
{[
|
||||
{ id: 'section-attributs', label: '📡 Attributs' },
|
||||
{ id: 'section-synthese', label: '🔎 Synthèse' },
|
||||
{ id: 'section-reputation', label: '🌍 Réputation' },
|
||||
{ id: 'section-correlations', label: '🕸️ Corrélations' },
|
||||
{ id: 'section-geo', label: '🌐 Géo / JA4' },
|
||||
{ id: 'section-classification', label: '🏷️ Classification' },
|
||||
].map(({ id, label }) => (
|
||||
<a key={id} href={`#${id}`} className="shrink-0 px-3 py-1 rounded-full bg-background-card text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors">
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Attributs détectés (ex-DetailsView) */}
|
||||
<div id="section-attributs">
|
||||
<DetectionAttributesSection ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* Synthèse multi-sources */}
|
||||
<div id="section-synthese">
|
||||
<IPActivitySummary ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* Réputation (1/3) + Graph de corrélations (2/3) */}
|
||||
<div id="section-reputation" 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>
|
||||
<div id="section-correlations" 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="600px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subnet / Country / JA4 */}
|
||||
<div id="section-geo" className="grid grid-cols-3 gap-6 items-start">
|
||||
<SubnetAnalysis ip={ip} />
|
||||
<CountryAnalysis ip={ip} />
|
||||
<JA4Analysis ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* User-Agents (1/2) + Classification (1/2) */}
|
||||
<div id="section-classification" className="grid grid-cols-2 gap-6 items-start">
|
||||
<UserAgentAnalysis ip={ip} />
|
||||
<CorrelationSummary ip={ip} onClassify={handleClassify} />
|
||||
</div>
|
||||
|
||||
{/* 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 flex items-center gap-1">
|
||||
🔏 JA4 Légitimes (baseline)
|
||||
<InfoTip content={TIPS.baseline_ja4} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,336 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { JA4CorrelationSummary } from './analysis/JA4CorrelationSummary';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDateShort } from '../utils/dateUtils';
|
||||
|
||||
interface JA4InvestigationData {
|
||||
ja4: string;
|
||||
total_detections: number;
|
||||
unique_ips: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
top_ips: { ip: string; count: number; percentage: number }[];
|
||||
top_countries: { code: string; name: string; count: number; percentage: number }[];
|
||||
top_asns: { asn: string; org: string; count: number; percentage: number }[];
|
||||
top_hosts: { host: string; count: number; percentage: number }[];
|
||||
user_agents: { ua: string; count: number; percentage: number; classification: string }[];
|
||||
}
|
||||
|
||||
export function JA4InvestigationView() {
|
||||
const { ja4 } = useParams<{ ja4: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<JA4InvestigationData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchJA4Investigation = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Récupérer les données de base
|
||||
const baseResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}`);
|
||||
if (!baseResponse.ok) throw new Error('Erreur chargement données JA4');
|
||||
const baseData = await baseResponse.json();
|
||||
|
||||
// Récupérer les IPs associées
|
||||
const ipsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/ips?limit=20`);
|
||||
const ipsData = await ipsResponse.json();
|
||||
|
||||
// Récupérer les attributs associés
|
||||
const countriesResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=countries&limit=10`);
|
||||
const countriesData = await countriesResponse.json();
|
||||
|
||||
const asnsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=asns&limit=10`);
|
||||
const asnsData = await asnsResponse.json();
|
||||
|
||||
const hostsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/attributes?target_attr=hosts&limit=10`);
|
||||
const hostsData = await hostsResponse.json();
|
||||
|
||||
// Récupérer les user-agents
|
||||
const uaResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4 || '')}/user_agents?limit=10`);
|
||||
const uaData = await uaResponse.json();
|
||||
|
||||
// Formater les données
|
||||
setData({
|
||||
ja4: ja4 || '',
|
||||
total_detections: baseData.total_detections || 0,
|
||||
unique_ips: ipsData.total || 0,
|
||||
first_seen: baseData.date_range?.first_seen || '',
|
||||
last_seen: baseData.date_range?.last_seen || '',
|
||||
top_ips: ipsData.ips?.slice(0, 10).map((item: any) => ({
|
||||
ip: typeof item === 'string' ? item : item.ip,
|
||||
count: typeof item === 'string' ? 0 : (item.count || 0),
|
||||
percentage: typeof item === 'string' ? 0 : (item.percentage || 0)
|
||||
})) || [],
|
||||
top_countries: countriesData.items?.map((item: any) => ({
|
||||
code: item.value,
|
||||
name: item.value,
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
})) || [],
|
||||
top_asns: asnsData.items?.map((item: any) => {
|
||||
const match = item.value.match(/AS(\d+)/);
|
||||
return {
|
||||
asn: match ? `AS${match[1]}` : item.value,
|
||||
org: item.value.replace(/AS\d+\s*-\s*/, ''),
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
};
|
||||
}) || [],
|
||||
top_hosts: hostsData.items?.map((item: any) => ({
|
||||
host: item.value,
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
})) || [],
|
||||
user_agents: uaData.user_agents?.map((ua: any) => ({
|
||||
ua: ua.value,
|
||||
count: ua.count,
|
||||
percentage: ua.percentage,
|
||||
classification: ua.classification || 'normal'
|
||||
})) || []
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (ja4) {
|
||||
fetchJA4Investigation();
|
||||
}
|
||||
}, [ja4]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-6">
|
||||
<div className="text-threat-critical mb-4">Erreur: {error || 'Données non disponibles'}</div>
|
||||
<button
|
||||
onClick={() => navigate('/detections')}
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
← Retour aux détections
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getFlag = (code: string) => {
|
||||
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
};
|
||||
|
||||
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>;
|
||||
case 'bot':
|
||||
return <span className="bg-threat-medium/20 text-threat-medium px-2 py-0.5 rounded text-xs">⚠️ Bot</span>;
|
||||
case 'script':
|
||||
return <span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs">❌ Script</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<button
|
||||
onClick={() => navigate('/detections')}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Investigation JA4</h1>
|
||||
</div>
|
||||
<div className="text-text-secondary text-sm">
|
||||
Analyse de fingerprint JA4 pour classification SOC
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats principales */}
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-text-secondary mb-2 flex items-center gap-1">JA4 Fingerprint<InfoTip content={TIPS.ja4} /></div>
|
||||
<div className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all">
|
||||
{data.ja4}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-6">
|
||||
<div className="text-3xl font-bold text-text-primary">{data.total_detections.toLocaleString()}</div>
|
||||
<div className="text-text-secondary text-sm">détections (24h)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatBox label="IPs Uniques" value={data.unique_ips.toLocaleString()} tip={TIPS.unique_ips_stat} />
|
||||
<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>
|
||||
|
||||
{/* 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-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>
|
||||
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4"><span className="flex items-center gap-1">🏢 TOP ASNs<InfoTip content={TIPS.asn} /></span></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>
|
||||
|
||||
{/* 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">🖥️ 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 leading-relaxed">{ua.ua}</div>
|
||||
{getClassificationBadge(ua.classification)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-text-secondary text-xs">{ua.count} IPs</div>
|
||||
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.user_agents.length === 0 && (
|
||||
<div className="text-center text-text-secondary py-8">Aucun User-Agent trouvé</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ligne 4: Classification JA4 (full width) */}
|
||||
<JA4CorrelationSummary ja4={ja4 || ''} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ label, value, tip }: { label: string; value: string; tip?: string }) {
|
||||
return (
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-sm text-text-secondary mb-1 flex items-center gap-1">{label}{tip && <InfoTip content={tip} />}</div>
|
||||
<div className="text-2xl font-bold text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-';
|
||||
return formatDateShort(dateStr);
|
||||
}
|
||||
559
services/dashboard/frontend/src/components/MLFeaturesView.tsx
Normal file
559
services/dashboard/frontend/src/components/MLFeaturesView.tsx
Normal file
@ -0,0 +1,559 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MLAnomaly {
|
||||
ip: string;
|
||||
ja4: string;
|
||||
host: string;
|
||||
hits: number;
|
||||
fuzzing_index: number;
|
||||
hit_velocity: number;
|
||||
temporal_entropy: number;
|
||||
is_fake_navigation: boolean;
|
||||
ua_ch_mismatch: boolean;
|
||||
sni_host_mismatch: boolean;
|
||||
is_ua_rotating: boolean;
|
||||
path_diversity_ratio: number;
|
||||
anomalous_payload_ratio: number;
|
||||
asn_label: string;
|
||||
bot_name: string;
|
||||
attack_type: string;
|
||||
}
|
||||
|
||||
interface RadarData {
|
||||
ip: string;
|
||||
fuzzing_score: number;
|
||||
velocity_score: number;
|
||||
fake_nav_score: number;
|
||||
ua_mismatch_score: number;
|
||||
sni_mismatch_score: number;
|
||||
orphan_score: number;
|
||||
path_repetition_score: number;
|
||||
payload_anomaly_score: number;
|
||||
}
|
||||
|
||||
interface ScatterPoint {
|
||||
ip: string;
|
||||
ja4: string;
|
||||
fuzzing_index: number;
|
||||
hit_velocity: number;
|
||||
hits: number;
|
||||
attack_type: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
function attackTypeEmoji(type: string): string {
|
||||
switch (type) {
|
||||
case 'brute_force': return '🔑';
|
||||
case 'flood': return '🌊';
|
||||
case 'scraper': return '🕷️';
|
||||
case 'spoofing': return '🎭';
|
||||
case 'scanner': return '🔍';
|
||||
default: return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
function attackTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'brute_force': return '#ef4444';
|
||||
case 'flood': return '#3b82f6';
|
||||
case 'scraper': return '#a855f7';
|
||||
case 'spoofing': return '#f97316';
|
||||
case 'scanner': return '#eab308';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function fuzzingBadgeClass(value: number): string {
|
||||
if (value >= 200) return 'bg-threat-critical/20 text-threat-critical';
|
||||
if (value >= 100) return 'bg-threat-high/20 text-threat-high';
|
||||
if (value >= 50) return 'bg-threat-medium/20 text-threat-medium';
|
||||
return 'bg-background-card text-text-secondary';
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Radar Chart (SVG octagonal) ─────────────────────────────────────────────
|
||||
|
||||
const RADAR_AXES = [
|
||||
{ key: 'fuzzing_score', label: 'Fuzzing', tip: TIPS.fuzzing },
|
||||
{ key: 'velocity_score', label: 'Vélocité', tip: TIPS.velocity },
|
||||
{ key: 'fake_nav_score', label: 'Fausse nav', tip: TIPS.fake_nav },
|
||||
{ key: 'ua_mismatch_score', label: 'UA/CH mismatch', tip: TIPS.ua_mismatch },
|
||||
{ key: 'sni_mismatch_score', label: 'SNI mismatch', tip: TIPS.sni_mismatch },
|
||||
{ key: 'orphan_score', label: 'Orphan ratio', tip: TIPS.orphan_ratio },
|
||||
{ key: 'path_repetition_score', label: 'Répétition URL', tip: TIPS.path_repetition },
|
||||
{ key: 'payload_anomaly_score', label: 'Payload anormal', tip: TIPS.payload_anomaly },
|
||||
] as const;
|
||||
|
||||
type RadarKey = typeof RADAR_AXES[number]['key'];
|
||||
|
||||
function RadarChart({ data }: { data: RadarData }) {
|
||||
const size = 280;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const maxR = 100;
|
||||
const n = RADAR_AXES.length;
|
||||
|
||||
const angleOf = (i: number) => (i * 2 * Math.PI) / n - Math.PI / 2;
|
||||
|
||||
const pointFor = (i: number, r: number): [number, number] => {
|
||||
const a = angleOf(i);
|
||||
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
|
||||
};
|
||||
|
||||
// Background rings (at 25%, 50%, 75%, 100%)
|
||||
const rings = [25, 50, 75, 100];
|
||||
|
||||
const dataPoints = RADAR_AXES.map((axis, i) => {
|
||||
const val = Math.min((data[axis.key as RadarKey] ?? 0), 100);
|
||||
return pointFor(i, (val / 100) * maxR);
|
||||
});
|
||||
|
||||
const polygonPoints = dataPoints.map(([x, y]) => `${x},${y}`).join(' ');
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="overflow-visible">
|
||||
{/* Background rings */}
|
||||
{rings.map((r) => {
|
||||
const pts = RADAR_AXES.map((_, i) => {
|
||||
const [x, y] = pointFor(i, (r / 100) * maxR);
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return (
|
||||
<polygon
|
||||
key={r}
|
||||
points={pts}
|
||||
fill="none"
|
||||
stroke="rgba(100,116,139,0.2)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Axis lines */}
|
||||
{RADAR_AXES.map((_, i) => {
|
||||
const [x, y] = pointFor(i, maxR);
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={cx} y1={cy}
|
||||
x2={x} y2={y}
|
||||
stroke="rgba(100,116,139,0.35)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Data polygon */}
|
||||
<polygon
|
||||
points={polygonPoints}
|
||||
fill="rgba(239,68,68,0.2)"
|
||||
stroke="rgba(239,68,68,0.85)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Data dots */}
|
||||
{dataPoints.map(([x, y], i) => (
|
||||
<circle key={i} cx={x} cy={y} r="3" fill="rgba(239,68,68,0.9)" />
|
||||
))}
|
||||
|
||||
{/* Axis labels — survolez pour la définition */}
|
||||
{RADAR_AXES.map((axis, i) => {
|
||||
const [x, y] = pointFor(i, maxR + 18);
|
||||
const anchor = x < cx - 5 ? 'end' : x > cx + 5 ? 'start' : 'middle';
|
||||
return (
|
||||
<text
|
||||
key={axis.key}
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor={anchor}
|
||||
fontSize="10"
|
||||
fill="rgba(148,163,184,0.9)"
|
||||
dominantBaseline="middle"
|
||||
style={{ cursor: 'help' }}
|
||||
>
|
||||
<title>{axis.tip}</title>
|
||||
{axis.label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Percentage labels on vertical axis */}
|
||||
{rings.map((r) => {
|
||||
const [, y] = pointFor(0, (r / 100) * maxR);
|
||||
return (
|
||||
<text key={r} x={cx + 3} y={y} fontSize="8" fill="rgba(100,116,139,0.6)" dominantBaseline="middle">
|
||||
{r}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Scatter plot ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ScatterPlot({ points }: { points: ScatterPoint[] }) {
|
||||
const [tooltip, setTooltip] = useState<{ ip: string; type: string; x: number; y: number } | null>(null);
|
||||
|
||||
const W = 600;
|
||||
const H = 200;
|
||||
const padL = 40;
|
||||
const padB = 30;
|
||||
const padT = 10;
|
||||
const padR = 20;
|
||||
|
||||
const maxX = 350;
|
||||
const maxY = 1;
|
||||
|
||||
const toSvgX = (v: number) => padL + ((v / maxX) * (W - padL - padR));
|
||||
const toSvgY = (v: number) => padT + ((1 - v / maxY) * (H - padT - padB));
|
||||
|
||||
// X axis ticks
|
||||
const xTicks = [0, 50, 100, 150, 200, 250, 300, 350];
|
||||
const yTicks = [0, 0.25, 0.5, 0.75, 1.0];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
width="100%"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="overflow-visible"
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{xTicks.map((v) => (
|
||||
<line key={v} x1={toSvgX(v)} y1={padT} x2={toSvgX(v)} y2={H - padB} stroke="rgba(100,116,139,0.15)" strokeWidth="1" />
|
||||
))}
|
||||
{yTicks.map((v) => (
|
||||
<line key={v} x1={padL} y1={toSvgY(v)} x2={W - padR} y2={toSvgY(v)} stroke="rgba(100,116,139,0.15)" strokeWidth="1" />
|
||||
))}
|
||||
|
||||
{/* X axis */}
|
||||
<line x1={padL} y1={H - padB} x2={W - padR} y2={H - padB} stroke="rgba(100,116,139,0.4)" strokeWidth="1" />
|
||||
{xTicks.map((v) => (
|
||||
<text key={v} x={toSvgX(v)} y={H - padB + 12} textAnchor="middle" fontSize="9" fill="rgba(148,163,184,0.7)">{v}</text>
|
||||
))}
|
||||
<text x={(W - padL - padR) / 2 + padL} y={H - 2} textAnchor="middle" fontSize="10" fill="rgba(148,163,184,0.8)" style={{ cursor: 'help' }}>
|
||||
<title>{TIPS.fuzzing_index}</title>
|
||||
Fuzzing Index →
|
||||
</text>
|
||||
|
||||
{/* Y axis */}
|
||||
<line x1={padL} y1={padT} x2={padL} y2={H - padB} stroke="rgba(100,116,139,0.4)" strokeWidth="1" />
|
||||
{yTicks.map((v) => (
|
||||
<text key={v} x={padL - 4} y={toSvgY(v)} textAnchor="end" fontSize="9" fill="rgba(148,163,184,0.7)" dominantBaseline="middle">{v.toFixed(2)}</text>
|
||||
))}
|
||||
|
||||
{/* Data points */}
|
||||
{points.map((pt, i) => {
|
||||
const x = toSvgX(Math.min(pt.fuzzing_index, maxX));
|
||||
const y = toSvgY(Math.min(pt.hit_velocity, maxY));
|
||||
const color = attackTypeColor(pt.attack_type);
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={3}
|
||||
fill={color}
|
||||
fillOpacity={0.75}
|
||||
stroke={color}
|
||||
strokeWidth="0.5"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={() => setTooltip({ ip: pt.ip, type: pt.attack_type, x, y })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<g>
|
||||
<rect
|
||||
x={Math.min(tooltip.x + 6, W - 120)}
|
||||
y={Math.max(tooltip.y - 28, padT)}
|
||||
width={110}
|
||||
height={28}
|
||||
rx={3}
|
||||
fill="rgba(15,23,42,0.95)"
|
||||
stroke="rgba(100,116,139,0.4)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={Math.min(tooltip.x + 11, W - 115)}
|
||||
y={Math.max(tooltip.y - 18, padT + 8)}
|
||||
fontSize="9"
|
||||
fill="white"
|
||||
>
|
||||
{tooltip.ip}
|
||||
</text>
|
||||
<text
|
||||
x={Math.min(tooltip.x + 11, W - 115)}
|
||||
y={Math.max(tooltip.y - 7, padT + 19)}
|
||||
fontSize="9"
|
||||
fill="rgba(148,163,184,0.9)"
|
||||
>
|
||||
{attackTypeEmoji(tooltip.type)} {tooltip.type}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Anomalies DataTable ─────────────────────────────────────────────────────
|
||||
|
||||
function AnomaliesTable({
|
||||
anomalies,
|
||||
selectedIP,
|
||||
onRowClick,
|
||||
}: {
|
||||
anomalies: MLAnomaly[];
|
||||
selectedIP: string | null;
|
||||
onRowClick: (row: MLAnomaly) => void;
|
||||
}) {
|
||||
const columns = useMemo((): Column<MLAnomaly>[] => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
render: (v: string, row: MLAnomaly) => (
|
||||
<span className={`font-mono text-xs ${selectedIP === row.ip ? 'text-accent-primary' : 'text-text-primary'}`}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'host',
|
||||
label: 'Host',
|
||||
render: (v: string) => (
|
||||
<span className="text-text-secondary max-w-[120px] truncate block" title={v}>
|
||||
{v || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hits',
|
||||
label: 'Hits',
|
||||
align: 'right',
|
||||
render: (v: number) => formatNumber(v),
|
||||
},
|
||||
{
|
||||
key: 'fuzzing_index',
|
||||
label: 'Fuzzing',
|
||||
tooltip: TIPS.fuzzing_index,
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(v)}`}>
|
||||
{Math.round(v)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attack_type',
|
||||
label: 'Type',
|
||||
tooltip: 'Type d\'attaque détecté : Brute Force 🔑, Flood 🌊, Scraper 🕷️, Spoofing 🎭, Scanner 🔍',
|
||||
render: (v: string) => (
|
||||
<span title={v} className="text-sm">{attackTypeEmoji(v)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_signals',
|
||||
label: 'Signaux',
|
||||
tooltip: '⚠️ UA/CH mismatch · 🎭 Fausse navigation · 🔄 UA rotatif · 🌐 SNI mismatch',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: MLAnomaly) => (
|
||||
<span className="flex gap-0.5">
|
||||
{row.ua_ch_mismatch && <span title="UA/CH mismatch">⚠️</span>}
|
||||
{row.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
|
||||
{row.is_ua_rotating && <span title="UA rotatif">🔄</span>}
|
||||
{row.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
], [selectedIP]);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto max-h-[500px]">
|
||||
<DataTable
|
||||
data={anomalies}
|
||||
columns={columns}
|
||||
rowKey="ip"
|
||||
defaultSortKey="fuzzing_index"
|
||||
onRowClick={onRowClick}
|
||||
emptyMessage="Aucune anomalie détectée"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function MLFeaturesView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [anomalies, setAnomalies] = useState<MLAnomaly[]>([]);
|
||||
const [anomaliesLoading, setAnomaliesLoading] = useState(true);
|
||||
const [anomaliesError, setAnomaliesError] = useState<string | null>(null);
|
||||
|
||||
const [scatter, setScatter] = useState<ScatterPoint[]>([]);
|
||||
const [scatterLoading, setScatterLoading] = useState(true);
|
||||
const [scatterError, setScatterError] = useState<string | null>(null);
|
||||
|
||||
const [selectedIP, setSelectedIP] = useState<string | null>(null);
|
||||
const [radarData, setRadarData] = useState<RadarData | null>(null);
|
||||
const [radarLoading, setRadarLoading] = useState(false);
|
||||
const [radarError, setRadarError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAnomalies = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/ml/top-anomalies?limit=50');
|
||||
if (!res.ok) throw new Error('Erreur chargement des anomalies');
|
||||
const data: { items: MLAnomaly[] } = await res.json();
|
||||
setAnomalies(data.items ?? []);
|
||||
} catch (err) {
|
||||
setAnomaliesError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setAnomaliesLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchScatter = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/ml/scatter?limit=200');
|
||||
if (!res.ok) throw new Error('Erreur chargement du scatter');
|
||||
const data: { points: ScatterPoint[] } = await res.json();
|
||||
setScatter(data.points ?? []);
|
||||
} catch (err) {
|
||||
setScatterError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setScatterLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAnomalies();
|
||||
fetchScatter();
|
||||
}, []);
|
||||
|
||||
const loadRadar = async (ip: string) => {
|
||||
if (selectedIP === ip) {
|
||||
setSelectedIP(null);
|
||||
setRadarData(null);
|
||||
return;
|
||||
}
|
||||
setSelectedIP(ip);
|
||||
setRadarLoading(true);
|
||||
setRadarError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/ml/ip/${encodeURIComponent(ip)}/radar`);
|
||||
if (!res.ok) throw new Error('Erreur chargement du radar');
|
||||
const data: RadarData = await res.json();
|
||||
setRadarData(data);
|
||||
} catch (err) {
|
||||
setRadarError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setRadarLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🤖 Analyse Features ML</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Visualisation des features ML pour la détection d'anomalies comportementales.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main two-column layout */}
|
||||
<div className="flex gap-6 flex-col lg:flex-row">
|
||||
{/* Left: anomalies table */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h2 className="text-text-primary font-semibold text-sm">Top anomalies</h2>
|
||||
</div>
|
||||
{anomaliesLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : anomaliesError ? (
|
||||
<div className="p-4"><ErrorMessage message={anomaliesError} /></div>
|
||||
) : (
|
||||
<AnomaliesTable
|
||||
anomalies={anomalies}
|
||||
selectedIP={selectedIP}
|
||||
onRowClick={(row) => loadRadar(row.ip)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Radar chart */}
|
||||
<div className="lg:w-80 xl:w-96">
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-4 h-full flex flex-col">
|
||||
<h2 className="text-text-primary font-semibold text-sm mb-3">
|
||||
Radar ML {selectedIP ? <span className="text-accent-primary font-mono text-xs">— {selectedIP}</span> : ''}
|
||||
</h2>
|
||||
{!selectedIP ? (
|
||||
<div className="flex-1 flex items-center justify-center text-text-disabled text-sm text-center">
|
||||
Cliquez sur une IP<br />pour afficher le radar
|
||||
</div>
|
||||
) : radarLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : radarError ? (
|
||||
<ErrorMessage message={radarError} />
|
||||
) : radarData ? (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<RadarChart data={radarData} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${selectedIP}`)}
|
||||
className="mt-3 w-full text-xs bg-accent-primary/10 text-accent-primary px-3 py-2 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
🔍 Investiguer {selectedIP}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scatter plot */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
<h2 className="text-text-primary font-semibold mb-4">Nuage de points — Fuzzing Index × Vélocité</h2>
|
||||
{scatterLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : scatterError ? (
|
||||
<ErrorMessage message={scatterError} />
|
||||
) : (
|
||||
<>
|
||||
<ScatterPlot points={scatter} />
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 mt-3 text-xs text-text-secondary">
|
||||
{['brute_force', 'flood', 'scraper', 'spoofing', 'scanner'].map((type) => (
|
||||
<span key={type} className="flex items-center gap-1.5">
|
||||
<span
|
||||
style={{ backgroundColor: attackTypeColor(type), display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%' }}
|
||||
/>
|
||||
{attackTypeEmoji(type)} {type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
services/dashboard/frontend/src/components/PivotView.tsx
Normal file
365
services/dashboard/frontend/src/components/PivotView.tsx
Normal file
@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
// ─── 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; tip?: string }[] = [
|
||||
{ key: 'ja4', label: 'JA4 Fingerprint', icon: '🔐', tip: TIPS.ja4 },
|
||||
{ key: 'user_agents', label: 'User-Agents', icon: '🤖' },
|
||||
{ key: 'countries', label: 'Pays', icon: '🌍' },
|
||||
{ key: 'asns', label: 'ASN', icon: '🏢', tip: TIPS.asn },
|
||||
{ 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';
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
{row.tip && <InfoTip content={row.tip} />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
182
services/dashboard/frontend/src/components/QuickSearch.tsx
Normal file
182
services/dashboard/frontend/src/components/QuickSearch.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface SearchResult {
|
||||
type: 'ip' | 'ja4' | 'host' | 'asn';
|
||||
value: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
url: string;
|
||||
investigation_url?: string;
|
||||
}
|
||||
|
||||
interface QuickSearchProps {
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<string, string> = { ip: '🌐', ja4: '🔏', host: '🖥️', asn: '🏢' };
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
ip: 'bg-blue-500/20 text-blue-400',
|
||||
ja4: 'bg-purple-500/20 text-purple-400',
|
||||
host: 'bg-green-500/20 text-green-400',
|
||||
asn: 'bg-orange-500/20 text-orange-400',
|
||||
};
|
||||
|
||||
export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Recherche via le nouvel endpoint unifié
|
||||
useEffect(() => {
|
||||
if (query.length < 2) { setResults([]); return; }
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 250);
|
||||
}, [query]);
|
||||
|
||||
// Raccourci clavier Cmd+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setIsOpen(true);
|
||||
}
|
||||
if (e.key === 'Escape') { setIsOpen(false); setQuery(''); }
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Navigation clavier dans les résultats
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen || results.length === 0) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, results.length - 1)); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)); }
|
||||
if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleSelect(results[selectedIndex], (e as any).metaKey || (e as any).ctrlKey);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, results, selectedIndex]);
|
||||
|
||||
// Click en dehors
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setIsOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (result: SearchResult, useInvestigation = false) => {
|
||||
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
|
||||
navigate(url);
|
||||
setIsOpen(false);
|
||||
setQuery('');
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (results[0]) handleSelect(results[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-2xl">
|
||||
{/* Barre de recherche */}
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="flex items-center bg-background-card border border-background-card rounded-lg focus-within:border-accent-primary transition-colors">
|
||||
<span className="pl-4 text-text-secondary">{loading ? <span className="animate-pulse">⌛</span> : '🔍'}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setIsOpen(true); }}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder="Rechercher IP, JA4, ASN, Host... (Cmd+K)"
|
||||
className="flex-1 bg-transparent border-none px-4 py-3 text-text-primary placeholder-text-secondary focus:outline-none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<kbd className="hidden md:inline-flex items-center gap-1 px-2 py-1.5 mr-2 text-xs text-text-secondary bg-background-secondary rounded border border-background-card">
|
||||
<span>⌘</span>K
|
||||
</kbd>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Dropdown résultats */}
|
||||
{isOpen && query.length >= 2 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-background-secondary border border-background-card rounded-xl shadow-2xl z-50 max-h-96 overflow-y-auto">
|
||||
{results.length > 0 ? (
|
||||
<ul className="py-1">
|
||||
{results.map((result, i) => (
|
||||
<li key={`${result.type}-${result.value}-${i}`}>
|
||||
<button
|
||||
onClick={() => handleSelect(result)}
|
||||
className={[
|
||||
'w-full flex items-center gap-3 px-4 py-2.5 transition-colors text-left',
|
||||
i === selectedIndex ? 'bg-accent-primary/10 border-l-2 border-accent-primary' : 'hover:bg-background-card/50 border-l-2 border-transparent',
|
||||
].join(' ')}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
<span className="text-lg shrink-0">{TYPE_ICON[result.type] ?? '🔍'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-sm text-text-primary truncate">{result.label}</div>
|
||||
<div className="text-xs text-text-secondary">{result.meta}</div>
|
||||
</div>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-bold ${TYPE_COLOR[result.type] ?? ''}`}>
|
||||
{result.type.toUpperCase()}
|
||||
</span>
|
||||
{result.investigation_url && (
|
||||
<button
|
||||
className="shrink-0 text-xs text-accent-primary hover:underline ml-1"
|
||||
onClick={e => { e.stopPropagation(); handleSelect(result, true); }}
|
||||
title="Investigation complète"
|
||||
>→</button>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : !loading ? (
|
||||
<div className="px-4 py-6 text-center text-text-disabled text-sm">
|
||||
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Hints */}
|
||||
<div className="border-t border-background-card px-4 py-2 flex items-center gap-3 text-xs text-text-disabled">
|
||||
<span><kbd className="bg-background-card px-1 rounded">↑↓</kbd> naviguer</span>
|
||||
<span><kbd className="bg-background-card px-1 rounded">↵</kbd> ouvrir</span>
|
||||
<span><kbd className="bg-background-card px-1 rounded">⌘↵</kbd> investigation</span>
|
||||
<span className="ml-auto opacity-60">24h</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
services/dashboard/frontend/src/components/ReputationPanel.tsx
Normal file
216
services/dashboard/frontend/src/components/ReputationPanel.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { formatDate } from '../utils/dateUtils';
|
||||
|
||||
interface ReputationData {
|
||||
ip: string;
|
||||
timestamp: string;
|
||||
sources: {
|
||||
[key: string]: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
aggregated: {
|
||||
is_proxy: boolean;
|
||||
is_hosting: boolean;
|
||||
is_vpn: boolean;
|
||||
is_tor: boolean;
|
||||
threat_score: number;
|
||||
threat_level: 'unknown' | 'clean' | 'low' | 'medium' | 'high' | 'critical';
|
||||
country: string | null;
|
||||
country_code: string | null;
|
||||
asn: string | null;
|
||||
asn_org: string | null;
|
||||
org: string | null;
|
||||
city: string | null;
|
||||
warnings: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ReputationPanelProps {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export function ReputationPanel({ ip }: ReputationPanelProps) {
|
||||
const [reputation, setReputation] = useState<ReputationData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReputation = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/reputation/ip/${encodeURIComponent(ip)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setReputation(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (ip) {
|
||||
fetchReputation();
|
||||
}
|
||||
}, [ip]);
|
||||
|
||||
const getThreatLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'critical': return 'text-red-500 bg-red-500/10 border-red-500';
|
||||
case 'high': return 'text-orange-500 bg-orange-500/10 border-orange-500';
|
||||
case 'medium': return 'text-yellow-500 bg-yellow-500/10 border-yellow-500';
|
||||
case 'low': return 'text-blue-500 bg-blue-500/10 border-blue-500';
|
||||
case 'clean': return 'text-green-500 bg-green-500/10 border-green-500';
|
||||
default: return 'text-gray-500 bg-gray-500/10 border-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getThreatLevelLabel = (level: string) => {
|
||||
const labels: { [key: string]: string } = {
|
||||
'critical': '🔴 Critique',
|
||||
'high': '🟠 Élevé',
|
||||
'medium': '🟡 Moyen',
|
||||
'low': '🔵 Faible',
|
||||
'clean': '🟢 Propre',
|
||||
'unknown': '⚪ Inconnu'
|
||||
};
|
||||
return labels[level] || level;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 text-center text-text-secondary">
|
||||
Vérification de la réputation...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-center text-red-500">
|
||||
Erreur: {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!reputation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { aggregated } = reputation;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Threat Level Badge */}
|
||||
<div className={`p-4 rounded-lg border ${getThreatLevelColor(aggregated.threat_level)}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-1">Niveau de menace</div>
|
||||
<div className="text-2xl font-bold">{getThreatLevelLabel(aggregated.threat_level)}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium mb-1">Score</div>
|
||||
<div className="text-2xl font-bold">{aggregated.threat_score}/100</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3 w-full bg-background-secondary rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
aggregated.threat_score >= 80 ? 'bg-red-500' :
|
||||
aggregated.threat_score >= 60 ? 'bg-orange-500' :
|
||||
aggregated.threat_score >= 40 ? 'bg-yellow-500' :
|
||||
aggregated.threat_score >= 20 ? 'bg-blue-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${aggregated.threat_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detection Badges */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<DetectionBadge
|
||||
label="Proxy"
|
||||
detected={aggregated.is_proxy}
|
||||
icon="🌐"
|
||||
/>
|
||||
<DetectionBadge
|
||||
label="Hosting"
|
||||
detected={aggregated.is_hosting}
|
||||
icon="☁️"
|
||||
/>
|
||||
<DetectionBadge
|
||||
label="VPN"
|
||||
detected={aggregated.is_vpn}
|
||||
icon="🔒"
|
||||
/>
|
||||
<DetectionBadge
|
||||
label="Tor"
|
||||
detected={aggregated.is_tor}
|
||||
icon="🧅"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="Pays" value={aggregated.country || '-'} />
|
||||
<InfoField label="Ville" value={aggregated.city || '-'} />
|
||||
<InfoField label="ASN" value={aggregated.asn ? `AS${aggregated.asn}` : '-'} />
|
||||
<InfoField label="Organisation" value={aggregated.org || aggregated.asn_org || '-'} />
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{aggregated.warnings.length > 0 && (
|
||||
<div className="bg-orange-500/10 border border-orange-500 rounded-lg p-3">
|
||||
<div className="text-sm font-medium text-orange-500 mb-2">
|
||||
⚠️ Avertissements ({aggregated.warnings.length})
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{aggregated.warnings.map((warning, index) => (
|
||||
<li key={index} className="text-xs text-text-secondary">
|
||||
• {warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sources */}
|
||||
<div className="text-xs text-text-secondary text-center">
|
||||
Sources: {Object.keys(reputation.sources).join(', ')} • {formatDate(reputation.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionBadge({ label, detected, icon }: { label: string; detected: boolean; icon: string }) {
|
||||
return (
|
||||
<div className={`p-2 rounded-lg border text-center ${
|
||||
detected
|
||||
? 'bg-red-500/10 border-red-500 text-red-500'
|
||||
: 'bg-background-card border-background-card text-text-secondary'
|
||||
}`}>
|
||||
<div className="text-lg mb-1">{icon}</div>
|
||||
<div className="text-xs font-medium">{label}</div>
|
||||
<div className={`text-xs font-bold ${detected ? 'text-red-500' : 'text-text-secondary'}`}>
|
||||
{detected ? 'DÉTECTÉ' : 'Non'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-background-card rounded-lg p-2">
|
||||
<div className="text-xs text-text-secondary mb-1">{label}</div>
|
||||
<div className="text-sm text-text-primary font-medium truncate" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,262 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDateShort } from '../utils/dateUtils';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
interface SubnetIP {
|
||||
ip: string;
|
||||
total_detections: number;
|
||||
unique_ja4: number;
|
||||
unique_ua: number;
|
||||
primary_country: string;
|
||||
primary_asn: string;
|
||||
threat_level: string;
|
||||
avg_score: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface SubnetStats {
|
||||
subnet: string;
|
||||
total_ips: number;
|
||||
total_detections: number;
|
||||
unique_ja4: number;
|
||||
unique_ua: number;
|
||||
unique_hosts: number;
|
||||
primary_country: string;
|
||||
primary_asn: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export function SubnetInvestigation() {
|
||||
const { subnet } = useParams<{ subnet: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState<SubnetStats | null>(null);
|
||||
const [ips, setIps] = useState<SubnetIP[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Convertir le format d'URL (ex: 192.168.1.0_24 -> 192.168.1.0/24)
|
||||
const formattedSubnet = subnet ? subnet.replace('_', '/') : null;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSubnet = async () => {
|
||||
if (!formattedSubnet) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/entities/subnet/${encodeURIComponent(formattedSubnet)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data.stats);
|
||||
setIps(data.ips || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching subnet:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSubnet();
|
||||
}, [formattedSubnet]);
|
||||
|
||||
;
|
||||
|
||||
const getThreatLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'CRITICAL': return 'text-red-500 bg-red-500/10';
|
||||
case 'HIGH': return 'text-orange-500 bg-orange-500/10';
|
||||
case 'MEDIUM': return 'text-yellow-500 bg-yellow-500/10';
|
||||
case 'LOW': return 'text-green-500 bg-green-500/10';
|
||||
default: return 'text-gray-500 bg-gray-500/10';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="mb-4 px-4 py-2 bg-accent-primary text-white rounded hover:bg-accent-primary/80"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<div className="text-red-500">
|
||||
Sous-réseau non trouvé: {subnet}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="px-4 py-2 bg-background-card text-text-primary rounded hover:bg-background-card/80 transition-colors"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">
|
||||
Investigation: Sous-réseau
|
||||
</h1>
|
||||
<p className="font-mono text-text-secondary">{subnet}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-sm text-text-secondary mb-1">Total Détections</div>
|
||||
<div className="text-2xl font-bold text-text-primary">{stats.total_detections.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-sm text-text-secondary mb-1 flex items-center gap-1">JA4 Uniques<InfoTip content={TIPS.ja4} /></div>
|
||||
<div className="text-2xl font-bold text-text-primary">{stats.unique_ja4}</div>
|
||||
</div>
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<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>
|
||||
</div>
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-sm text-text-secondary mb-1">Pays Principal</div>
|
||||
<div className="text-2xl font-bold text-text-primary">
|
||||
{getCountryFlag(stats.primary_country)} {stats.primary_country}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-sm text-text-secondary mb-1 flex items-center gap-1">ASN Principal<InfoTip content={TIPS.asn} /></div>
|
||||
<div className="text-2xl font-bold text-text-primary">AS{stats.primary_asn}</div>
|
||||
</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 font-medium">
|
||||
{formatDateShort(stats.first_seen)} – {formatDateShort(stats.last_seen)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IPs Table */}
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-4">
|
||||
IPs du Sous-réseau ({ips.length})
|
||||
</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">IP</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Détections</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">JA4</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">UA</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"><span className="flex items-center gap-1">Menace<InfoTip content={TIPS.threat_level} /></span></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Score<InfoTip content={TIPS.risk_score_inv} /></span></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-background-card">
|
||||
{ips.map((ipData) => (
|
||||
<tr
|
||||
key={ipData.ip}
|
||||
className="hover:bg-background-card/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-sm text-text-primary">
|
||||
{ipData.ip}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">
|
||||
{ipData.total_detections}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">
|
||||
{ipData.unique_ja4}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">
|
||||
{ipData.unique_ua}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">
|
||||
{getCountryFlag(ipData.primary_country)} {ipData.primary_country}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">
|
||||
AS{ipData.primary_asn}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getThreatLevelColor(ipData.threat_level)}`}>
|
||||
{ipData.threat_level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-background-secondary rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
ipData.avg_score >= 80 ? 'bg-red-500' :
|
||||
ipData.avg_score >= 60 ? 'bg-orange-500' :
|
||||
ipData.avg_score >= 40 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, ipData.avg_score * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-text-primary">{Math.round(ipData.avg_score * 100)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${ipData.ip}`)}
|
||||
className="px-2 py-1 bg-accent-primary text-white rounded text-xs hover:bg-accent-primary/80"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/entities/ip/${ipData.ip}`)}
|
||||
className="px-2 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
|
||||
>
|
||||
Détails
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ips.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-4 py-12 text-center text-text-secondary">
|
||||
Aucune IP trouvée dans ce sous-réseau
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
573
services/dashboard/frontend/src/components/TcpSpoofingView.tsx
Normal file
573
services/dashboard/frontend/src/components/TcpSpoofingView.tsx
Normal file
@ -0,0 +1,573 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TcpSpoofingOverview {
|
||||
total_entries: number;
|
||||
unique_ips: number;
|
||||
no_tcp_data: number;
|
||||
with_tcp_data: number;
|
||||
linux_mac_fingerprint: number;
|
||||
windows_fingerprint: number;
|
||||
cisco_bsd_fingerprint: number;
|
||||
bot_scanner_fingerprint: number;
|
||||
ttl_distribution: { ttl: number; count: number; ips: number }[];
|
||||
mss_distribution: { mss: number; count: number; ips: number }[];
|
||||
window_size_distribution: { window_size: number; count: number }[];
|
||||
}
|
||||
|
||||
interface TcpSpoofingItem {
|
||||
ip: string;
|
||||
ja4: string;
|
||||
tcp_ttl: number;
|
||||
tcp_window_size: number;
|
||||
tcp_win_scale: number;
|
||||
tcp_mss: number;
|
||||
hits: number;
|
||||
first_ua: string;
|
||||
suspected_os: string;
|
||||
initial_ttl: number;
|
||||
hop_count: number;
|
||||
confidence: number;
|
||||
network_path: string;
|
||||
is_bot_tool: boolean;
|
||||
declared_os: string;
|
||||
spoof_flag: boolean;
|
||||
spoof_reason: string;
|
||||
}
|
||||
|
||||
interface OsMatrixEntry {
|
||||
suspected_os: string;
|
||||
declared_os: string;
|
||||
count: number;
|
||||
is_spoof: boolean;
|
||||
is_bot_tool: boolean;
|
||||
}
|
||||
|
||||
type ActiveTab = 'detections' | 'matrix';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
function confidenceBar(conf: number): JSX.Element {
|
||||
const pct = Math.round(conf * 100);
|
||||
const color =
|
||||
pct >= 85 ? 'bg-threat-low' :
|
||||
pct >= 65 ? 'bg-threat-medium' :
|
||||
pct >= 45 ? 'bg-accent-primary' :
|
||||
'bg-text-disabled';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-16 bg-background-secondary rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mssLabel(mss: number): string {
|
||||
if (mss >= 1460) return 'Ethernet';
|
||||
if (mss >= 1452) return 'PPPoE';
|
||||
if (mss >= 1420) return 'VPN';
|
||||
if (mss >= 1380) return 'VPN/Tunnel';
|
||||
if (mss > 0) return 'Bas débit';
|
||||
return '—';
|
||||
}
|
||||
|
||||
function mssColor(mss: number): string {
|
||||
if (mss >= 1460) return 'text-threat-low';
|
||||
if (mss >= 1436) return 'text-text-secondary';
|
||||
if (mss >= 1380) return 'text-threat-medium';
|
||||
return 'text-threat-critical';
|
||||
}
|
||||
|
||||
function osIcon(name: string): string {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('bot') || n.includes('scanner') || n.includes('mirai') || n.includes('zmap')) return '🤖';
|
||||
if (n.includes('windows')) return '🪟';
|
||||
if (n.includes('ios') || n.includes('macos')) return '🍎';
|
||||
if (n.includes('android')) return '🤖';
|
||||
if (n.includes('linux')) return '🐧';
|
||||
if (n.includes('cisco') || n.includes('cdn') || n.includes('réseau')) return '🔌';
|
||||
if (n.includes('bsd')) return '😈';
|
||||
return '❓';
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Detections DataTable ─────────────────────────────────────────────────────
|
||||
|
||||
function TcpDetectionsTable({
|
||||
items,
|
||||
navigate,
|
||||
}: {
|
||||
items: TcpSpoofingItem[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const columns = useMemo((): Column<TcpSpoofingItem>[] => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||
},
|
||||
{
|
||||
key: 'tcp_ttl',
|
||||
label: 'TTL obs. / init.',
|
||||
tooltip: TIPS.ttl,
|
||||
align: 'right',
|
||||
render: (_: number, row: TcpSpoofingItem) => (
|
||||
<span className="font-mono text-xs">
|
||||
<span className="text-text-secondary">{row.tcp_ttl}</span>
|
||||
<span className="text-text-disabled mx-1">/</span>
|
||||
<span className="text-accent-primary font-semibold">{row.initial_ttl}</span>
|
||||
{row.hop_count >= 0 && (
|
||||
<span className="text-text-disabled ml-1 text-[10px]">({row.hop_count} hops)</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tcp_mss',
|
||||
label: 'MSS',
|
||||
tooltip: TIPS.mss,
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<span className={`font-mono text-xs ${mssColor(v)}`} title={mssLabel(v)}>
|
||||
{v || '—'} <span className="text-[10px] text-text-disabled">{mssLabel(v)}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tcp_win_scale',
|
||||
label: 'Scale',
|
||||
tooltip: TIPS.win_scale,
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<span className="font-mono text-xs text-text-secondary">{v}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'suspected_os',
|
||||
label: 'OS suspecté (TCP)',
|
||||
tooltip: TIPS.os_tcp,
|
||||
render: (v: string, row: TcpSpoofingItem) => (
|
||||
<span className={`text-xs flex items-center gap-1 ${row.is_bot_tool ? 'text-threat-critical font-semibold' : 'text-text-primary'}`}>
|
||||
<span>{osIcon(v)}</span>
|
||||
<span>{v || '—'}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'confidence',
|
||||
label: 'Confiance',
|
||||
tooltip: TIPS.confidence,
|
||||
render: (v: number) => confidenceBar(v),
|
||||
},
|
||||
{
|
||||
key: 'network_path',
|
||||
label: 'Réseau',
|
||||
render: (v: string) => <span className="text-xs text-text-secondary">{v || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'declared_os',
|
||||
label: 'OS déclaré (UA)',
|
||||
tooltip: TIPS.os_ua,
|
||||
render: (v: string) => <span className="text-xs text-text-secondary">{v || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'spoof_flag',
|
||||
label: 'Verdict',
|
||||
tooltip: TIPS.spoof_verdict,
|
||||
sortable: false,
|
||||
render: (v: boolean, row: TcpSpoofingItem) => {
|
||||
if (row.is_bot_tool) {
|
||||
return (
|
||||
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-0.5 rounded-full whitespace-nowrap" title={row.spoof_reason}>
|
||||
🤖 Bot/Scanner
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (v) {
|
||||
return (
|
||||
<span className="bg-threat-high/20 text-threat-high text-xs px-2 py-0.5 rounded-full whitespace-nowrap" title={row.spoof_reason}>
|
||||
🚨 Spoof
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '_actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: TcpSpoofingItem) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
|
||||
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
),
|
||||
},
|
||||
], [navigate]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
rowKey="ip"
|
||||
defaultSortKey="hits"
|
||||
emptyMessage="Aucune détection"
|
||||
compact
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function TcpSpoofingView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('detections');
|
||||
|
||||
const [spoofOnly, setSpoofOnly] = useState(false);
|
||||
|
||||
const [overview, setOverview] = useState<TcpSpoofingOverview | null>(null);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
|
||||
const [items, setItems] = useState<TcpSpoofingItem[]>([]);
|
||||
const [itemsLoading, setItemsLoading] = useState(true);
|
||||
const [itemsError, setItemsError] = useState<string | null>(null);
|
||||
|
||||
const [matrix, setMatrix] = useState<OsMatrixEntry[]>([]);
|
||||
const [matrixLoading, setMatrixLoading] = useState(false);
|
||||
const [matrixError, setMatrixError] = useState<string | null>(null);
|
||||
const [matrixLoaded, setMatrixLoaded] = useState(false);
|
||||
|
||||
const [filterText, setFilterText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOverview = async () => {
|
||||
setOverviewLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/tcp-spoofing/overview');
|
||||
if (!res.ok) throw new Error('Erreur chargement overview');
|
||||
const data: TcpSpoofingOverview = await res.json();
|
||||
setOverview(data);
|
||||
} catch (err) {
|
||||
setOverviewError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
};
|
||||
fetchOverview();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItems = async () => {
|
||||
setItemsLoading(true);
|
||||
setItemsError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '200' });
|
||||
if (spoofOnly) params.set('spoof_only', 'true');
|
||||
const res = await fetch(`/api/tcp-spoofing/list?${params}`);
|
||||
if (!res.ok) throw new Error('Erreur chargement des détections');
|
||||
const data: { items: TcpSpoofingItem[]; total: number } = await res.json();
|
||||
setItems(data.items ?? []);
|
||||
} catch (err) {
|
||||
setItemsError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setItemsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchItems();
|
||||
}, [spoofOnly]);
|
||||
|
||||
const loadMatrix = async () => {
|
||||
if (matrixLoaded) return;
|
||||
setMatrixLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/tcp-spoofing/matrix');
|
||||
if (!res.ok) throw new Error('Erreur chargement matrice OS');
|
||||
const data: { matrix: OsMatrixEntry[] } = await res.json();
|
||||
setMatrix(data.matrix ?? []);
|
||||
setMatrixLoaded(true);
|
||||
} catch (err) {
|
||||
setMatrixError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setMatrixLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: ActiveTab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab === 'matrix') loadMatrix();
|
||||
};
|
||||
|
||||
const filteredItems = items.filter(
|
||||
(item) =>
|
||||
(!spoofOnly || item.spoof_flag || item.is_bot_tool) &&
|
||||
(!filterText ||
|
||||
item.ip.includes(filterText) ||
|
||||
item.suspected_os.toLowerCase().includes(filterText.toLowerCase()) ||
|
||||
item.declared_os.toLowerCase().includes(filterText.toLowerCase()) ||
|
||||
item.network_path.toLowerCase().includes(filterText.toLowerCase()))
|
||||
);
|
||||
|
||||
// Build matrix axes
|
||||
const suspectedOSes = [...new Set(matrix.map((e) => e.suspected_os))].sort();
|
||||
const declaredOSes = [...new Set(matrix.map((e) => e.declared_os))].sort();
|
||||
const matrixMax = matrix.reduce((m, e) => Math.max(m, e.count), 1);
|
||||
|
||||
function matrixCellColor(count: number): string {
|
||||
if (count === 0) return 'bg-background-card';
|
||||
const ratio = count / matrixMax;
|
||||
if (ratio >= 0.75) return 'bg-threat-critical/80';
|
||||
if (ratio >= 0.5) return 'bg-threat-high/70';
|
||||
if (ratio >= 0.25) return 'bg-threat-medium/60';
|
||||
return 'bg-threat-low/40';
|
||||
}
|
||||
|
||||
const tabs: { id: ActiveTab; label: string }[] = [
|
||||
{ id: 'detections', label: '📋 Détections' },
|
||||
{ id: 'matrix', label: '📊 Matrice OS' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🧬 Spoofing TCP/OS</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Fingerprinting multi-signal (TTL + MSS + fenêtre + scale) — détection bots, spoofs et anomalies TCP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
{overviewLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : overviewError ? (
|
||||
<ErrorMessage message={overviewError} />
|
||||
) : overview ? (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="Avec données TCP" value={formatNumber(overview.with_tcp_data)} accent="text-text-primary" />
|
||||
<StatCard label="Fingerprint Linux/Mac" value={formatNumber(overview.linux_mac_fingerprint)} accent="text-threat-low" />
|
||||
<StatCard label="Fingerprint Windows" value={formatNumber(overview.windows_fingerprint)} accent="text-accent-primary" />
|
||||
<StatCard label="🤖 Bots/Scanners détectés" value={formatNumber(overview.bot_scanner_fingerprint)} accent="text-threat-critical" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Distribution MSS */}
|
||||
<div className="bg-background-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3">Distribution MSS (type de réseau)</h3>
|
||||
<div className="space-y-1.5">
|
||||
{overview.mss_distribution.map((m) => {
|
||||
const label = m.mss >= 1460 ? 'Ethernet' : m.mss >= 1452 ? 'PPPoE' : m.mss >= 1420 ? 'VPN léger' : m.mss >= 1380 ? 'VPN/Tunnel' : 'Bas débit';
|
||||
const color = m.mss >= 1460 ? 'bg-threat-low' : m.mss >= 1436 ? 'bg-accent-primary' : m.mss >= 1380 ? 'bg-threat-medium' : 'bg-threat-critical';
|
||||
const maxCount = overview.mss_distribution[0]?.count || 1;
|
||||
return (
|
||||
<div key={m.mss} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-disabled w-12 text-right font-mono">{m.mss}</span>
|
||||
<div className="flex-1 h-2 bg-background-secondary rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${(m.count / maxCount) * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-text-secondary w-16">{formatNumber(m.count)}</span>
|
||||
<span className="text-text-disabled w-20">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Distribution TTL */}
|
||||
<div className="bg-background-card border border-border rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3">Distribution TTL observé</h3>
|
||||
<div className="space-y-1.5">
|
||||
{overview.ttl_distribution.map((t) => {
|
||||
const family = t.ttl <= 64 ? 'Linux/Mac' : t.ttl <= 128 ? 'Windows' : 'Cisco/BSD';
|
||||
const color = t.ttl <= 64 ? 'bg-threat-low' : t.ttl <= 128 ? 'bg-accent-primary' : 'bg-threat-medium';
|
||||
const maxCount = overview.ttl_distribution[0]?.count || 1;
|
||||
return (
|
||||
<div key={t.ttl} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-disabled w-8 text-right font-mono">{t.ttl}</span>
|
||||
<div className="flex-1 h-2 bg-background-secondary rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${(t.count / maxCount) * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-text-secondary w-16">{formatNumber(t.count)}</span>
|
||||
<span className="text-text-disabled w-20">{family}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background-card border border-border rounded-lg px-4 py-3 text-sm text-text-secondary flex items-center gap-2">
|
||||
<span className="text-threat-medium">⚠️</span>
|
||||
<span>
|
||||
<strong className="text-text-primary">{formatNumber(overview.no_tcp_data)}</strong> entrées sans données TCP (passées par proxy/CDN) — exclues.{' '}
|
||||
<strong className="text-threat-critical">{formatNumber(overview.bot_scanner_fingerprint)}</strong> entrées avec signature Masscan/scanner identifiée (win=5808, mss=1452, scale=4).
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-accent-primary border-b-2 border-accent-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Détections tab */}
|
||||
{activeTab === 'detections' && (
|
||||
<>
|
||||
<div className="flex gap-3 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrer par IP ou OS..."
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
className="bg-background-card border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-72"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={spoofOnly}
|
||||
onChange={(e) => setSpoofOnly(e.target.checked)}
|
||||
className="accent-accent-primary"
|
||||
/>
|
||||
Spoofs & Bots uniquement (corrélation confirmée)
|
||||
</label>
|
||||
</div>
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{itemsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : itemsError ? (
|
||||
<div className="p-4"><ErrorMessage message={itemsError} /></div>
|
||||
) : (
|
||||
<TcpDetectionsTable items={filteredItems} navigate={navigate} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Matrice OS tab */}
|
||||
{activeTab === 'matrix' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
{matrixLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : matrixError ? (
|
||||
<ErrorMessage message={matrixError} />
|
||||
) : matrix.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm">Aucune donnée disponible.</p>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<h2 className="text-text-primary font-semibold mb-4">OS Suspecté × OS Déclaré</h2>
|
||||
<table className="text-xs border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-text-secondary text-left border border-border bg-background-card">
|
||||
Suspecté \ Déclaré
|
||||
</th>
|
||||
{declaredOSes.map((os) => (
|
||||
<th key={os} className="px-3 py-2 text-text-secondary text-center border border-border bg-background-card max-w-[80px]">
|
||||
<span className="block truncate w-20" title={os}>{os}</span>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-text-secondary text-center border border-border bg-background-card">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suspectedOSes.map((sos) => {
|
||||
const rowEntries = declaredOSes.map((dos) => {
|
||||
const entry = matrix.find((e) => e.suspected_os === sos && e.declared_os === dos);
|
||||
return entry?.count ?? 0;
|
||||
});
|
||||
const rowTotal = rowEntries.reduce((s, v) => s + v, 0);
|
||||
return (
|
||||
<tr key={sos}>
|
||||
<td className="px-3 py-2 text-text-primary border border-border bg-background-card font-medium max-w-[120px]">
|
||||
<span className="block truncate w-28" title={sos}>{sos}</span>
|
||||
</td>
|
||||
{rowEntries.map((count, ci) => {
|
||||
const dos = declaredOSes[ci];
|
||||
const entry = matrix.find((e) => e.suspected_os === sos && e.declared_os === dos);
|
||||
const isSpoofCell = entry?.is_spoof ?? false;
|
||||
const isBotCell = entry?.is_bot_tool ?? false;
|
||||
return (
|
||||
<td
|
||||
key={ci}
|
||||
className={`px-3 py-2 text-center border border-border font-mono ${
|
||||
isBotCell && count > 0
|
||||
? 'bg-threat-critical/30 text-threat-critical font-bold'
|
||||
: isSpoofCell && count > 0
|
||||
? 'bg-threat-high/25 text-threat-high font-bold'
|
||||
: matrixCellColor(count) + (count > 0 ? ' text-text-primary' : ' text-text-disabled')
|
||||
}`}
|
||||
title={isBotCell ? '🤖 Outil de scan/bot' : isSpoofCell ? '🚨 OS mismatch confirmé' : undefined}
|
||||
>
|
||||
{count > 0
|
||||
? isBotCell ? `🤖 ${formatNumber(count)}`
|
||||
: isSpoofCell ? `🚨 ${formatNumber(count)}`
|
||||
: formatNumber(count)
|
||||
: '—'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-3 py-2 text-center border border-border font-semibold text-text-primary bg-background-card">
|
||||
{formatNumber(rowTotal)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr>
|
||||
<td className="px-3 py-2 text-text-secondary border border-border bg-background-card font-semibold">Total</td>
|
||||
{declaredOSes.map((dos) => {
|
||||
const colTotal = matrix
|
||||
.filter((e) => e.declared_os === dos)
|
||||
.reduce((s, e) => s + e.count, 0);
|
||||
return (
|
||||
<td key={dos} className="px-3 py-2 text-center border border-border font-semibold text-text-primary bg-background-card">
|
||||
{formatNumber(colTotal)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-3 py-2 text-center border border-border font-semibold text-accent-primary bg-background-card">
|
||||
{formatNumber(matrix.reduce((s, e) => s + e.count, 0))}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
326
services/dashboard/frontend/src/components/ThreatIntelView.tsx
Normal file
326
services/dashboard/frontend/src/components/ThreatIntelView.tsx
Normal file
@ -0,0 +1,326 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDateShort } from '../utils/dateUtils';
|
||||
|
||||
interface Classification {
|
||||
ip?: string;
|
||||
ja4?: string;
|
||||
label: 'legitimate' | 'suspicious' | 'malicious';
|
||||
tags: string[];
|
||||
comment: string;
|
||||
confidence: number;
|
||||
analyst: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ClassificationStats {
|
||||
label: string;
|
||||
total: number;
|
||||
unique_ips: number;
|
||||
avg_confidence: number;
|
||||
}
|
||||
|
||||
export function ThreatIntelView() {
|
||||
const navigate = useNavigate();
|
||||
const [classifications, setClassifications] = useState<Classification[]>([]);
|
||||
const [stats, setStats] = useState<ClassificationStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterLabel, setFilterLabel] = useState<string>('all');
|
||||
const [filterTag, setFilterTag] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchThreatIntel = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch classifications
|
||||
const classificationsResponse = await fetch('/api/analysis/classifications?page_size=100');
|
||||
if (classificationsResponse.ok) {
|
||||
const data = await classificationsResponse.json();
|
||||
setClassifications(data.items || []);
|
||||
}
|
||||
|
||||
// Fetch stats
|
||||
const statsResponse = await fetch('/api/analysis/classifications/stats');
|
||||
if (statsResponse.ok) {
|
||||
const data = await statsResponse.json();
|
||||
setStats(data.stats || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching threat intel:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchThreatIntel();
|
||||
}, []);
|
||||
|
||||
const filteredClassifications = classifications.filter(c => {
|
||||
if (filterLabel !== 'all' && c.label !== filterLabel) return false;
|
||||
if (filterTag && !c.tags.includes(filterTag)) return false;
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
const ipMatch = c.ip?.toLowerCase().includes(searchLower);
|
||||
const ja4Match = c.ja4?.toLowerCase().includes(searchLower);
|
||||
const tagMatch = c.tags.some(t => t.toLowerCase().includes(searchLower));
|
||||
const commentMatch = c.comment.toLowerCase().includes(searchLower);
|
||||
if (!ipMatch && !ja4Match && !tagMatch && !commentMatch) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const allTags = Array.from(new Set(classifications.flatMap(c => c.tags)));
|
||||
|
||||
const getLabelIcon = (label: string) => {
|
||||
switch (label) {
|
||||
case 'legitimate': return '✅';
|
||||
case 'suspicious': return '⚠️';
|
||||
case 'malicious': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelColor = (label: string) => {
|
||||
switch (label) {
|
||||
case 'legitimate': return 'bg-threat-low/20 text-threat-low';
|
||||
case 'suspicious': return 'bg-threat-medium/20 text-threat-medium';
|
||||
case 'malicious': return 'bg-threat-high/20 text-threat-high';
|
||||
default: return 'bg-gray-500/20 text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getTagColor = (tag: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'scraping': 'bg-blue-500/20 text-blue-400',
|
||||
'bot-network': 'bg-red-500/20 text-red-400',
|
||||
'scanner': 'bg-orange-500/20 text-orange-400',
|
||||
'hosting-asn': 'bg-purple-500/20 text-purple-400',
|
||||
'distributed': 'bg-yellow-500/20 text-yellow-400',
|
||||
'ja4-rotation': 'bg-pink-500/20 text-pink-400',
|
||||
'ua-rotation': 'bg-cyan-500/20 text-cyan-400',
|
||||
};
|
||||
return colors[tag] || 'bg-gray-500/20 text-gray-400';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-secondary">Chargement de la Threat Intel...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">📚 Threat Intelligence</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
Base de connaissances des classifications SOC
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="🤖 Malicious"
|
||||
value={stats.find(s => s.label === 'malicious')?.total || 0}
|
||||
subtitle="Entités malveillantes"
|
||||
color="bg-threat-high/20"
|
||||
/>
|
||||
<StatCard
|
||||
title="⚠️ Suspicious"
|
||||
value={stats.find(s => s.label === 'suspicious')?.total || 0}
|
||||
subtitle="Entités suspectes"
|
||||
color="bg-threat-medium/20"
|
||||
/>
|
||||
<StatCard
|
||||
title="✅ Légitime"
|
||||
value={stats.find(s => s.label === 'legitimate')?.total || 0}
|
||||
subtitle="Entités légitimes"
|
||||
color="bg-threat-low/20"
|
||||
/>
|
||||
<StatCard
|
||||
title="📊 Total"
|
||||
value={classifications.length}
|
||||
subtitle="Classifications totales"
|
||||
color="bg-accent-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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 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>
|
||||
))}
|
||||
</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">Commentaire</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Confiance<InfoTip content={TIPS.confiance} /></span></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) => {
|
||||
const entity = classification.ip || classification.ja4;
|
||||
const isIP = !!classification.ip;
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-background-card/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-text-secondary whitespace-nowrap">
|
||||
{formatDateShort(classification.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(isIP ? `/investigation/${encodeURIComponent(entity!)}` : `/investigation/ja4/${encodeURIComponent(entity!)}`)}
|
||||
className="font-mono text-sm text-accent-primary hover:underline text-left truncate max-w-[160px] block"
|
||||
title={entity}
|
||||
>
|
||||
{entity}
|
||||
</button>
|
||||
</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, 4).map((tag, tagIdx) => (
|
||||
<span key={tagIdx} className={`px-2 py-0.5 rounded text-xs ${getTagColor(tag)}`}>{tag}</span>
|
||||
))}
|
||||
{classification.tags.length > 4 && (
|
||||
<span className="text-xs text-text-secondary">+{classification.tags.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary max-w-[200px]">
|
||||
<span className="truncate block" title={(classification as any).comment || ''}>
|
||||
{(classification as any).comment || <span className="text-text-disabled">—</span>}
|
||||
</span>
|
||||
</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 min-w-[60px]">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat Card Component
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
color
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
subtitle: string;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${color} rounded-lg p-6`}>
|
||||
<h3 className="text-text-secondary text-sm font-medium">{title}</h3>
|
||||
<p className="text-3xl font-bold text-text-primary mt-2">{value.toLocaleString()}</p>
|
||||
<p className="text-text-disabled text-xs mt-2">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
services/dashboard/frontend/src/components/VariabilityPanel.tsx
Normal file
258
services/dashboard/frontend/src/components/VariabilityPanel.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { VariabilityAttributes, AttributeValue } from '../api/client';
|
||||
|
||||
interface VariabilityPanelProps {
|
||||
attributes: VariabilityAttributes;
|
||||
/** When true, hides the "Voir IPs associées" button (e.g. when already on an IP page) */
|
||||
hideAssociatedIPs?: boolean;
|
||||
}
|
||||
|
||||
export function VariabilityPanel({ attributes, hideAssociatedIPs = false }: VariabilityPanelProps) {
|
||||
const [modal, setModal] = useState<{
|
||||
title: string;
|
||||
items: string[];
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
|
||||
const data = await res.json();
|
||||
setModal({
|
||||
title: `${data.total || total} IPs associées à ${value.length > 40 ? value.substring(0, 40) + '…' : value}`,
|
||||
items: data.ips || [],
|
||||
total: data.total || total,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const sections: Array<{
|
||||
title: string;
|
||||
icon: string;
|
||||
items: AttributeValue[] | undefined;
|
||||
getLink: (v: AttributeValue) => string;
|
||||
attrType?: string;
|
||||
mono?: boolean;
|
||||
}> = [
|
||||
{
|
||||
title: 'JA4 Fingerprints',
|
||||
icon: '🔏',
|
||||
items: attributes.ja4,
|
||||
getLink: (v) => `/investigation/ja4/${encodeURIComponent(v.value)}`,
|
||||
attrType: 'ja4',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
title: 'Hosts ciblés',
|
||||
icon: '🌐',
|
||||
items: attributes.hosts,
|
||||
getLink: (v) => `/detections/host/${encodeURIComponent(v.value)}`,
|
||||
attrType: 'host',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
title: 'ASN',
|
||||
icon: '🏢',
|
||||
items: attributes.asns,
|
||||
getLink: (v) => {
|
||||
const n = v.value.match(/AS(\d+)/)?.[1] || v.value;
|
||||
return `/detections/asn/${encodeURIComponent(n)}`;
|
||||
},
|
||||
attrType: 'asn',
|
||||
},
|
||||
{
|
||||
title: 'Pays',
|
||||
icon: '🌍',
|
||||
items: attributes.countries,
|
||||
getLink: (v) => `/detections/country/${encodeURIComponent(v.value)}`,
|
||||
attrType: 'country',
|
||||
},
|
||||
{
|
||||
title: 'Niveaux de menace',
|
||||
icon: '⚠️',
|
||||
items: attributes.threat_levels,
|
||||
getLink: (v) => `/detections?threat_level=${encodeURIComponent(v.value)}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Attributs observés</h2>
|
||||
|
||||
{/* User-Agents — plein format avec texte long */}
|
||||
{attributes.user_agents && attributes.user_agents.length > 0 && (
|
||||
<UASection items={attributes.user_agents} />
|
||||
)}
|
||||
|
||||
{/* Grille 2 colonnes pour les autres attributs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{sections.map((s) =>
|
||||
s.items && s.items.length > 0 ? (
|
||||
<AttributeSection
|
||||
key={s.title}
|
||||
title={s.title}
|
||||
icon={s.icon}
|
||||
items={s.items}
|
||||
getLink={s.getLink}
|
||||
attrType={s.attrType}
|
||||
mono={s.mono}
|
||||
hideAssociatedIPs={hideAssociatedIPs}
|
||||
onLoadIPs={loadAssociatedIPs}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal IPs associées */}
|
||||
{(modal || loading) && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-background-secondary rounded-xl max-w-2xl w-full max-h-[80vh] flex flex-col shadow-2xl">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-background-card">
|
||||
<h3 className="font-semibold text-text-primary">{modal?.title ?? 'Chargement…'}</h3>
|
||||
<button onClick={() => setModal(null)} className="text-text-secondary hover:text-text-primary text-2xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<p className="text-center text-text-secondary py-8">Chargement…</p>
|
||||
) : modal && modal.items.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{modal.items.map((ip, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/detections/ip/${ip}`}
|
||||
onClick={() => setModal(null)}
|
||||
className="bg-background-card hover:bg-background-card/70 rounded px-3 py-2 font-mono text-sm text-accent-primary transition-colors"
|
||||
>
|
||||
{ip}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-text-secondary py-8">Aucune donnée</p>
|
||||
)}
|
||||
{modal && modal.total > modal.items.length && (
|
||||
<p className="text-center text-text-secondary text-xs mt-4">
|
||||
{modal.items.length} / {modal.total} affichées
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-background-card text-right">
|
||||
<button onClick={() => setModal(null)} className="bg-accent-primary hover:bg-accent-primary/80 text-white px-5 py-2 rounded-lg text-sm">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── AttributeSection ─────────────────────────────────────────────────────── */
|
||||
function AttributeSection({
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
getLink,
|
||||
attrType,
|
||||
mono,
|
||||
hideAssociatedIPs,
|
||||
onLoadIPs,
|
||||
}: {
|
||||
title: string;
|
||||
icon: string;
|
||||
items: AttributeValue[];
|
||||
getLink: (v: AttributeValue) => string;
|
||||
attrType?: string;
|
||||
mono?: boolean;
|
||||
hideAssociatedIPs?: boolean;
|
||||
onLoadIPs: (type: string, value: string, count: number) => void;
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const LIMIT = 8;
|
||||
const displayed = showAll ? items : items.slice(0, LIMIT);
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span>{icon}</span> {title} <span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{displayed.map((item, i) => {
|
||||
const pct = item.percentage || 0;
|
||||
const barColor =
|
||||
pct >= 50 ? 'bg-threat-critical' :
|
||||
pct >= 25 ? 'bg-threat-high' :
|
||||
pct >= 10 ? 'bg-threat-medium' : 'bg-threat-low';
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
to={getLink(item)}
|
||||
className={`flex-1 text-xs hover:text-accent-primary transition-colors text-text-primary truncate ${mono ? 'font-mono' : ''}`}
|
||||
title={item.value}
|
||||
>
|
||||
{item.value}
|
||||
</Link>
|
||||
<span className="text-xs text-text-secondary shrink-0">{item.count} ({pct.toFixed(0)}%)</span>
|
||||
{!hideAssociatedIPs && attrType && (
|
||||
<button
|
||||
onClick={() => onLoadIPs(attrType, item.value, item.count)}
|
||||
className="shrink-0 text-xs text-text-secondary hover:text-accent-primary transition-colors"
|
||||
title="Voir les IPs associées"
|
||||
>
|
||||
👥
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full bg-background-card rounded-full h-1.5">
|
||||
<div className={`h-1.5 rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.length > LIMIT && (
|
||||
<button
|
||||
onClick={() => setShowAll(v => !v)}
|
||||
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80"
|
||||
>
|
||||
{showAll ? '↑ Réduire' : `↓ ${items.length - LIMIT} de plus`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── UASection ─────────────────────────────────────────────────────────────── */
|
||||
function UASection({ items }: { items: AttributeValue[] }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span>🖥️</span> User-Agents
|
||||
<span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, i) => {
|
||||
const pct = item.percentage || 0;
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<span className="flex-1 text-xs font-mono text-text-primary break-all leading-relaxed">{item.value}</span>
|
||||
<span className="shrink-0 text-xs text-text-secondary">{item.count} ({pct.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-background-card rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full bg-threat-medium" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,289 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PREDEFINED_TAGS } from '../../utils/classifications';
|
||||
|
||||
interface CorrelationIndicators {
|
||||
subnet_ips_count: number;
|
||||
asn_ips_count: number;
|
||||
country_percentage: number;
|
||||
ja4_shared_ips: number;
|
||||
user_agents_count: number;
|
||||
bot_ua_percentage: number;
|
||||
}
|
||||
|
||||
interface ClassificationRecommendation {
|
||||
label: 'legitimate' | 'suspicious' | 'malicious';
|
||||
confidence: number;
|
||||
indicators: CorrelationIndicators;
|
||||
suggested_tags: string[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface CorrelationSummaryProps {
|
||||
ip: string;
|
||||
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
|
||||
}
|
||||
|
||||
export function CorrelationSummary({ ip, onClassify }: CorrelationSummaryProps) {
|
||||
const [data, setData] = useState<ClassificationRecommendation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedLabel, setSelectedLabel] = useState<string>('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [comment, setComment] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecommendation = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/recommendation`);
|
||||
if (!response.ok) throw new Error('Erreur chargement recommandation');
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
setSelectedLabel(result.label);
|
||||
setSelectedTags(result.suggested_tags || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecommendation();
|
||||
}, [ip]);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/analysis/classifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ip,
|
||||
label: selectedLabel,
|
||||
tags: selectedTags,
|
||||
comment,
|
||||
confidence: data?.confidence || 0.5,
|
||||
features: data?.indicators || {},
|
||||
analyst: 'soc_user'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erreur sauvegarde');
|
||||
|
||||
if (onClassify) {
|
||||
onClassify(selectedLabel, selectedTags, comment, data?.confidence || 0.5);
|
||||
}
|
||||
|
||||
alert('Classification sauvegardée !');
|
||||
} catch (err) {
|
||||
alert(`Erreur: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportML = async () => {
|
||||
try {
|
||||
const mlData = {
|
||||
ip,
|
||||
label: selectedLabel,
|
||||
confidence: data?.confidence || 0.5,
|
||||
tags: selectedTags,
|
||||
features: {
|
||||
subnet_ips_count: data?.indicators.subnet_ips_count || 0,
|
||||
asn_ips_count: data?.indicators.asn_ips_count || 0,
|
||||
country_percentage: data?.indicators.country_percentage || 0,
|
||||
ja4_shared_ips: data?.indicators.ja4_shared_ips || 0,
|
||||
user_agents_count: data?.indicators.user_agents_count || 0,
|
||||
bot_ua_percentage: data?.indicators.bot_ua_percentage || 0,
|
||||
},
|
||||
comment,
|
||||
analyst: 'soc_user',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(mlData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `classification_${ip}_${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert(`Erreur export: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">5. CORRELATION SUMMARY</h3>
|
||||
|
||||
{/* Indicateurs */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
<IndicatorCard
|
||||
label="IPs subnet"
|
||||
value={data.indicators.subnet_ips_count}
|
||||
alert={data.indicators.subnet_ips_count > 10}
|
||||
/>
|
||||
<IndicatorCard
|
||||
label="IPs ASN"
|
||||
value={data.indicators.asn_ips_count}
|
||||
alert={data.indicators.asn_ips_count > 100}
|
||||
/>
|
||||
<IndicatorCard
|
||||
label="JA4 partagés"
|
||||
value={data.indicators.ja4_shared_ips}
|
||||
alert={data.indicators.ja4_shared_ips > 50}
|
||||
/>
|
||||
<IndicatorCard
|
||||
label="Bots UA"
|
||||
value={`${data.indicators.bot_ua_percentage.toFixed(0)}%`}
|
||||
alert={data.indicators.bot_ua_percentage > 20}
|
||||
/>
|
||||
<IndicatorCard
|
||||
label="UAs différents"
|
||||
value={data.indicators.user_agents_count}
|
||||
alert={data.indicators.user_agents_count > 5}
|
||||
/>
|
||||
<IndicatorCard
|
||||
label="Confiance"
|
||||
value={`${(data.confidence * 100).toFixed(0)}%`}
|
||||
alert={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Raison */}
|
||||
{data.reason && (
|
||||
<div className="bg-background-card rounded-lg p-4 mb-6">
|
||||
<div className="text-sm text-text-secondary mb-2">Analyse</div>
|
||||
<div className="text-text-primary">{data.reason}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification */}
|
||||
<div className="border-t border-background-card pt-6">
|
||||
<h4 className="text-md font-medium text-text-primary mb-4">CLASSIFICATION</h4>
|
||||
|
||||
{/* Boutons de label */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => setSelectedLabel('legitimate')}
|
||||
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'legitimate'
|
||||
? 'bg-threat-low text-white'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
✅ LÉGITIME
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedLabel('suspicious')}
|
||||
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'suspicious'
|
||||
? 'bg-threat-medium text-white'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
⚠️ SUSPECT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedLabel('malicious')}
|
||||
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'malicious'
|
||||
? 'bg-threat-high text-white'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
❌ MALVEILLANT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-text-secondary mb-3">Tags</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PREDEFINED_TAGS.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`px-3 py-1 rounded text-xs transition-colors ${
|
||||
selectedTags.includes(tag)
|
||||
? 'bg-accent-primary text-white'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commentaire */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-text-secondary mb-2">Commentaire</div>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Notes d'analyse..."
|
||||
className="w-full bg-background-card border border-background-card rounded-lg p-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !selectedLabel}
|
||||
className="flex-1 bg-accent-primary hover:bg-accent-primary/80 disabled:opacity-50 disabled:cursor-not-allowed text-white py-3 px-4 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{saving ? 'Sauvegarde...' : '💾 Sauvegarder'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportML}
|
||||
className="flex-1 bg-background-card hover:bg-background-card/80 text-text-primary py-3 px-4 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
📤 Export ML
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IndicatorCard({ label, value, alert }: { label: string; value: string | number; alert: boolean }) {
|
||||
return (
|
||||
<div className={`bg-background-card rounded-lg p-3 ${alert ? 'border-2 border-threat-high' : ''}`}>
|
||||
<div className="text-xs text-text-secondary mb-1">{label}</div>
|
||||
<div className={`text-xl font-bold ${alert ? 'text-threat-high' : 'text-text-primary'}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { InfoTip } from '../ui/Tooltip';
|
||||
|
||||
interface CountryData {
|
||||
code: string;
|
||||
name: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface CountryAnalysisProps {
|
||||
ip?: string; // Si fourni, affiche stats relatives à cette IP
|
||||
asn?: string; // Si fourni, affiche stats relatives à cet ASN
|
||||
}
|
||||
|
||||
interface CountryAnalysisData {
|
||||
ip_country?: { code: string; name: string };
|
||||
asn_countries: CountryData[];
|
||||
}
|
||||
|
||||
export function CountryAnalysis({ ip, asn }: CountryAnalysisProps) {
|
||||
const [data, setData] = useState<CountryAnalysisData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCountryAnalysis = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (ip) {
|
||||
// Mode Investigation IP: Récupérer le pays de l'IP + répartition ASN
|
||||
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/country`);
|
||||
if (!response.ok) throw new Error('Erreur chargement pays');
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} else if (asn) {
|
||||
// Mode Investigation ASN
|
||||
const response = await fetch(`/api/analysis/asn/${encodeURIComponent(asn)}/country`);
|
||||
if (!response.ok) throw new Error('Erreur chargement pays');
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} else {
|
||||
// Mode Global (stats générales)
|
||||
const response = await fetch('/api/analysis/country?days=1');
|
||||
if (!response.ok) throw new Error('Erreur chargement pays');
|
||||
const result = await response.json();
|
||||
setData({
|
||||
ip_country: undefined,
|
||||
asn_countries: result.top_countries || []
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCountryAnalysis();
|
||||
}, [ip, asn]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getFlag = (code: string) => {
|
||||
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
};
|
||||
|
||||
// Mode Investigation IP avec pays unique
|
||||
if (ip && data.ip_country) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-text-primary">2. PAYS DE L'IP</h3>
|
||||
<InfoTip content={
|
||||
'Source : logs de détection internes (ClickHouse).\n' +
|
||||
'Le pays est enregistré au moment de l\'ingestion des logs,\n' +
|
||||
'via la base GeoIP du pipeline d\'enrichissement.\n\n' +
|
||||
'Peut différer des sources de réputation externes\n' +
|
||||
'(ip-api.com, ipinfo.io) pour les IPs anycast/CDN\n' +
|
||||
'et les grands fournisseurs cloud (Microsoft, Google,\n' +
|
||||
'Amazon) dont les plages IP sont routées vers plusieurs pays.'
|
||||
} />
|
||||
</div>
|
||||
|
||||
{/* Pays de l'IP */}
|
||||
<div className="bg-background-card rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-4xl">{getFlag(data.ip_country.code)}</span>
|
||||
<div>
|
||||
<div className="text-text-primary font-bold text-lg">
|
||||
{data.ip_country.name} ({data.ip_country.code})
|
||||
</div>
|
||||
<div className="text-text-secondary text-sm">Pays de l'IP</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Répartition ASN par pays */}
|
||||
{data.asn_countries.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-3">
|
||||
Autres pays du même ASN (24h)
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.asn_countries.slice(0, 5).map((country, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{getFlag(country.code)}</span>
|
||||
<span className="text-text-primary text-sm">{country.name}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-text-primary font-bold text-sm">{country.count}</div>
|
||||
<div className="text-text-secondary text-xs">{country.percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mode Global ou ASN
|
||||
const getThreatColor = (percentage: number, baseline: number) => {
|
||||
if (baseline > 0 && percentage > baseline * 2) return 'bg-threat-high';
|
||||
if (percentage > 30) return 'bg-threat-medium';
|
||||
return 'bg-accent-primary';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-text-primary">
|
||||
{asn ? '2. TOP Pays (ASN)' : '2. TOP Pays (Global)'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{data.asn_countries.map((country, idx) => {
|
||||
const baselinePct = 0; // Pas de baseline en mode ASN
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="text-text-primary font-medium text-sm">
|
||||
{country.name} ({country.code})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-text-primary font-bold">{country.count}</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 transition-all ${getThreatColor(country.percentage, baselinePct)}`}
|
||||
style={{ width: `${Math.min(country.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface JA4SubnetData {
|
||||
subnet: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface JA4Analysis {
|
||||
ja4: string;
|
||||
shared_ips_count: number;
|
||||
top_subnets: JA4SubnetData[];
|
||||
other_ja4_for_ip: string[];
|
||||
}
|
||||
|
||||
interface JA4AnalysisProps {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export function JA4Analysis({ ip }: JA4AnalysisProps) {
|
||||
const [data, setData] = useState<JA4Analysis | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchJA4Analysis = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/ja4`);
|
||||
if (!response.ok) throw new Error('Erreur chargement JA4');
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchJA4Analysis();
|
||||
}, [ip]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data || !data.ja4) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-text-secondary">JA4 non disponible</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-text-primary">3. JA4 FINGERPRINT ANALYSIS</h3>
|
||||
{data.shared_ips_count > 50 && (
|
||||
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
|
||||
🔴 {data.shared_ips_count} IPs
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* JA4 Fingerprint */}
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-2">JA4 Fingerprint</div>
|
||||
<div className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all">
|
||||
{data.ja4}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* IPs avec même JA4 */}
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-2">
|
||||
IPs avec le MÊME JA4 (24h)
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-text-primary mb-2">
|
||||
{data.shared_ips_count}
|
||||
</div>
|
||||
{data.shared_ips_count > 50 && (
|
||||
<div className="text-threat-high text-sm">
|
||||
🔴 PATTERN: Même outil/bot sur {data.shared_ips_count} IPs
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Autres JA4 pour cette IP */}
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-2">
|
||||
Autres JA4 pour cette IP
|
||||
</div>
|
||||
{data.other_ja4_for_ip.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{data.other_ja4_for_ip.slice(0, 3).map((ja4, idx) => (
|
||||
<div key={idx} className="bg-background-card rounded p-2 font-mono text-xs text-text-primary truncate">
|
||||
{ja4}
|
||||
</div>
|
||||
))}
|
||||
{data.other_ja4_for_ip.length > 3 && (
|
||||
<div className="text-text-secondary text-xs">
|
||||
+{data.other_ja4_for_ip.length - 3} autres
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-secondary text-sm">
|
||||
1 seul JA4 → Comportement stable
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top subnets */}
|
||||
{data.top_subnets.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-2">
|
||||
Top subnets pour ce JA4
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{data.top_subnets.map((subnet, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-background-card rounded-lg p-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="font-mono text-sm text-text-primary">{subnet.subnet}</div>
|
||||
<div className="text-text-primary font-bold">{subnet.count} IPs</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,348 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PREDEFINED_TAGS_JA4 } from '../../utils/classifications';
|
||||
|
||||
interface CorrelationIndicators {
|
||||
subnet_ips_count: number;
|
||||
asn_ips_count: number;
|
||||
country_percentage: number;
|
||||
ja4_shared_ips: number;
|
||||
user_agents_count: number;
|
||||
bot_ua_percentage: number;
|
||||
}
|
||||
|
||||
interface JA4ClassificationRecommendation {
|
||||
label: 'legitimate' | 'suspicious' | 'malicious';
|
||||
confidence: number;
|
||||
indicators: CorrelationIndicators;
|
||||
suggested_tags: string[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface JA4CorrelationSummaryProps {
|
||||
ja4: string;
|
||||
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
|
||||
}
|
||||
|
||||
export function JA4CorrelationSummary({ ja4, onClassify }: JA4CorrelationSummaryProps) {
|
||||
const [data, setData] = useState<JA4ClassificationRecommendation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedLabel, setSelectedLabel] = useState<string>('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [comment, setComment] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecommendation = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Récupérer les IPs associées
|
||||
const ipsResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4)}/ips?limit=100`);
|
||||
const ipsData = await ipsResponse.json();
|
||||
|
||||
// Récupérer les user-agents
|
||||
const uaResponse = await fetch(`/api/variability/ja4/${encodeURIComponent(ja4)}/user_agents?limit=100`);
|
||||
const uaData = await uaResponse.json();
|
||||
|
||||
// Calculer les indicateurs
|
||||
const indicators: CorrelationIndicators = {
|
||||
subnet_ips_count: 0,
|
||||
asn_ips_count: ipsData.total || 0,
|
||||
country_percentage: 0,
|
||||
ja4_shared_ips: ipsData.total || 0,
|
||||
user_agents_count: uaData.user_agents?.length || 0,
|
||||
bot_ua_percentage: 0
|
||||
};
|
||||
|
||||
// Calculer le pourcentage de bots
|
||||
if (uaData.user_agents?.length > 0) {
|
||||
const botCount = uaData.user_agents
|
||||
.filter((ua: any) => ua.classification === 'bot' || ua.classification === 'script')
|
||||
.reduce((sum: number, ua: any) => sum + ua.count, 0);
|
||||
const totalCount = uaData.user_agents.reduce((sum: number, ua: any) => sum + ua.count, 0);
|
||||
indicators.bot_ua_percentage = totalCount > 0 ? (botCount / totalCount * 100) : 0;
|
||||
}
|
||||
|
||||
// Score de confiance
|
||||
let score = 0.0;
|
||||
const reasons: string[] = [];
|
||||
const tags: string[] = [];
|
||||
|
||||
// JA4 partagé > 50 IPs
|
||||
if (indicators.ja4_shared_ips > 50) {
|
||||
score += 0.30;
|
||||
reasons.push(`${indicators.ja4_shared_ips} IPs avec même JA4`);
|
||||
tags.push('ja4-rotation');
|
||||
}
|
||||
|
||||
// Bot UA > 20%
|
||||
if (indicators.bot_ua_percentage > 20) {
|
||||
score += 0.25;
|
||||
reasons.push(`${indicators.bot_ua_percentage.toFixed(0)}% UAs bots/scripts`);
|
||||
tags.push('bot-ua');
|
||||
}
|
||||
|
||||
// Multiple UAs
|
||||
if (indicators.user_agents_count > 5) {
|
||||
score += 0.15;
|
||||
reasons.push(`${indicators.user_agents_count} UAs différents`);
|
||||
tags.push('ua-rotation');
|
||||
}
|
||||
|
||||
// Déterminer label
|
||||
if (score >= 0.7) {
|
||||
score = Math.min(score, 1.0);
|
||||
tags.push('known-bot');
|
||||
} else if (score >= 0.4) {
|
||||
score = Math.min(score, 1.0);
|
||||
}
|
||||
|
||||
const reason = reasons.join(' | ') || 'Aucun indicateur fort';
|
||||
|
||||
setData({
|
||||
label: score >= 0.7 ? 'malicious' : score >= 0.4 ? 'suspicious' : 'legitimate',
|
||||
confidence: score,
|
||||
indicators,
|
||||
suggested_tags: tags,
|
||||
reason
|
||||
});
|
||||
|
||||
setSelectedLabel(score >= 0.7 ? 'malicious' : score >= 0.4 ? 'suspicious' : 'legitimate');
|
||||
setSelectedTags(tags);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (ja4) {
|
||||
fetchRecommendation();
|
||||
}
|
||||
}, [ja4]);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/analysis/classifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ja4,
|
||||
label: selectedLabel,
|
||||
tags: selectedTags,
|
||||
comment,
|
||||
confidence: data?.confidence || 0.5,
|
||||
features: data?.indicators || {},
|
||||
analyst: 'soc_user'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Erreur sauvegarde');
|
||||
}
|
||||
|
||||
if (onClassify) {
|
||||
onClassify(selectedLabel, selectedTags, comment, data?.confidence || 0.5);
|
||||
}
|
||||
|
||||
alert('Classification JA4 sauvegardée !');
|
||||
} catch (err) {
|
||||
alert(`Erreur: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportML = async () => {
|
||||
try {
|
||||
const mlData = {
|
||||
ja4,
|
||||
label: selectedLabel,
|
||||
confidence: data?.confidence || 0.5,
|
||||
tags: selectedTags,
|
||||
features: {
|
||||
ja4_shared_ips: data?.indicators.ja4_shared_ips || 0,
|
||||
user_agents_count: data?.indicators.user_agents_count || 0,
|
||||
bot_ua_percentage: data?.indicators.bot_ua_percentage || 0,
|
||||
},
|
||||
comment,
|
||||
analyst: 'soc_user',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(mlData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `classification_ja4_${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert(`Erreur export: ${err instanceof Error ? err.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">5. CORRELATION SUMMARY</h3>
|
||||
|
||||
{/* Indicateurs */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
<IndicatorCard
|
||||
label="IPs partagées"
|
||||
value={data.indicators.ja4_shared_ips}
|
||||
alert={data.indicators.ja4_shared_ips > 50}
|
||||
/>
|
||||
<IndicatorCard
|
||||
label="UAs différents"
|
||||
value={data.indicators.user_agents_count}
|
||||
alert={data.indicators.user_agents_count > 5}
|
||||
/>
|
||||
<IndicatorCard
|
||||
label="Bots UA"
|
||||
value={`${data.indicators.bot_ua_percentage.toFixed(0)}%`}
|
||||
alert={data.indicators.bot_ua_percentage > 20}
|
||||
/>
|
||||
<IndicatorCard
|
||||
label="Confiance"
|
||||
value={`${(data.confidence * 100).toFixed(0)}%`}
|
||||
alert={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Raison */}
|
||||
{data.reason && (
|
||||
<div className="bg-background-card rounded-lg p-4 mb-6">
|
||||
<div className="text-sm text-text-secondary mb-2">Analyse</div>
|
||||
<div className="text-text-primary">{data.reason}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification */}
|
||||
<div className="border-t border-background-card pt-6">
|
||||
<h4 className="text-md font-medium text-text-primary mb-4">CLASSIFICATION</h4>
|
||||
|
||||
{/* Boutons de label */}
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => setSelectedLabel('legitimate')}
|
||||
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'legitimate'
|
||||
? 'bg-threat-low text-white'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
✅ LÉGITIME
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedLabel('suspicious')}
|
||||
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'suspicious'
|
||||
? 'bg-threat-medium text-white'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
⚠️ SUSPECT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedLabel('malicious')}
|
||||
className={`flex-1 py-3 px-4 rounded-lg font-medium transition-colors ${
|
||||
selectedLabel === 'malicious'
|
||||
? 'bg-threat-high text-white'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
❌ MALVEILLANT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-text-secondary mb-3">Tags</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PREDEFINED_TAGS_JA4.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`px-3 py-1 rounded text-xs transition-colors ${
|
||||
selectedTags.includes(tag)
|
||||
? 'bg-accent-primary text-white'
|
||||
: 'bg-background-card text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commentaire */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-text-secondary mb-2">Commentaire</div>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Notes d'analyse..."
|
||||
className="w-full bg-background-card border border-background-card rounded-lg p-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !selectedLabel}
|
||||
className="flex-1 bg-accent-primary hover:bg-accent-primary/80 disabled:opacity-50 disabled:cursor-not-allowed text-white py-3 px-4 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{saving ? 'Sauvegarde...' : '💾 Sauvegarder'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportML}
|
||||
className="flex-1 bg-background-card hover:bg-background-card/80 text-text-primary py-3 px-4 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
📤 Export ML
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IndicatorCard({ label, value, alert }: { label: string; value: string | number; alert: boolean }) {
|
||||
return (
|
||||
<div className={`bg-background-card rounded-lg p-3 ${alert ? 'border-2 border-threat-high' : ''}`}>
|
||||
<div className="text-xs text-text-secondary mb-1">{label}</div>
|
||||
<div className={`text-xl font-bold ${alert ? 'text-threat-high' : 'text-text-primary'}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface SubnetAnalysisData {
|
||||
ip: string;
|
||||
subnet: string;
|
||||
ips_in_subnet: string[];
|
||||
total_in_subnet: number;
|
||||
asn_number: string;
|
||||
asn_org: string;
|
||||
total_in_asn: number;
|
||||
alert: boolean;
|
||||
}
|
||||
|
||||
interface SubnetAnalysisProps {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export function SubnetAnalysis({ ip }: SubnetAnalysisProps) {
|
||||
const [data, setData] = useState<SubnetAnalysisData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSubnetAnalysis = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/subnet`);
|
||||
if (!response.ok) throw new Error('Erreur chargement subnet');
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSubnetAnalysis();
|
||||
}, [ip]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-threat-high">Erreur: {error || 'Données non disponibles'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-text-primary">1. SUBNET / ASN ANALYSIS</h3>
|
||||
{data.alert && (
|
||||
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
|
||||
⚠️ {data.total_in_subnet} IPs du subnet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Subnet */}
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-2">Subnet (/24)</div>
|
||||
<div className="text-text-primary font-mono text-sm">{data.subnet}</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-sm text-text-secondary mb-2">
|
||||
IPs du même subnet ({data.total_in_subnet})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.ips_in_subnet.slice(0, 15).map((ipAddr: string, idx: number) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="bg-background-card px-2 py-1 rounded text-xs font-mono text-text-primary"
|
||||
>
|
||||
{ipAddr.split('.').slice(0, 3).join('.')}.{ipAddr.split('.')[3]}
|
||||
</span>
|
||||
))}
|
||||
{data.ips_in_subnet.length > 15 && (
|
||||
<span className="bg-background-card px-2 py-1 rounded text-xs text-text-secondary">
|
||||
+{data.ips_in_subnet.length - 15} autres
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ASN */}
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-2">ASN</div>
|
||||
<div className="text-text-primary font-medium">{data.asn_org || 'Unknown'}</div>
|
||||
<div className="text-sm text-text-secondary font-mono">AS{data.asn_number}</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="text-sm text-text-secondary mb-2">
|
||||
Total IPs dans l'ASN (24h)
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-text-primary">{data.total_in_asn}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.alert && (
|
||||
<div className="mt-4 bg-threat-high/10 border border-threat-high rounded-lg p-3">
|
||||
<div className="text-threat-high text-sm font-medium">
|
||||
🔴 PATTERN: {data.total_in_subnet} IPs du même subnet en 24h
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface UserAgentData {
|
||||
value: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
classification: 'normal' | 'bot' | 'script';
|
||||
}
|
||||
|
||||
interface UserAgentAnalysis {
|
||||
ip_user_agents: UserAgentData[];
|
||||
ja4_user_agents: UserAgentData[];
|
||||
bot_percentage: number;
|
||||
alert: boolean;
|
||||
}
|
||||
|
||||
interface UserAgentAnalysisProps {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analysis/${encodeURIComponent(ip)}/user-agents`);
|
||||
if (!response.ok) throw new Error('Erreur chargement User-Agents');
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserAgentAnalysis();
|
||||
}, [ip]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="text-center text-text-secondary">User-Agents non disponibles</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 whitespace-nowrap">✅ Normal</span>;
|
||||
case 'bot':
|
||||
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 whitespace-nowrap">❌ Script</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const INITIAL_COUNT = 5;
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-text-primary">4. USER-AGENT ANALYSIS</h3>
|
||||
{data.alert && (
|
||||
<span className="bg-threat-high text-white px-3 py-1 rounded text-xs font-medium">
|
||||
⚠️ {data.bot_percentage.toFixed(0)}% bots/scripts
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{(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 leading-relaxed">
|
||||
{ua.value}
|
||||
</div>
|
||||
{getClassificationBadge(ua.classification)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-text-secondary text-xs">{ua.count} requêtes</div>
|
||||
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.ip_user_agents.length === 0 && (
|
||||
<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 */}
|
||||
<div>
|
||||
<div className="text-sm text-text-secondary mb-3">
|
||||
User-Agents pour le JA4 (toutes IPs)
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(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 leading-relaxed">
|
||||
{ua.value}
|
||||
</div>
|
||||
{getClassificationBadge(ua.classification)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-text-secondary text-xs">{ua.count} IPs</div>
|
||||
<div className="text-text-secondary text-xs">{ua.percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Stats bots */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-text-secondary">Pourcentage de bots/scripts</div>
|
||||
<div className={`text-lg font-bold ${data.bot_percentage > 20 ? 'text-threat-high' : 'text-text-primary'}`}>
|
||||
{data.bot_percentage.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-background-card rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all ${
|
||||
data.bot_percentage > 50 ? 'bg-threat-high' :
|
||||
data.bot_percentage > 20 ? 'bg-threat-medium' :
|
||||
'bg-threat-low'
|
||||
}`}
|
||||
style={{ width: `${Math.min(data.bot_percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{data.bot_percentage > 20 && (
|
||||
<div className="mt-2 text-threat-high text-sm">
|
||||
⚠️ ALERT: {data.bot_percentage.toFixed(0)}% d'UAs bots/scripts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
services/dashboard/frontend/src/components/ui/Card.tsx
Normal file
26
services/dashboard/frontend/src/components/ui/Card.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Card({ title, actions, children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-background-secondary border border-background-card rounded-lg overflow-hidden ${className}`}
|
||||
>
|
||||
{(title || actions) && (
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-background-card">
|
||||
{title && (
|
||||
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
|
||||
)}
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
services/dashboard/frontend/src/components/ui/DataTable.tsx
Normal file
165
services/dashboard/frontend/src/components/ui/DataTable.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { useSort, SortDir } from '../../hooks/useSort';
|
||||
import { InfoTip } from './Tooltip';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
tooltip?: string;
|
||||
sortable?: boolean;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
width?: string;
|
||||
render?: (value: any, row: T) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
defaultSortKey?: string;
|
||||
defaultSortDir?: SortDir;
|
||||
onRowClick?: (row: T) => void;
|
||||
onSort?: (key: string, dir: SortDir) => void;
|
||||
rowKey: keyof T | ((row: T) => string);
|
||||
emptyMessage?: string;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export default function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
defaultSortKey,
|
||||
defaultSortDir = 'desc',
|
||||
onRowClick,
|
||||
onSort,
|
||||
rowKey,
|
||||
emptyMessage = 'Aucune donnée disponible',
|
||||
loading = false,
|
||||
className = '',
|
||||
compact = false,
|
||||
maxHeight,
|
||||
}: DataTableProps<T>) {
|
||||
const firstSortableKey =
|
||||
defaultSortKey ||
|
||||
columns.find((c) => c.sortable !== false)?.key ||
|
||||
columns[0]?.key ||
|
||||
'id';
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSort<T>(
|
||||
data,
|
||||
firstSortableKey as keyof T,
|
||||
defaultSortDir
|
||||
);
|
||||
|
||||
const cell = compact ? 'px-3 py-1.5' : 'px-4 py-2.5';
|
||||
|
||||
const getRowKey = (row: T): string => {
|
||||
if (typeof rowKey === 'function') return rowKey(row);
|
||||
return String(row[rowKey as keyof T] ?? '');
|
||||
};
|
||||
|
||||
const alignClass = (align?: 'left' | 'right' | 'center') => {
|
||||
if (align === 'right') return 'text-right';
|
||||
if (align === 'center') return 'text-center';
|
||||
return 'text-left';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${maxHeight ? `${maxHeight} overflow-y-auto` : ''} ${className}`}>
|
||||
<table className="w-full">
|
||||
<thead style={{ position: 'sticky', top: 0, zIndex: 10 }}>
|
||||
<tr>
|
||||
{columns.map((col) => {
|
||||
const isSortable = col.sortable !== false;
|
||||
const isActive = String(sortKey) === col.key;
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={[
|
||||
cell,
|
||||
'text-xs font-semibold text-text-disabled uppercase tracking-wider',
|
||||
'bg-background-secondary border-b border-background-card',
|
||||
col.width ?? '',
|
||||
alignClass(col.align),
|
||||
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
|
||||
].join(' ')}
|
||||
onClick={isSortable ? () => {
|
||||
handleSort(col.key as keyof T);
|
||||
if (onSort) {
|
||||
const newDir = String(sortKey) === col.key
|
||||
? (sortDir === 'asc' ? 'desc' : 'asc')
|
||||
: 'desc';
|
||||
onSort(col.key, newDir);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.tooltip && <InfoTip content={col.tooltip} />}
|
||||
{isSortable &&
|
||||
(isActive ? (
|
||||
<span className="text-accent-primary">
|
||||
{sortDir === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-disabled opacity-50">⇅</span>
|
||||
))}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`${cell} border-b border-background-card`}>
|
||||
<div className="bg-background-card/50 rounded animate-pulse h-4" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : sorted.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="text-center py-8 text-text-disabled text-sm"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sorted.map((row) => (
|
||||
<tr
|
||||
key={getRowKey(row)}
|
||||
className={[
|
||||
'border-b border-background-card transition-colors',
|
||||
'hover:bg-background-card/50',
|
||||
onRowClick ? 'cursor-pointer' : '',
|
||||
].join(' ')}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const value = row[col.key as keyof T];
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={[cell, alignClass(col.align), col.className ?? ''].join(' ')}
|
||||
>
|
||||
{col.render ? col.render(value, row) : (value as React.ReactNode)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
services/dashboard/frontend/src/components/ui/Feedback.tsx
Normal file
26
services/dashboard/frontend/src/components/ui/Feedback.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Composants UI réutilisables pour les états de chargement et d'erreur.
|
||||
* Utiliser ces composants plutôt que de re-déclarer des versions locales.
|
||||
*/
|
||||
|
||||
/** Spinner centré — affiché pendant le chargement d'une section. */
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorMessageProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Bandeau d'erreur rouge — affiché quand une requête échoue. */
|
||||
export function ErrorMessage({ message }: ErrorMessageProps) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
services/dashboard/frontend/src/components/ui/StatCard.tsx
Normal file
35
services/dashboard/frontend/src/components/ui/StatCard.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
type Color = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'slate';
|
||||
|
||||
const COLOR_MAP: Record<Color, string> = {
|
||||
red: 'text-red-400',
|
||||
orange: 'text-orange-400',
|
||||
yellow: 'text-yellow-400',
|
||||
green: 'text-green-400',
|
||||
blue: 'text-blue-400',
|
||||
purple: 'text-purple-400',
|
||||
slate: 'text-slate-400',
|
||||
};
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
color?: Color;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, sub, color, icon }: StatCardProps) {
|
||||
const valueClass = color ? COLOR_MAP[color] : 'text-text-primary';
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="text-xs text-text-disabled uppercase tracking-wider flex items-center gap-1">
|
||||
{icon && <span>{icon}</span>}
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${valueClass}`}>{value}</div>
|
||||
{sub && <div className="text-xs text-text-secondary mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
type ThreatLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'NORMAL' | 'KNOWN_BOT';
|
||||
|
||||
const BADGE_STYLES: Record<ThreatLevel, string> = {
|
||||
CRITICAL: 'bg-red-900/50 text-red-400 border border-red-800/50',
|
||||
HIGH: 'bg-orange-900/50 text-orange-400 border border-orange-800/50',
|
||||
MEDIUM: 'bg-yellow-900/50 text-yellow-400 border border-yellow-800/50',
|
||||
LOW: 'bg-green-900/50 text-green-400 border border-green-800/50',
|
||||
NORMAL: 'bg-slate-700/50 text-slate-400 border border-slate-600/50',
|
||||
KNOWN_BOT: 'bg-purple-900/50 text-purple-400 border border-purple-800/50',
|
||||
};
|
||||
|
||||
interface ThreatBadgeProps {
|
||||
level: string;
|
||||
}
|
||||
|
||||
export default function ThreatBadge({ level }: ThreatBadgeProps) {
|
||||
const key = (level?.toUpperCase() ?? 'NORMAL') as ThreatLevel;
|
||||
const cls = BADGE_STYLES[key] ?? BADGE_STYLES.NORMAL;
|
||||
return (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium uppercase ${cls}`}>
|
||||
{level || 'NORMAL'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
145
services/dashboard/frontend/src/components/ui/Tooltip.tsx
Normal file
145
services/dashboard/frontend/src/components/ui/Tooltip.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tooltip — composant universel de survol
|
||||
*
|
||||
* Rendu via createPortal dans document.body pour éviter tout clipping
|
||||
* par les conteneurs overflow:hidden / overflow-y:auto.
|
||||
*
|
||||
* Usage :
|
||||
* <Tooltip content="Explication…"><span>label</span></Tooltip>
|
||||
* <InfoTip content="Explication…" /> ← ajoute un ⓘ cliquable
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface TooltipProps {
|
||||
content: string | React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
interface TooltipPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function Tooltip({ content, children, className = '', delay = 250, maxWidth = 300 }: TooltipProps) {
|
||||
const [pos, setPos] = useState<TooltipPos | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const show = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
// Position au-dessus du centre de l'élément, ajustée si trop haut
|
||||
const x = Math.round(rect.left + rect.width / 2);
|
||||
const y = Math.round(rect.top);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setPos({ x, y }), delay);
|
||||
},
|
||||
[delay],
|
||||
);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
setPos(null);
|
||||
}, []);
|
||||
|
||||
// Nettoyage si le composant est démonté pendant le délai
|
||||
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
||||
|
||||
if (!content) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={spanRef}
|
||||
className={`inline-flex items-center ${className}`}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
{pos &&
|
||||
createPortal(
|
||||
<TooltipBubble x={pos.x} y={pos.y} maxWidth={maxWidth}>
|
||||
{content}
|
||||
</TooltipBubble>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bulle de tooltip positionnée en fixed par rapport au viewport */
|
||||
function TooltipBubble({
|
||||
x,
|
||||
y,
|
||||
maxWidth,
|
||||
children,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
maxWidth: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const [adjust, setAdjust] = useState({ dx: 0, dy: 0 });
|
||||
|
||||
// Ajustement viewport pour éviter les débordements
|
||||
useEffect(() => {
|
||||
if (!bubbleRef.current) return;
|
||||
const el = bubbleRef.current;
|
||||
const rect = el.getBoundingClientRect();
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (rect.left < 8) dx = 8 - rect.left;
|
||||
if (rect.right > window.innerWidth - 8) dx = window.innerWidth - 8 - rect.right;
|
||||
if (rect.top < 8) dy = 16; // bascule en dessous si trop haut
|
||||
setAdjust({ dx, dy });
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
left: x + adjust.dx,
|
||||
top: y + adjust.dy - 8,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-100 shadow-2xl leading-relaxed whitespace-pre-line text-left"
|
||||
style={{ maxWidth }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{/* Flèche vers le bas */}
|
||||
<div className="w-0 h-0 mx-auto border-[5px] border-transparent border-t-slate-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* InfoTip — icône ⓘ avec tooltip intégré.
|
||||
* S'insère après un label pour donner une explication au survol.
|
||||
*/
|
||||
export function InfoTip({
|
||||
content,
|
||||
className = '',
|
||||
}: {
|
||||
content: string | React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip content={content} className={className}>
|
||||
<span className="ml-1 text-[10px] text-slate-500 cursor-help select-none hover:text-slate-300 transition-colors leading-none">
|
||||
ⓘ
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
415
services/dashboard/frontend/src/components/ui/tooltips.ts
Normal file
415
services/dashboard/frontend/src/components/ui/tooltips.ts
Normal file
@ -0,0 +1,415 @@
|
||||
/**
|
||||
* tooltips.ts — Textes d'aide pour tous les termes techniques du dashboard.
|
||||
*
|
||||
* Toutes les chaînes sont en français, multi-lignes via \n.
|
||||
* Utilisé avec <InfoTip content={TIPS.xxx} /> ou <Tooltip content={TIPS.xxx}>.
|
||||
*/
|
||||
|
||||
export const TIPS = {
|
||||
|
||||
// ── Clustering ──────────────────────────────────────────────────────────────
|
||||
|
||||
sensitivity:
|
||||
'Contrôle la granularité du clustering.\n' +
|
||||
'· Grossière → grands groupes comportementaux\n' +
|
||||
'· Fine → distinction fine entre sous-comportements\n' +
|
||||
'· Extrême → jusqu\'à k × 5 clusters, calcul long',
|
||||
|
||||
k_base:
|
||||
'Nombre de clusters de base (k).\n' +
|
||||
'Clusters effectifs = k × sensibilité (limité à 300).\n' +
|
||||
'Augmenter k permet plus de nuance dans la classification.',
|
||||
|
||||
k_actual:
|
||||
'Nombre réel de clusters calculés = k × sensibilité.\n' +
|
||||
'Exemple : k=20 × sensibilité=3 = 60 clusters effectifs.\n' +
|
||||
'Limité à 300 pour rester calculable.',
|
||||
|
||||
show_edges:
|
||||
'Affiche les relations de similarité entre clusters proches.\n' +
|
||||
'Seules les paires au-dessus d\'un seuil de similarité sont reliées.\n' +
|
||||
'Utile pour identifier des groupes comportementaux liés.',
|
||||
|
||||
ips_bots:
|
||||
'IPs avec score de risque ML > 70 %.\n' +
|
||||
'Présentent les signaux les plus forts de comportement automatisé.\n' +
|
||||
'Action immédiate recommandée.',
|
||||
|
||||
high_risk:
|
||||
'IPs avec score de risque entre 45 % et 70 %.\n' +
|
||||
'Activité suspecte nécessitant une analyse approfondie.',
|
||||
|
||||
total_hits:
|
||||
'Nombre total de requêtes HTTP de toutes les IPs\n' +
|
||||
'dans la fenêtre d\'analyse sélectionnée.',
|
||||
|
||||
calc_time:
|
||||
'Durée du calcul K-means++ sur l\'ensemble des IPs.\n' +
|
||||
'Augmente avec k × sensibilité et le nombre d\'IPs.',
|
||||
|
||||
pca_2d:
|
||||
'Projection PCA (Analyse en Composantes Principales).\n' +
|
||||
'31 features → 2 dimensions pour la visualisation.\n' +
|
||||
'Les clusters proches ont des comportements similaires.',
|
||||
|
||||
features_31:
|
||||
'31 métriques utilisées pour le clustering :\n' +
|
||||
'· TCP : TTL, MSS, fenêtre de congestion\n' +
|
||||
'· ML : score de détection bot\n' +
|
||||
'· TLS/JA4 : fingerprint client\n' +
|
||||
'· User-Agent, OS, headless, UA-CH\n' +
|
||||
'· Pays, ASN (cloud/datacenter)\n' +
|
||||
'· Headers HTTP : Accept-Language, Encoding, Sec-Fetch\n' +
|
||||
'· Fingerprint headers : popularité, rotation, cookie, referer',
|
||||
|
||||
// ── Légende risque ──────────────────────────────────────────────────────────
|
||||
|
||||
risk_critical:
|
||||
'CRITICAL — Score > 70 %\nBot très probable. Action immédiate recommandée.',
|
||||
|
||||
risk_high:
|
||||
'HIGH — Score 45–70 %\nActivité suspecte. Investigation recommandée.',
|
||||
|
||||
risk_medium:
|
||||
'MEDIUM — Score 25–45 %\nComportement anormal. Surveillance renforcée.',
|
||||
|
||||
risk_low:
|
||||
'LOW — Score < 25 %\nTrafic probablement légitime.',
|
||||
|
||||
// ── Sidebar cluster ─────────────────────────────────────────────────────────
|
||||
|
||||
risk_score:
|
||||
'Score composite [0–100 %] calculé à partir de 14 sous-scores pondérés :\n' +
|
||||
'· ML score (25 %) · Fuzzing (9 %)\n' +
|
||||
'· UA-CH mismatch (7 %) · Headless (6 %)\n' +
|
||||
'· Pays risqué (9 %) · ASN cloud (6 %)\n' +
|
||||
'· Headers HTTP (12 %) · Fingerprint (12 %)\n' +
|
||||
'· Vélocité (5 %) · IP ID zéro (5 %)',
|
||||
|
||||
radar_profile:
|
||||
'Radar comportemental sur les features principales.\n' +
|
||||
'Chaque axe = un sous-score normalisé entre 0 et 1.\n' +
|
||||
'Survolez un point pour voir la valeur exacte.',
|
||||
|
||||
mean_ttl:
|
||||
'Time-To-Live TCP moyen du cluster.\n' +
|
||||
'· Linux ≈ 64 · Windows ≈ 128 · Cisco ≈ 255\n' +
|
||||
'Différence TTL_initial − TTL_observé = nombre de sauts réseau.',
|
||||
|
||||
mean_mss:
|
||||
'Maximum Segment Size TCP moyen.\n' +
|
||||
'· Ethernet = 1460 B · PPPoE ≈ 1452 B\n' +
|
||||
'· VPN ≈ 1380–1420 B · Bas débit < 1380 B\n' +
|
||||
'MSS anormalement bas → probable tunnel ou VPN.',
|
||||
|
||||
mean_score:
|
||||
'Score moyen du modèle ML de détection de bots.\n' +
|
||||
'0 % = trafic légitime · 100 % = bot confirmé',
|
||||
|
||||
mean_velocity:
|
||||
'Nombre moyen de requêtes par seconde (rps).\n' +
|
||||
'Taux élevé → outil automatisé ou attaque volumétrique.',
|
||||
|
||||
mean_headless:
|
||||
'Proportion d\'IPs utilisant un navigateur headless.\n' +
|
||||
'(Puppeteer, Playwright, PhantomJS, Chromium sans UI…)\n' +
|
||||
'Les bots utilisent fréquemment des navigateurs sans interface.',
|
||||
|
||||
mean_ua_ch:
|
||||
'User-Agent / Client Hints mismatch.\n' +
|
||||
'Le UA déclaré (ex: Chrome/Windows) contredit les hints\n' +
|
||||
'(ex: Linux, version différente).\n' +
|
||||
'Signal fort de spoofing d\'identité navigateur.',
|
||||
|
||||
// ── ML Features ─────────────────────────────────────────────────────────────
|
||||
|
||||
fuzzing:
|
||||
'Score de fuzzing : variété anormale de paramètres ou payloads.\n' +
|
||||
'Caractéristique des scanners de vulnérabilités\n' +
|
||||
'(SQLi, XSS, path traversal, injection d\'en-têtes…).',
|
||||
|
||||
velocity:
|
||||
'Score de vélocité : taux de requêtes / unité de temps normalisé.\n' +
|
||||
'Au-dessus du seuil → outil automatisé confirmé.',
|
||||
|
||||
fake_nav:
|
||||
'Fausse navigation : séquences de pages non conformes au comportement humain.\n' +
|
||||
'Ex : accès direct à des API sans passer par les pages d\'entrée.',
|
||||
|
||||
ua_mismatch:
|
||||
'User-Agent / Client Hints mismatch.\n' +
|
||||
'Contradiction entre l\'OS/navigateur déclaré dans le UA\n' +
|
||||
'et les Client Hints envoyés par le navigateur réel.',
|
||||
|
||||
sni_mismatch:
|
||||
'SNI mismatch : le nom dans le SNI TLS ≠ le Host HTTP.\n' +
|
||||
'Signe de proxying, de bot, ou de spoofing TLS.',
|
||||
|
||||
orphan_ratio:
|
||||
'Orphan ratio : proportion de requêtes sans referer ni session.\n' +
|
||||
'Les bots accèdent souvent directement aux URLs\n' +
|
||||
'sans parcours préalable sur le site.',
|
||||
|
||||
path_repetition:
|
||||
'Répétition URL : taux de requêtes sur les mêmes endpoints.\n' +
|
||||
'Les bots ciblés répètent des patterns d\'URLs précis\n' +
|
||||
'(ex : /login, /api/search, /admin…).',
|
||||
|
||||
payload_anomaly:
|
||||
'Payload anormal : ratio de requêtes avec contenu inhabituel.\n' +
|
||||
'(taille hors norme, encoding bizarre, corps non standard)\n' +
|
||||
'Peut indiquer une injection ou une tentative de bypass.',
|
||||
|
||||
fuzzing_index:
|
||||
'Indice brut de fuzzing mesuré sur les paramètres des requêtes.\n' +
|
||||
'Valeur haute → tentative d\'injection ou fuzzing actif.',
|
||||
|
||||
hit_velocity_scatter:
|
||||
'Taux de requêtes par seconde de cette IP.\n' +
|
||||
'Valeur haute → outil automatisé ou attaque volumétrique.',
|
||||
|
||||
temporal_entropy:
|
||||
'Entropie temporelle : irrégularité des intervalles entre requêtes.\n' +
|
||||
'· Faible = bot régulier (machine, intervalles constants)\n' +
|
||||
'· Élevée = humain ou bot à timing aléatoire',
|
||||
|
||||
anomalous_payload_ratio:
|
||||
'Ratio de requêtes avec payload anormal / total de l\'IP.\n' +
|
||||
'Ex : headers malformés, corps non HTTP standard.',
|
||||
|
||||
attack_brute_force:
|
||||
'Brute Force : tentatives répétées d\'authentification\n' +
|
||||
'ou d\'énumération de ressources (login, tokens, IDs…).',
|
||||
|
||||
attack_flood:
|
||||
'Flood : envoi massif de requêtes pour saturer le service.\n' +
|
||||
'(DoS/DDoS, rate limit bypass…)',
|
||||
|
||||
attack_scraper:
|
||||
'Scraper : extraction systématique de contenu.\n' +
|
||||
'(web scraping, crawling non autorisé, récolte de données)',
|
||||
|
||||
attack_spoofing:
|
||||
'Spoofing : usurpation d\'identité UA/TLS\n' +
|
||||
'pour contourner la détection de bots.',
|
||||
|
||||
attack_scanner:
|
||||
'Scanner : exploration automatique des endpoints\n' +
|
||||
'pour découvrir des vulnérabilités (CVE, misconfigs…).',
|
||||
|
||||
// ── TCP Spoofing ─────────────────────────────────────────────────────────────
|
||||
|
||||
ttl:
|
||||
'TTL (Time-To-Live) : valeur observée vs initiale estimée.\n' +
|
||||
'· Initiale typique : Linux=64, Windows=128, Cisco=255\n' +
|
||||
'· Hops réseau = TTL_init − TTL_observé',
|
||||
|
||||
mss:
|
||||
'Maximum Segment Size TCP.\n' +
|
||||
'· Ethernet = 1460 B · PPPoE ≈ 1452 B\n' +
|
||||
'· VPN ≈ 1380–1420 B · Bas débit < 1380 B\n' +
|
||||
'Révèle le type de réseau sous-jacent.',
|
||||
|
||||
win_scale:
|
||||
'Window Scale TCP (RFC 1323).\n' +
|
||||
'Facteur d\'échelle de la fenêtre de congestion.\n' +
|
||||
'· Linux ≈ 7 · Windows ≈ 8 · Absent = vieux OS ou bot',
|
||||
|
||||
os_tcp:
|
||||
'OS suspecté via fingerprinting TCP passif.\n' +
|
||||
'Analyse combinée : TTL + MSS + Window Scale + options TCP.\n' +
|
||||
'Indépendant du User-Agent déclaré.',
|
||||
|
||||
os_ua:
|
||||
'OS déclaré dans le User-Agent HTTP.\n' +
|
||||
'Comparé à l\'OS TCP pour détecter les usurpations d\'identité.',
|
||||
|
||||
confidence:
|
||||
'Niveau de confiance du fingerprinting TCP [0–100 %].\n' +
|
||||
'Basé sur le nombre de signaux concordants\n' +
|
||||
'(TTL, MSS, Window Scale, options TCP).',
|
||||
|
||||
spoof_verdict:
|
||||
'Verdict de spoofing : OS TCP (réel) vs OS User-Agent (déclaré).\n' +
|
||||
'Un écart indique une probable usurpation d\'identité.\n' +
|
||||
'Ex : TCP→Linux mais UA→Windows/Chrome.',
|
||||
|
||||
// ── Général ──────────────────────────────────────────────────────────────────
|
||||
|
||||
ja4:
|
||||
'JA4 : fingerprint TLS client.\n' +
|
||||
'Basé sur : version TLS, suites chiffrées, extensions,\n' +
|
||||
'algorithmes de signature et SNI.\n' +
|
||||
'Identifie un client TLS de façon quasi-unique.',
|
||||
|
||||
asn:
|
||||
'ASN (Autonomous System Number).\n' +
|
||||
'Identifiant du réseau auquel appartient l\'IP.\n' +
|
||||
'Permet d\'identifier l\'opérateur (AWS, GCP, Azure, hébergeur, FAI…).',
|
||||
|
||||
header_fingerprint:
|
||||
'Fingerprint des headers HTTP.\n' +
|
||||
'· Popularité : fréquence de ce profil dans le trafic global\n' +
|
||||
' (rare = suspect, populaire = navigateur standard)\n' +
|
||||
'· Rotation : le client change fréquemment de profil headers\n' +
|
||||
' (signal fort de bot rotatif)',
|
||||
|
||||
header_count:
|
||||
'Nombre de headers HTTP envoyés par le client.\n' +
|
||||
'Navigateur standard ≈ 10–15 headers.\n' +
|
||||
'Bot HTTP basique ≈ 2–5 headers.',
|
||||
|
||||
accept_language:
|
||||
'Header Accept-Language.\n' +
|
||||
'Absent chez les bots HTTP basiques.\n' +
|
||||
'Présent chez les navigateurs légitimes (fr-FR, en-US…).',
|
||||
|
||||
accept_encoding:
|
||||
'Header Accept-Encoding.\n' +
|
||||
'Absent → client HTTP basique ou bot simple.\n' +
|
||||
'Présent (gzip, br…) → navigateur ou client HTTP moderne.',
|
||||
|
||||
sec_fetch:
|
||||
'Headers Sec-Fetch-* (Site, Mode, Dest, User).\n' +
|
||||
'Envoyés uniquement par les navigateurs Chromium/Firefox réels.\n' +
|
||||
'Absent → bot, curl, ou client HTTP non-navigateur.',
|
||||
|
||||
alertes_24h:
|
||||
'Alertes générées par le moteur de détection ML\n' +
|
||||
'dans les dernières 24 heures, classifiées par niveau de menace.',
|
||||
|
||||
threat_level:
|
||||
'Niveau de menace composite :\n' +
|
||||
'· CRITICAL > 70 % · HIGH 45–70 %\n' +
|
||||
'· MEDIUM 25–45 % · LOW < 25 %',
|
||||
|
||||
// ── Nouveau ──────────────────────────────────────────────────────────────────
|
||||
|
||||
risk_score_inv:
|
||||
'Score de risque composite [0–100] calculé à partir de multiples sources :\n' +
|
||||
'détections ML, TCP spoofing, brute force, persistance,\n' +
|
||||
'réputation IP, géolocalisation, fingerprint JA4.',
|
||||
|
||||
browser_score:
|
||||
'Score de légitimité navigateur [0–100].\n' +
|
||||
'Basé sur la cohérence TLS/JA4, User-Agent, Client Hints.\n' +
|
||||
'100 = navigateur parfaitement légitime · 0 = outil scriptant.',
|
||||
|
||||
spoofing_score:
|
||||
'Score de spoofing [0–100] : probabilité que le UA déclaré\n' +
|
||||
'ne corresponde pas au client réel.\n' +
|
||||
'Basé sur UA/CH mismatch, SNI mismatch, JA4 rareté, rotation.',
|
||||
|
||||
ja4_rare_pct:
|
||||
'Pourcentage de requêtes utilisant un JA4 fingerprint rare.\n' +
|
||||
'Un JA4 rare (vu par < 0,1 % du trafic) peut indiquer\n' +
|
||||
'un outil custom, un bot ou un scanner.',
|
||||
|
||||
ja4_rotation:
|
||||
'Rotation JA4 : le client change fréquemment de fingerprint TLS.\n' +
|
||||
'Signal fort de bot rotatif ou d\'évasion de détection.\n' +
|
||||
'> 3 JA4 distincts par heure = rotation anormale.',
|
||||
|
||||
ua_rotation:
|
||||
'Rotation de User-Agent : le client change fréquemment d\'identité navigateur.\n' +
|
||||
'Technique utilisée par les bots pour éviter la détection.\n' +
|
||||
'> 3 UA distincts / heure = rotation suspecte.',
|
||||
|
||||
persistence:
|
||||
'Persistance : l\'IP est réapparue sur plusieurs fenêtres temporelles.\n' +
|
||||
'Une IP persistante est plus susceptible d\'être un bot opérationnel\n' +
|
||||
'qu\'une attaque ponctuelle.',
|
||||
|
||||
credential_stuffing:
|
||||
'Credential Stuffing : test automatisé de couples login/mot de passe\n' +
|
||||
'issus de bases de données volées.\n' +
|
||||
'Caractérisé par de nombreux paramètres distincts sur les mêmes endpoints.',
|
||||
|
||||
enumeration:
|
||||
'Énumération : exploration automatique de ressources ou d\'identifiants\n' +
|
||||
'(comptes, IDs, chemins). Diffère du brute force\n' +
|
||||
'car vise la découverte plutôt que l\'authentification.',
|
||||
|
||||
params_combos:
|
||||
'Nombre de combinaisons de paramètres uniques envoyées.\n' +
|
||||
'Valeur haute → outil automatisé testant des payloads variés\n' +
|
||||
'(fuzzing, credential stuffing, énumération).',
|
||||
|
||||
confiance:
|
||||
'Niveau de confiance de la détection [0–100 %].\n' +
|
||||
'Basé sur le nombre de signaux concordants.\n' +
|
||||
'> 80 % = très fiable · < 40 % = signal faible.',
|
||||
|
||||
botnet_global:
|
||||
'Botnet Global : IPs réparties dans > 10 pays distincts.\n' +
|
||||
'Caractéristique d\'un réseau de machines compromises (botnet) distribué mondialement.',
|
||||
|
||||
botnet_regional:
|
||||
'Botnet Régional : IPs concentrées dans 3–10 pays.\n' +
|
||||
'Peut indiquer un réseau de proxies régionaux ou une campagne ciblée.',
|
||||
|
||||
botnet_concentrated:
|
||||
'Botnet Concentré : IPs majoritairement dans 1–2 pays.\n' +
|
||||
'Peut être un datacenter, un VPN ou un opérateur malveillant local.',
|
||||
|
||||
hash_cluster:
|
||||
'Cluster d\'empreinte headers : groupe d\'IPs partageant\n' +
|
||||
'exactement le même profil de headers HTTP.\n' +
|
||||
'IPs dans le même cluster utilisent probablement le même outil/bot.',
|
||||
|
||||
sec_fetch_dest:
|
||||
'Sec-Fetch-Dest : destination de la requête selon le navigateur.\n' +
|
||||
'Valeurs : document, image, script, font, xhr…\n' +
|
||||
'Absent = client non-navigateur (bot, curl, outil HTTP).',
|
||||
|
||||
sec_fetch_site:
|
||||
'Sec-Fetch-Site : origine de la requête par rapport au contexte.\n' +
|
||||
'Valeurs : same-origin, cross-site, none.\n' +
|
||||
'Absent = client non-navigateur (bot, curl, outil HTTP).',
|
||||
|
||||
tendance:
|
||||
'Tendance sur les dernières 24h par rapport à la période précédente.\n' +
|
||||
'↑ +X% = augmentation du volume de détections.\n' +
|
||||
'↓ -X% = diminution.',
|
||||
|
||||
subnet_cidr:
|
||||
'Sous-réseau CIDR /24 : plage de 256 adresses IP contiguës.\n' +
|
||||
'Plusieurs IPs malveillantes dans le même /24 suggèrent\n' +
|
||||
'un datacenter, un opérateur ou un réseau compromis.',
|
||||
|
||||
total_detections_stat:
|
||||
'Nombre total d\'événements de détection enregistrés\n' +
|
||||
'par le moteur ML dans la fenêtre d\'analyse.',
|
||||
|
||||
unique_ips_stat:
|
||||
'Nombre d\'adresses IP distinctes ayant généré des détections\n' +
|
||||
'dans la fenêtre d\'analyse.',
|
||||
|
||||
ja4_distinct:
|
||||
'Nombre de fingerprints JA4 distincts utilisés par cette IP.\n' +
|
||||
'> 3 = rotation de fingerprint TLS (signal de bot évasif).',
|
||||
|
||||
baseline_ja4:
|
||||
'JA4 légitimes (baseline) : fingerprints TLS observés chez\n' +
|
||||
'des navigateurs légitimes confirmés (Chrome, Firefox, Safari…).\n' +
|
||||
'Comparez le JA4 de l\'IP avec cette baseline pour évaluer le risque.',
|
||||
|
||||
correlation_node:
|
||||
'Nœud de corrélation : entité reliée à l\'IP analysée.\n' +
|
||||
'Les connexions (arêtes) représentent des relations directes\n' +
|
||||
'(même subnet, même ASN, même JA4, même host cible).',
|
||||
|
||||
anubis_identification:
|
||||
'Identification des bots par les règles Anubis\n' +
|
||||
'(github.com/TecharoHQ/anubis) :\n\n' +
|
||||
'• User-Agent : correspondance par expression régulière\n' +
|
||||
' (ex. Googlebot, GPTBot, AhrefsBot…)\n' +
|
||||
'• IP / CIDR : plages d\'adresses connues des crawlers\n' +
|
||||
'• ASN : numéro de système autonome (ex. AS15169 = Google)\n' +
|
||||
'• Pays : code ISO du pays source\n\n' +
|
||||
'La règle la plus spécifique prend la priorité.\n\n' +
|
||||
'Actions :\n' +
|
||||
' ALLOW → bot légitime, exclu de l\'analyse ML\n' +
|
||||
' DENY → menace connue, flaggée directement\n' +
|
||||
' WEIGH → suspect, scoré par l\'IsolationForest',
|
||||
};
|
||||
39
services/dashboard/frontend/src/config.ts
Normal file
39
services/dashboard/frontend/src/config.ts
Normal file
@ -0,0 +1,39 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Configuration centrale du dashboard JA4 SOC
|
||||
// Toutes les valeurs modifiables sont regroupées ici.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const CONFIG = {
|
||||
// ── API ──────────────────────────────────────────────────────────────────
|
||||
/** URL de base de l'API backend (relative, proxifiée par Vite en dev) */
|
||||
API_BASE_URL: '/api' as const,
|
||||
|
||||
// ── Thème ─────────────────────────────────────────────────────────────────
|
||||
/** Thème appliqué au premier chargement si aucune préférence n'est sauvegardée.
|
||||
* 'auto' = suit prefers-color-scheme du navigateur */
|
||||
DEFAULT_THEME: 'auto' as 'dark' | 'light' | 'auto',
|
||||
|
||||
/** Clé localStorage pour la préférence de thème */
|
||||
THEME_STORAGE_KEY: 'soc_theme',
|
||||
|
||||
// ── Rafraîchissement ──────────────────────────────────────────────────────
|
||||
/** Intervalle de rafraîchissement automatique des métriques (ms). */
|
||||
METRICS_REFRESH_MS: 30_000,
|
||||
|
||||
// ── Anubis ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Les bots sont identifiés par les règles Anubis (https://github.com/TecharoHQ/anubis).
|
||||
* Chaque règle peut correspondre sur :
|
||||
* - User-Agent (expression régulière)
|
||||
* - Adresse IP ou plage CIDR (IP_TRIE ClickHouse)
|
||||
* - Numéro ASN (Autonomous System Number)
|
||||
* - Code pays
|
||||
* La règle la plus spécifique (ID le plus bas dans le REGEXP_TREE) est appliquée en premier.
|
||||
* Actions possibles :
|
||||
* ALLOW → bot légitime identifié (Googlebot, Bingbot…) — exclu de l'analyse IF
|
||||
* DENY → menace connue — flaggée directement, bypass IsolationForest
|
||||
* WEIGH → trafic suspect — scoré par l'IsolationForest avec signal anubis_is_flagged=1
|
||||
*/
|
||||
ANUBIS_RULES_URL: 'https://github.com/TecharoHQ/anubis/tree/main/data',
|
||||
} as const;
|
||||
|
||||
52
services/dashboard/frontend/src/hooks/useDetections.ts
Normal file
52
services/dashboard/frontend/src/hooks/useDetections.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { detectionsApi, DetectionsListResponse } from '../api/client';
|
||||
|
||||
interface UseDetectionsParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
threat_level?: string;
|
||||
model_name?: string;
|
||||
country_code?: string;
|
||||
asn_number?: string;
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
group_by_ip?: boolean;
|
||||
score_type?: string;
|
||||
}
|
||||
|
||||
export function useDetections(params: UseDetectionsParams = {}) {
|
||||
const [data, setData] = useState<DetectionsListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetections = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await detectionsApi.getDetections(params);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetections();
|
||||
}, [
|
||||
params.page,
|
||||
params.page_size,
|
||||
params.threat_level,
|
||||
params.model_name,
|
||||
params.country_code,
|
||||
params.asn_number,
|
||||
params.search,
|
||||
params.sort_by,
|
||||
params.sort_order,
|
||||
params.group_by_ip,
|
||||
params.score_type,
|
||||
]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
57
services/dashboard/frontend/src/hooks/useFetch.ts
Normal file
57
services/dashboard/frontend/src/hooks/useFetch.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface FetchState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook générique pour les appels fetch avec gestion loading/error.
|
||||
* Annule automatiquement la requête si le composant est démonté
|
||||
* ou si l'URL change avant que la réponse arrive.
|
||||
*
|
||||
* @param url URL relative ou absolue à appeler (typiquement "/api/...")
|
||||
* @param deps Dépendances supplémentaires qui déclenchent un re-fetch
|
||||
* (en plus de url). Équivalent au tableau de deps de useEffect.
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useFetch<MyType>('/api/metrics');
|
||||
*/
|
||||
export function useFetch<T>(url: string, deps: unknown[] = []): FetchState<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return res.json() as Promise<T>;
|
||||
})
|
||||
.then((json) => {
|
||||
if (!cancelled) {
|
||||
setData(json);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, ...deps]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
28
services/dashboard/frontend/src/hooks/useMetrics.ts
Normal file
28
services/dashboard/frontend/src/hooks/useMetrics.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { metricsApi, MetricsResponse } from '../api/client';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
export function useMetrics() {
|
||||
const [data, setData] = useState<MetricsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const response = await metricsApi.getMetrics();
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetrics();
|
||||
const interval = setInterval(fetchMetrics, CONFIG.METRICS_REFRESH_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
42
services/dashboard/frontend/src/hooks/useSort.ts
Normal file
42
services/dashboard/frontend/src/hooks/useSort.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
export function useSort<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
defaultKey: keyof T,
|
||||
defaultDir: SortDir = 'desc'
|
||||
): {
|
||||
sorted: T[];
|
||||
sortKey: keyof T;
|
||||
sortDir: SortDir;
|
||||
handleSort: (key: keyof T) => void;
|
||||
} {
|
||||
const [sortKey, setSortKey] = useState<keyof T>(defaultKey);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(defaultDir);
|
||||
|
||||
const handleSort = (key: keyof T) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
let cmp = 0;
|
||||
if (av == null && bv == null) cmp = 0;
|
||||
else if (av == null) cmp = 1;
|
||||
else if (bv == null) cmp = -1;
|
||||
else if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv;
|
||||
else cmp = String(av).localeCompare(String(bv));
|
||||
return sortDir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
}, [data, sortKey, sortDir]);
|
||||
|
||||
return { sorted, sortKey, sortDir, handleSort };
|
||||
}
|
||||
30
services/dashboard/frontend/src/hooks/useVariability.ts
Normal file
30
services/dashboard/frontend/src/hooks/useVariability.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { variabilityApi, VariabilityResponse } from '../api/client';
|
||||
|
||||
export function useVariability(type: string, value: string) {
|
||||
const [data, setData] = useState<VariabilityResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!type || !value) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchVariability = async () => {
|
||||
try {
|
||||
const response = await variabilityApi.getVariability(type, value);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVariability();
|
||||
}, [type, value]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
13
services/dashboard/frontend/src/main.tsx
Normal file
13
services/dashboard/frontend/src/main.tsx
Normal file
@ -0,0 +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>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
59
services/dashboard/frontend/src/styles/globals.css
Normal file
59
services/dashboard/frontend/src/styles/globals.css
Normal file
@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Dark theme (default, SOC standard) ── */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--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;
|
||||
}
|
||||
|
||||
::-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); }
|
||||
|
||||
@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; } }
|
||||
|
||||
.animate-fade-in { animation: fadeIn 0.25s ease-in-out; }
|
||||
.animate-slide-up { animation: slideUp 0.35s ease-out; }
|
||||
305
services/dashboard/frontend/src/utils/STIXExporter.ts
Normal file
305
services/dashboard/frontend/src/utils/STIXExporter.ts
Normal file
@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Export STIX 2.1 pour Threat Intelligence
|
||||
* Format standard pour l'échange d'informations de cybermenaces
|
||||
*/
|
||||
|
||||
interface STIXIndicator {
|
||||
id: string;
|
||||
type: string;
|
||||
spec_version: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pattern: string;
|
||||
pattern_type: string;
|
||||
valid_from: string;
|
||||
labels: string[];
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface STIXObservables {
|
||||
id: string;
|
||||
type: string;
|
||||
spec_version: string;
|
||||
value?: string;
|
||||
hashes?: {
|
||||
MD5?: string;
|
||||
'SHA-256'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface STIXBundle {
|
||||
type: string;
|
||||
id: string;
|
||||
objects: (STIXIndicator | STIXObservables)[];
|
||||
}
|
||||
|
||||
export class STIXExporter {
|
||||
/**
|
||||
* Génère un bundle STIX 2.1 à partir d'une liste d'IPs
|
||||
*/
|
||||
static exportIPs(ips: string[], metadata: {
|
||||
label: string;
|
||||
tags: string[];
|
||||
confidence: number;
|
||||
analyst: string;
|
||||
comment: string;
|
||||
}): STIXBundle {
|
||||
const now = new Date().toISOString();
|
||||
const objects: (STIXIndicator | STIXObservables)[] = [];
|
||||
|
||||
// Identity (organisation SOC)
|
||||
objects.push({
|
||||
id: `identity--${this.generateUUID()}`,
|
||||
type: 'identity',
|
||||
spec_version: '2.1',
|
||||
name: 'SOC Bot Detector',
|
||||
identity_class: 'system',
|
||||
created: now,
|
||||
modified: now
|
||||
} as any);
|
||||
|
||||
// Create indicators and observables for each IP
|
||||
ips.forEach((ip) => {
|
||||
const indicatorId = `indicator--${this.generateUUID()}`;
|
||||
const observableId = `ipv4-addr--${this.generateUUID()}`;
|
||||
|
||||
// STIX Indicator
|
||||
objects.push({
|
||||
id: indicatorId,
|
||||
type: 'indicator',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
name: `Malicious IP - ${ip}`,
|
||||
description: `${metadata.comment} | Tags: ${metadata.tags.join(', ')} | Analyst: ${metadata.analyst}`,
|
||||
pattern: `[ipv4-addr:value = '${ip}']`,
|
||||
pattern_type: 'stix',
|
||||
valid_from: now,
|
||||
labels: [...metadata.tags, metadata.label],
|
||||
confidence: Math.round(metadata.confidence * 100),
|
||||
created_by_ref: objects[0].id,
|
||||
object_marking_refs: [`marking-definition--${this.generateUUID()}`]
|
||||
} as STIXIndicator);
|
||||
|
||||
// STIX Observable (IPv4 Address)
|
||||
objects.push({
|
||||
id: observableId,
|
||||
type: 'ipv4-addr',
|
||||
spec_version: '2.1',
|
||||
value: ip,
|
||||
object_marking_refs: [`marking-definition--${this.generateUUID()}`]
|
||||
} as STIXObservables);
|
||||
|
||||
// Relationship between indicator and observable
|
||||
objects.push({
|
||||
id: `relationship--${this.generateUUID()}`,
|
||||
type: 'relationship',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
relationship_type: 'indicates',
|
||||
source_ref: indicatorId,
|
||||
target_ref: observableId,
|
||||
description: 'Indicator indicates malicious IP address'
|
||||
} as any);
|
||||
});
|
||||
|
||||
// Marking Definition (TLP:AMBER)
|
||||
objects.push({
|
||||
id: 'marking-definition--78ca4366-f5b8-4764-83f7-34ce38198e27',
|
||||
type: 'marking-definition',
|
||||
spec_version: '2.1',
|
||||
name: 'TLP:AMBER',
|
||||
created: '2017-01-20T00:00:00.000Z',
|
||||
definition_type: 'statement',
|
||||
definition: { statement: 'This information is TLP:AMBER' }
|
||||
} as any);
|
||||
|
||||
return {
|
||||
type: 'bundle',
|
||||
id: `bundle--${this.generateUUID()}`,
|
||||
objects
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un bundle STIX pour un incident complet
|
||||
*/
|
||||
static exportIncident(incident: {
|
||||
id: string;
|
||||
subnet: string;
|
||||
ips: string[];
|
||||
ja4?: string;
|
||||
severity: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}): STIXBundle {
|
||||
const now = new Date().toISOString();
|
||||
const objects: any[] = [];
|
||||
|
||||
// Identity
|
||||
objects.push({
|
||||
id: `identity--${this.generateUUID()}`,
|
||||
type: 'identity',
|
||||
spec_version: '2.1',
|
||||
name: 'SOC Bot Detector',
|
||||
identity_class: 'system',
|
||||
created: now,
|
||||
modified: now
|
||||
});
|
||||
|
||||
// Incident
|
||||
objects.push({
|
||||
id: `incident--${this.generateUUID()}`,
|
||||
type: 'incident',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
name: `Bot Detection Incident ${incident.id}`,
|
||||
description: incident.description,
|
||||
objective: 'Detect and classify bot activity',
|
||||
first_seen: incident.first_seen,
|
||||
last_seen: incident.last_seen,
|
||||
status: 'active',
|
||||
labels: [...incident.tags, incident.severity]
|
||||
});
|
||||
|
||||
// Campaign (for the attack pattern)
|
||||
objects.push({
|
||||
id: `campaign--${this.generateUUID()}`,
|
||||
type: 'campaign',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
name: `Bot Campaign - ${incident.subnet}`,
|
||||
description: `Automated bot activity from subnet ${incident.subnet}`,
|
||||
first_seen: incident.first_seen,
|
||||
last_seen: incident.last_seen,
|
||||
labels: incident.tags
|
||||
});
|
||||
|
||||
// Relationship: Campaign uses Attack Pattern
|
||||
objects.push({
|
||||
id: `relationship--${this.generateUUID()}`,
|
||||
type: 'relationship',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
relationship_type: 'related-to',
|
||||
source_ref: objects[objects.length - 1].id, // campaign
|
||||
target_ref: objects[objects.length - 2].id // incident
|
||||
});
|
||||
|
||||
// Add indicators for each IP
|
||||
incident.ips.slice(0, 100).forEach(ip => {
|
||||
const indicatorId = `indicator--${this.generateUUID()}`;
|
||||
|
||||
objects.push({
|
||||
id: indicatorId,
|
||||
type: 'indicator',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
name: `Malicious IP - ${ip}`,
|
||||
description: `Part of incident ${incident.id}`,
|
||||
pattern: `[ipv4-addr:value = '${ip}']`,
|
||||
pattern_type: 'stix',
|
||||
valid_from: now,
|
||||
labels: incident.tags,
|
||||
confidence: 80
|
||||
});
|
||||
|
||||
// Relationship: Incident indicates IP
|
||||
objects.push({
|
||||
id: `relationship--${this.generateUUID()}`,
|
||||
type: 'relationship',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
relationship_type: 'related-to',
|
||||
source_ref: objects[objects.length - 2].id, // incident
|
||||
target_ref: indicatorId
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'bundle',
|
||||
id: `bundle--${this.generateUUID()}`,
|
||||
objects
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Télécharge le bundle STIX
|
||||
*/
|
||||
static download(bundle: STIXBundle, filename?: string): void {
|
||||
const json = JSON.stringify(bundle, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `stix_export_${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un UUID v4
|
||||
*/
|
||||
private static generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export au format MISP (alternative à STIX)
|
||||
*/
|
||||
static exportMISP(ips: string[], metadata: any): object {
|
||||
return {
|
||||
response: {
|
||||
Event: {
|
||||
id: this.generateUUID(),
|
||||
orgc: 'SOC Bot Detector',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
threat_level_id: metadata.label === 'malicious' ? '1' :
|
||||
metadata.label === 'suspicious' ? '2' : '3',
|
||||
analysis: '2', // Completed
|
||||
info: `Bot Detection: ${metadata.comment}`,
|
||||
uuid: this.generateUUID(),
|
||||
Attribute: ips.map((ip) => ({
|
||||
type: 'ip-dst',
|
||||
category: 'Network activity',
|
||||
value: ip,
|
||||
to_ids: true,
|
||||
uuid: this.generateUUID(),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
comment: `${metadata.tags.join(', ')} | Confidence: ${metadata.confidence}`
|
||||
})),
|
||||
Tag: metadata.tags.map((tag: string) => ({
|
||||
name: tag,
|
||||
colour: this.getTagColor(tag)
|
||||
}))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static getTagColor(tag: string): string {
|
||||
// Generate consistent colors for tags
|
||||
const colors = [
|
||||
'#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4',
|
||||
'#ffeaa7', '#dfe6e9', '#fd79a8', '#a29bfe'
|
||||
];
|
||||
const hash = tag.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
}
|
||||
36
services/dashboard/frontend/src/utils/classifications.ts
Normal file
36
services/dashboard/frontend/src/utils/classifications.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Tags prédéfinis pour la classification SOC.
|
||||
*
|
||||
* Utilisé par BulkClassification, CorrelationSummary, JA4CorrelationSummary.
|
||||
* Ajouter de nouveaux tags ici pour les propager partout.
|
||||
*/
|
||||
export const PREDEFINED_TAGS: readonly string[] = [
|
||||
'scraping',
|
||||
'bot-network',
|
||||
'scanner',
|
||||
'bruteforce',
|
||||
'data-exfil',
|
||||
'ddos',
|
||||
'spam',
|
||||
'proxy',
|
||||
'tor',
|
||||
'vpn',
|
||||
'hosting-asn',
|
||||
'distributed',
|
||||
'ja4-rotation',
|
||||
'ua-rotation',
|
||||
'country-cn',
|
||||
'country-us',
|
||||
'country-ru',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tags supplémentaires spécifiques aux fingerprints JA4.
|
||||
* S'étend de PREDEFINED_TAGS.
|
||||
*/
|
||||
export const PREDEFINED_TAGS_JA4: readonly string[] = [
|
||||
...PREDEFINED_TAGS,
|
||||
'known-bot',
|
||||
'crawler',
|
||||
'search-engine',
|
||||
];
|
||||
11
services/dashboard/frontend/src/utils/countryUtils.ts
Normal file
11
services/dashboard/frontend/src/utils/countryUtils.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Convertit un code pays ISO 3166-1 alpha-2 en emoji drapeau.
|
||||
* Utilise les Regional Indicator Symbols Unicode (U+1F1E6…U+1F1FF).
|
||||
* Retourne 🌐 pour les codes invalides ou vides.
|
||||
*/
|
||||
export function getCountryFlag(code: string): string {
|
||||
if (!code || code.length !== 2) return '🌐';
|
||||
return code
|
||||
.toUpperCase()
|
||||
.replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
}
|
||||
86
services/dashboard/frontend/src/utils/dateUtils.ts
Normal file
86
services/dashboard/frontend/src/utils/dateUtils.ts
Normal file
@ -0,0 +1,86 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilitaires de formatage des dates et des nombres
|
||||
//
|
||||
// Les dates sont stockées en UTC dans ClickHouse (sans suffixe TZ).
|
||||
// Ces fonctions les convertissent dans le fuseau horaire local du navigateur
|
||||
// et utilisent la locale du navigateur pour l'affichage.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalise une chaîne datetime ClickHouse (sans TZ) en Date UTC.
|
||||
* ClickHouse retourne "2024-01-15 14:32:00" → on force Z pour UTC.
|
||||
*/
|
||||
function parseUTC(iso: string): Date {
|
||||
if (!iso) return new Date(NaN);
|
||||
// Déjà un ISO complet avec TZ → pas de modification
|
||||
if (iso.endsWith('Z') || iso.includes('+')) return new Date(iso);
|
||||
// "2024-01-15 14:32:00" ou "2024-01-15T14:32:00" → forcer UTC
|
||||
const normalized = iso.replace(' ', 'T');
|
||||
return new Date(normalized + 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date/heure complète dans la locale et le fuseau du navigateur.
|
||||
* Exemple : "15/01/2024, 15:32" (fr) ou "1/15/2024, 3:32 PM" (en-US)
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
const d = parseUTC(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(navigator.language || undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date courte (jour/mois heure:min) pour les tableaux.
|
||||
* Exemple : "15/01 15:32"
|
||||
*/
|
||||
export function formatDateShort(iso: string): string {
|
||||
const d = parseUTC(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(navigator.language || undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate uniquement la partie date.
|
||||
* Exemple : "15/01/2024"
|
||||
*/
|
||||
export function formatDateOnly(iso: string): string {
|
||||
const d = parseUTC(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(navigator.language || undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate uniquement l'heure (heure:min).
|
||||
* Exemple : "15:32"
|
||||
*/
|
||||
export function formatTimeOnly(iso: string): string {
|
||||
const d = parseUTC(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleTimeString(navigator.language || undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate un nombre entier avec séparateurs de milliers selon la locale du navigateur.
|
||||
* Exemple : 1234567 → "1 234 567" (fr) ou "1,234,567" (en-US)
|
||||
*/
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString(navigator.language || undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user