suite des maj
This commit is contained in:
@ -18,9 +18,6 @@ import { CampaignsView } from './components/CampaignsView';
|
||||
import { BruteForceView } from './components/BruteForceView';
|
||||
import { TcpSpoofingView } from './components/TcpSpoofingView';
|
||||
import { HeaderFingerprintView } from './components/HeaderFingerprintView';
|
||||
import { HeatmapView } from './components/HeatmapView';
|
||||
import { BotnetMapView } from './components/BotnetMapView';
|
||||
import { RotationView } from './components/RotationView';
|
||||
import { MLFeaturesView } from './components/MLFeaturesView';
|
||||
import { useTheme } from './ThemeContext';
|
||||
|
||||
@ -83,9 +80,6 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) {
|
||||
{ path: '/bruteforce', label: 'Brute Force', icon: '🔥', aliases: [] },
|
||||
{ path: '/tcp-spoofing', label: 'TCP Spoofing', icon: '🧬', aliases: [] },
|
||||
{ path: '/headers', label: 'Header Fingerprint', icon: '📡', aliases: [] },
|
||||
{ path: '/heatmap', label: 'Heatmap Temporelle', icon: '⏱️', aliases: [] },
|
||||
{ path: '/botnets', label: 'Botnets Distribués', icon: '🌍', aliases: [] },
|
||||
{ path: '/rotation', label: 'Rotation & Persistance', icon: '🔄', aliases: [] },
|
||||
{ path: '/ml-features', label: 'Features ML', icon: '🤖', aliases: [] },
|
||||
];
|
||||
|
||||
@ -245,9 +239,6 @@ function TopHeader({ counts }: { counts: AlertCounts | null }) {
|
||||
if (p.startsWith('/bruteforce')) return 'Brute Force & Credential Stuffing';
|
||||
if (p.startsWith('/tcp-spoofing')) return 'Spoofing TCP/OS';
|
||||
if (p.startsWith('/headers')) return 'Header Fingerprint Clustering';
|
||||
if (p.startsWith('/heatmap')) return 'Heatmap Temporelle';
|
||||
if (p.startsWith('/botnets')) return 'Botnets Distribués';
|
||||
if (p.startsWith('/rotation')) return 'Rotation JA4 & Persistance';
|
||||
if (p.startsWith('/ml-features')) return 'Features ML / Radar';
|
||||
return '';
|
||||
};
|
||||
@ -380,9 +371,9 @@ export default function App() {
|
||||
<Route path="/bruteforce" element={<BruteForceView />} />
|
||||
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
|
||||
<Route path="/headers" element={<HeaderFingerprintView />} />
|
||||
<Route path="/heatmap" element={<HeatmapView />} />
|
||||
<Route path="/botnets" element={<BotnetMapView />} />
|
||||
<Route path="/rotation" element={<RotationView />} />
|
||||
<Route path="/heatmap" element={<Navigate to="/" replace />} />
|
||||
<Route path="/botnets" element={<Navigate to="/campaigns" replace />} />
|
||||
<Route path="/rotation" element={<Navigate to="/fingerprints" replace />} />
|
||||
<Route path="/ml-features" element={<MLFeaturesView />} />
|
||||
<Route path="/detections" element={<DetectionsList />} />
|
||||
<Route path="/detections/:type/:value" element={<DetailsView />} />
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -61,6 +62,70 @@ function ErrorMessage({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Attackers DataTable ─────────────────────────────────────────────────────
|
||||
|
||||
function AttackersTable({
|
||||
attackers,
|
||||
navigate,
|
||||
}: {
|
||||
attackers: BruteForceAttacker[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const columns = useMemo((): Column<BruteForceAttacker>[] => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||
},
|
||||
{ key: 'distinct_hosts', label: 'Hosts ciblés', align: 'right' },
|
||||
{
|
||||
key: 'total_hits',
|
||||
label: 'Hits',
|
||||
align: 'right',
|
||||
render: (v: number) => formatNumber(v),
|
||||
},
|
||||
{
|
||||
key: 'total_params',
|
||||
label: 'Params',
|
||||
align: 'right',
|
||||
render: (v: number) => formatNumber(v),
|
||||
},
|
||||
{
|
||||
key: 'ja4',
|
||||
label: 'JA4',
|
||||
render: (v: string) => (
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{v ? `${v.slice(0, 16)}…` : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: BruteForceAttacker) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
|
||||
className="text-xs bg-threat-high/10 text-threat-high px-3 py-1 rounded hover:bg-threat-high/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
),
|
||||
},
|
||||
], [navigate]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={attackers}
|
||||
columns={columns}
|
||||
rowKey="ip"
|
||||
defaultSortKey="total_hits"
|
||||
emptyMessage="Aucun attaquant trouvé"
|
||||
compact
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface HostAttacker { ip: string; total_hits: number; total_params: number; ja4: string; attack_type: string; }
|
||||
@ -338,37 +403,7 @@ export function BruteForceView() {
|
||||
) : attackersError ? (
|
||||
<div className="p-4"><ErrorMessage message={attackersError} /></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">IP</th>
|
||||
<th className="px-4 py-3">Hosts ciblés</th>
|
||||
<th className="px-4 py-3">Hits</th>
|
||||
<th className="px-4 py-3">Params</th>
|
||||
<th className="px-4 py-3">JA4</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attackers.map((a) => (
|
||||
<tr key={a.ip} className="border-b border-border hover:bg-background-card transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-text-primary text-xs">{a.ip}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{a.distinct_hosts}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(a.total_hits)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(a.total_params)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-secondary">{a.ja4 ? a.ja4.slice(0, 16) : '—'}…</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${a.ip}`)}
|
||||
className="text-xs bg-threat-high/10 text-threat-high px-3 py-1 rounded hover:bg-threat-high/20 transition-colors"
|
||||
>
|
||||
Investiguer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<AttackersTable attackers={attackers} navigate={navigate} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import ThreatBadge from './ui/ThreatBadge';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -44,7 +46,7 @@ interface JA4AttributesResponse {
|
||||
items: JA4AttributeItem[];
|
||||
}
|
||||
|
||||
type ActiveTab = 'clusters' | 'ja4' | 'behavioral';
|
||||
type ActiveTab = 'clusters' | 'ja4' | 'behavioral' | 'botnets';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -256,6 +258,7 @@ export function CampaignsView() {
|
||||
{ id: 'clusters', label: 'Clusters réseau' },
|
||||
{ id: 'ja4', label: 'Fingerprints JA4' },
|
||||
{ id: 'behavioral', label: 'Analyse comportementale' },
|
||||
{ id: 'botnets', label: '🌍 Botnets Distribués' },
|
||||
] as const
|
||||
).map(tab => (
|
||||
<button
|
||||
@ -324,6 +327,8 @@ export function CampaignsView() {
|
||||
{activeTab === 'behavioral' && (
|
||||
<BehavioralTab clusters={clusters} />
|
||||
)}
|
||||
|
||||
{activeTab === 'botnets' && <BotnetTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -721,72 +726,345 @@ function BehavioralTab({ clusters }: BehavioralTabProps) {
|
||||
)}
|
||||
|
||||
{/* Behavioral matrix */}
|
||||
<div className="bg-background-secondary rounded-lg p-4">
|
||||
<h3 className="text-text-primary font-semibold mb-4">Matrice de signaux comportementaux</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Subnet</th>
|
||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Score</th>
|
||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Tendance</th>
|
||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Niveau menace</th>
|
||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Pays</th>
|
||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs text-right">IPs</th>
|
||||
<th className="px-4 py-2 text-text-secondary font-medium text-xs text-right">Détections</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clusters.map((cluster, idx) => {
|
||||
const { bg, text } = getThreatColors(cluster.severity.toLowerCase());
|
||||
return (
|
||||
<tr
|
||||
key={cluster.subnet}
|
||||
className={`hover:bg-background-card/50 transition-colors ${
|
||||
idx < clusters.length - 1 ? 'border-b border-border/50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-2 font-mono text-text-primary text-xs">{cluster.subnet}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`text-xs font-mono font-semibold ${getConfidenceTextColor(cluster.score / 100)}`}>
|
||||
{cluster.score}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{cluster.trend === 'up' ? (
|
||||
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs">
|
||||
↑ +{cluster.trend_percentage}%
|
||||
</span>
|
||||
) : cluster.trend === 'new' ? (
|
||||
<span className="bg-accent-primary/20 text-accent-primary px-2 py-0.5 rounded text-xs">
|
||||
Nouveau
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-disabled text-xs">{cluster.trend}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${bg} ${text}`}>
|
||||
{getThreatLabel(cluster.severity.toLowerCase())}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{cluster.countries?.slice(0, 2).map(c => (
|
||||
<span key={c.code} className="text-text-secondary text-xs mr-1">{c.code}</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-text-primary text-xs font-mono text-right">
|
||||
{cluster.unique_ips}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-text-primary text-xs font-mono text-right">
|
||||
{cluster.total_detections.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bg-background-secondary rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-2.5 border-b border-background-card">
|
||||
<h3 className="text-text-primary font-semibold text-sm">Matrice de signaux comportementaux</h3>
|
||||
</div>
|
||||
<BehavioralMatrix clusters={clusters} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Behavioral Matrix DataTable ─────────────────────────────────────────────
|
||||
|
||||
interface BehavioralRow {
|
||||
subnet: string;
|
||||
score: number;
|
||||
severity: string;
|
||||
trend: string;
|
||||
trend_percentage: number;
|
||||
unique_ips: number;
|
||||
total_detections: number;
|
||||
countries: { code: string; percentage: number }[];
|
||||
}
|
||||
|
||||
function BehavioralMatrix({ clusters }: { clusters: ClusterData[] }) {
|
||||
const columns = useMemo((): Column<BehavioralRow>[] => [
|
||||
{
|
||||
key: 'subnet',
|
||||
label: 'Subnet',
|
||||
render: (v: string) => <span className="font-mono text-text-primary text-xs">{v}</span>,
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
label: 'Score',
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<span className={`text-xs font-mono font-semibold ${getConfidenceTextColor(v / 100)}`}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
label: 'Niveau',
|
||||
render: (v: string) => <ThreatBadge level={v} />,
|
||||
},
|
||||
{
|
||||
key: 'trend',
|
||||
label: 'Tendance',
|
||||
sortable: false,
|
||||
render: (_: string, row: BehavioralRow) =>
|
||||
row.trend === 'up' ? (
|
||||
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs">
|
||||
↑ +{row.trend_percentage}%
|
||||
</span>
|
||||
) : row.trend === 'new' ? (
|
||||
<span className="bg-accent-primary/20 text-accent-primary px-2 py-0.5 rounded text-xs">
|
||||
Nouveau
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-disabled text-xs">{row.trend}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'countries',
|
||||
label: 'Pays',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: BehavioralRow) => (
|
||||
<span>
|
||||
{row.countries?.slice(0, 2).map((c) => (
|
||||
<span key={c.code} className="text-text-secondary text-xs mr-1">{c.code}</span>
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'unique_ips', label: 'IPs', align: 'right' },
|
||||
{
|
||||
key: 'total_detections',
|
||||
label: 'Détections',
|
||||
align: 'right',
|
||||
render: (v: number) => formatNumber(v),
|
||||
},
|
||||
], []);
|
||||
|
||||
const rows: BehavioralRow[] = clusters;
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
rowKey="subnet"
|
||||
defaultSortKey="score"
|
||||
emptyMessage="Aucun cluster disponible"
|
||||
compact
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab: Botnets Distribués ──────────────────────────────────────────────────
|
||||
|
||||
interface BotnetItem {
|
||||
ja4: string;
|
||||
unique_ips: number;
|
||||
unique_countries: number;
|
||||
targeted_hosts: number;
|
||||
distribution_score: number;
|
||||
botnet_class: string;
|
||||
}
|
||||
|
||||
interface BotnetSummary {
|
||||
total_global_botnets: number;
|
||||
total_ips_in_botnets: number;
|
||||
most_spread_ja4: string;
|
||||
most_ips_ja4: string;
|
||||
}
|
||||
|
||||
interface BotnetCountryEntry {
|
||||
country_code: string;
|
||||
unique_ips: number;
|
||||
hits: number;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('fr-FR');
|
||||
}
|
||||
|
||||
function getCountryFlag(code: string): string {
|
||||
if (!code || code.length !== 2) return '🌐';
|
||||
return code.toUpperCase().replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
}
|
||||
|
||||
function botnetClassBadge(cls: string): { bg: string; text: string; label: string } {
|
||||
switch (cls) {
|
||||
case 'global_botnet': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🌐 Global' };
|
||||
case 'regional_botnet': return { bg: 'bg-threat-high/20', text: 'text-threat-high', label: '🗺️ Régional' };
|
||||
case 'concentrated': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium', label: '📍 Concentré' };
|
||||
default: return { bg: 'bg-background-card', text: 'text-text-secondary', label: cls };
|
||||
}
|
||||
}
|
||||
|
||||
function BotnetRow({ item, onInvestigate }: { item: BotnetItem; onInvestigate: (ja4: string) => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [countries, setCountries] = useState<BotnetCountryEntry[]>([]);
|
||||
const [countriesLoading, setCountriesLoading] = useState(false);
|
||||
const [countriesError, setCountriesError] = useState<string | null>(null);
|
||||
const [countriesLoaded, setCountriesLoaded] = useState(false);
|
||||
|
||||
const toggle = async () => {
|
||||
setExpanded((prev) => !prev);
|
||||
if (!countriesLoaded && !expanded) {
|
||||
setCountriesLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/botnets/ja4/${encodeURIComponent(item.ja4)}/countries`);
|
||||
if (!res.ok) throw new Error('Erreur chargement des pays');
|
||||
const data: { items: BotnetCountryEntry[] } = await res.json();
|
||||
setCountries(data.items ?? []);
|
||||
setCountriesLoaded(true);
|
||||
} catch (err) {
|
||||
setCountriesError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setCountriesLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const badge = botnetClassBadge(item.botnet_class);
|
||||
|
||||
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" title={item.ja4}>
|
||||
{item.ja4 ? item.ja4.slice(0, 20) + '…' : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(item.unique_ips)}</td>
|
||||
<td className="px-4 py-3 text-text-primary">🌍 {formatNumber(item.unique_countries)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{formatNumber(item.targeted_hosts)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 bg-background-card rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full bg-accent-primary" style={{ width: `${Math.min(item.distribution_score, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{Math.round(item.distribution_score)}</span>
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onInvestigate(item.ja4); }}
|
||||
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
|
||||
>
|
||||
Investiguer JA4
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border bg-background-card">
|
||||
<td colSpan={7} className="px-6 py-4">
|
||||
{countriesLoading ? (
|
||||
<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 pays…
|
||||
</div>
|
||||
) : countriesError ? (
|
||||
<span className="text-threat-critical text-sm">⚠️ {countriesError}</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{countries.map((c) => (
|
||||
<span
|
||||
key={c.country_code}
|
||||
title={`${c.country_code}: ${c.unique_ips} IPs, ${c.hits} hits`}
|
||||
className="inline-flex items-center gap-1 bg-background-secondary border border-border rounded-full px-2 py-1 text-xs text-text-primary"
|
||||
>
|
||||
{getCountryFlag(c.country_code)} {c.country_code}
|
||||
<span className="text-text-disabled">·</span>
|
||||
<span className="text-accent-primary">{formatNumber(c.unique_ips)} IPs</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BotnetTab() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<BotnetItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [summary, setSummary] = useState<BotnetSummary | null>(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState(true);
|
||||
const [summaryError, setSummaryError] = useState<string | null>(null);
|
||||
|
||||
const [sortField, setSortField] = useState<'unique_ips' | 'unique_countries' | 'targeted_hosts'>('unique_ips');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/botnets/ja4-spread')
|
||||
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement des botnets'))
|
||||
.then((data: { items: BotnetItem[] }) => setItems(data.items ?? []))
|
||||
.catch(err => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
fetch('/api/botnets/summary')
|
||||
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement du résumé'))
|
||||
.then((data: BotnetSummary) => setSummary(data))
|
||||
.catch(err => setSummaryError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setSummaryLoading(false));
|
||||
}, []);
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => b[sortField] - a[sortField]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
{summaryLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="w-6 h-6 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : summaryError ? (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical text-sm">⚠️ {summaryError}</div>
|
||||
) : summary ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">Total Global Botnets</span>
|
||||
<span className="text-2xl font-bold text-threat-critical">{formatNumber(summary.total_global_botnets)}</span>
|
||||
</div>
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">IPs impliquées</span>
|
||||
<span className="text-2xl font-bold text-threat-high">{formatNumber(summary.total_ips_in_botnets)}</span>
|
||||
</div>
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">JA4 le + répandu</span>
|
||||
<span className="font-mono text-xs font-bold text-accent-primary truncate" title={summary.most_spread_ja4}>{summary.most_spread_ja4 || '—'}</span>
|
||||
</div>
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">IPs max par JA4</span>
|
||||
<span className="font-mono text-xs font-bold text-accent-primary truncate" title={summary.most_ips_ja4}>{summary.most_ips_ja4 || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-secondary text-sm">Trier par :</span>
|
||||
{(['unique_ips', 'unique_countries', 'targeted_hosts'] as const).map((field) => (
|
||||
<button
|
||||
key={field}
|
||||
onClick={() => setSortField(field)}
|
||||
className={`text-xs px-3 py-1.5 rounded transition-colors ${
|
||||
sortField === field ? 'bg-accent-primary/20 text-accent-primary' : 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{field === 'unique_ips' ? '🖥️ IPs' : field === 'unique_countries' ? '🌍 Pays' : '🎯 Hosts'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{loading ? (
|
||||
<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>
|
||||
) : error ? (
|
||||
<div className="p-4 bg-threat-critical/10 border border-threat-critical/30 text-threat-critical text-sm">⚠️ {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">JA4</th>
|
||||
<th className="px-4 py-3">IPs distinctes</th>
|
||||
<th className="px-4 py-3">Pays</th>
|
||||
<th className="px-4 py-3">Hosts ciblés</th>
|
||||
<th className="px-4 py-3">Score distribution</th>
|
||||
<th className="px-4 py-3">Classe</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((item) => (
|
||||
<BotnetRow
|
||||
key={item.ja4}
|
||||
item={item}
|
||||
onInvestigate={(ja4) => navigate(`/investigation/ja4/${encodeURIComponent(ja4)}`)}
|
||||
/>
|
||||
))}
|
||||
{sortedItems.length === 0 && (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-secondary">Aucun botnet détecté</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useDetections } from '../hooks/useDetections';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
|
||||
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
@ -12,6 +13,28 @@ interface ColumnConfig {
|
||||
sortable: boolean;
|
||||
}
|
||||
|
||||
interface DetectionRow {
|
||||
src_ip: string;
|
||||
ja4?: string;
|
||||
host?: string;
|
||||
client_headers?: string;
|
||||
model_name: string;
|
||||
anomaly_score: number;
|
||||
hits?: number;
|
||||
hit_velocity?: number;
|
||||
asn_org?: string;
|
||||
asn_number?: string | number;
|
||||
asn_score?: number | null;
|
||||
asn_rep_label?: string;
|
||||
country_code?: string;
|
||||
detected_at: string;
|
||||
first_seen?: string;
|
||||
last_seen?: string;
|
||||
unique_ja4s?: string[];
|
||||
unique_hosts?: string[];
|
||||
unique_client_headers?: string[];
|
||||
}
|
||||
|
||||
export function DetectionsList() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@ -72,26 +95,6 @@ export function DetectionsList() {
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
const currentSortField = newParams.get('sort_by') || 'detected_at';
|
||||
const currentOrder = newParams.get('sort_order') || 'desc';
|
||||
|
||||
if (currentSortField === field) {
|
||||
// Inverser l'ordre ou supprimer le tri
|
||||
if (currentOrder === 'desc') {
|
||||
newParams.set('sort_order', 'asc');
|
||||
} else {
|
||||
newParams.delete('sort_by');
|
||||
newParams.delete('sort_order');
|
||||
}
|
||||
} else {
|
||||
newParams.set('sort_by', field);
|
||||
newParams.set('sort_order', 'desc');
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const toggleColumn = (key: string) => {
|
||||
setColumns(cols => cols.map(col =>
|
||||
col.key === key ? { ...col, visible: !col.visible } : col
|
||||
@ -104,20 +107,6 @@ export function DetectionsList() {
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return '⇅';
|
||||
return sortOrder === 'asc' ? '↑' : '↓';
|
||||
};
|
||||
|
||||
// Par défaut, trier par score croissant (scores négatifs en premier)
|
||||
const getDefaultSortIcon = (field: SortField) => {
|
||||
if (!searchParams.has('sort_by') && !searchParams.has('sort')) {
|
||||
if (field === 'anomaly_score') return '↑';
|
||||
return '⇅';
|
||||
}
|
||||
return getSortIcon(field);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@ -190,6 +179,208 @@ export function DetectionsList() {
|
||||
};
|
||||
})();
|
||||
|
||||
// Build DataTable columns from visible column configs
|
||||
const tableColumns: Column<DetectionRow>[] = columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col): Column<DetectionRow> => {
|
||||
switch (col.key) {
|
||||
case 'ip_ja4':
|
||||
return {
|
||||
key: 'src_ip',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div>
|
||||
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
|
||||
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||
{ja4}
|
||||
</div>
|
||||
))}
|
||||
{row.unique_ja4s.length > 3 && (
|
||||
<div className="font-mono text-xs text-text-disabled">
|
||||
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||
{row.ja4 || '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case 'host':
|
||||
return {
|
||||
key: 'host',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) =>
|
||||
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{row.unique_hosts.slice(0, 3).map((host, idx) => (
|
||||
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||
{host}
|
||||
</div>
|
||||
))}
|
||||
{row.unique_hosts.length > 3 && (
|
||||
<div className="text-xs text-text-disabled">
|
||||
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||
{row.host || '-'}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case 'client_headers':
|
||||
return {
|
||||
key: 'client_headers',
|
||||
label: col.label,
|
||||
sortable: false,
|
||||
render: (_, row) =>
|
||||
groupByIP && row.unique_client_headers && row.unique_client_headers.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{row.unique_client_headers.length} Header{row.unique_client_headers.length > 1 ? 's' : ''} unique{row.unique_client_headers.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{row.unique_client_headers.slice(0, 3).map((header, idx) => (
|
||||
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
{row.unique_client_headers.length > 3 && (
|
||||
<div className="text-xs text-text-disabled">
|
||||
+{row.unique_client_headers.length - 3} autre{row.unique_client_headers.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
||||
{row.client_headers || '-'}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case 'model_name':
|
||||
return {
|
||||
key: 'model_name',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) => <ModelBadge model={row.model_name} />,
|
||||
};
|
||||
case 'anomaly_score':
|
||||
return {
|
||||
key: 'anomaly_score',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
render: (_, row) => <ScoreBadge score={row.anomaly_score} />,
|
||||
};
|
||||
case 'hits':
|
||||
return {
|
||||
key: 'hits',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
render: (_, row) => (
|
||||
<div className="text-sm text-text-primary font-medium">{row.hits ?? 0}</div>
|
||||
),
|
||||
};
|
||||
case 'hit_velocity':
|
||||
return {
|
||||
key: 'hit_velocity',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
align: 'right' as const,
|
||||
render: (_, row) => (
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
row.hit_velocity && row.hit_velocity > 10
|
||||
? 'text-threat-high'
|
||||
: row.hit_velocity && row.hit_velocity > 1
|
||||
? 'text-threat-medium'
|
||||
: 'text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{row.hit_velocity ? row.hit_velocity.toFixed(2) : '0.00'}
|
||||
<span className="text-xs text-text-secondary ml-1">req/s</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case 'asn':
|
||||
return {
|
||||
key: 'asn_org',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div>
|
||||
{row.asn_number && (
|
||||
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
|
||||
)}
|
||||
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
case 'country':
|
||||
return {
|
||||
key: 'country_code',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
align: 'center' as const,
|
||||
render: (_, row) =>
|
||||
row.country_code ? (
|
||||
<span className="text-lg">{getFlag(row.country_code)}</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
),
|
||||
};
|
||||
case 'detected_at':
|
||||
return {
|
||||
key: 'detected_at',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) =>
|
||||
groupByIP && row.first_seen ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Premier:</span>{' '}
|
||||
{new Date(row.first_seen).toLocaleDateString('fr-FR')}{' '}
|
||||
{new Date(row.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Dernier:</span>{' '}
|
||||
{new Date(row.last_seen!).toLocaleDateString('fr-FR')}{' '}
|
||||
{new Date(row.last_seen!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-text-primary">
|
||||
{new Date(row.detected_at).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{new Date(row.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
};
|
||||
default:
|
||||
return { key: col.key, label: col.label, sortable: col.sortable };
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* En-tête */}
|
||||
@ -291,223 +482,15 @@ export function DetectionsList() {
|
||||
|
||||
{/* Tableau */}
|
||||
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-background-card">
|
||||
<tr>
|
||||
{columns.filter(col => col.visible).map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase ${col.sortable ? 'cursor-pointer hover:text-text-primary' : ''}`}
|
||||
onClick={() => col.sortable && handleSort(col.key as SortField)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable && (
|
||||
<span className="text-text-disabled">{getDefaultSortIcon(col.key as SortField)}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-background-card">
|
||||
{processedData.items.map((detection) => (
|
||||
<tr
|
||||
key={`${detection.src_ip}-${detection.detected_at}-${groupByIP ? 'grouped' : 'individual'}`}
|
||||
className="hover:bg-background-card/50 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
navigate(`/detections/ip/${encodeURIComponent(detection.src_ip)}`);
|
||||
}}
|
||||
>
|
||||
{columns.filter(col => col.visible).map(col => {
|
||||
if (col.key === 'ip_ja4') {
|
||||
const detectionAny = detection as any;
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="font-mono text-sm text-text-primary">{detection.src_ip}</div>
|
||||
{groupByIP && detectionAny.unique_ja4s?.length > 0 ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{detectionAny.unique_ja4s.length} JA4{detectionAny.unique_ja4s.length > 1 ? 's' : ''} unique{detectionAny.unique_ja4s.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{detectionAny.unique_ja4s.slice(0, 3).map((ja4: string, idx: number) => (
|
||||
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||
{ja4}
|
||||
</div>
|
||||
))}
|
||||
{detectionAny.unique_ja4s.length > 3 && (
|
||||
<div className="font-mono text-xs text-text-disabled">
|
||||
+{detectionAny.unique_ja4s.length - 3} autre{detectionAny.unique_ja4s.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||
{detection.ja4 || '-'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'host') {
|
||||
const detectionAny = detection as any;
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
{groupByIP && detectionAny.unique_hosts?.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{detectionAny.unique_hosts.length} Host{detectionAny.unique_hosts.length > 1 ? 's' : ''} unique{detectionAny.unique_hosts.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{detectionAny.unique_hosts.slice(0, 3).map((host: string, idx: number) => (
|
||||
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||
{host}
|
||||
</div>
|
||||
))}
|
||||
{detectionAny.unique_hosts.length > 3 && (
|
||||
<div className="text-xs text-text-disabled">
|
||||
+{detectionAny.unique_hosts.length - 3} autre{detectionAny.unique_hosts.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||
{detection.host || '-'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'client_headers') {
|
||||
const detectionAny = detection as any;
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
{groupByIP && detectionAny.unique_client_headers?.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{detectionAny.unique_client_headers.length} Header{detectionAny.unique_client_headers.length > 1 ? 's' : ''} unique{detectionAny.unique_client_headers.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
{detectionAny.unique_client_headers.slice(0, 3).map((header: string, idx: number) => (
|
||||
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
{detectionAny.unique_client_headers.length > 3 && (
|
||||
<div className="text-xs text-text-disabled">
|
||||
+{detectionAny.unique_client_headers.length - 3} autre{detectionAny.unique_client_headers.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
|
||||
{detection.client_headers || '-'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'model_name') {
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<ModelBadge model={detection.model_name} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'anomaly_score') {
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<ScoreBadge score={detection.anomaly_score} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'hits') {
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="text-sm text-text-primary font-medium">
|
||||
{detection.hits || 0}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'hit_velocity') {
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className={`text-sm font-medium ${
|
||||
detection.hit_velocity && detection.hit_velocity > 10
|
||||
? 'text-threat-high'
|
||||
: detection.hit_velocity && detection.hit_velocity > 1
|
||||
? 'text-threat-medium'
|
||||
: 'text-text-primary'
|
||||
}`}>
|
||||
{detection.hit_velocity ? detection.hit_velocity.toFixed(2) : '0.00'}
|
||||
<span className="text-xs text-text-secondary ml-1">req/s</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'asn') {
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="text-sm text-text-primary">{detection.asn_org || detection.asn_number || '-'}</div>
|
||||
{detection.asn_number && (
|
||||
<div className="text-xs text-text-secondary">AS{detection.asn_number}</div>
|
||||
)}
|
||||
<AsnRepBadge score={detection.asn_score} label={detection.asn_rep_label} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'country') {
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
{detection.country_code ? (
|
||||
<span className="text-lg">{getFlag(detection.country_code)}</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
if (col.key === 'detected_at') {
|
||||
const detectionAny = detection as any;
|
||||
return (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
{groupByIP && detectionAny.first_seen ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Premier:</span>{' '}
|
||||
{new Date(detectionAny.first_seen).toLocaleDateString('fr-FR')}{' '}
|
||||
{new Date(detectionAny.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Dernier:</span>{' '}
|
||||
{new Date(detectionAny.last_seen).toLocaleDateString('fr-FR')}{' '}
|
||||
{new Date(detectionAny.last_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-text-primary">
|
||||
{new Date(detection.detected_at).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{new Date(detection.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{data.items.length === 0 && (
|
||||
<div className="text-center py-12 text-text-secondary">
|
||||
Aucune détection trouvée
|
||||
</div>
|
||||
)}
|
||||
<DataTable<DetectionRow>
|
||||
data={processedData.items as DetectionRow[]}
|
||||
columns={tableColumns}
|
||||
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
|
||||
defaultSortKey="anomaly_score"
|
||||
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
|
||||
emptyMessage="Aucune détection trouvée"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback, Fragment } from 'react';
|
||||
import { useState, useEffect, useCallback, Fragment, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import ThreatBadge from './ui/ThreatBadge';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -32,7 +34,8 @@ interface IPsData {
|
||||
}
|
||||
|
||||
type SortField = 'ip_count' | 'detections' | 'botnet_score';
|
||||
type ActiveTab = 'ja4' | 'spoofing' | 'ua_analysis';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
type ActiveTab = 'ja4' | 'spoofing' | 'ua_analysis' | 'rotation';
|
||||
|
||||
// ─── Spoofing types ───────────────────────────────────────────────────────────
|
||||
|
||||
@ -904,6 +907,11 @@ export function FingerprintsView() {
|
||||
const [expandedJa4, setExpandedJa4] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('ip_count');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const handleColSort = (field: SortField) => {
|
||||
if (field === sortField) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortField(field); setSortDir('desc'); }
|
||||
};
|
||||
const [minIps, setMinIps] = useState(0);
|
||||
const [copiedJa4, setCopiedJa4] = useState<string | null>(null);
|
||||
|
||||
@ -1053,24 +1061,25 @@ export function FingerprintsView() {
|
||||
.sort((a, b) => {
|
||||
const va = variabilityCache.get(a.value);
|
||||
const vb = variabilityCache.get(b.value);
|
||||
let diff = 0;
|
||||
if (sortField === 'ip_count') {
|
||||
const ia = va ? va.unique_ips : a.count;
|
||||
const ib = vb ? vb.unique_ips : b.count;
|
||||
return ib - ia;
|
||||
}
|
||||
if (sortField === 'detections') {
|
||||
diff = ib - ia;
|
||||
} else if (sortField === 'detections') {
|
||||
const da = va ? va.total_detections : a.count;
|
||||
const db = vb ? vb.total_detections : b.count;
|
||||
return db - da;
|
||||
diff = db - da;
|
||||
} else {
|
||||
const sa = va
|
||||
? botnetScore(va.unique_ips, botUaPercentage(va.attributes.user_agents))
|
||||
: 0;
|
||||
const sb = vb
|
||||
? botnetScore(vb.unique_ips, botUaPercentage(vb.attributes.user_agents))
|
||||
: 0;
|
||||
diff = sb - sa;
|
||||
}
|
||||
// botnet_score
|
||||
const sa = va
|
||||
? botnetScore(va.unique_ips, botUaPercentage(va.attributes.user_agents))
|
||||
: 0;
|
||||
const sb = vb
|
||||
? botnetScore(vb.unique_ips, botUaPercentage(vb.attributes.user_agents))
|
||||
: 0;
|
||||
return sb - sa;
|
||||
return sortDir === 'desc' ? diff : -diff;
|
||||
});
|
||||
|
||||
// ── Loading state ──
|
||||
@ -1098,11 +1107,12 @@ export function FingerprintsView() {
|
||||
{ id: 'ja4', label: '🔏 JA4 Actifs', desc: 'Fingerprints TLS & IPs associées' },
|
||||
{ id: 'spoofing', label: '🎭 Spoofing JA4', desc: 'Détection spoofing navigateur' },
|
||||
{ id: 'ua_analysis', label: '🧬 Analyse UA', desc: 'User-Agents & rotation' },
|
||||
{ id: 'rotation', label: '🔄 Rotation JA4', desc: 'IPs changeant de fingerprint TLS' },
|
||||
] as { id: ActiveTab; label: string; desc: string }[]).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px ${
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab.id
|
||||
? 'border-accent-primary text-accent-primary bg-accent-primary/5'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary hover:bg-background-card'
|
||||
@ -1119,6 +1129,9 @@ export function FingerprintsView() {
|
||||
{/* ── UA Analysis tab ── */}
|
||||
{activeTab === 'ua_analysis' && <UAAnalysisPanel />}
|
||||
|
||||
{/* ── Rotation tab ── */}
|
||||
{activeTab === 'rotation' && <RotationTab />}
|
||||
|
||||
{/* ── JA4 tab (original content) ── */}
|
||||
{activeTab === 'ja4' && (<>
|
||||
|
||||
@ -1146,15 +1159,6 @@ export function FingerprintsView() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-background-secondary border border-background-card text-text-primary text-sm placeholder:text-text-disabled focus:outline-none focus:border-accent-primary"
|
||||
/>
|
||||
<select
|
||||
value={sortField}
|
||||
onChange={(e) => setSortField(e.target.value as SortField)}
|
||||
className="px-3 py-2 rounded-lg bg-background-secondary border border-background-card text-text-primary text-sm focus:outline-none focus:border-accent-primary"
|
||||
>
|
||||
<option value="ip_count">Trier par IPs</option>
|
||||
<option value="detections">Trier par détections</option>
|
||||
<option value="botnet_score">Trier par score botnet</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-text-secondary whitespace-nowrap">IPs min :</label>
|
||||
<input
|
||||
@ -1170,33 +1174,57 @@ export function FingerprintsView() {
|
||||
{/* ── Table ── */}
|
||||
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-background-card">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
<thead style={{ position: 'sticky', top: 0, zIndex: 10 }}>
|
||||
<tr className="bg-background-secondary">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||
JA4
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
IPs actives
|
||||
<th
|
||||
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
|
||||
onClick={() => handleColSort('ip_count')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
IPs actives
|
||||
{sortField === 'ip_count'
|
||||
? <span className="text-accent-primary">{sortDir === 'desc' ? '↓' : '↑'}</span>
|
||||
: <span className="text-text-disabled opacity-50">⇅</span>}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Détections
|
||||
<th
|
||||
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
|
||||
onClick={() => handleColSort('detections')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Détections
|
||||
{sortField === 'detections'
|
||||
? <span className="text-accent-primary">{sortDir === 'desc' ? '↓' : '↑'}</span>
|
||||
: <span className="text-text-disabled opacity-50">⇅</span>}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Score botnet
|
||||
<th
|
||||
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
|
||||
onClick={() => handleColSort('botnet_score')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Score botnet
|
||||
{sortField === 'botnet_score'
|
||||
? <span className="text-accent-primary">{sortDir === 'desc' ? '↓' : '↑'}</span>
|
||||
: <span className="text-text-disabled opacity-50">⇅</span>}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||
Top pays
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||
Top ASN
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||
% Bot UA
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||
Insights
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
@ -1383,3 +1411,541 @@ export function FingerprintsView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab: Rotation JA4 ────────────────────────────────────────────────────────
|
||||
|
||||
interface JA4Rotator {
|
||||
ip: string;
|
||||
distinct_ja4_count: number;
|
||||
total_hits: number;
|
||||
evasion_score: number;
|
||||
}
|
||||
|
||||
interface PersistentThreat {
|
||||
ip: string;
|
||||
recurrence: number;
|
||||
worst_score: number;
|
||||
worst_threat_level: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
persistence_score: number;
|
||||
}
|
||||
|
||||
interface JA4HistoryEntry {
|
||||
ja4: string;
|
||||
hits: number;
|
||||
window_start: string;
|
||||
}
|
||||
|
||||
interface SophisticationItem {
|
||||
ip: string;
|
||||
ja4_rotation_count: number;
|
||||
recurrence: number;
|
||||
bruteforce_hits: number;
|
||||
sophistication_score: number;
|
||||
tier: string;
|
||||
}
|
||||
|
||||
interface ProactiveHuntItem {
|
||||
ip: string;
|
||||
recurrence: number;
|
||||
worst_score: number;
|
||||
worst_threat_level: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
days_active: number;
|
||||
risk_assessment: string;
|
||||
}
|
||||
|
||||
type RotationSubTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt';
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function tierBadge(tier: string): { bg: string; text: string } {
|
||||
switch (tier) {
|
||||
case 'APT-like': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical' };
|
||||
case 'Advanced': return { bg: 'bg-threat-high/20', text: 'text-threat-high' };
|
||||
case 'Automated': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium' };
|
||||
default: return { bg: 'bg-background-card', text: 'text-text-secondary' };
|
||||
}
|
||||
}
|
||||
|
||||
function RotatorRow({ item }: { item: JA4Rotator }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [history, setHistory] = useState<JA4HistoryEntry[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [historyLoaded, setHistoryLoaded] = useState(false);
|
||||
|
||||
const toggle = async () => {
|
||||
setExpanded((prev) => !prev);
|
||||
if (!historyLoaded && !expanded) {
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/rotation/ip/${encodeURIComponent(item.ip)}/ja4-history`);
|
||||
if (!res.ok) throw new Error('Erreur chargement historique JA4');
|
||||
const data: { ja4_history: JA4HistoryEntry[] } = await res.json();
|
||||
setHistory(data.ja4_history ?? []);
|
||||
setHistoryLoaded(true);
|
||||
} catch (err) {
|
||||
setHistoryError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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">{item.ip}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-semibold ${item.distinct_ja4_count > 5 ? 'bg-threat-critical/20 text-threat-critical' : 'bg-threat-medium/20 text-threat-medium'}`}>
|
||||
{item.distinct_ja4_count} JA4
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(item.total_hits)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-background-card rounded-full h-2">
|
||||
<div className="h-2 rounded-full bg-threat-critical" style={{ width: `${Math.min(item.evasion_score, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-threat-critical font-semibold">{Math.round(item.evasion_score)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border bg-background-card">
|
||||
<td colSpan={4} className="px-6 py-4">
|
||||
{historyLoading ? (
|
||||
<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 de l'historique…
|
||||
</div>
|
||||
) : historyError ? (
|
||||
<span className="text-threat-critical text-sm">⚠️ {historyError}</span>
|
||||
) : history.length === 0 ? (
|
||||
<span className="text-text-secondary text-sm">Aucun historique disponible.</span>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-text-secondary text-xs mb-2">Historique des JA4 utilisés :</p>
|
||||
{history.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono text-text-primary bg-background-secondary border border-border rounded px-2 py-0.5">{entry.ja4}</span>
|
||||
<span className="text-text-secondary">{formatNumber(entry.hits)} hits</span>
|
||||
<span className="text-text-disabled">{formatDate(entry.window_start)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rotation DataTable sub-components ───────────────────────────────────────
|
||||
|
||||
function PersistentTable({
|
||||
data,
|
||||
navigate,
|
||||
}: {
|
||||
data: PersistentThreat[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const columns = useMemo((): Column<PersistentThreat>[] => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||
},
|
||||
{
|
||||
key: 'recurrence',
|
||||
label: 'Récurrence',
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">{v}j</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'worst_score',
|
||||
label: 'Score menace',
|
||||
align: 'right',
|
||||
render: (v: number) => <span className="text-text-primary font-semibold">{Math.round(v)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'worst_threat_level',
|
||||
label: 'Niveau',
|
||||
render: (v: string) => <ThreatBadge level={v} />,
|
||||
},
|
||||
{
|
||||
key: 'first_seen',
|
||||
label: 'Première vue',
|
||||
render: (v: string) => <span className="text-text-secondary text-xs">{formatDate(v)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'last_seen',
|
||||
label: 'Dernière vue',
|
||||
render: (v: string) => <span className="text-text-secondary text-xs">{formatDate(v)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'persistence_score',
|
||||
label: 'Score persistance',
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<div className="w-20 bg-background-card rounded-full h-2">
|
||||
<div className="h-2 rounded-full bg-threat-high" style={{ width: `${Math.min(v, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{Math.round(v)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: PersistentThreat) => (
|
||||
<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={data} columns={columns} rowKey="ip" defaultSortKey="persistence_score" compact />
|
||||
);
|
||||
}
|
||||
|
||||
function SophisticationTable({
|
||||
data,
|
||||
navigate,
|
||||
}: {
|
||||
data: SophisticationItem[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const columns = useMemo((): Column<SophisticationItem>[] => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||
},
|
||||
{
|
||||
key: 'ja4_rotation_count',
|
||||
label: 'Rotation JA4',
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<span className="bg-threat-medium/10 text-threat-medium text-xs px-2 py-1 rounded-full">{v} JA4</span>
|
||||
),
|
||||
},
|
||||
{ key: 'recurrence', label: 'Récurrence', align: 'right' },
|
||||
{
|
||||
key: 'bruteforce_hits',
|
||||
label: 'Hits bruteforce',
|
||||
align: 'right',
|
||||
render: (v: number) => formatNumber(v),
|
||||
},
|
||||
{
|
||||
key: 'sophistication_score',
|
||||
label: 'Score sophistication',
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<div className="w-24 bg-background-card rounded-full h-2">
|
||||
<div className="h-2 rounded-full bg-threat-critical" style={{ width: `${Math.min(v, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-threat-critical">{v}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tier',
|
||||
label: 'Tier',
|
||||
render: (v: string) => {
|
||||
const tb = tierBadge(v);
|
||||
return <span className={`text-xs px-2 py-1 rounded-full ${tb.bg} ${tb.text} font-semibold`}>{v}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: '_actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: SophisticationItem) => (
|
||||
<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={data} columns={columns} rowKey="ip" defaultSortKey="sophistication_score" compact />
|
||||
);
|
||||
}
|
||||
|
||||
function ProactiveTable({
|
||||
data,
|
||||
navigate,
|
||||
}: {
|
||||
data: ProactiveHuntItem[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const columns = useMemo((): Column<ProactiveHuntItem>[] => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
|
||||
},
|
||||
{
|
||||
key: 'recurrence',
|
||||
label: 'Récurrence',
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">{v}×</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'worst_score',
|
||||
label: 'Score max',
|
||||
align: 'right',
|
||||
render: (v: number) => <span className="text-threat-medium font-semibold">{v.toFixed(3)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'days_active',
|
||||
label: 'Jours actifs',
|
||||
align: 'right',
|
||||
render: (v: number) => <span className="text-text-primary font-medium">{v}j</span>,
|
||||
},
|
||||
{
|
||||
key: 'risk_assessment',
|
||||
label: 'Évaluation',
|
||||
render: (v: string) => (
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-semibold ${v === 'Évadeur potentiel' ? 'bg-threat-critical/20 text-threat-critical' : 'bg-threat-medium/20 text-threat-medium'}`}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_actions',
|
||||
label: '',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: ProactiveHuntItem) => (
|
||||
<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"
|
||||
>
|
||||
Lancer investigation
|
||||
</button>
|
||||
),
|
||||
},
|
||||
], [navigate]);
|
||||
|
||||
return (
|
||||
<DataTable data={data} columns={columns} rowKey="ip" defaultSortKey="days_active" compact />
|
||||
);
|
||||
}
|
||||
|
||||
function RotationTab() {
|
||||
const navigate = useNavigate();
|
||||
const [activeSubTab, setActiveSubTab] = useState<RotationSubTab>('rotators');
|
||||
const [rotators, setRotators] = useState<JA4Rotator[]>([]);
|
||||
const [rotatorsLoading, setRotatorsLoading] = useState(true);
|
||||
const [rotatorsError, setRotatorsError] = useState<string | null>(null);
|
||||
|
||||
const [persistent, setPersistent] = useState<PersistentThreat[]>([]);
|
||||
const [persistentLoading, setPersistentLoading] = useState(false);
|
||||
const [persistentError, setPersistentError] = useState<string | null>(null);
|
||||
const [persistentLoaded, setPersistentLoaded] = useState(false);
|
||||
|
||||
const [sophistication, setSophistication] = useState<SophisticationItem[]>([]);
|
||||
const [sophisticationLoading, setSophisticationLoading] = useState(false);
|
||||
const [sophisticationError, setSophisticationError] = useState<string | null>(null);
|
||||
const [sophisticationLoaded, setSophisticationLoaded] = useState(false);
|
||||
|
||||
const [proactive, setProactive] = useState<ProactiveHuntItem[]>([]);
|
||||
const [proactiveLoading, setProactiveLoading] = useState(false);
|
||||
const [proactiveError, setProactiveError] = useState<string | null>(null);
|
||||
const [proactiveLoaded, setProactiveLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/rotation/ja4-rotators?limit=50')
|
||||
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement des rotateurs'))
|
||||
.then((data: { items: JA4Rotator[] }) => setRotators(data.items ?? []))
|
||||
.catch(err => setRotatorsError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setRotatorsLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadPersistent = () => {
|
||||
if (persistentLoaded) return;
|
||||
setPersistentLoading(true);
|
||||
fetch('/api/rotation/persistent-threats?limit=100')
|
||||
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement menaces persistantes'))
|
||||
.then((data: { items: PersistentThreat[] }) => {
|
||||
setPersistent([...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score));
|
||||
setPersistentLoaded(true);
|
||||
})
|
||||
.catch(err => setPersistentError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setPersistentLoading(false));
|
||||
};
|
||||
|
||||
const loadSophistication = () => {
|
||||
if (sophisticationLoaded) return;
|
||||
setSophisticationLoading(true);
|
||||
fetch('/api/rotation/sophistication?limit=50')
|
||||
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement sophistication'))
|
||||
.then((data: { items: SophisticationItem[] }) => {
|
||||
setSophistication(data.items ?? []);
|
||||
setSophisticationLoaded(true);
|
||||
})
|
||||
.catch(err => setSophisticationError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setSophisticationLoading(false));
|
||||
};
|
||||
|
||||
const loadProactive = () => {
|
||||
if (proactiveLoaded) return;
|
||||
setProactiveLoading(true);
|
||||
fetch('/api/rotation/proactive-hunt?min_recurrence=1&min_days=0&limit=50')
|
||||
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement chasse proactive'))
|
||||
.then((data: { items: ProactiveHuntItem[] }) => {
|
||||
setProactive(data.items ?? []);
|
||||
setProactiveLoaded(true);
|
||||
})
|
||||
.catch(err => setProactiveError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setProactiveLoading(false));
|
||||
};
|
||||
|
||||
const handleSubTabChange = (tab: RotationSubTab) => {
|
||||
setActiveSubTab(tab);
|
||||
if (tab === 'persistent') loadPersistent();
|
||||
if (tab === 'sophistication') loadSophistication();
|
||||
if (tab === 'hunt') loadProactive();
|
||||
};
|
||||
|
||||
const maxEvasion = rotators.length > 0 ? Math.max(...rotators.map(r => r.evasion_score)) : 0;
|
||||
const maxPersistence = persistent.length > 0 ? Math.max(...persistent.map(p => p.persistence_score)) : 0;
|
||||
|
||||
const subTabs: { id: RotationSubTab; label: string }[] = [
|
||||
{ id: 'rotators', label: '🎭 Rotateurs JA4' },
|
||||
{ id: 'persistent', label: '🕰️ Menaces Persistantes' },
|
||||
{ id: 'sophistication', label: '🏆 Sophistication' },
|
||||
{ id: 'hunt', label: '🕵️ Chasse proactive' },
|
||||
];
|
||||
|
||||
const Spinner = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="IPs en rotation" value={formatNumber(rotators.length)} accent="text-threat-high" />
|
||||
<StatCard label="Score évasion max" value={Math.round(maxEvasion)} accent="text-threat-critical" />
|
||||
<StatCard label="IPs persistantes" value={persistentLoaded ? formatNumber(persistent.length) : '—'} accent="text-threat-medium" />
|
||||
<StatCard label="Score persistance max" value={persistentLoaded ? Math.round(maxPersistence) : '—'} accent="text-threat-critical" />
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleSubTabChange(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeSubTab === tab.id
|
||||
? 'text-accent-primary border-b-2 border-accent-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rotateurs */}
|
||||
{activeSubTab === 'rotators' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{rotatorsLoading ? <Spinner /> : rotatorsError ? (
|
||||
<div className="p-4 text-threat-critical text-sm">⚠️ {rotatorsError}</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">IP</th>
|
||||
<th className="px-4 py-3">JA4 distincts</th>
|
||||
<th className="px-4 py-3">Total hits</th>
|
||||
<th className="px-4 py-3">Score d'évasion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rotators.map((item) => <RotatorRow key={item.ip} item={item} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Persistantes */}
|
||||
{activeSubTab === 'persistent' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{persistentLoading ? <Spinner /> : persistentError ? (
|
||||
<div className="p-4 text-threat-critical text-sm">⚠️ {persistentError}</div>
|
||||
) : (
|
||||
<PersistentTable data={persistent} navigate={navigate} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sophistication */}
|
||||
{activeSubTab === 'sophistication' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{sophisticationLoading ? <Spinner /> : sophisticationError ? (
|
||||
<div className="p-4 text-threat-critical text-sm">⚠️ {sophisticationError}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-4 border-b border-border text-text-secondary text-sm">Score de sophistication = rotation JA4 × 10 + récurrence × 20 + log(bruteforce+1) × 5</div>
|
||||
<SophisticationTable data={sophistication} navigate={navigate} />
|
||||
{sophistication.length === 0 && <div className="text-center py-8 text-text-secondary text-sm">Aucune donnée de sophistication disponible.</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chasse proactive */}
|
||||
{activeSubTab === 'hunt' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{proactiveLoading ? <Spinner /> : proactiveError ? (
|
||||
<div className="p-4 text-threat-critical text-sm">⚠️ {proactiveError}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-4 border-b border-border text-text-secondary text-sm">IPs récurrentes volant sous le radar (score < 0.5) — persistantes mais non détectées comme critiques.</div>
|
||||
<ProactiveTable data={proactive} navigate={navigate} />
|
||||
{proactive.length === 0 && <div className="text-center py-8 text-text-secondary text-sm">Aucune IP sous le radar détectée avec ces critères.</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -469,6 +469,9 @@ export function IncidentsView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end grid */}
|
||||
<div className="mt-6">
|
||||
<MiniHeatmap />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -500,3 +503,57 @@ function MetricCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface HeatmapHour {
|
||||
hour: number;
|
||||
hits: number;
|
||||
unique_ips: number;
|
||||
}
|
||||
|
||||
function MiniHeatmap() {
|
||||
const [data, setData] = useState<HeatmapHour[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/heatmap/hourly')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d) setData(d.hours ?? d.items ?? []); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const maxHits = Math.max(...data.map(d => d.hits), 1);
|
||||
|
||||
const barColor = (hits: number) => {
|
||||
const pct = (hits / maxHits) * 100;
|
||||
if (pct >= 75) return 'bg-red-500/70';
|
||||
if (pct >= 50) return 'bg-purple-500/60';
|
||||
if (pct >= 25) return 'bg-blue-500/50';
|
||||
if (pct >= 5) return 'bg-blue-400/30';
|
||||
return 'bg-slate-700/30';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary border border-border rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-text-primary mb-3">⏱️ Activité par heure (72h)</div>
|
||||
<div className="flex items-end gap-px h-16">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="relative flex-1 flex flex-col items-center justify-end group">
|
||||
<div
|
||||
className={`w-full rounded-sm ${barColor(d.hits)}`}
|
||||
style={{ height: `${Math.max((d.hits / maxHits) * 100, 2)}%` }}
|
||||
/>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:flex bg-background-card border border-border text-xs text-text-primary rounded px-2 py-1 whitespace-nowrap z-10 pointer-events-none">
|
||||
{d.hits.toLocaleString()} hits — {d.unique_ips} IPs
|
||||
</div>
|
||||
<div className="text-[9px] text-text-disabled mt-0.5 leading-none">
|
||||
{[0, 6, 12, 18].includes(d.hour) ? `${d.hour}h` : '\u00a0'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -321,6 +322,89 @@ function ScatterPlot({ points }: { points: ScatterPoint[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Anomalies DataTable ─────────────────────────────────────────────────────
|
||||
|
||||
function AnomaliesTable({
|
||||
anomalies,
|
||||
selectedIP,
|
||||
onRowClick,
|
||||
}: {
|
||||
anomalies: MLAnomaly[];
|
||||
selectedIP: string | null;
|
||||
onRowClick: (row: MLAnomaly) => void;
|
||||
}) {
|
||||
const columns = useMemo((): Column<MLAnomaly>[] => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
render: (v: string, row: MLAnomaly) => (
|
||||
<span className={`font-mono text-xs ${selectedIP === row.ip ? 'text-accent-primary' : 'text-text-primary'}`}>
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'host',
|
||||
label: 'Host',
|
||||
render: (v: string) => (
|
||||
<span className="text-text-secondary max-w-[120px] truncate block" title={v}>
|
||||
{v || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hits',
|
||||
label: 'Hits',
|
||||
align: 'right',
|
||||
render: (v: number) => formatNumber(v),
|
||||
},
|
||||
{
|
||||
key: 'fuzzing_index',
|
||||
label: 'Fuzzing',
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(v)}`}>
|
||||
{Math.round(v)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attack_type',
|
||||
label: 'Type',
|
||||
render: (v: string) => (
|
||||
<span title={v} className="text-sm">{attackTypeEmoji(v)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_signals',
|
||||
label: 'Signaux',
|
||||
sortable: false,
|
||||
render: (_: unknown, row: MLAnomaly) => (
|
||||
<span className="flex gap-0.5">
|
||||
{row.ua_ch_mismatch && <span title="UA/CH mismatch">⚠️</span>}
|
||||
{row.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
|
||||
{row.is_ua_rotating && <span title="UA rotatif">🔄</span>}
|
||||
{row.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
], [selectedIP]);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto max-h-[500px]">
|
||||
<DataTable
|
||||
data={anomalies}
|
||||
columns={columns}
|
||||
rowKey="ip"
|
||||
defaultSortKey="fuzzing_index"
|
||||
onRowClick={onRowClick}
|
||||
emptyMessage="Aucune anomalie détectée"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function MLFeaturesView() {
|
||||
@ -412,57 +496,11 @@ export function MLFeaturesView() {
|
||||
) : anomaliesError ? (
|
||||
<div className="p-4"><ErrorMessage message={anomaliesError} /></div>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-[500px]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-background-secondary z-10">
|
||||
<tr className="border-b border-border text-text-secondary text-left">
|
||||
<th className="px-3 py-2">IP</th>
|
||||
<th className="px-3 py-2">Host</th>
|
||||
<th className="px-3 py-2">Hits</th>
|
||||
<th className="px-3 py-2">Fuzzing</th>
|
||||
<th className="px-3 py-2">Type</th>
|
||||
<th className="px-3 py-2">Signaux</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{anomalies.map((item) => (
|
||||
<tr
|
||||
key={item.ip}
|
||||
onClick={() => loadRadar(item.ip)}
|
||||
className={`border-b border-border cursor-pointer transition-colors ${
|
||||
selectedIP === item.ip
|
||||
? 'bg-accent-primary/10'
|
||||
: 'hover:bg-background-card'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-text-primary">{item.ip}</td>
|
||||
<td className="px-3 py-2 text-text-secondary max-w-[120px] truncate" title={item.host}>
|
||||
{item.host || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-text-primary">{formatNumber(item.hits)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(item.fuzzing_index)}`}>
|
||||
{Math.round(item.fuzzing_index)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span title={item.attack_type} className="text-sm">
|
||||
{attackTypeEmoji(item.attack_type)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex gap-0.5">
|
||||
{item.ua_ch_mismatch && <span title="UA/CH mismatch">⚠️</span>}
|
||||
{item.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
|
||||
{item.is_ua_rotating && <span title="UA rotatif">🔄</span>}
|
||||
{item.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<AnomaliesTable
|
||||
anomalies={anomalies}
|
||||
selectedIP={selectedIP}
|
||||
onRowClick={(row) => loadRadar(row.ip)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -75,6 +76,94 @@ function ErrorMessage({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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() {
|
||||
@ -261,53 +350,7 @@ export function TcpSpoofingView() {
|
||||
) : itemsError ? (
|
||||
<div className="p-4"><ErrorMessage message={itemsError} /></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">IP</th>
|
||||
<th className="px-4 py-3">JA4</th>
|
||||
<th className="px-4 py-3">TTL observé</th>
|
||||
<th className="px-4 py-3">Fenêtre TCP</th>
|
||||
<th className="px-4 py-3">OS suspecté</th>
|
||||
<th className="px-4 py-3">OS déclaré</th>
|
||||
<th className="px-4 py-3">Spoof</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item) => (
|
||||
<tr key={item.ip} className="border-b border-border hover:bg-background-card transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-primary">{item.ip}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-secondary">
|
||||
{item.ja4 ? `${item.ja4.slice(0, 14)}…` : '—'}
|
||||
</td>
|
||||
<td className={`px-4 py-3 font-mono font-semibold ${ttlColor(item.tcp_ttl)}`}>
|
||||
{item.tcp_ttl}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">
|
||||
{formatNumber(item.tcp_window_size)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary text-xs">{item.suspected_os || '—'}</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">{item.declared_os || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.spoof_flag && (
|
||||
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-0.5 rounded-full">
|
||||
🚨 Spoof
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${item.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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<TcpDetectionsTable items={filteredItems} navigate={navigate} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
26
frontend/src/components/ui/Card.tsx
Normal file
26
frontend/src/components/ui/Card.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Card({ title, actions, children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-background-secondary border border-background-card rounded-lg overflow-hidden ${className}`}
|
||||
>
|
||||
{(title || actions) && (
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-background-card">
|
||||
{title && (
|
||||
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
|
||||
)}
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
frontend/src/components/ui/DataTable.tsx
Normal file
152
frontend/src/components/ui/DataTable.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { useSort, SortDir } from '../../hooks/useSort';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
width?: string;
|
||||
render?: (value: any, row: T) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
defaultSortKey?: string;
|
||||
defaultSortDir?: SortDir;
|
||||
onRowClick?: (row: T) => void;
|
||||
rowKey: keyof T | ((row: T) => string);
|
||||
emptyMessage?: string;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export default function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
defaultSortKey,
|
||||
defaultSortDir = 'desc',
|
||||
onRowClick,
|
||||
rowKey,
|
||||
emptyMessage = 'Aucune donnée disponible',
|
||||
loading = false,
|
||||
className = '',
|
||||
compact = false,
|
||||
maxHeight,
|
||||
}: DataTableProps<T>) {
|
||||
const firstSortableKey =
|
||||
defaultSortKey ||
|
||||
columns.find((c) => c.sortable !== false)?.key ||
|
||||
columns[0]?.key ||
|
||||
'id';
|
||||
|
||||
const { sorted, sortKey, sortDir, handleSort } = useSort<T>(
|
||||
data,
|
||||
firstSortableKey as keyof T,
|
||||
defaultSortDir
|
||||
);
|
||||
|
||||
const cell = compact ? 'px-3 py-1.5' : 'px-4 py-2.5';
|
||||
|
||||
const getRowKey = (row: T): string => {
|
||||
if (typeof rowKey === 'function') return rowKey(row);
|
||||
return String(row[rowKey as keyof T] ?? '');
|
||||
};
|
||||
|
||||
const alignClass = (align?: 'left' | 'right' | 'center') => {
|
||||
if (align === 'right') return 'text-right';
|
||||
if (align === 'center') return 'text-center';
|
||||
return 'text-left';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${maxHeight ? `${maxHeight} overflow-y-auto` : ''} ${className}`}>
|
||||
<table className="w-full">
|
||||
<thead style={{ position: 'sticky', top: 0, zIndex: 10 }}>
|
||||
<tr>
|
||||
{columns.map((col) => {
|
||||
const isSortable = col.sortable !== false;
|
||||
const isActive = String(sortKey) === col.key;
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={[
|
||||
cell,
|
||||
'text-xs font-semibold text-text-disabled uppercase tracking-wider',
|
||||
'bg-background-secondary border-b border-background-card',
|
||||
col.width ?? '',
|
||||
alignClass(col.align),
|
||||
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
|
||||
].join(' ')}
|
||||
onClick={isSortable ? () => handleSort(col.key as keyof T) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.label}
|
||||
{isSortable &&
|
||||
(isActive ? (
|
||||
<span className="text-accent-primary">
|
||||
{sortDir === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-disabled opacity-50">⇅</span>
|
||||
))}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`${cell} border-b border-background-card`}>
|
||||
<div className="bg-background-card/50 rounded animate-pulse h-4" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : sorted.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="text-center py-8 text-text-disabled text-sm"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sorted.map((row) => (
|
||||
<tr
|
||||
key={getRowKey(row)}
|
||||
className={[
|
||||
'border-b border-background-card transition-colors',
|
||||
'hover:bg-background-card/50',
|
||||
onRowClick ? 'cursor-pointer' : '',
|
||||
].join(' ')}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const value = row[col.key as keyof T];
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={[cell, alignClass(col.align), col.className ?? ''].join(' ')}
|
||||
>
|
||||
{col.render ? col.render(value, row) : (value as React.ReactNode)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/components/ui/StatCard.tsx
Normal file
35
frontend/src/components/ui/StatCard.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
type Color = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'slate';
|
||||
|
||||
const COLOR_MAP: Record<Color, string> = {
|
||||
red: 'text-red-400',
|
||||
orange: 'text-orange-400',
|
||||
yellow: 'text-yellow-400',
|
||||
green: 'text-green-400',
|
||||
blue: 'text-blue-400',
|
||||
purple: 'text-purple-400',
|
||||
slate: 'text-slate-400',
|
||||
};
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
color?: Color;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function StatCard({ label, value, sub, color, icon }: StatCardProps) {
|
||||
const valueClass = color ? COLOR_MAP[color] : 'text-text-primary';
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="text-xs text-text-disabled uppercase tracking-wider flex items-center gap-1">
|
||||
{icon && <span>{icon}</span>}
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${valueClass}`}>{value}</div>
|
||||
{sub && <div className="text-xs text-text-secondary mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/ui/ThreatBadge.tsx
Normal file
24
frontend/src/components/ui/ThreatBadge.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
type ThreatLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'NORMAL' | 'KNOWN_BOT';
|
||||
|
||||
const BADGE_STYLES: Record<ThreatLevel, string> = {
|
||||
CRITICAL: 'bg-red-900/50 text-red-400 border border-red-800/50',
|
||||
HIGH: 'bg-orange-900/50 text-orange-400 border border-orange-800/50',
|
||||
MEDIUM: 'bg-yellow-900/50 text-yellow-400 border border-yellow-800/50',
|
||||
LOW: 'bg-green-900/50 text-green-400 border border-green-800/50',
|
||||
NORMAL: 'bg-slate-700/50 text-slate-400 border border-slate-600/50',
|
||||
KNOWN_BOT: 'bg-purple-900/50 text-purple-400 border border-purple-800/50',
|
||||
};
|
||||
|
||||
interface ThreatBadgeProps {
|
||||
level: string;
|
||||
}
|
||||
|
||||
export default function ThreatBadge({ level }: ThreatBadgeProps) {
|
||||
const key = (level?.toUpperCase() ?? 'NORMAL') as ThreatLevel;
|
||||
const cls = BADGE_STYLES[key] ?? BADGE_STYLES.NORMAL;
|
||||
return (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded font-medium uppercase ${cls}`}>
|
||||
{level || 'NORMAL'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
42
frontend/src/hooks/useSort.ts
Normal file
42
frontend/src/hooks/useSort.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
export function useSort<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
defaultKey: keyof T,
|
||||
defaultDir: SortDir = 'desc'
|
||||
): {
|
||||
sorted: T[];
|
||||
sortKey: keyof T;
|
||||
sortDir: SortDir;
|
||||
handleSort: (key: keyof T) => void;
|
||||
} {
|
||||
const [sortKey, setSortKey] = useState<keyof T>(defaultKey);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(defaultDir);
|
||||
|
||||
const handleSort = (key: keyof T) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
let cmp = 0;
|
||||
if (av == null && bv == null) cmp = 0;
|
||||
else if (av == null) cmp = 1;
|
||||
else if (bv == null) cmp = -1;
|
||||
else if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv;
|
||||
else cmp = String(av).localeCompare(String(bv));
|
||||
return sortDir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
}, [data, sortKey, sortDir]);
|
||||
|
||||
return { sorted, sortKey, sortDir, handleSort };
|
||||
}
|
||||
Reference in New Issue
Block a user