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,320 @@
import { useState, useEffect } from 'react';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HourlyEntry {
hour: number;
hits: number;
unique_ips: number;
max_rps: number;
}
interface TopHost {
host: string;
total_hits: number;
unique_ips: number;
unique_ja4s: number;
hourly_hits: number[];
}
interface HeatmapMatrix {
hosts: string[];
matrix: number[][];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string {
return n.toLocaleString('fr-FR');
}
function heatmapCellStyle(value: number, maxValue: number): React.CSSProperties {
if (maxValue === 0 || value === 0) return { backgroundColor: 'transparent' };
const ratio = value / maxValue;
if (ratio >= 0.75) return { backgroundColor: 'rgba(239, 68, 68, 0.85)' };
if (ratio >= 0.5) return { backgroundColor: 'rgba(168, 85, 247, 0.7)' };
if (ratio >= 0.25) return { backgroundColor: 'rgba(59, 130, 246, 0.6)' };
if (ratio >= 0.05) return { backgroundColor: 'rgba(96, 165, 250, 0.35)' };
return { backgroundColor: 'rgba(147, 197, 253, 0.15)' };
}
// ─── 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>
);
}
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>
);
}
// Mini sparkline for a host row
function Sparkline({ data }: { data: number[] }) {
const max = Math.max(...data, 1);
return (
<div className="flex items-end gap-px" style={{ height: '24px', width: '96px' }}>
{data.map((v, i) => {
const pct = (v / max) * 100;
return (
<div
key={i}
style={{ height: `${Math.max(pct, 5)}%`, width: '4px' }}
className={`rounded-sm ${pct >= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/50'}`}
/>
);
})}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HeatmapView() {
const [hourly, setHourly] = useState<HourlyEntry[]>([]);
const [hourlyLoading, setHourlyLoading] = useState(true);
const [hourlyError, setHourlyError] = useState<string | null>(null);
const [topHosts, setTopHosts] = useState<TopHost[]>([]);
const [topHostsLoading, setTopHostsLoading] = useState(true);
const [topHostsError, setTopHostsError] = useState<string | null>(null);
const [matrixData, setMatrixData] = useState<HeatmapMatrix | null>(null);
const [matrixLoading, setMatrixLoading] = useState(true);
const [matrixError, setMatrixError] = useState<string | null>(null);
useEffect(() => {
const fetchHourly = async () => {
try {
const res = await fetch('/api/heatmap/hourly');
if (!res.ok) throw new Error('Erreur chargement courbe horaire');
const data: { hours: HourlyEntry[] } = await res.json();
setHourly(data.hours ?? []);
} catch (err) {
setHourlyError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setHourlyLoading(false);
}
};
const fetchTopHosts = async () => {
try {
const res = await fetch('/api/heatmap/top-hosts?limit=20');
if (!res.ok) throw new Error('Erreur chargement top hosts');
const data: { hosts: TopHost[] } = await res.json();
setTopHosts(data.hosts ?? []);
} catch (err) {
setTopHostsError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setTopHostsLoading(false);
}
};
const fetchMatrix = async () => {
try {
const res = await fetch('/api/heatmap/matrix');
if (!res.ok) throw new Error('Erreur chargement heatmap matrix');
const data: HeatmapMatrix = await res.json();
setMatrixData(data);
} catch (err) {
setMatrixError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setMatrixLoading(false);
}
};
fetchHourly();
fetchTopHosts();
fetchMatrix();
}, []);
const peakHour = hourly.reduce((best, h) => (h.hits > best.hits ? h : best), { hour: 0, hits: 0, unique_ips: 0, max_rps: 0 });
const totalHits = hourly.reduce((s, h) => s + h.hits, 0);
const maxHits = hourly.length > 0 ? Math.max(...hourly.map((h) => h.hits)) : 1;
const matrixMax = matrixData
? Math.max(...matrixData.matrix.flatMap((row) => row), 1)
: 1;
const displayHosts = matrixData ? matrixData.hosts.slice(0, 15) : [];
return (
<div className="p-6 space-y-8 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary"> Heatmap Temporelle d'Attaques</h1>
<p className="text-text-secondary mt-1">
Distribution horaire de l'activité malveillante par host cible.
</p>
</div>
{/* Stat cards */}
<div className="grid grid-cols-3 gap-4">
<StatCard
label="Heure de pic"
value={peakHour.hits > 0 ? `${peakHour.hour}h (${formatNumber(peakHour.hits)} hits)` : '—'}
accent="text-threat-critical"
/>
<StatCard label="Total hits (24h)" value={formatNumber(totalHits)} accent="text-text-primary" />
<StatCard label="Hosts ciblés" value={formatNumber(matrixData?.hosts.length ?? 0)} accent="text-threat-high" />
</div>
{/* Section 1: Courbe horaire */}
<div className="bg-background-secondary rounded-lg border border-border p-6">
<h2 className="text-text-primary font-semibold mb-4">Activité horaire 24h</h2>
{hourlyLoading ? (
<LoadingSpinner />
) : hourlyError ? (
<ErrorMessage message={hourlyError} />
) : (
<>
<div className="flex items-end gap-1" style={{ height: '160px' }}>
{Array.from({ length: 24 }, (_, i) => {
const entry = hourly.find((h) => h.hour === i) ?? { hour: i, hits: 0, unique_ips: 0, max_rps: 0 };
const pct = maxHits > 0 ? (entry.hits / maxHits) * 100 : 0;
return (
<div key={i} className="flex flex-col items-center flex-1 gap-1 h-full">
<div className="w-full flex flex-col justify-end flex-1">
<div
title={`${i}h: ${entry.hits} hits`}
style={{ height: `${Math.max(pct, 1)}%` }}
className={`w-full rounded-t transition-all ${
pct >= 70 ? 'bg-threat-critical' : 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-3 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" /> É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>
{/* Section 2: Heatmap matrix */}
<div className="bg-background-secondary rounded-lg border border-border p-6">
<h2 className="text-text-primary font-semibold mb-4">Heatmap Host × Heure</h2>
{matrixLoading ? (
<LoadingSpinner />
) : matrixError ? (
<ErrorMessage message={matrixError} />
) : !matrixData || displayHosts.length === 0 ? (
<p className="text-text-secondary text-sm">Aucune donnée disponible.</p>
) : (
<div className="overflow-auto">
<div className="inline-block min-w-full">
{/* Hour headers */}
<div className="flex" style={{ marginLeft: '180px' }}>
{Array.from({ length: 24 }, (_, i) => (
<div key={i} className="text-center text-xs text-text-disabled" style={{ width: '28px', minWidth: '28px' }}>
{i}
</div>
))}
</div>
{/* Rows */}
{displayHosts.map((host, rowIdx) => {
const rowData = matrixData.matrix[rowIdx] ?? Array(24).fill(0);
return (
<div key={host} className="flex items-center" style={{ height: '24px', marginBottom: '2px' }}>
<div className="text-xs text-text-secondary truncate text-right pr-2" style={{ width: '180px', minWidth: '180px' }} title={host}>
{host}
</div>
{Array.from({ length: 24 }, (_, h) => {
const val = rowData[h] ?? 0;
return (
<div
key={h}
title={`${host} ${h}h: ${val} hits`}
style={{
width: '26px',
minWidth: '26px',
height: '20px',
marginRight: '2px',
borderRadius: '2px',
...heatmapCellStyle(val, matrixMax),
}}
/>
);
})}
</div>
);
})}
</div>
<div className="flex gap-3 mt-4 text-xs text-text-secondary items-center">
<span>Intensité :</span>
{[
{ label: 'Nul', style: { backgroundColor: 'transparent', border: '1px solid rgba(255,255,255,0.1)' } },
{ label: 'Très faible', style: { backgroundColor: 'rgba(147, 197, 253, 0.15)' } },
{ label: 'Faible', style: { backgroundColor: 'rgba(96, 165, 250, 0.35)' } },
{ label: 'Moyen', style: { backgroundColor: 'rgba(59, 130, 246, 0.6)' } },
{ label: 'Élevé', style: { backgroundColor: 'rgba(168, 85, 247, 0.7)' } },
{ label: 'Critique', style: { backgroundColor: 'rgba(239, 68, 68, 0.85)' } },
].map(({ label, style }) => (
<span key={label} className="flex items-center gap-1">
<span style={{ ...style, display: 'inline-block', width: '14px', height: '14px', borderRadius: '2px' }} />
{label}
</span>
))}
</div>
</div>
)}
</div>
{/* Section 3: Top hosts table */}
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-text-primary font-semibold">Top Hosts ciblés</h2>
</div>
{topHostsLoading ? (
<LoadingSpinner />
) : topHostsError ? (
<div className="p-4"><ErrorMessage message={topHostsError} /></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</th>
<th className="px-4 py-3">Total hits</th>
<th className="px-4 py-3">IPs uniques</th>
<th className="px-4 py-3">JA4 uniques</th>
<th className="px-4 py-3">Activité 24h</th>
</tr>
</thead>
<tbody>
{topHosts.map((h) => (
<tr key={h.host} className="border-b border-border hover:bg-background-card transition-colors">
<td className="px-4 py-3 font-mono text-xs text-text-primary">{h.host}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(h.total_hits)}</td>
<td className="px-4 py-3 text-text-secondary">{formatNumber(h.unique_ips)}</td>
<td className="px-4 py-3 text-text-secondary">{formatNumber(h.unique_ja4s)}</td>
<td className="px-4 py-3">
<Sparkline data={h.hourly_hits ?? Array(24).fill(0)} />
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}