- 🔥 Brute Force & Credential Stuffing (view_form_bruteforce_detected) - 🧬 TCP/OS Spoofing (view_tcp_spoofing_detected, 86K détections) - 📡 Header Fingerprint Clustering (agg_header_fingerprint_1h, 1374 clusters) - ⏱️ Heatmap Temporelle (agg_host_ip_ja4_1h, pic à 20h) - 🌍 Botnets Distribués / JA4 spread (view_host_ja4_anomalies) - 🔄 Rotation JA4 & Persistance (view_host_ip_ja4_rotation + view_ip_recurrence) - 🤖 Features ML / Radar (view_ai_features_1h, radar SVG + scatter plot) Backend: 7 nouveaux router FastAPI avec requêtes ClickHouse optimisées Frontend: 7 nouveaux composants React + navigation 'Analyse Avancée' dans la sidebar Fixes: alias fuzzing_index → max_fuzzing (ORDER BY ClickHouse), normalisation IPs ::ffff: Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
530 lines
19 KiB
TypeScript
530 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
// ─── 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 formatNumber(n: number): string {
|
||
return n.toLocaleString('fr-FR');
|
||
}
|
||
|
||
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 ───────────────────────────────────────────────────────────
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function ErrorMessage({ message }: { message: string }) {
|
||
return (
|
||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||
⚠️ {message}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Radar Chart (SVG octagonal) ─────────────────────────────────────────────
|
||
|
||
const RADAR_AXES = [
|
||
{ key: 'fuzzing_score', label: 'Fuzzing' },
|
||
{ key: 'velocity_score', label: 'Vélocité' },
|
||
{ key: 'fake_nav_score', label: 'Fausse nav' },
|
||
{ key: 'ua_mismatch_score', label: 'UA/CH mismatch' },
|
||
{ key: 'sni_mismatch_score', label: 'SNI mismatch' },
|
||
{ key: 'orphan_score', label: 'Orphan ratio' },
|
||
{ key: 'path_repetition_score', label: 'Répétition URL' },
|
||
{ key: 'payload_anomaly_score', label: 'Payload anormal' },
|
||
] 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 */}
|
||
{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"
|
||
>
|
||
{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)">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>
|
||
);
|
||
}
|
||
|
||
// ─── 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>
|
||
) : (
|
||
<div className="overflow-auto max-h-[500px]">
|
||
<table className="w-full text-xs">
|
||
<thead className="sticky top-0 bg-background-secondary z-10">
|
||
<tr className="border-b border-border text-text-secondary text-left">
|
||
<th className="px-3 py-2">IP</th>
|
||
<th className="px-3 py-2">Host</th>
|
||
<th className="px-3 py-2">Hits</th>
|
||
<th className="px-3 py-2">Fuzzing</th>
|
||
<th className="px-3 py-2">Type</th>
|
||
<th className="px-3 py-2">Signaux</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{anomalies.map((item) => (
|
||
<tr
|
||
key={item.ip}
|
||
onClick={() => loadRadar(item.ip)}
|
||
className={`border-b border-border cursor-pointer transition-colors ${
|
||
selectedIP === item.ip
|
||
? 'bg-accent-primary/10'
|
||
: 'hover:bg-background-card'
|
||
}`}
|
||
>
|
||
<td className="px-3 py-2 font-mono text-text-primary">{item.ip}</td>
|
||
<td className="px-3 py-2 text-text-secondary max-w-[120px] truncate" title={item.host}>
|
||
{item.host || '—'}
|
||
</td>
|
||
<td className="px-3 py-2 text-text-primary">{formatNumber(item.hits)}</td>
|
||
<td className="px-3 py-2">
|
||
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(item.fuzzing_index)}`}>
|
||
{Math.round(item.fuzzing_index)}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<span title={item.attack_type} className="text-sm">
|
||
{attackTypeEmoji(item.attack_type)}
|
||
</span>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<span className="flex gap-0.5">
|
||
{item.ua_ch_mismatch && <span title="UA/CH mismatch">⚠️</span>}
|
||
{item.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
|
||
{item.is_ua_rotating && <span title="UA rotatif">🔄</span>}
|
||
{item.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|