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:
toto
2026-04-07 16:42:59 +02:00
commit d469e39da7
278 changed files with 1621301 additions and 0 deletions

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bot Detector Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,33 @@
{
"name": "bot-detector-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"axios": "^1.6.0",
"recharts": "^2.10.0",
"@tanstack/react-table": "^8.11.0",
"date-fns": "^3.0.0",
"reactflow": "^11.10.0",
"@deck.gl/react": "^9.0.0",
"@deck.gl/core": "^9.0.0",
"@deck.gl/layers": "^9.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@ -0,0 +1,376 @@
import { useEffect, useState, useRef } from 'react';
import { format, parseISO } from 'date-fns';
import { fr } from 'date-fns/locale';
interface TimelineEvent {
timestamp: string;
type: 'detection' | 'escalation' | 'peak' | 'stabilization' | 'classification';
severity?: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
count?: number;
description?: string;
ip?: string;
ja4?: string;
}
interface InteractiveTimelineProps {
ip?: string;
events?: TimelineEvent[];
hours?: number;
height?: string;
}
export function InteractiveTimeline({
ip,
events: propEvents,
hours = 24,
height = '300px'
}: InteractiveTimelineProps) {
const [events, setEvents] = useState<TimelineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [zoom, setZoom] = useState(1);
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchTimelineData = async () => {
setLoading(true);
try {
if (ip) {
// Fetch detections for this IP to build timeline
const response = await fetch(`/api/detections?search=${encodeURIComponent(ip)}&page_size=100&sort_by=detected_at&sort_order=asc`);
if (response.ok) {
const data = await response.json();
const timelineEvents = buildTimelineFromDetections(data.items);
setEvents(timelineEvents);
}
} else if (propEvents) {
setEvents(propEvents);
}
} catch (error) {
console.error('Error fetching timeline data:', error);
} finally {
setLoading(false);
}
};
fetchTimelineData();
}, [ip, propEvents]);
const buildTimelineFromDetections = (detections: any[]): TimelineEvent[] => {
if (!detections || detections.length === 0) return [];
const events: TimelineEvent[] = [];
// First detection
events.push({
timestamp: detections[0]?.detected_at,
type: 'detection',
severity: detections[0]?.threat_level,
count: 1,
description: 'Première détection',
ip: detections[0]?.src_ip,
});
// Group by time windows (5 minutes)
const timeWindows = new Map<string, any[]>();
detections.forEach((d: any) => {
const window = format(parseISO(d.detected_at), 'yyyy-MM-dd HH:mm', { locale: fr });
if (!timeWindows.has(window)) {
timeWindows.set(window, []);
}
timeWindows.get(window)!.push(d);
});
// Find peaks
let maxCount = 0;
timeWindows.forEach((items) => {
if (items.length > maxCount) {
maxCount = items.length;
}
if (items.length >= 10) {
events.push({
timestamp: items[0]?.detected_at,
type: 'peak',
severity: items[0]?.threat_level,
count: items.length,
description: `Pic d'activité: ${items.length} détections`,
});
}
});
// Escalation detection
const sortedWindows = Array.from(timeWindows.entries()).sort((a, b) =>
new Date(a[0]).getTime() - new Date(b[0]).getTime()
);
for (let i = 1; i < sortedWindows.length; i++) {
const prevCount = sortedWindows[i - 1][1].length;
const currCount = sortedWindows[i][1].length;
if (currCount > prevCount * 2 && currCount >= 5) {
events.push({
timestamp: sortedWindows[i][1][0]?.detected_at,
type: 'escalation',
severity: 'HIGH',
count: currCount,
description: `Escalade: ${prevCount}${currCount} détections`,
});
}
}
// Last detection
if (detections.length > 1) {
events.push({
timestamp: detections[detections.length - 1]?.detected_at,
type: 'detection',
severity: detections[detections.length - 1]?.threat_level,
count: detections.length,
description: 'Dernière détection',
});
}
// Sort by timestamp
return events.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
};
const getEventTypeColor = (type: string) => {
switch (type) {
case 'detection': return 'bg-blue-500';
case 'escalation': return 'bg-orange-500';
case 'peak': return 'bg-red-500';
case 'stabilization': return 'bg-green-500';
case 'classification': return 'bg-purple-500';
default: return 'bg-gray-500';
}
};
const getEventTypeIcon = (type: string) => {
switch (type) {
case 'detection': return '🔍';
case 'escalation': return '📈';
case 'peak': return '🔥';
case 'stabilization': return '📉';
case 'classification': return '🏷️';
default: return '📍';
}
};
const getSeverityColor = (severity?: string) => {
switch (severity) {
case 'CRITICAL': return 'text-red-500';
case 'HIGH': return 'text-orange-500';
case 'MEDIUM': return 'text-yellow-500';
case 'LOW': return 'text-green-500';
default: return 'text-gray-400';
}
};
const visibleEvents = events.slice(
Math.max(0, Math.floor((events.length * (1 - zoom)) / 2)),
Math.min(events.length, Math.ceil(events.length * (1 + zoom) / 2))
);
if (loading) {
return (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-text-secondary">Chargement de la timeline...</div>
</div>
);
}
if (events.length === 0) {
return (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-text-secondary text-center">
<div className="text-4xl mb-2">📭</div>
<div className="text-sm">Aucun événement dans cette période</div>
</div>
</div>
);
}
return (
<div ref={containerRef} className="w-full" style={{ height }}>
{/* Controls */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-text-secondary">
{events.length} événements sur {hours}h
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
Zoom
</button>
<button
onClick={() => setZoom(1)}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
100%
</button>
<button
onClick={() => setZoom(Math.min(2, zoom + 0.25))}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
+ Zoom
</button>
</div>
</div>
{/* Timeline */}
<div className="relative overflow-x-auto">
<div className="min-w-full">
{/* Time axis */}
<div className="flex justify-between mb-4 text-xs text-text-secondary">
{events.length > 0 && (
<>
<span>{format(parseISO(events[0].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
<span>{format(parseISO(events[events.length - 1].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
</>
)}
</div>
{/* Events line */}
<div className="relative h-24 border-t-2 border-background-card">
{visibleEvents.map((event, idx) => {
const position = (idx / (visibleEvents.length - 1 || 1)) * 100;
return (
<button
key={idx}
onClick={() => setSelectedEvent(event)}
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
style={{ left: `${position}%` }}
>
<div className={`w-4 h-4 rounded-full ${getEventTypeColor(event.type)} border-2 border-background-secondary shadow-lg group-hover:scale-150 transition-transform`}>
<div className="text-xs text-center leading-3">
{getEventTypeIcon(event.type)}
</div>
</div>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block">
<div className="bg-background-secondary border border-background-card rounded-lg p-2 shadow-xl whitespace-nowrap z-10">
<div className="text-xs text-text-primary font-bold">
{event.description}
</div>
<div className="text-xs text-text-secondary mt-1">
{format(parseISO(event.timestamp), 'dd/MM HH:mm:ss', { locale: fr })}
</div>
{event.count && (
<div className="text-xs text-text-secondary mt-1">
{event.count} détections
</div>
)}
</div>
</div>
</button>
);
})}
</div>
{/* Event cards */}
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-64 overflow-y-auto">
{visibleEvents.slice(0, 12).map((event, idx) => (
<div
key={idx}
onClick={() => setSelectedEvent(event)}
className={`bg-background-card rounded-lg p-3 cursor-pointer hover:bg-background-card/80 transition-colors border-l-4 ${
event.severity === 'CRITICAL' ? 'border-threat-critical' :
event.severity === 'HIGH' ? 'border-threat-high' :
event.severity === 'MEDIUM' ? 'border-threat-medium' :
'border-threat-low'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{getEventTypeIcon(event.type)}</span>
<div>
<div className="text-sm text-text-primary font-medium">
{event.description}
</div>
<div className="text-xs text-text-secondary mt-1">
{format(parseISO(event.timestamp), 'dd/MM HH:mm', { locale: fr })}
</div>
</div>
</div>
{event.count && (
<div className="text-xs text-text-primary font-bold bg-background-secondary px-2 py-1 rounded">
{event.count}
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Selected Event Modal */}
{selectedEvent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setSelectedEvent(null)}>
<div className="bg-background-secondary rounded-lg p-6 max-w-md mx-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">{getEventTypeIcon(selectedEvent.type)}</span>
<h3 className="text-lg font-bold text-text-primary">Détails de l'événement</h3>
</div>
<button onClick={() => setSelectedEvent(null)} className="text-text-secondary hover:text-text-primary">
</button>
</div>
<div className="space-y-3">
<div>
<div className="text-xs text-text-secondary">Type</div>
<div className="text-text-primary capitalize">{selectedEvent.type}</div>
</div>
<div>
<div className="text-xs text-text-secondary">Timestamp</div>
<div className="text-text-primary font-mono">
{format(parseISO(selectedEvent.timestamp), 'dd/MM/yyyy HH:mm:ss', { locale: fr })}
</div>
</div>
{selectedEvent.description && (
<div>
<div className="text-xs text-text-secondary">Description</div>
<div className="text-text-primary">{selectedEvent.description}</div>
</div>
)}
{selectedEvent.count && (
<div>
<div className="text-xs text-text-secondary">Nombre de détections</div>
<div className="text-text-primary font-bold">{selectedEvent.count}</div>
</div>
)}
{selectedEvent.severity && (
<div>
<div className="text-xs text-text-secondary">Sévérité</div>
<div className={`font-bold ${getSeverityColor(selectedEvent.severity)}`}>
{selectedEvent.severity}
</div>
</div>
)}
{selectedEvent.ip && (
<div>
<div className="text-xs text-text-secondary">IP</div>
<div className="text-text-primary font-mono text-sm">{selectedEvent.ip}</div>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setSelectedEvent(null)}
className="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-accent-primary/80"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

View 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 4570 %\nActivité suspecte. Investigation recommandée.',
risk_medium:
'MEDIUM — Score 2545 %\nComportement anormal. Surveillance renforcée.',
risk_low:
'LOW — Score < 25 %\nTrafic probablement légitime.',
// ── Sidebar cluster ─────────────────────────────────────────────────────────
risk_score:
'Score composite [0100 %] 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 ≈ 13801420 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 ≈ 13801420 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 [0100 %].\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 ≈ 1015 headers.\n' +
'Bot HTTP basique ≈ 25 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 4570 %\n' +
'· MEDIUM 2545 % · LOW < 25 %',
// ── Nouveau ──────────────────────────────────────────────────────────────────
risk_score_inv:
'Score de risque composite [0100] 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 [0100].\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 [0100] : 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 [0100 %].\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 310 pays.\n' +
'Peut indiquer un réseau de proxies régionaux ou une campagne ciblée.',
botnet_concentrated:
'Botnet Concentré : IPs majoritairement dans 12 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',
};

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

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

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

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

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

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

View 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>,
)

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

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

View 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',
];

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

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

View File

@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// Backgrounds — CSS-variable driven for dark/light theming
background: {
DEFAULT: 'rgb(var(--color-bg) / <alpha-value>)',
secondary: 'rgb(var(--color-bg-secondary) / <alpha-value>)',
card: 'rgb(var(--color-bg-card) / <alpha-value>)',
},
text: {
primary: 'rgb(var(--color-text-primary) / <alpha-value>)',
secondary: 'rgb(var(--color-text-secondary)/ <alpha-value>)',
disabled: 'rgb(var(--color-text-disabled) / <alpha-value>)',
},
// Threat levels — vivid, same in both themes
threat: {
critical: '#EF4444',
critical_bg: '#7F1D1D',
high: '#F97316',
high_bg: '#7C2D12',
medium: '#EAB308',
medium_bg: '#713F12',
low: '#22C55E',
low_bg: '#14532D',
},
// Accents
accent: {
primary: '#3B82F6',
success: '#10B981',
},
},
},
},
plugins: [],
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/',
build: {
outDir: 'dist',
sourcemap: false
},
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})