feat: ajout de 7 nouveaux dashboards d'analyse avancée
- 🔥 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>
This commit is contained in:
529
frontend/src/components/MLFeaturesView.tsx
Normal file
529
frontend/src/components/MLFeaturesView.tsx
Normal file
@ -0,0 +1,529 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user