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

446 lines
17 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.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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