Files
dashboard/frontend/src/components/HeaderFingerprintView.tsx
2026-03-18 09:00:47 +01:00

333 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}