446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import DataTable, { Column } from './ui/DataTable';
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
interface TcpSpoofingOverview {
|
||
total_entries: number;
|
||
unique_ips: number;
|
||
no_tcp_data: number;
|
||
with_tcp_data: number;
|
||
linux_fingerprint: number;
|
||
windows_fingerprint: number;
|
||
ttl_distribution: { ttl: number; count: number; ips: number }[];
|
||
window_size_distribution: { window_size: number; count: number }[];
|
||
}
|
||
|
||
interface TcpSpoofingItem {
|
||
ip: string;
|
||
ja4: string;
|
||
tcp_ttl: number;
|
||
tcp_window_size: number;
|
||
first_ua: string;
|
||
suspected_os: string;
|
||
declared_os: string;
|
||
spoof_flag: boolean;
|
||
}
|
||
|
||
interface OsMatrixEntry {
|
||
suspected_os: string;
|
||
declared_os: string;
|
||
count: number;
|
||
is_spoof: boolean;
|
||
}
|
||
|
||
type ActiveTab = 'detections' | 'matrix';
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function formatNumber(n: number): string {
|
||
return n.toLocaleString('fr-FR');
|
||
}
|
||
|
||
function ttlColor(ttl: number): string {
|
||
if (ttl === 0) return 'text-threat-critical';
|
||
if (ttl < 48 || ttl > 200) return 'text-threat-critical';
|
||
if (ttl < 60 || (ttl > 70 && ttl <= 80)) return 'text-threat-medium';
|
||
if (ttl >= 60 && ttl <= 70) return 'text-threat-low';
|
||
return 'text-text-secondary';
|
||
}
|
||
|
||
// ─── 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>
|
||
);
|
||
}
|
||
|
||
// ─── Detections DataTable ─────────────────────────────────────────────────────
|
||
|
||
function TcpDetectionsTable({
|
||
items,
|
||
navigate,
|
||
}: {
|
||
items: TcpSpoofingItem[];
|
||
navigate: (path: string) => void;
|
||
}) {
|
||
const columns = useMemo((): Column<TcpSpoofingItem>[] => [
|
||
{
|
||
key: 'ip',
|
||
label: 'IP',
|
||
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||
},
|
||
{
|
||
key: 'ja4',
|
||
label: 'JA4',
|
||
render: (v: string) => (
|
||
<span className="font-mono text-xs text-text-secondary">
|
||
{v ? `${v.slice(0, 14)}…` : '—'}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'tcp_ttl',
|
||
label: 'TTL observé',
|
||
align: 'right',
|
||
render: (v: number) => (
|
||
<span className={`font-mono font-semibold ${ttlColor(v)}`}>{v}</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'tcp_window_size',
|
||
label: 'Fenêtre TCP',
|
||
align: 'right',
|
||
render: (v: number) => (
|
||
<span className="text-text-secondary text-xs">{formatNumber(v)}</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'suspected_os',
|
||
label: 'OS suspecté',
|
||
render: (v: string) => <span className="text-text-primary text-xs">{v || '—'}</span>,
|
||
},
|
||
{
|
||
key: 'declared_os',
|
||
label: 'OS déclaré',
|
||
render: (v: string) => <span className="text-text-secondary text-xs">{v || '—'}</span>,
|
||
},
|
||
{
|
||
key: 'spoof_flag',
|
||
label: 'Spoof',
|
||
sortable: false,
|
||
render: (v: boolean) =>
|
||
v ? (
|
||
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-0.5 rounded-full">
|
||
🚨 Spoof
|
||
</span>
|
||
) : null,
|
||
},
|
||
{
|
||
key: '_actions',
|
||
label: '',
|
||
sortable: false,
|
||
render: (_: unknown, row: TcpSpoofingItem) => (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
|
||
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
|
||
>
|
||
Investiguer
|
||
</button>
|
||
),
|
||
},
|
||
], [navigate]);
|
||
|
||
return (
|
||
<DataTable
|
||
data={items}
|
||
columns={columns}
|
||
rowKey="ip"
|
||
defaultSortKey="tcp_ttl"
|
||
emptyMessage="Aucune détection"
|
||
compact
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||
|
||
export function TcpSpoofingView() {
|
||
const navigate = useNavigate();
|
||
|
||
const [activeTab, setActiveTab] = useState<ActiveTab>('detections');
|
||
|
||
const [spoofOnly, setSpoofOnly] = useState(false);
|
||
|
||
const [overview, setOverview] = useState<TcpSpoofingOverview | null>(null);
|
||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||
|
||
const [items, setItems] = useState<TcpSpoofingItem[]>([]);
|
||
const [itemsLoading, setItemsLoading] = useState(true);
|
||
const [itemsError, setItemsError] = useState<string | null>(null);
|
||
|
||
const [matrix, setMatrix] = useState<OsMatrixEntry[]>([]);
|
||
const [matrixLoading, setMatrixLoading] = useState(false);
|
||
const [matrixError, setMatrixError] = useState<string | null>(null);
|
||
const [matrixLoaded, setMatrixLoaded] = useState(false);
|
||
|
||
const [filterText, setFilterText] = useState('');
|
||
|
||
useEffect(() => {
|
||
const fetchOverview = async () => {
|
||
setOverviewLoading(true);
|
||
try {
|
||
const res = await fetch('/api/tcp-spoofing/overview');
|
||
if (!res.ok) throw new Error('Erreur chargement overview');
|
||
const data: TcpSpoofingOverview = await res.json();
|
||
setOverview(data);
|
||
} catch (err) {
|
||
setOverviewError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||
} finally {
|
||
setOverviewLoading(false);
|
||
}
|
||
};
|
||
fetchOverview();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const fetchItems = async () => {
|
||
setItemsLoading(true);
|
||
setItemsError(null);
|
||
try {
|
||
const params = new URLSearchParams({ limit: '200' });
|
||
if (spoofOnly) params.set('spoof_only', 'true');
|
||
const res = await fetch(`/api/tcp-spoofing/list?${params}`);
|
||
if (!res.ok) throw new Error('Erreur chargement des détections');
|
||
const data: { items: TcpSpoofingItem[]; total: number } = await res.json();
|
||
setItems(data.items ?? []);
|
||
} catch (err) {
|
||
setItemsError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||
} finally {
|
||
setItemsLoading(false);
|
||
}
|
||
};
|
||
fetchItems();
|
||
}, [spoofOnly]);
|
||
|
||
const loadMatrix = async () => {
|
||
if (matrixLoaded) return;
|
||
setMatrixLoading(true);
|
||
try {
|
||
const res = await fetch('/api/tcp-spoofing/matrix');
|
||
if (!res.ok) throw new Error('Erreur chargement matrice OS');
|
||
const data: { matrix: OsMatrixEntry[] } = await res.json();
|
||
setMatrix(data.matrix ?? []);
|
||
setMatrixLoaded(true);
|
||
} catch (err) {
|
||
setMatrixError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||
} finally {
|
||
setMatrixLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleTabChange = (tab: ActiveTab) => {
|
||
setActiveTab(tab);
|
||
if (tab === 'matrix') loadMatrix();
|
||
};
|
||
|
||
const filteredItems = items.filter(
|
||
(item) =>
|
||
(!spoofOnly || item.spoof_flag) &&
|
||
(!filterText ||
|
||
item.ip.includes(filterText) ||
|
||
item.suspected_os.toLowerCase().includes(filterText.toLowerCase()) ||
|
||
item.declared_os.toLowerCase().includes(filterText.toLowerCase()))
|
||
);
|
||
|
||
// Build matrix axes
|
||
const suspectedOSes = [...new Set(matrix.map((e) => e.suspected_os))].sort();
|
||
const declaredOSes = [...new Set(matrix.map((e) => e.declared_os))].sort();
|
||
const matrixMax = matrix.reduce((m, e) => Math.max(m, e.count), 1);
|
||
|
||
function matrixCellColor(count: number): string {
|
||
if (count === 0) return 'bg-background-card';
|
||
const ratio = count / matrixMax;
|
||
if (ratio >= 0.75) return 'bg-threat-critical/80';
|
||
if (ratio >= 0.5) return 'bg-threat-high/70';
|
||
if (ratio >= 0.25) return 'bg-threat-medium/60';
|
||
return 'bg-threat-low/40';
|
||
}
|
||
|
||
const tabs: { id: ActiveTab; label: string }[] = [
|
||
{ id: 'detections', label: '📋 Détections' },
|
||
{ id: 'matrix', label: '📊 Matrice OS' },
|
||
];
|
||
|
||
return (
|
||
<div className="p-6 space-y-6 animate-fade-in">
|
||
{/* Header */}
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-text-primary">🧬 Spoofing TCP/OS</h1>
|
||
<p className="text-text-secondary mt-1">
|
||
Détection des incohérences entre TTL/fenêtre TCP et l'OS déclaré.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Stat cards */}
|
||
{overviewLoading ? (
|
||
<LoadingSpinner />
|
||
) : overviewError ? (
|
||
<ErrorMessage message={overviewError} />
|
||
) : overview ? (
|
||
<>
|
||
<div className="grid grid-cols-4 gap-4">
|
||
<StatCard label="Total entrées" value={formatNumber(overview.total_entries)} accent="text-text-primary" />
|
||
<StatCard label="Avec données TCP" value={formatNumber(overview.with_tcp_data)} accent="text-threat-medium" />
|
||
<StatCard label="Fingerprint Linux" value={formatNumber(overview.linux_fingerprint)} accent="text-threat-low" />
|
||
<StatCard label="Fingerprint Windows" value={formatNumber(overview.windows_fingerprint)} accent="text-accent-primary" />
|
||
</div>
|
||
<div className="bg-background-card border border-border rounded-lg px-4 py-3 text-sm text-text-secondary flex items-center gap-2">
|
||
<span className="text-threat-medium">⚠️</span>
|
||
<span>
|
||
<strong className="text-text-primary">{formatNumber(overview.no_tcp_data)}</strong> entrées sans données TCP (TTL=0, passées par proxy/CDN) — exclues de l'analyse de corrélation.
|
||
</span>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-2 border-b border-border">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => handleTabChange(tab.id)}
|
||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||
activeTab === tab.id
|
||
? 'text-accent-primary border-b-2 border-accent-primary'
|
||
: 'text-text-secondary hover:text-text-primary'
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Détections tab */}
|
||
{activeTab === 'detections' && (
|
||
<>
|
||
<div className="flex gap-3 items-center">
|
||
<input
|
||
type="text"
|
||
placeholder="Filtrer par IP ou OS..."
|
||
value={filterText}
|
||
onChange={(e) => setFilterText(e.target.value)}
|
||
className="bg-background-card border border-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-72"
|
||
/>
|
||
<label className="flex items-center gap-2 text-sm text-text-secondary cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={spoofOnly}
|
||
onChange={(e) => setSpoofOnly(e.target.checked)}
|
||
className="accent-accent-primary"
|
||
/>
|
||
Spoofs uniquement (TTL corrélé + OS mismatch)
|
||
</label>
|
||
</div>
|
||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||
{itemsLoading ? (
|
||
<LoadingSpinner />
|
||
) : itemsError ? (
|
||
<div className="p-4"><ErrorMessage message={itemsError} /></div>
|
||
) : (
|
||
<TcpDetectionsTable items={filteredItems} navigate={navigate} />
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Matrice OS tab */}
|
||
{activeTab === 'matrix' && (
|
||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||
{matrixLoading ? (
|
||
<LoadingSpinner />
|
||
) : matrixError ? (
|
||
<ErrorMessage message={matrixError} />
|
||
) : matrix.length === 0 ? (
|
||
<p className="text-text-secondary text-sm">Aucune donnée disponible.</p>
|
||
) : (
|
||
<div className="overflow-auto">
|
||
<h2 className="text-text-primary font-semibold mb-4">OS Suspecté × OS Déclaré</h2>
|
||
<table className="text-xs border-collapse">
|
||
<thead>
|
||
<tr>
|
||
<th className="px-3 py-2 text-text-secondary text-left border border-border bg-background-card">
|
||
Suspecté \ Déclaré
|
||
</th>
|
||
{declaredOSes.map((os) => (
|
||
<th key={os} className="px-3 py-2 text-text-secondary text-center border border-border bg-background-card max-w-[80px]">
|
||
<span className="block truncate w-20" title={os}>{os}</span>
|
||
</th>
|
||
))}
|
||
<th className="px-3 py-2 text-text-secondary text-center border border-border bg-background-card">Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{suspectedOSes.map((sos) => {
|
||
const rowEntries = declaredOSes.map((dos) => {
|
||
const entry = matrix.find((e) => e.suspected_os === sos && e.declared_os === dos);
|
||
return entry?.count ?? 0;
|
||
});
|
||
const rowTotal = rowEntries.reduce((s, v) => s + v, 0);
|
||
return (
|
||
<tr key={sos}>
|
||
<td className="px-3 py-2 text-text-primary border border-border bg-background-card font-medium max-w-[120px]">
|
||
<span className="block truncate w-28" title={sos}>{sos}</span>
|
||
</td>
|
||
{rowEntries.map((count, ci) => {
|
||
const dos = declaredOSes[ci];
|
||
const entry = matrix.find((e) => e.suspected_os === sos && e.declared_os === dos);
|
||
const isSpoofCell = entry?.is_spoof ?? false;
|
||
return (
|
||
<td
|
||
key={ci}
|
||
className={`px-3 py-2 text-center border border-border font-mono ${
|
||
isSpoofCell && count > 0
|
||
? 'bg-threat-critical/25 text-threat-critical font-bold'
|
||
: matrixCellColor(count) + (count > 0 ? ' text-text-primary' : ' text-text-disabled')
|
||
}`}
|
||
title={isSpoofCell ? '🚨 OS mismatch confirmé' : undefined}
|
||
>
|
||
{count > 0 ? (isSpoofCell ? `🚨 ${formatNumber(count)}` : formatNumber(count)) : '—'}
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="px-3 py-2 text-center border border-border font-semibold text-text-primary bg-background-card">
|
||
{formatNumber(rowTotal)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
<tr>
|
||
<td className="px-3 py-2 text-text-secondary border border-border bg-background-card font-semibold">Total</td>
|
||
{declaredOSes.map((dos) => {
|
||
const colTotal = matrix
|
||
.filter((e) => e.declared_os === dos)
|
||
.reduce((s, e) => s + e.count, 0);
|
||
return (
|
||
<td key={dos} className="px-3 py-2 text-center border border-border font-semibold text-text-primary bg-background-card">
|
||
{formatNumber(colTotal)}
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="px-3 py-2 text-center border border-border font-semibold text-accent-primary bg-background-card">
|
||
{formatNumber(matrix.reduce((s, e) => s + e.count, 0))}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|