333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
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 (
|
||
<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 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>
|
||
);
|
||
}
|
||
|
||
// ─── 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);
|
||
|
||
const [expandedHash, setExpandedHash] = useState<string | null>(null);
|
||
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
|
||
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
|
||
const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
|
||
|
||
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<HeaderCluster>[] = [
|
||
{
|
||
key: 'hash',
|
||
label: 'Hash cluster',
|
||
sortable: true,
|
||
render: (_, row) => (
|
||
<span>
|
||
<span className="text-accent-primary text-xs mr-2">{expandedHash === row.hash ? '▾' : '▸'}</span>
|
||
<span className="font-mono text-xs text-text-primary">{row.hash.slice(0, 16)}…</span>
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'unique_ips',
|
||
label: 'IPs',
|
||
sortable: true,
|
||
align: 'right',
|
||
render: (v) => <span className="text-text-primary">{formatNumber(v)}</span>,
|
||
},
|
||
{
|
||
key: 'avg_browser_score',
|
||
label: 'Browser Score',
|
||
sortable: true,
|
||
render: (v) => (
|
||
<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(v)}`} style={{ width: `${v}%` }} />
|
||
</div>
|
||
<span className="text-xs text-text-secondary">{Math.round(v)}</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'ua_ch_mismatch_pct',
|
||
label: 'UA/CH Mismatch %',
|
||
sortable: true,
|
||
align: 'right',
|
||
render: (v) => (
|
||
<span className={`font-semibold text-sm ${mismatchColor(v)}`}>{Math.round(v)}%</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'classification',
|
||
label: 'Classification',
|
||
sortable: true,
|
||
render: (v) => {
|
||
const badge = classificationBadge(v);
|
||
return (
|
||
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
key: 'top_sec_fetch_modes',
|
||
label: 'Sec-Fetch modes',
|
||
sortable: false,
|
||
render: (v) => (
|
||
<div className="flex flex-wrap gap-1">
|
||
{(v ?? []).slice(0, 3).map((mode: string) => (
|
||
<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>
|
||
),
|
||
},
|
||
];
|
||
|
||
const ipColumns: Column<ClusterIP>[] = [
|
||
{
|
||
key: 'ip',
|
||
label: 'IP',
|
||
sortable: true,
|
||
render: (v) => <span className="font-mono text-text-primary">{v}</span>,
|
||
},
|
||
{
|
||
key: 'browser_score',
|
||
label: 'Browser Score',
|
||
sortable: true,
|
||
align: 'right',
|
||
render: (v) => (
|
||
<span className={v >= 70 ? 'text-threat-low' : v >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
|
||
{Math.round(v)}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'ua_ch_mismatch',
|
||
label: 'UA/CH Mismatch',
|
||
sortable: true,
|
||
render: (v) =>
|
||
v ? (
|
||
<span className="text-threat-critical">⚠️ Oui</span>
|
||
) : (
|
||
<span className="text-threat-low">✓ Non</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'sec_fetch_mode',
|
||
label: 'Sec-Fetch Mode',
|
||
sortable: true,
|
||
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
|
||
},
|
||
{
|
||
key: 'sec_fetch_dest',
|
||
label: 'Sec-Fetch Dest',
|
||
sortable: true,
|
||
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
|
||
},
|
||
{
|
||
key: 'actions',
|
||
label: '',
|
||
sortable: false,
|
||
render: (_, row) => (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
|
||
className="bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded hover:bg-accent-primary/20 transition-colors text-xs"
|
||
>
|
||
Investiguer
|
||
</button>
|
||
),
|
||
},
|
||
];
|
||
|
||
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>
|
||
|
||
{/* Clusters DataTable */}
|
||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||
{error ? (
|
||
<div className="p-4"><ErrorMessage message={error} /></div>
|
||
) : (
|
||
<DataTable<HeaderCluster>
|
||
data={clusters}
|
||
columns={clusterColumns}
|
||
rowKey="hash"
|
||
defaultSortKey="unique_ips"
|
||
onRowClick={(row) => handleToggleCluster(row.hash)}
|
||
loading={loading}
|
||
emptyMessage="Aucun cluster détecté"
|
||
compact
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Expanded IPs panel */}
|
||
{expandedHash && (
|
||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
||
<span className="text-sm font-semibold text-text-primary">
|
||
IPs du cluster{' '}
|
||
<span className="font-mono text-xs text-accent-primary">{expandedHash.slice(0, 16)}…</span>
|
||
</span>
|
||
<button
|
||
onClick={() => setExpandedHash(null)}
|
||
className="text-text-secondary hover:text-text-primary text-xs"
|
||
>
|
||
✕ Fermer
|
||
</button>
|
||
</div>
|
||
{ipErrors[expandedHash] ? (
|
||
<div className="p-4"><ErrorMessage message={ipErrors[expandedHash]} /></div>
|
||
) : (
|
||
<DataTable<ClusterIP>
|
||
data={clusterIPsMap[expandedHash] ?? []}
|
||
columns={ipColumns}
|
||
rowKey="ip"
|
||
defaultSortKey="browser_score"
|
||
loading={loadingHashes.has(expandedHash)}
|
||
emptyMessage="Aucune IP trouvée"
|
||
compact
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !error && (
|
||
<p className="text-text-secondary text-xs">{formatNumber(totalClusters)} cluster(s) détecté(s)</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|