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:
302
frontend/src/components/HeaderFingerprintView.tsx
Normal file
302
frontend/src/components/HeaderFingerprintView.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HeaderCluster {
|
||||
hash: string;
|
||||
unique_ips: number;
|
||||
avg_browser_score: number;
|
||||
ua_ch_mismatch_count: number;
|
||||
ua_ch_mismatch_pct: number;
|
||||
top_sec_fetch_modes: string[];
|
||||
has_cookie_pct: number;
|
||||
has_referer_pct: number;
|
||||
classification: string;
|
||||
}
|
||||
|
||||
interface ClusterIP {
|
||||
ip: string;
|
||||
browser_score: number;
|
||||
ua_ch_mismatch: boolean;
|
||||
sec_fetch_mode: string;
|
||||
sec_fetch_dest: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('fr-FR');
|
||||
}
|
||||
|
||||
function mismatchColor(pct: number): string {
|
||||
if (pct > 50) return 'text-threat-critical';
|
||||
if (pct > 10) return 'text-threat-medium';
|
||||
return 'text-threat-low';
|
||||
}
|
||||
|
||||
function browserScoreColor(score: number): string {
|
||||
if (score >= 70) return 'bg-threat-low';
|
||||
if (score >= 40) return 'bg-threat-medium';
|
||||
return 'bg-threat-critical';
|
||||
}
|
||||
|
||||
function classificationBadge(cls: string): { bg: string; text: string; label: string } {
|
||||
switch (cls) {
|
||||
case 'bot':
|
||||
return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🤖 Bot' };
|
||||
case 'suspicious':
|
||||
return { bg: 'bg-threat-high/20', text: 'text-threat-high', label: '⚠️ Suspect' };
|
||||
case 'legitimate':
|
||||
return { bg: 'bg-threat-low/20', text: 'text-threat-low', label: '✅ Légitime' };
|
||||
default:
|
||||
return { bg: 'bg-background-card', text: 'text-text-secondary', label: cls };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cluster row with expandable IPs ─────────────────────────────────────────
|
||||
|
||||
function ClusterRow({
|
||||
cluster,
|
||||
onInvestigateIP,
|
||||
}: {
|
||||
cluster: HeaderCluster;
|
||||
onInvestigateIP: (ip: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [clusterIPs, setClusterIPs] = useState<ClusterIP[]>([]);
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsError, setIpsError] = useState<string | null>(null);
|
||||
const [ipsLoaded, setIpsLoaded] = useState(false);
|
||||
|
||||
const toggle = async () => {
|
||||
setExpanded((prev) => !prev);
|
||||
if (!ipsLoaded && !expanded) {
|
||||
setIpsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/headers/cluster/${cluster.hash}/ips?limit=50`);
|
||||
if (!res.ok) throw new Error('Erreur chargement IPs');
|
||||
const data: { ips: ClusterIP[] } = await res.json();
|
||||
setClusterIPs(data.ips ?? []);
|
||||
setIpsLoaded(true);
|
||||
} catch (err) {
|
||||
setIpsError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setIpsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const badge = classificationBadge(cluster.classification);
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b border-border hover:bg-background-card transition-colors cursor-pointer"
|
||||
onClick={toggle}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-accent-primary text-xs mr-2">{expanded ? '▾' : '▸'}</span>
|
||||
<span className="font-mono text-xs text-text-primary">{cluster.hash.slice(0, 16)}…</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(cluster.unique_ips)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 bg-background-card rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${browserScoreColor(cluster.avg_browser_score)}`}
|
||||
style={{ width: `${cluster.avg_browser_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{Math.round(cluster.avg_browser_score)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-4 py-3 font-semibold text-sm ${mismatchColor(cluster.ua_ch_mismatch_pct)}`}>
|
||||
{Math.round(cluster.ua_ch_mismatch_pct)}%
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(cluster.top_sec_fetch_modes ?? []).slice(0, 3).map((mode) => (
|
||||
<span key={mode} className="text-xs bg-background-card border border-border px-1.5 py-0.5 rounded text-text-secondary">
|
||||
{mode}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border bg-background-card">
|
||||
<td colSpan={6} className="px-6 py-4">
|
||||
{ipsLoading ? (
|
||||
<div className="flex items-center gap-2 text-text-secondary text-sm">
|
||||
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
Chargement des IPs…
|
||||
</div>
|
||||
) : ipsError ? (
|
||||
<span className="text-threat-critical text-sm">⚠️ {ipsError}</span>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-64">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-text-secondary">
|
||||
<th className="text-left py-1 pr-4">IP</th>
|
||||
<th className="text-left py-1 pr-4">Browser Score</th>
|
||||
<th className="text-left py-1 pr-4">UA/CH Mismatch</th>
|
||||
<th className="text-left py-1 pr-4">Sec-Fetch Mode</th>
|
||||
<th className="text-left py-1 pr-4">Sec-Fetch Dest</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clusterIPs.map((ip) => (
|
||||
<tr key={ip.ip} className="border-t border-border/50">
|
||||
<td className="py-1.5 pr-4 font-mono text-text-primary">{ip.ip}</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
<span className={ip.browser_score >= 70 ? 'text-threat-low' : ip.browser_score >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
|
||||
{Math.round(ip.browser_score)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
{ip.ua_ch_mismatch ? (
|
||||
<span className="text-threat-critical">⚠️ Oui</span>
|
||||
) : (
|
||||
<span className="text-threat-low">✓ Non</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-4 text-text-secondary">{ip.sec_fetch_mode || '—'}</td>
|
||||
<td className="py-1.5 pr-4 text-text-secondary">{ip.sec_fetch_dest || '—'}</td>
|
||||
<td className="py-1.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onInvestigateIP(ip.ip); }}
|
||||
className="bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HeaderFingerprintView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [clusters, setClusters] = useState<HeaderCluster[]>([]);
|
||||
const [totalClusters, setTotalClusters] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchClusters = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/headers/clusters?limit=50');
|
||||
if (!res.ok) throw new Error('Erreur chargement des clusters');
|
||||
const data: { clusters: HeaderCluster[]; total_clusters: number } = await res.json();
|
||||
setClusters(data.clusters ?? []);
|
||||
setTotalClusters(data.total_clusters ?? 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchClusters();
|
||||
}, []);
|
||||
|
||||
const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length;
|
||||
const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">📡 Fingerprint HTTP Headers</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Clustering par ordre et composition des headers HTTP pour identifier les bots et fingerprints suspects.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard label="Total clusters" value={formatNumber(totalClusters)} accent="text-text-primary" />
|
||||
<StatCard label="Clusters suspects (UA/CH >50%)" value={formatNumber(suspiciousClusters)} accent="text-threat-critical" />
|
||||
<StatCard label="Clusters légitimes" value={formatNumber(legitimateClusters)} accent="text-threat-low" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : error ? (
|
||||
<div className="p-4"><ErrorMessage message={error} /></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">Hash cluster</th>
|
||||
<th className="px-4 py-3">IPs</th>
|
||||
<th className="px-4 py-3">Browser Score</th>
|
||||
<th className="px-4 py-3">UA/CH Mismatch %</th>
|
||||
<th className="px-4 py-3">Classification</th>
|
||||
<th className="px-4 py-3">Sec-Fetch modes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clusters.map((cluster) => (
|
||||
<ClusterRow
|
||||
key={cluster.hash}
|
||||
cluster={cluster}
|
||||
onInvestigateIP={(ip) => navigate(`/investigation/${ip}`)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{!loading && !error && (
|
||||
<p className="text-text-secondary text-xs">{formatNumber(totalClusters)} cluster(s) détecté(s)</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user