- Backend: Add score_type query parameter to filter detections by threat level (BOT, REGLE, BOT_REGLE, SCORE) - Frontend: Add score_type dropdown filter in DetectionsList component - Frontend: Add IP detection route redirect (/detections/ip/:ip → /investigation/:ip) - Frontend: Add DetectionAttributesSection component showing variability metrics - API client: Update detectionsApi to support score_type parameter Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
638 lines
24 KiB
TypeScript
638 lines
24 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { useDetections } from '../hooks/useDetections';
|
|
import DataTable, { Column } from './ui/DataTable';
|
|
import { InfoTip } from './ui/Tooltip';
|
|
import { TIPS } from './ui/tooltips';
|
|
import { formatDate, formatDateOnly, formatTimeOnly } from '../utils/dateUtils';
|
|
|
|
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
|
|
type SortOrder = 'asc' | 'desc';
|
|
|
|
interface ColumnConfig {
|
|
key: string;
|
|
label: string;
|
|
visible: boolean;
|
|
sortable: boolean;
|
|
}
|
|
|
|
interface DetectionRow {
|
|
src_ip: string;
|
|
ja4?: string;
|
|
host?: string;
|
|
client_headers?: string;
|
|
model_name: string;
|
|
anomaly_score: number;
|
|
threat_level?: string;
|
|
bot_name?: string;
|
|
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[];
|
|
anubis_bot_name?: string;
|
|
anubis_bot_action?: string;
|
|
anubis_bot_category?: string;
|
|
}
|
|
|
|
export function DetectionsList() {
|
|
const navigate = useNavigate();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const page = parseInt(searchParams.get('page') || '1');
|
|
const modelName = searchParams.get('model_name') || undefined;
|
|
const search = searchParams.get('search') || undefined;
|
|
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'detected_at') as SortField;
|
|
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'desc') as SortOrder;
|
|
const scoreType = searchParams.get('score_type') || undefined;
|
|
|
|
const [groupByIP, setGroupByIP] = useState(true);
|
|
|
|
const { data, loading, error } = useDetections({
|
|
page,
|
|
page_size: 25,
|
|
model_name: modelName,
|
|
search,
|
|
sort_by: sortField,
|
|
sort_order: sortOrder,
|
|
group_by_ip: groupByIP,
|
|
score_type: scoreType,
|
|
});
|
|
|
|
const [searchInput, setSearchInput] = useState(search || '');
|
|
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
|
const [columns, setColumns] = useState<ColumnConfig[]>([
|
|
{ key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true },
|
|
{ key: 'host', label: 'Host', visible: true, sortable: true },
|
|
{ key: 'client_headers', label: 'Client Headers', visible: false, sortable: false },
|
|
{ key: 'model_name', label: 'Modèle', visible: true, sortable: true },
|
|
{ key: 'anomaly_score', label: 'Score', visible: true, sortable: true },
|
|
{ key: 'anubis', label: '🤖 Anubis', visible: true, sortable: false },
|
|
{ key: 'hits', label: 'Hits', visible: true, sortable: true },
|
|
{ key: 'hit_velocity', label: 'Velocity', visible: true, sortable: true },
|
|
{ key: 'asn', label: 'ASN', visible: true, sortable: true },
|
|
{ key: 'country', label: 'Pays', visible: true, sortable: true },
|
|
{ key: 'detected_at', label: 'Date', visible: true, sortable: true },
|
|
]);
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const newParams = new URLSearchParams(searchParams);
|
|
if (searchInput.trim()) {
|
|
newParams.set('search', searchInput.trim());
|
|
} else {
|
|
newParams.delete('search');
|
|
}
|
|
newParams.set('page', '1');
|
|
setSearchParams(newParams);
|
|
};
|
|
|
|
const handleFilterChange = (key: string, value: string) => {
|
|
const newParams = new URLSearchParams(searchParams);
|
|
if (value) {
|
|
newParams.set(key, value);
|
|
} else {
|
|
newParams.delete(key);
|
|
}
|
|
newParams.set('page', '1');
|
|
setSearchParams(newParams);
|
|
};
|
|
|
|
const toggleColumn = (key: string) => {
|
|
setColumns(cols => cols.map(col =>
|
|
col.key === key ? { ...col, visible: !col.visible } : col
|
|
));
|
|
};
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
const newParams = new URLSearchParams(searchParams);
|
|
newParams.set('page', newPage.toString());
|
|
setSearchParams(newParams);
|
|
};
|
|
|
|
const handleSort = (key: string, dir: 'asc' | 'desc') => {
|
|
const newParams = new URLSearchParams(searchParams);
|
|
newParams.set('sort_by', key);
|
|
newParams.set('sort_order', dir);
|
|
newParams.set('page', '1');
|
|
setSearchParams(newParams);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-text-secondary">Chargement...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-4">
|
|
<p className="text-threat-critical">Erreur: {error.message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data) return null;
|
|
|
|
// Backend handles grouping — data is already grouped when groupByIP=true
|
|
const processedData = data;
|
|
|
|
// 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 'anubis':
|
|
return {
|
|
key: 'anubis_bot_name',
|
|
label: (
|
|
<span className="inline-flex items-center gap-1">
|
|
🤖 Anubis
|
|
<InfoTip content={TIPS.anubis_identification} />
|
|
</span>
|
|
),
|
|
sortable: false,
|
|
render: (_, row) => {
|
|
const name = row.anubis_bot_name;
|
|
const action = row.anubis_bot_action;
|
|
const category = row.anubis_bot_category;
|
|
if (!name) return <span className="text-text-disabled text-xs">—</span>;
|
|
const actionColor =
|
|
action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' :
|
|
action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' :
|
|
'bg-yellow-500/15 text-yellow-400 border-yellow-500/30';
|
|
return (
|
|
<div className="space-y-0.5">
|
|
<div className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${actionColor}`}>
|
|
<span className="font-medium">{name}</span>
|
|
</div>
|
|
<div className="flex gap-1 flex-wrap">
|
|
{action && <span className="text-[10px] text-text-secondary">{action}</span>}
|
|
{category && <span className="text-[10px] text-text-disabled">· {category}</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
};
|
|
case 'anomaly_score':
|
|
return {
|
|
key: 'anomaly_score',
|
|
label: col.label,
|
|
sortable: true,
|
|
align: 'right' as const,
|
|
render: (_, row) => (
|
|
<ScoreBadge
|
|
score={row.anomaly_score}
|
|
threatLevel={row.threat_level}
|
|
botName={row.bot_name}
|
|
anubisAction={row.anubis_bot_action}
|
|
/>
|
|
),
|
|
};
|
|
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 ? (() => {
|
|
const first = new Date(row.first_seen!);
|
|
const last = new Date(row.last_seen!);
|
|
const sameTime = first.getTime() === last.getTime();
|
|
const fmt = (d: Date) => formatDate(d.toISOString());
|
|
return sameTime ? (
|
|
<div className="text-xs text-text-secondary">{fmt(last)}</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<div className="text-xs text-text-secondary">
|
|
<span className="font-medium">Premier:</span> {fmt(first)}
|
|
</div>
|
|
<div className="text-xs text-text-secondary">
|
|
<span className="font-medium">Dernier:</span> {fmt(last)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})() : (
|
|
<>
|
|
<div className="text-sm text-text-primary">
|
|
{formatDateOnly(row.detected_at)}
|
|
</div>
|
|
<div className="text-xs text-text-secondary">
|
|
{formatTimeOnly(row.detected_at)}
|
|
</div>
|
|
</>
|
|
),
|
|
};
|
|
default:
|
|
return { key: col.key, label: col.label, sortable: col.sortable };
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-4 animate-fade-in">
|
|
{/* En-tête */}
|
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
|
|
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
|
<span>{data.items.length}</span>
|
|
<span>→</span>
|
|
<span>{data.total} détections</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{/* Toggle Grouper par IP */}
|
|
<button
|
|
onClick={() => setGroupByIP(!groupByIP)}
|
|
className={`border rounded-lg px-4 py-2 text-sm transition-colors ${
|
|
groupByIP
|
|
? 'bg-accent-primary text-white border-accent-primary'
|
|
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
|
}`}
|
|
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
|
|
>
|
|
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
|
|
</button>
|
|
|
|
{/* Sélecteur de colonnes */}
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
|
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-primary transition-colors"
|
|
>
|
|
Colonnes ▾
|
|
</button>
|
|
|
|
{showColumnSelector && (
|
|
<div className="absolute right-0 mt-2 w-48 bg-background-secondary border border-background-card rounded-lg shadow-lg z-10 p-2">
|
|
<p className="text-xs text-text-secondary mb-2 px-2">Afficher les colonnes</p>
|
|
{columns.map(col => (
|
|
<label
|
|
key={col.key}
|
|
className="flex items-center gap-2 px-2 py-1.5 hover:bg-background-card rounded cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={col.visible}
|
|
onChange={() => toggleColumn(col.key)}
|
|
className="rounded bg-background-card border-background-card text-accent-primary"
|
|
/>
|
|
<span className="text-sm text-text-primary">{col.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recherche */}
|
|
<form onSubmit={handleSearch} className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
placeholder="Rechercher IP, JA4, Host..."
|
|
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-64"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
|
|
>
|
|
Rechercher
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filtres */}
|
|
<div className="bg-background-secondary rounded-lg p-4">
|
|
<div className="flex flex-wrap gap-3 items-center">
|
|
<select
|
|
value={modelName || ''}
|
|
onChange={(e) => handleFilterChange('model_name', e.target.value)}
|
|
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
|
>
|
|
<option value="">Tous modèles</option>
|
|
<option value="Complet">Complet</option>
|
|
<option value="Applicatif">Applicatif</option>
|
|
</select>
|
|
|
|
<select
|
|
value={scoreType || ''}
|
|
onChange={(e) => handleFilterChange('score_type', e.target.value)}
|
|
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
|
>
|
|
<option value="">Tous types de score</option>
|
|
<option value="BOT">🟢 BOT seulement</option>
|
|
<option value="REGLE">🔴 RÈGLE seulement</option>
|
|
<option value="BOT_REGLE">BOT + RÈGLE</option>
|
|
<option value="SCORE">Score numérique seulement</option>
|
|
</select>
|
|
|
|
{(modelName || scoreType || search || sortField !== 'detected_at') && (
|
|
<button
|
|
onClick={() => setSearchParams({})}
|
|
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-secondary hover:text-text-primary transition-colors"
|
|
>
|
|
Effacer filtres
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tableau */}
|
|
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
|
<DataTable<DetectionRow>
|
|
data={processedData.items as DetectionRow[]}
|
|
columns={tableColumns}
|
|
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
|
|
defaultSortKey={sortField}
|
|
defaultSortDir={sortOrder}
|
|
onSort={handleSort}
|
|
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
|
|
emptyMessage="Aucune détection trouvée"
|
|
compact
|
|
/>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{data.total_pages > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-text-secondary text-sm">
|
|
Page {data.page} sur {data.total_pages} ({data.total} détections)
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handlePageChange(data.page - 1)}
|
|
disabled={data.page === 1}
|
|
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
|
>
|
|
← Précédent
|
|
</button>
|
|
<button
|
|
onClick={() => handlePageChange(data.page + 1)}
|
|
disabled={data.page === data.total_pages}
|
|
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
|
>
|
|
Suivant →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Composant ModelBadge
|
|
function ModelBadge({ model }: { model: string }) {
|
|
const styles: Record<string, string> = {
|
|
Complet: 'bg-accent-primary/20 text-accent-primary',
|
|
Applicatif: 'bg-purple-500/20 text-purple-400',
|
|
};
|
|
|
|
return (
|
|
<span className={`${styles[model] || 'bg-background-card'} px-2 py-1 rounded text-xs`}>
|
|
{model}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Composant ScoreBadge
|
|
// Les scores non-IF (ANUBIS_DENY, KNOWN_BOT) sont stockés comme sentinels
|
|
// (-1.0 et 0.0) et doivent être affichés comme des badges textuels,
|
|
// pas comme des scores numériques calculés par l'IsolationForest.
|
|
function ScoreBadge({
|
|
score,
|
|
threatLevel,
|
|
botName,
|
|
anubisAction,
|
|
}: {
|
|
score: number;
|
|
threatLevel?: string;
|
|
botName?: string;
|
|
anubisAction?: string;
|
|
}) {
|
|
// ANUBIS_DENY : menace identifiée par règle, pas par IF
|
|
if (threatLevel === 'ANUBIS_DENY' || anubisAction === 'DENY') {
|
|
return (
|
|
<span className="inline-flex items-center text-xs px-1.5 py-0.5 rounded border bg-red-500/15 text-red-400 border-red-500/30 font-medium">
|
|
RÈGLE
|
|
</span>
|
|
);
|
|
}
|
|
// KNOWN_BOT : bot légitime identifié par dictionnaire ou Anubis ALLOW
|
|
if (threatLevel === 'KNOWN_BOT' || (botName && botName !== '')) {
|
|
return (
|
|
<span className="inline-flex items-center text-xs px-1.5 py-0.5 rounded border bg-green-500/15 text-green-400 border-green-500/30 font-medium">
|
|
BOT
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Score IF réel
|
|
let color = 'text-threat-low';
|
|
if (score < -0.3) color = 'text-threat-critical';
|
|
else if (score < -0.15) color = 'text-threat-high';
|
|
else if (score < -0.05) color = 'text-threat-medium';
|
|
|
|
return (
|
|
<span className={`font-mono text-sm ${color}`}>
|
|
{score.toFixed(3)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Helper pour les drapeaux
|
|
function getFlag(countryCode: string): string {
|
|
const code = countryCode.toUpperCase();
|
|
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
|
}
|
|
|
|
// Badge de réputation ASN
|
|
function AsnRepBadge({ score, label }: { score?: number | null; label?: string }) {
|
|
if (score == null) return null;
|
|
let bg: string;
|
|
let text: string;
|
|
let display: string;
|
|
if (score < 0.3) {
|
|
bg = 'bg-threat-critical/20';
|
|
text = 'text-threat-critical';
|
|
} else if (score < 0.6) {
|
|
bg = 'bg-threat-medium/20';
|
|
text = 'text-threat-medium';
|
|
} else {
|
|
bg = 'bg-threat-low/20';
|
|
text = 'text-threat-low';
|
|
}
|
|
display = label || (score < 0.3 ? 'malicious' : score < 0.6 ? 'suspect' : 'ok');
|
|
return (
|
|
<span className={`mt-1 inline-block text-xs px-1.5 py-0.5 rounded ${bg} ${text}`}>
|
|
{display}
|
|
</span>
|
|
);
|
|
}
|