BruteForce:
- Attaquants: strip ::ffff: des IPs (replaceRegexpAll dans SQL)
- Cibles: 'Voir détails' remplacé par expansion inline avec top IPs par host
+ nouveau endpoint GET /api/bruteforce/host/{host}/attackers
+ interface BruteForceTarget: top_ja4 → top_ja4s (cohérence avec API)
Header Fingerprint:
- Détail cluster: data.ips → data.items (clé API incorrecte)
Heatmap Temporelle:
- Top hosts ciblés: data.hosts → data.items (clé API incorrecte)
- Type annotation corrigé: { hosts: TopHost[] } → { items: TopHost[] }
Botnets Distribués:
- Clic sur ligne: data.countries → data.items (clé API incorrecte)
Rotation & Persistance:
- IPs rotateurs: strip ::ffff: (replaceRegexpAll dans SQL)
- IPs menaces persistantes: strip ::ffff: (replaceRegexpAll dans SQL)
- Historique JA4: data.history → data.ja4_history (clé API incorrecte)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
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: { items: TopHost[] } = await res.json();
|
||
setTopHosts(data.items ?? []);
|
||
} 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>
|
||
);
|
||
}
|