Refactor frontend components and cleanup
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@ -1,314 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 CountryEntry {
|
||||
country_code: string;
|
||||
unique_ips: number;
|
||||
hits: number;
|
||||
}
|
||||
|
||||
type SortField = 'unique_ips' | 'unique_countries' | 'targeted_hosts';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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':
|
||||
return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🌐 Global' };
|
||||
case 'regional':
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, accent, mono }: { label: string; value: string | number; accent?: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-xl font-bold truncate ${mono ? 'font-mono text-sm' : 'text-2xl'} ${accent ?? 'text-text-primary'}`} title={String(value)}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Botnet row with expandable countries ─────────────────────────────────────
|
||||
|
||||
function BotnetRow({
|
||||
item,
|
||||
onInvestigate,
|
||||
}: {
|
||||
item: BotnetItem;
|
||||
onInvestigate: (ja4: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [countries, setCountries] = useState<CountryEntry[]>([]);
|
||||
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?limit=30`);
|
||||
if (!res.ok) throw new Error('Erreur chargement des pays');
|
||||
const data: { items: CountryEntry[] } = 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">{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">
|
||||
<span className="flex items-center gap-1">🌍 {formatNumber(item.unique_countries)}</span>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BotnetMapView() {
|
||||
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<SortField>('unique_ips');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/botnets/ja4-spread');
|
||||
if (!res.ok) throw new Error('Erreur chargement des botnets');
|
||||
const data: { items: BotnetItem[]; total: number } = await res.json();
|
||||
setItems(data.items ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchSummary = async () => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/botnets/summary');
|
||||
if (!res.ok) throw new Error('Erreur chargement du résumé');
|
||||
const data: BotnetSummary = await res.json();
|
||||
setSummary(data);
|
||||
} catch (err) {
|
||||
setSummaryError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
};
|
||||
fetchItems();
|
||||
fetchSummary();
|
||||
}, []);
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => b[sortField] - a[sortField]);
|
||||
|
||||
const sortButton = (field: SortField, label: string) => (
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🌍 Botnets Distribués</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Analyse de la distribution géographique des fingerprints JA4 pour détecter les botnets globaux.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
{summaryLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : summaryError ? (
|
||||
<ErrorMessage message={summaryError} />
|
||||
) : summary ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="Botnets globaux (>100 pays)" value={formatNumber(summary.total_global_botnets)} accent="text-threat-critical" />
|
||||
<StatCard label="Total IPs en botnets" value={formatNumber(summary.total_ips_in_botnets)} accent="text-threat-high" />
|
||||
<StatCard label="JA4 le plus répandu" value={summary.most_spread_ja4 || '—'} accent="text-accent-primary" mono />
|
||||
<StatCard label="JA4 avec le plus d'IPs" value={summary.most_ips_ja4 || '—'} accent="text-accent-primary" mono />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Sort controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-secondary text-sm">Trier par :</span>
|
||||
{sortButton('unique_ips', '🖥️ IPs')}
|
||||
{sortButton('unique_countries', '🌍 Pays')}
|
||||
{sortButton('targeted_hosts', '🎯 Hosts')}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : 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">JA4</th>
|
||||
<th className="px-4 py-3">IPs</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/${ja4}`)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import DataTable, { Column } from './ui/DataTable';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -46,15 +47,7 @@ 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 }) {
|
||||
: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { PREDEFINED_TAGS } from '../utils/classifications';
|
||||
|
||||
interface BulkClassificationProps {
|
||||
selectedIPs: string[];
|
||||
@ -6,26 +7,6 @@ interface BulkClassificationProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const PREDEFINED_TAGS = [
|
||||
'scraping',
|
||||
'bot-network',
|
||||
'scanner',
|
||||
'bruteforce',
|
||||
'data-exfil',
|
||||
'ddos',
|
||||
'spam',
|
||||
'proxy',
|
||||
'tor',
|
||||
'vpn',
|
||||
'hosting-asn',
|
||||
'distributed',
|
||||
'ja4-rotation',
|
||||
'ua-rotation',
|
||||
'country-cn',
|
||||
'country-us',
|
||||
'country-ru',
|
||||
];
|
||||
|
||||
export function BulkClassification({ selectedIPs, onClose, onSuccess }: BulkClassificationProps) {
|
||||
const [selectedLabel, setSelectedLabel] = useState<string>('suspicious');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
@ -4,6 +4,8 @@ import DataTable, { Column } from './ui/DataTable';
|
||||
import ThreatBadge from './ui/ThreatBadge';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -854,14 +856,6 @@ interface BotnetCountryEntry {
|
||||
hits: number;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString(navigator.language || undefined);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@ -17,6 +17,7 @@ import 'reactflow/dist/style.css';
|
||||
import { useEffect, useState, useCallback, memo } from 'react';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -47,12 +48,6 @@ function cleanIP(address: string): string {
|
||||
return address.replace(/^::ffff:/i, '');
|
||||
}
|
||||
|
||||
function getCountryFlag(code: string): string {
|
||||
return (code || '').toUpperCase().replace(/./g, (char) =>
|
||||
String.fromCodePoint(char.charCodeAt(0) + 127397)
|
||||
);
|
||||
}
|
||||
|
||||
function classifyUA(ua: string): 'bot' | 'script' | 'normal' {
|
||||
const u = ua.toLowerCase();
|
||||
if (u.includes('bot') || u.includes('crawler') || u.includes('spider')) return 'bot';
|
||||
|
||||
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDateOnly } from '../utils/dateUtils';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
interface EntityStats {
|
||||
entity_type: string;
|
||||
@ -84,15 +85,7 @@ export function EntityInvestigationView() {
|
||||
return labels[entityType] || entityType;
|
||||
};
|
||||
|
||||
const getCountryFlag = (code: string) => {
|
||||
const flags: Record<string, string> = {
|
||||
CN: '🇨🇳', US: '🇺🇸', FR: '🇫🇷', DE: '🇩🇪', GB: '🇬🇧',
|
||||
RU: '🇷🇺', CA: '🇨🇦', AU: '🇦🇺', JP: '🇯🇵', IN: '🇮🇳',
|
||||
BR: '🇧🇷', IT: '🇮🇹', ES: '🇪🇸', NL: '🇳🇱', BE: '🇧🇪',
|
||||
CH: '🇨🇭', SE: '🇸🇪', NO: '🇳🇴', DK: '🇩🇰', FI: '🇫🇮'
|
||||
};
|
||||
return flags[code] || code;
|
||||
};
|
||||
;
|
||||
|
||||
|
||||
if (loading) {
|
||||
|
||||
@ -4,7 +4,8 @@ import DataTable, { Column } from './ui/DataTable';
|
||||
import ThreatBadge from './ui/ThreatBadge';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber as fmtNum, formatDateShort } from '../utils/dateUtils';
|
||||
import { formatNumber, formatDateShort } from '../utils/dateUtils';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -123,16 +124,6 @@ function botnetScore(uniqueIps: number, botUaPct: number): number {
|
||||
return Math.round(ipScore + botBonus);
|
||||
}
|
||||
|
||||
function getCountryFlag(code: string): string {
|
||||
return code
|
||||
.toUpperCase()
|
||||
.replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return fmtNum(n);
|
||||
}
|
||||
|
||||
function botUaPercentage(userAgents: AttributeValue[]): number {
|
||||
if (!userAgents.length) return 0;
|
||||
const botOrScript = userAgents.filter((ua) => classifyUA(ua.value) !== 'normal');
|
||||
|
||||
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { ErrorMessage } from './ui/Feedback';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -65,7 +66,7 @@ function StatCard({ label, value, accent }: { label: string; value: string | num
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
|
||||
@ -1,318 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HourlyEntry {
|
||||
hour: number;
|
||||
hits: number;
|
||||
unique_ips: number;
|
||||
max_rps: number;
|
||||
}
|
||||
|
||||
interface TopHost {
|
||||
host: string;
|
||||
total_hits: number;
|
||||
unique_ips: number;
|
||||
unique_ja4s: number;
|
||||
hourly_hits: number[];
|
||||
}
|
||||
|
||||
interface HeatmapMatrix {
|
||||
hosts: string[];
|
||||
matrix: number[][];
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
function heatmapCellStyle(value: number, maxValue: number): React.CSSProperties {
|
||||
if (maxValue === 0 || value === 0) return { backgroundColor: 'transparent' };
|
||||
const ratio = value / maxValue;
|
||||
if (ratio >= 0.75) return { backgroundColor: 'rgba(239, 68, 68, 0.85)' };
|
||||
if (ratio >= 0.5) return { backgroundColor: 'rgba(168, 85, 247, 0.7)' };
|
||||
if (ratio >= 0.25) return { backgroundColor: 'rgba(59, 130, 246, 0.6)' };
|
||||
if (ratio >= 0.05) return { backgroundColor: 'rgba(96, 165, 250, 0.35)' };
|
||||
return { backgroundColor: 'rgba(147, 197, 253, 0.15)' };
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mini sparkline for a host row
|
||||
function Sparkline({ data }: { data: number[] }) {
|
||||
const max = Math.max(...data, 1);
|
||||
return (
|
||||
<div className="flex items-end gap-px" style={{ height: '24px', width: '96px' }}>
|
||||
{data.map((v, i) => {
|
||||
const pct = (v / max) * 100;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{ height: `${Math.max(pct, 5)}%`, width: '4px' }}
|
||||
className={`rounded-sm ${pct >= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/50'}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function HeatmapView() {
|
||||
const [hourly, setHourly] = useState<HourlyEntry[]>([]);
|
||||
const [hourlyLoading, setHourlyLoading] = useState(true);
|
||||
const [hourlyError, setHourlyError] = useState<string | null>(null);
|
||||
|
||||
const [topHosts, setTopHosts] = useState<TopHost[]>([]);
|
||||
const [topHostsLoading, setTopHostsLoading] = useState(true);
|
||||
const [topHostsError, setTopHostsError] = useState<string | null>(null);
|
||||
|
||||
const [matrixData, setMatrixData] = useState<HeatmapMatrix | null>(null);
|
||||
const [matrixLoading, setMatrixLoading] = useState(true);
|
||||
const [matrixError, setMatrixError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHourly = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/heatmap/hourly');
|
||||
if (!res.ok) throw new Error('Erreur chargement courbe horaire');
|
||||
const data: { hours: HourlyEntry[] } = await res.json();
|
||||
setHourly(data.hours ?? []);
|
||||
} catch (err) {
|
||||
setHourlyError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setHourlyLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchTopHosts = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/heatmap/top-hosts?limit=20');
|
||||
if (!res.ok) throw new Error('Erreur chargement top hosts');
|
||||
const data: { items: TopHost[] } = await res.json();
|
||||
setTopHosts(data.items ?? []);
|
||||
} catch (err) {
|
||||
setTopHostsError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setTopHostsLoading(false);
|
||||
}
|
||||
};
|
||||
const fetchMatrix = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/heatmap/matrix');
|
||||
if (!res.ok) throw new Error('Erreur chargement heatmap matrix');
|
||||
const data: HeatmapMatrix = await res.json();
|
||||
setMatrixData(data);
|
||||
} catch (err) {
|
||||
setMatrixError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setMatrixLoading(false);
|
||||
}
|
||||
};
|
||||
fetchHourly();
|
||||
fetchTopHosts();
|
||||
fetchMatrix();
|
||||
}, []);
|
||||
|
||||
const peakHour = hourly.reduce((best, h) => (h.hits > best.hits ? h : best), { hour: 0, hits: 0, unique_ips: 0, max_rps: 0 });
|
||||
const totalHits = hourly.reduce((s, h) => s + h.hits, 0);
|
||||
const maxHits = hourly.length > 0 ? Math.max(...hourly.map((h) => h.hits)) : 1;
|
||||
|
||||
const matrixMax = matrixData
|
||||
? Math.max(...matrixData.matrix.flatMap((row) => row), 1)
|
||||
: 1;
|
||||
|
||||
const displayHosts = matrixData ? matrixData.hosts.slice(0, 15) : [];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">⏱️ Heatmap Temporelle d'Attaques</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Distribution horaire de l'activité malveillante par host cible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
label="Heure de pic"
|
||||
value={peakHour.hits > 0 ? `${peakHour.hour}h (${formatNumber(peakHour.hits)} hits)` : '—'}
|
||||
accent="text-threat-critical"
|
||||
/>
|
||||
<StatCard label="Total hits (24h)" value={formatNumber(totalHits)} accent="text-text-primary" />
|
||||
<StatCard label="Hosts ciblés" value={formatNumber(matrixData?.hosts.length ?? 0)} accent="text-threat-high" />
|
||||
</div>
|
||||
|
||||
{/* Section 1: Courbe horaire */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
<h2 className="text-text-primary font-semibold mb-4">Activité horaire — 24h</h2>
|
||||
{hourlyLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : hourlyError ? (
|
||||
<ErrorMessage message={hourlyError} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-end gap-1" style={{ height: '160px' }}>
|
||||
{Array.from({ length: 24 }, (_, i) => {
|
||||
const entry = hourly.find((h) => h.hour === i) ?? { hour: i, hits: 0, unique_ips: 0, max_rps: 0 };
|
||||
const pct = maxHits > 0 ? (entry.hits / maxHits) * 100 : 0;
|
||||
return (
|
||||
<div key={i} className="flex flex-col items-center flex-1 gap-1 h-full">
|
||||
<div className="w-full flex flex-col justify-end flex-1">
|
||||
<div
|
||||
title={`${i}h: ${entry.hits} hits`}
|
||||
style={{ height: `${Math.max(pct, 1)}%` }}
|
||||
className={`w-full rounded-t transition-all ${
|
||||
pct >= 70 ? 'bg-threat-critical' : pct >= 30 ? 'bg-threat-medium' : 'bg-accent-primary/60'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-disabled text-xs">{i}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-4 mt-3 text-xs text-text-secondary">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-critical rounded-sm inline-block" /> Élevé (≥70%)</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-threat-medium rounded-sm inline-block" /> Moyen (≥30%)</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 bg-accent-primary/60 rounded-sm inline-block" /> Faible</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Heatmap matrix */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border p-6">
|
||||
<h2 className="text-text-primary font-semibold mb-4">Heatmap Host × Heure</h2>
|
||||
{matrixLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : matrixError ? (
|
||||
<ErrorMessage message={matrixError} />
|
||||
) : !matrixData || displayHosts.length === 0 ? (
|
||||
<p className="text-text-secondary text-sm">Aucune donnée disponible.</p>
|
||||
) : (
|
||||
<div className="overflow-auto">
|
||||
<div className="inline-block min-w-full">
|
||||
{/* Hour headers */}
|
||||
<div className="flex" style={{ marginLeft: '180px' }}>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<div key={i} className="text-center text-xs text-text-disabled" style={{ width: '28px', minWidth: '28px' }}>
|
||||
{i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{displayHosts.map((host, rowIdx) => {
|
||||
const rowData = matrixData.matrix[rowIdx] ?? Array(24).fill(0);
|
||||
return (
|
||||
<div key={host} className="flex items-center" style={{ height: '24px', marginBottom: '2px' }}>
|
||||
<div className="text-xs text-text-secondary truncate text-right pr-2" style={{ width: '180px', minWidth: '180px' }} title={host}>
|
||||
{host}
|
||||
</div>
|
||||
{Array.from({ length: 24 }, (_, h) => {
|
||||
const val = rowData[h] ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
title={`${host} ${h}h: ${val} hits`}
|
||||
style={{
|
||||
width: '26px',
|
||||
minWidth: '26px',
|
||||
height: '20px',
|
||||
marginRight: '2px',
|
||||
borderRadius: '2px',
|
||||
...heatmapCellStyle(val, matrixMax),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-4 text-xs text-text-secondary items-center">
|
||||
<span>Intensité :</span>
|
||||
{[
|
||||
{ label: 'Nul', style: { backgroundColor: 'transparent', border: '1px solid rgba(255,255,255,0.1)' } },
|
||||
{ label: 'Très faible', style: { backgroundColor: 'rgba(147, 197, 253, 0.15)' } },
|
||||
{ label: 'Faible', style: { backgroundColor: 'rgba(96, 165, 250, 0.35)' } },
|
||||
{ label: 'Moyen', style: { backgroundColor: 'rgba(59, 130, 246, 0.6)' } },
|
||||
{ label: 'Élevé', style: { backgroundColor: 'rgba(168, 85, 247, 0.7)' } },
|
||||
{ label: 'Critique', style: { backgroundColor: 'rgba(239, 68, 68, 0.85)' } },
|
||||
].map(({ label, style }) => (
|
||||
<span key={label} className="flex items-center gap-1">
|
||||
<span style={{ ...style, display: 'inline-block', width: '14px', height: '14px', borderRadius: '2px' }} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3: Top hosts table */}
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-text-primary font-semibold">Top Hosts ciblés</h2>
|
||||
</div>
|
||||
{topHostsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : topHostsError ? (
|
||||
<div className="p-4"><ErrorMessage message={topHostsError} /></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">Host</th>
|
||||
<th className="px-4 py-3">Total hits</th>
|
||||
<th className="px-4 py-3">IPs uniques</th>
|
||||
<th className="px-4 py-3">JA4 uniques</th>
|
||||
<th className="px-4 py-3">Activité 24h</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topHosts.map((h) => (
|
||||
<tr key={h.host} className="border-b border-border hover:bg-background-card transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-xs text-text-primary">{h.host}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(h.total_hits)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{formatNumber(h.unique_ips)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary">{formatNumber(h.unique_ja4s)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Sparkline data={h.hourly_hits ?? Array(24).fill(0)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
interface IncidentCluster {
|
||||
id: string;
|
||||
@ -123,9 +124,7 @@ export function IncidentsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const getCountryFlag = (code: string) => {
|
||||
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
};
|
||||
;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@ -1,351 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface InvestigationPanelProps {
|
||||
entityType: 'ip' | 'ja4' | 'asn' | 'host';
|
||||
entityValue: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface AttributeValue {
|
||||
value: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
first_seen?: string;
|
||||
last_seen?: string;
|
||||
}
|
||||
|
||||
interface EntityData {
|
||||
type: string;
|
||||
value: string;
|
||||
total_detections: number;
|
||||
unique_ips: number;
|
||||
threat_level?: string;
|
||||
anomaly_score?: number;
|
||||
country_code?: string;
|
||||
asn_number?: string;
|
||||
user_agents?: { value: string; count: number }[];
|
||||
ja4s?: string[];
|
||||
hosts?: string[];
|
||||
attributes?: {
|
||||
user_agents?: AttributeValue[];
|
||||
ja4?: AttributeValue[];
|
||||
countries?: AttributeValue[];
|
||||
asns?: AttributeValue[];
|
||||
hosts?: AttributeValue[];
|
||||
};
|
||||
}
|
||||
|
||||
export function InvestigationPanel({ entityType, entityValue, onClose }: InvestigationPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<EntityData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [classifying, setClassifying] = useState(false);
|
||||
const [showAllUA, setShowAllUA] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/variability/${entityType}/${encodeURIComponent(entityValue)}`);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching entity data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [entityType, entityValue]);
|
||||
|
||||
const getSeverityColor = (score?: number) => {
|
||||
if (!score) return 'bg-gray-500';
|
||||
if (score < -0.7) return 'bg-threat-critical';
|
||||
if (score < -0.3) return 'bg-threat-high';
|
||||
if (score < 0) return 'bg-threat-medium';
|
||||
return 'bg-threat-low';
|
||||
};
|
||||
|
||||
const getSeverityLabel = (score?: number) => {
|
||||
if (!score) return 'Unknown';
|
||||
if (score < -0.7) return 'CRITICAL';
|
||||
if (score < -0.3) return 'HIGH';
|
||||
if (score < 0) return 'MEDIUM';
|
||||
return 'LOW';
|
||||
};
|
||||
|
||||
const getCountryFlag = (code?: string) => {
|
||||
if (!code) return '';
|
||||
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
};
|
||||
|
||||
const handleQuickClassify = async (label: string) => {
|
||||
setClassifying(true);
|
||||
try {
|
||||
await fetch('/api/analysis/classifications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
[entityType]: entityValue,
|
||||
label,
|
||||
tags: ['quick-classification'],
|
||||
comment: 'Classification rapide depuis panel latéral',
|
||||
confidence: 0.7,
|
||||
analyst: 'soc_user'
|
||||
})
|
||||
});
|
||||
alert(`Classification sauvegardée: ${label}`);
|
||||
} catch (error) {
|
||||
alert('Erreur lors de la classification');
|
||||
} finally {
|
||||
setClassifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative w-full max-w-md bg-background-secondary h-full shadow-2xl overflow-y-auto animate-slide-in-right">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-background-secondary border-b border-background-card p-6 z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
← Fermer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${entityType === 'ip' ? entityValue : ''}`)}
|
||||
className="text-accent-primary hover:text-accent-primary/80 text-sm transition-colors"
|
||||
>
|
||||
Vue complète →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-3xl">
|
||||
{entityType === 'ip' && '🌐'}
|
||||
{entityType === 'ja4' && '🔐'}
|
||||
{entityType === 'asn' && '🏢'}
|
||||
{entityType === 'host' && '🖥️'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-text-secondary uppercase tracking-wide">
|
||||
{entityType.toUpperCase()}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-text-primary break-all">
|
||||
{entityValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{loading ? (
|
||||
<div className="text-center text-text-secondary py-12">
|
||||
Chargement...
|
||||
</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatBox
|
||||
label="Détections"
|
||||
value={data.total_detections.toLocaleString()}
|
||||
/>
|
||||
<StatBox
|
||||
label="IPs Uniques"
|
||||
value={data.unique_ips.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Risk Score */}
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-sm text-text-secondary mb-2">Score de Risque Estimé</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`px-3 py-1 rounded font-bold text-white ${getSeverityColor(data.anomaly_score)}`}>
|
||||
{getSeverityLabel(data.anomaly_score)}
|
||||
</div>
|
||||
<div className="flex-1 bg-background-secondary rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full ${getSeverityColor(data.anomaly_score)}`}
|
||||
style={{ width: `${Math.min(100, Math.abs((data.anomaly_score || 0) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User-Agents */}
|
||||
{data.attributes?.user_agents && data.attributes.user_agents.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary mb-2">
|
||||
🤖 User-Agents ({data.attributes.user_agents.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(showAllUA ? data.attributes.user_agents : data.attributes.user_agents.slice(0, 5)).map((ua: any, idx: number) => (
|
||||
<div key={idx} className="bg-background-card rounded-lg p-3">
|
||||
<div className="text-xs text-text-primary font-mono break-all leading-relaxed">
|
||||
{ua.value}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary mt-1">
|
||||
{ua.count} détections • {ua.percentage.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.attributes.user_agents.length > 5 && (
|
||||
<button
|
||||
onClick={() => setShowAllUA(v => !v)}
|
||||
className="w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
|
||||
>
|
||||
{showAllUA ? '↑ Réduire' : `↓ Voir les ${data.attributes.user_agents.length - 5} autres`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JA4 Fingerprints */}
|
||||
{data.attributes?.ja4 && data.attributes.ja4.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary mb-2">
|
||||
🔐 JA4 Fingerprints ({data.attributes.ja4.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.attributes.ja4.slice(0, 5).map((ja4: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-background-card rounded-lg p-3 flex items-center justify-between cursor-pointer hover:bg-background-card/80 transition-colors"
|
||||
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(ja4.value)}`)}
|
||||
>
|
||||
<div className="font-mono text-xs text-text-primary break-all flex-1">
|
||||
{ja4.value}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary ml-2 whitespace-nowrap">
|
||||
{ja4.count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countries */}
|
||||
{data.attributes?.countries && data.attributes.countries.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-primary mb-2">
|
||||
🌍 Pays ({data.attributes.countries.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{data.attributes.countries.slice(0, 5).map((country: any, idx: number) => (
|
||||
<div key={idx} className="bg-background-card rounded-lg p-3 flex items-center gap-3">
|
||||
<span className="text-xl">
|
||||
{getCountryFlag(country.value)}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-text-primary">
|
||||
{country.value}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{country.percentage.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-text-primary font-bold">
|
||||
{country.count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Classification */}
|
||||
<div className="border-t border-background-card pt-6">
|
||||
<div className="text-sm font-semibold text-text-primary mb-4">
|
||||
⚡ Classification Rapide
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => handleQuickClassify('legitimate')}
|
||||
disabled={classifying}
|
||||
className="py-3 px-2 bg-threat-low/20 text-threat-low rounded-lg text-sm font-medium hover:bg-threat-low/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
✅ Légitime
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickClassify('suspicious')}
|
||||
disabled={classifying}
|
||||
className="py-3 px-2 bg-threat-medium/20 text-threat-medium rounded-lg text-sm font-medium hover:bg-threat-medium/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
⚠️ Suspect
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickClassify('malicious')}
|
||||
disabled={classifying}
|
||||
className="py-3 px-2 bg-threat-high/20 text-threat-high rounded-lg text-sm font-medium hover:bg-threat-high/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
❌ Malveillant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${entityType === 'ip' ? entityValue : ''}`)}
|
||||
className="flex-1 py-3 px-4 bg-accent-primary text-white rounded-lg text-sm font-medium hover:bg-accent-primary/80 transition-colors"
|
||||
>
|
||||
🔍 Investigation Complète
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Export IOC
|
||||
const blob = new Blob([JSON.stringify({
|
||||
type: entityType,
|
||||
value: entityValue,
|
||||
timestamp: new Date().toISOString()
|
||||
}, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ioc_${entityType}_${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}}
|
||||
className="py-3 px-4 bg-background-card text-text-primary rounded-lg text-sm font-medium hover:bg-background-card/80 transition-colors"
|
||||
>
|
||||
📤 Export IOC
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center text-text-secondary py-12">
|
||||
Aucune donnée disponible
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat Box Component
|
||||
function StatBox({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-xs text-text-secondary mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -80,15 +81,7 @@ function fuzzingBadgeClass(value: number): string {
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
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 }) {
|
||||
: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
|
||||
@ -14,6 +14,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -67,12 +68,6 @@ function detectType(input: string): EntityType {
|
||||
return 'ja4';
|
||||
}
|
||||
|
||||
function getCountryFlag(code: string): string {
|
||||
return (code || '').toUpperCase().replace(/./g, c =>
|
||||
String.fromCodePoint(c.charCodeAt(0) + 127397)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PivotView() {
|
||||
|
||||
@ -1,590 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { formatDateShort } { formatNumber; } from '../utils/dateUtils'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 ActiveTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return formatDateShort(iso);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function threatLevelBadge(level: string): { bg: string; text: string } {
|
||||
switch (level?.toLowerCase()) {
|
||||
case 'critical': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical' };
|
||||
case 'high': return { bg: 'bg-threat-high/20', text: 'text-threat-high' };
|
||||
case 'medium': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium' };
|
||||
case 'low': return { bg: 'bg-threat-low/20', text: 'text-threat-low' };
|
||||
default: return { bg: 'bg-background-card', text: 'text-text-secondary' };
|
||||
}
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
|
||||
<span className="text-text-secondary text-sm">{label}</span>
|
||||
<span className={`text-2xl font-bold ${accent ?? 'text-text-primary'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rotator row with expandable JA4 history ─────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isHighRotation = item.distinct_ja4_count > 5;
|
||||
|
||||
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 ${
|
||||
isHighRotation ? '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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function RotationView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('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(() => {
|
||||
const fetchRotators = async () => {
|
||||
setRotatorsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/rotation/ja4-rotators?limit=50');
|
||||
if (!res.ok) throw new Error('Erreur chargement des rotateurs');
|
||||
const data: { items: JA4Rotator[] } = await res.json();
|
||||
setRotators(data.items ?? []);
|
||||
} catch (err) {
|
||||
setRotatorsError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setRotatorsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRotators();
|
||||
}, []);
|
||||
|
||||
const loadPersistent = async () => {
|
||||
if (persistentLoaded) return;
|
||||
setPersistentLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/rotation/persistent-threats?limit=100');
|
||||
if (!res.ok) throw new Error('Erreur chargement des menaces persistantes');
|
||||
const data: { items: PersistentThreat[] } = await res.json();
|
||||
const sorted = [...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score);
|
||||
setPersistent(sorted);
|
||||
setPersistentLoaded(true);
|
||||
} catch (err) {
|
||||
setPersistentError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setPersistentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSophistication = async () => {
|
||||
if (sophisticationLoaded) return;
|
||||
setSophisticationLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/rotation/sophistication?limit=50');
|
||||
if (!res.ok) throw new Error('Erreur chargement sophistication');
|
||||
const data: { items: SophisticationItem[] } = await res.json();
|
||||
setSophistication(data.items ?? []);
|
||||
setSophisticationLoaded(true);
|
||||
} catch (err) {
|
||||
setSophisticationError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setSophisticationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadProactive = async () => {
|
||||
if (proactiveLoaded) return;
|
||||
setProactiveLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/rotation/proactive-hunt?min_recurrence=1&min_days=0&limit=50');
|
||||
if (!res.ok) throw new Error('Erreur chargement chasse proactive');
|
||||
const data: { items: ProactiveHuntItem[] } = await res.json();
|
||||
setProactive(data.items ?? []);
|
||||
setProactiveLoaded(true);
|
||||
} catch (err) {
|
||||
setProactiveError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
} finally {
|
||||
setProactiveLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = (tab: ActiveTab) => {
|
||||
setActiveTab(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 tabs: { id: ActiveTab; label: string }[] = [
|
||||
{ id: 'rotators', label: '🎭 Rotateurs JA4' },
|
||||
{ id: 'persistent', label: '🕰️ Menaces Persistantes' },
|
||||
{ id: 'sophistication', label: '🏆 Sophistication' },
|
||||
{ id: 'hunt', label: '🕵️ Chasse proactive' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">🔄 Rotation JA4 & Persistance</h1>
|
||||
<p className="text-text-secondary mt-1">
|
||||
Détection des IPs qui changent de fingerprint pour contourner les détections, et des menaces persistantes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-accent-primary border-b-2 border-accent-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rotateurs tab */}
|
||||
{activeTab === 'rotators' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{rotatorsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : rotatorsError ? (
|
||||
<div className="p-4"><ErrorMessage message={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 tab */}
|
||||
{activeTab === 'persistent' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{persistentLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : persistentError ? (
|
||||
<div className="p-4"><ErrorMessage message={persistentError} /></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">Récurrence</th>
|
||||
<th className="px-4 py-3">Score menace</th>
|
||||
<th className="px-4 py-3">Niveau</th>
|
||||
<th className="px-4 py-3">Première vue</th>
|
||||
<th className="px-4 py-3">Dernière vue</th>
|
||||
<th className="px-4 py-3">Score persistance</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{persistent.map((item) => {
|
||||
const badge = threatLevelBadge(item.worst_threat_level);
|
||||
return (
|
||||
<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">
|
||||
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">
|
||||
{item.recurrence}j
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary font-semibold">{Math.round(item.worst_score)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>
|
||||
{item.worst_threat_level?.toUpperCase() || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">{formatDate(item.first_seen)}</td>
|
||||
<td className="px-4 py-3 text-text-secondary text-xs">{formatDate(item.last_seen)}</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 bg-threat-high"
|
||||
style={{ width: `${Math.min(item.persistence_score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-secondary">{Math.round(item.persistence_score)}</span>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sophistication tab */}
|
||||
{activeTab === 'sophistication' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{sophisticationLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : sophisticationError ? (
|
||||
<div className="p-4"><ErrorMessage message={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>
|
||||
<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">Rotation JA4</th>
|
||||
<th className="px-4 py-3">Récurrence</th>
|
||||
<th className="px-4 py-3">Hits bruteforce</th>
|
||||
<th className="px-4 py-3">Score sophistication</th>
|
||||
<th className="px-4 py-3">Tier</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sophistication.map((item) => {
|
||||
const tb = tierBadge(item.tier);
|
||||
return (
|
||||
<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">
|
||||
<span className="bg-threat-medium/10 text-threat-medium text-xs px-2 py-1 rounded-full">
|
||||
{item.ja4_rotation_count} JA4
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary">{item.recurrence}</td>
|
||||
<td className="px-4 py-3 text-text-primary">{formatNumber(item.bruteforce_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.sophistication_score, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-threat-critical">
|
||||
{item.sophistication_score}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${tb.bg} ${tb.text} font-semibold`}>
|
||||
{item.tier}
|
||||
</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>
|
||||
{sophistication.length === 0 && (
|
||||
<div className="text-center py-8 text-text-secondary text-sm">Aucune donnée de sophistication disponible.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chasse proactive tab */}
|
||||
{activeTab === 'hunt' && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
{proactiveLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : proactiveError ? (
|
||||
<div className="p-4"><ErrorMessage message={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>
|
||||
<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">Récurrence</th>
|
||||
<th className="px-4 py-3">Score max</th>
|
||||
<th className="px-4 py-3">Jours actifs</th>
|
||||
<th className="px-4 py-3">Timeline</th>
|
||||
<th className="px-4 py-3">Évaluation</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proactive.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">
|
||||
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">
|
||||
{item.recurrence}×
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-threat-medium font-semibold">{item.worst_score.toFixed(3)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-primary font-medium">{item.days_active}j</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-xs text-text-secondary space-y-0.5">
|
||||
<div><span className="text-text-disabled">Premier:</span> {formatDate(item.first_seen)}</div>
|
||||
<div><span className="text-text-disabled">Dernier:</span> {formatDate(item.last_seen)}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-semibold ${
|
||||
item.risk_assessment === 'Évadeur potentiel'
|
||||
? 'bg-threat-critical/20 text-threat-critical'
|
||||
: 'bg-threat-medium/20 text-threat-medium'
|
||||
}`}>
|
||||
{item.risk_assessment}
|
||||
</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"
|
||||
>
|
||||
Lancer investigation
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{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,182 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface SearchResult {
|
||||
type: 'ip' | 'ja4' | 'host' | 'asn';
|
||||
value: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
url: string;
|
||||
investigation_url?: string;
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<string, string> = {
|
||||
ip: '🌐',
|
||||
ja4: '🔏',
|
||||
host: '🖥️',
|
||||
asn: '🏢',
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
ip: 'IP',
|
||||
ja4: 'JA4',
|
||||
host: 'Host',
|
||||
asn: 'ASN',
|
||||
};
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SearchModal({ open, onClose }: SearchModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setSelected(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const search = useCallback(async (q: string) => {
|
||||
if (q.length < 2) { setResults([]); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(q)}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
setSelected(0);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setQuery(val);
|
||||
if (debounce.current) clearTimeout(debounce.current);
|
||||
debounce.current = setTimeout(() => search(val), 200);
|
||||
};
|
||||
|
||||
const go = (result: SearchResult, useInvestigation = false) => {
|
||||
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
|
||||
navigate(url);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { onClose(); return; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, results.length - 1)); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)); }
|
||||
if (e.key === 'Enter' && results[selected]) {
|
||||
go(results[selected], e.metaKey || e.ctrlKey);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative w-full max-w-2xl bg-background-secondary border border-background-card rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-background-card">
|
||||
<span className="text-text-disabled text-lg">🔍</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder="Rechercher IP, JA4, host, ASN..."
|
||||
className="flex-1 bg-transparent text-text-primary placeholder-text-disabled outline-none text-base"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{loading && (
|
||||
<span className="text-text-disabled text-xs animate-pulse">…</span>
|
||||
)}
|
||||
<kbd className="hidden sm:inline text-xs text-text-disabled bg-background-card px-1.5 py-0.5 rounded border border-border">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<ul className="max-h-96 overflow-y-auto py-2">
|
||||
{results.map((r, i) => (
|
||||
<li key={`${r.type}-${r.value}-${i}`}>
|
||||
<button
|
||||
className={[
|
||||
'w-full flex items-start gap-3 px-4 py-2.5 text-left transition-colors',
|
||||
i === selected
|
||||
? 'bg-accent-primary/10 border-l-2 border-accent-primary'
|
||||
: 'hover:bg-background-card/50 border-l-2 border-transparent',
|
||||
].join(' ')}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
onClick={() => go(r)}
|
||||
>
|
||||
<span className="mt-0.5 text-base">{TYPE_ICON[r.type]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-text-disabled tracking-wider">
|
||||
{TYPE_LABEL[r.type]}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-text-primary truncate">{r.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary mt-0.5">{r.meta}</div>
|
||||
</div>
|
||||
{r.investigation_url && (
|
||||
<button
|
||||
className="shrink-0 text-xs text-accent-primary hover:underline"
|
||||
onClick={e => { e.stopPropagation(); go(r, true); }}
|
||||
title="Ouvrir l'investigation complète"
|
||||
>
|
||||
Investigation →
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{query.length >= 2 && !loading && results.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-text-disabled text-sm">
|
||||
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer hints */}
|
||||
<div className="px-4 py-2 border-t border-background-card flex items-center gap-4 text-xs text-text-disabled">
|
||||
<span><kbd className="bg-background-card px-1 rounded">↑↓</kbd> naviguer</span>
|
||||
<span><kbd className="bg-background-card px-1 rounded">↵</kbd> ouvrir</span>
|
||||
<span><kbd className="bg-background-card px-1 rounded">⌘↵</kbd> investigation</span>
|
||||
<span className="ml-auto">Recherche sur les 24 dernières heures</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDateShort } from '../utils/dateUtils';
|
||||
import { getCountryFlag } from '../utils/countryUtils';
|
||||
|
||||
interface SubnetIP {
|
||||
ip: string;
|
||||
@ -62,9 +63,7 @@ export function SubnetInvestigation() {
|
||||
fetchSubnet();
|
||||
}, [formattedSubnet]);
|
||||
|
||||
const getCountryFlag = (code: string) => {
|
||||
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
};
|
||||
;
|
||||
|
||||
const getThreatLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
@ -76,11 +75,6 @@ export function SubnetInvestigation() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '-';
|
||||
return formatDateShort(dateString);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@ -164,7 +158,7 @@ export function SubnetInvestigation() {
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-sm text-text-secondary mb-1">Période</div>
|
||||
<div className="text-sm text-text-primary font-medium">
|
||||
{formatDate(stats.first_seen)} – {formatDate(stats.last_seen)}
|
||||
{formatDateShort(stats.first_seen)} – {formatDateShort(stats.last_seen)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatNumber } from '../utils/dateUtils';
|
||||
import { LoadingSpinner, ErrorMessage } from './ui/Feedback';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -109,15 +110,7 @@ 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 }) {
|
||||
: { message: string }) {
|
||||
return (
|
||||
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
|
||||
⚠️ {message}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PREDEFINED_TAGS } from '../../utils/classifications';
|
||||
|
||||
interface CorrelationIndicators {
|
||||
subnet_ips_count: number;
|
||||
@ -22,26 +23,6 @@ interface CorrelationSummaryProps {
|
||||
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
|
||||
}
|
||||
|
||||
const PREDEFINED_TAGS = [
|
||||
'scraping',
|
||||
'bot-network',
|
||||
'scanner',
|
||||
'bruteforce',
|
||||
'data-exfil',
|
||||
'ddos',
|
||||
'spam',
|
||||
'proxy',
|
||||
'tor',
|
||||
'vpn',
|
||||
'hosting-asn',
|
||||
'distributed',
|
||||
'ja4-rotation',
|
||||
'ua-rotation',
|
||||
'country-cn',
|
||||
'country-us',
|
||||
'country-ru',
|
||||
];
|
||||
|
||||
export function CorrelationSummary({ ip, onClassify }: CorrelationSummaryProps) {
|
||||
const [data, setData] = useState<ClassificationRecommendation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PREDEFINED_TAGS_JA4 } from '../../utils/classifications';
|
||||
|
||||
interface CorrelationIndicators {
|
||||
subnet_ips_count: number;
|
||||
@ -22,29 +23,6 @@ interface JA4CorrelationSummaryProps {
|
||||
onClassify?: (label: string, tags: string[], comment: string, confidence: number) => void;
|
||||
}
|
||||
|
||||
const PREDEFINED_TAGS = [
|
||||
'scraping',
|
||||
'bot-network',
|
||||
'scanner',
|
||||
'bruteforce',
|
||||
'data-exfil',
|
||||
'ddos',
|
||||
'spam',
|
||||
'proxy',
|
||||
'tor',
|
||||
'vpn',
|
||||
'hosting-asn',
|
||||
'distributed',
|
||||
'ja4-rotation',
|
||||
'ua-rotation',
|
||||
'country-cn',
|
||||
'country-us',
|
||||
'country-ru',
|
||||
'known-bot',
|
||||
'crawler',
|
||||
'search-engine',
|
||||
];
|
||||
|
||||
export function JA4CorrelationSummary({ ja4, onClassify }: JA4CorrelationSummaryProps) {
|
||||
const [data, setData] = useState<JA4ClassificationRecommendation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -309,7 +287,7 @@ export function JA4CorrelationSummary({ ja4, onClassify }: JA4CorrelationSummary
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-text-secondary mb-3">Tags</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PREDEFINED_TAGS.map(tag => (
|
||||
{PREDEFINED_TAGS_JA4.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
|
||||
36
frontend/src/utils/classifications.ts
Normal file
36
frontend/src/utils/classifications.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Tags prédéfinis pour la classification SOC.
|
||||
*
|
||||
* Utilisé par BulkClassification, CorrelationSummary, JA4CorrelationSummary.
|
||||
* Ajouter de nouveaux tags ici pour les propager partout.
|
||||
*/
|
||||
export const PREDEFINED_TAGS: readonly string[] = [
|
||||
'scraping',
|
||||
'bot-network',
|
||||
'scanner',
|
||||
'bruteforce',
|
||||
'data-exfil',
|
||||
'ddos',
|
||||
'spam',
|
||||
'proxy',
|
||||
'tor',
|
||||
'vpn',
|
||||
'hosting-asn',
|
||||
'distributed',
|
||||
'ja4-rotation',
|
||||
'ua-rotation',
|
||||
'country-cn',
|
||||
'country-us',
|
||||
'country-ru',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tags supplémentaires spécifiques aux fingerprints JA4.
|
||||
* S'étend de PREDEFINED_TAGS.
|
||||
*/
|
||||
export const PREDEFINED_TAGS_JA4: readonly string[] = [
|
||||
...PREDEFINED_TAGS,
|
||||
'known-bot',
|
||||
'crawler',
|
||||
'search-engine',
|
||||
];
|
||||
Reference in New Issue
Block a user