suite des maj

This commit is contained in:
SOC Analyst
2026-03-18 09:00:47 +01:00
parent 446d3623ec
commit 32a96966dd
17 changed files with 2398 additions and 755 deletions

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
// ─── Types ────────────────────────────────────────────────────────────────────
@ -65,14 +66,6 @@ function StatCard({ label, value, accent }: { label: string; value: string | num
);
}
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">
@ -81,141 +74,6 @@ function ErrorMessage({ message }: { message: string }) {
);
}
// ─── 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: { items: ClusterIP[] } = await res.json();
setClusterIPs(data.items ?? []);
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() {
@ -226,6 +84,11 @@ export function HeaderFingerprintView() {
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);
@ -244,9 +107,157 @@ export function HeaderFingerprintView() {
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 */}
@ -264,36 +275,55 @@ export function HeaderFingerprintView() {
<StatCard label="Clusters légitimes" value={formatNumber(legitimateClusters)} accent="text-threat-low" />
</div>
{/* Table */}
{/* Clusters DataTable */}
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{loading ? (
<LoadingSpinner />
) : error ? (
{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>
<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>
)}