import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import DataTable, { Column } from './ui/DataTable'; // ─── 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 (
{label} {value}
); } function ErrorMessage({ message }: { message: string }) { return (
⚠️ {message}
); } // ─── Main Component ─────────────────────────────────────────────────────────── export function HeaderFingerprintView() { const navigate = useNavigate(); const [clusters, setClusters] = useState([]); const [totalClusters, setTotalClusters] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedHash, setExpandedHash] = useState(null); const [clusterIPsMap, setClusterIPsMap] = useState>({}); const [loadingHashes, setLoadingHashes] = useState>(new Set()); const [ipErrors, setIpErrors] = useState>({}); 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 handleToggleCluster = async (hash: string) => { if (expandedHash === hash) { setExpandedHash(null); return; } setExpandedHash(hash); if (clusterIPsMap[hash] !== undefined) return; setLoadingHashes((prev) => new Set(prev).add(hash)); try { const res = await fetch(`/api/headers/cluster/${hash}/ips?limit=50`); if (!res.ok) throw new Error('Erreur chargement IPs'); const data: { items: ClusterIP[] } = await res.json(); setClusterIPsMap((prev) => ({ ...prev, [hash]: data.items ?? [] })); } catch (err) { setIpErrors((prev) => ({ ...prev, [hash]: err instanceof Error ? err.message : 'Erreur inconnue' })); } finally { setLoadingHashes((prev) => { const next = new Set(prev); next.delete(hash); return next; }); } }; const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length; const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length; const clusterColumns: Column[] = [ { key: 'hash', label: 'Hash cluster', sortable: true, render: (_, row) => ( {expandedHash === row.hash ? '▾' : '▸'} {row.hash.slice(0, 16)}… ), }, { key: 'unique_ips', label: 'IPs', sortable: true, align: 'right', render: (v) => {formatNumber(v)}, }, { key: 'avg_browser_score', label: 'Browser Score', sortable: true, render: (v) => (
{Math.round(v)}
), }, { key: 'ua_ch_mismatch_pct', label: 'UA/CH Mismatch %', sortable: true, align: 'right', render: (v) => ( {Math.round(v)}% ), }, { key: 'classification', label: 'Classification', sortable: true, render: (v) => { const badge = classificationBadge(v); return ( {badge.label} ); }, }, { key: 'top_sec_fetch_modes', label: 'Sec-Fetch modes', sortable: false, render: (v) => (
{(v ?? []).slice(0, 3).map((mode: string) => ( {mode} ))}
), }, ]; const ipColumns: Column[] = [ { key: 'ip', label: 'IP', sortable: true, render: (v) => {v}, }, { key: 'browser_score', label: 'Browser Score', sortable: true, align: 'right', render: (v) => ( = 70 ? 'text-threat-low' : v >= 40 ? 'text-threat-medium' : 'text-threat-critical'}> {Math.round(v)} ), }, { key: 'ua_ch_mismatch', label: 'UA/CH Mismatch', sortable: true, render: (v) => v ? ( ⚠️ Oui ) : ( ✓ Non ), }, { key: 'sec_fetch_mode', label: 'Sec-Fetch Mode', sortable: true, render: (v) => {v || '—'}, }, { key: 'sec_fetch_dest', label: 'Sec-Fetch Dest', sortable: true, render: (v) => {v || '—'}, }, { key: 'actions', label: '', sortable: false, render: (_, row) => ( ), }, ]; return (
{/* Header */}

📡 Fingerprint HTTP Headers

Clustering par ordre et composition des headers HTTP pour identifier les bots et fingerprints suspects.

{/* Stat cards */}
{/* Clusters DataTable */}
{error ? (
) : ( data={clusters} columns={clusterColumns} rowKey="hash" defaultSortKey="unique_ips" onRowClick={(row) => handleToggleCluster(row.hash)} loading={loading} emptyMessage="Aucun cluster détecté" compact /> )}
{/* Expanded IPs panel */} {expandedHash && (
IPs du cluster{' '} {expandedHash.slice(0, 16)}…
{ipErrors[expandedHash] ? (
) : ( data={clusterIPsMap[expandedHash] ?? []} columns={ipColumns} rowKey="ip" defaultSortKey="browser_score" loading={loadingHashes.has(expandedHash)} emptyMessage="Aucune IP trouvée" compact /> )}
)} {!loading && !error && (

{formatNumber(totalClusters)} cluster(s) détecté(s)

)}
); }