Files
dashboard/frontend/src/components/MLFeaturesView.tsx
SOC Analyst e2bc4a47cd 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>
2026-03-15 23:57:27 +01:00

530 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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