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:
SOC Analyst
2026-03-15 23:57:27 +01:00
parent 1455e04303
commit e2bc4a47cd
16 changed files with 3499 additions and 1 deletions

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