feat: 6 améliorations SOC — synthèse IP, baseline, sophistication, chasse proactive, badge ASN, 2 nouveaux onglets rotation
- investigation_summary.py: nouveau endpoint GET /api/investigation/{ip}/summary
agrège 6 sources (ML, bruteforce, TCP spoofing, JA4 rotation, persistance, timeline 24h)
en un score de risque 0-100 avec signaux détaillés
- InvestigationView.tsx: widget IPActivitySummary avec jauge Risk Score SVG,
badges multi-sources et mini-timeline 24h barres
- metrics.py: endpoint GET /api/metrics/baseline — comparaison 24h vs hier
(total détections, IPs uniques, alertes CRITICAL) avec % de variation
- IncidentsView.tsx: widget baseline avec ▲▼ sur le dashboard principal
- rotation.py: endpoints /sophistication et /proactive-hunt
Score sophistication = JOIN 3 tables (rotation×10 + récurrence×20 + log(bf+1)×5)
Chasse proactive = IPs récurrentes sous le seuil ML (abs(score) < 0.5)
- RotationView.tsx: onglets 🏆 Sophistication et 🕵️ Chasse proactive
avec tier APT-like/Advanced/Automated/Basic et boutons investigation
- detections.py: LEFT JOIN asn_reputation, badge coloré rouge/orange/vert
selon label (bot/scanner → score 0.05, human → 0.9)
- models.py: ajout champs asn_score et asn_rep_label dans Detection
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -58,6 +58,8 @@ export interface Detection {
|
||||
post_ratio: number;
|
||||
reason: string;
|
||||
client_headers: string;
|
||||
asn_score?: number | null;
|
||||
asn_rep_label?: string;
|
||||
}
|
||||
|
||||
export interface DetectionsListResponse {
|
||||
|
||||
@ -451,6 +451,7 @@ export function DetectionsList() {
|
||||
{detection.asn_number && (
|
||||
<div className="text-xs text-text-secondary">AS{detection.asn_number}</div>
|
||||
)}
|
||||
<AsnRepBadge score={detection.asn_score} label={detection.asn_rep_label} />
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@ -570,3 +571,27 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,10 +30,22 @@ interface MetricsSummary {
|
||||
unique_ips: number;
|
||||
}
|
||||
|
||||
interface BaselineMetric {
|
||||
today: number;
|
||||
yesterday: number;
|
||||
pct_change: number;
|
||||
}
|
||||
interface BaselineData {
|
||||
total_detections: BaselineMetric;
|
||||
unique_ips: BaselineMetric;
|
||||
critical_alerts: BaselineMetric;
|
||||
}
|
||||
|
||||
export function IncidentsView() {
|
||||
const navigate = useNavigate();
|
||||
const [clusters, setClusters] = useState<IncidentCluster[]>([]);
|
||||
const [metrics, setMetrics] = useState<MetricsSummary | null>(null);
|
||||
const [baseline, setBaseline] = useState<BaselineData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedClusters, setSelectedClusters] = useState<Set<string>>(new Set());
|
||||
|
||||
@ -47,6 +59,11 @@ export function IncidentsView() {
|
||||
setMetrics(metricsData.summary);
|
||||
}
|
||||
|
||||
const baselineResponse = await fetch('/api/metrics/baseline');
|
||||
if (baselineResponse.ok) {
|
||||
setBaseline(await baselineResponse.json());
|
||||
}
|
||||
|
||||
const clustersResponse = await fetch('/api/incidents/clusters');
|
||||
if (clustersResponse.ok) {
|
||||
const clustersData = await clustersResponse.json();
|
||||
@ -126,6 +143,38 @@ export function IncidentsView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baseline comparison */}
|
||||
{baseline && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{([
|
||||
{ key: 'total_detections', label: 'Détections 24h', icon: '📊' },
|
||||
{ key: 'unique_ips', label: 'IPs uniques', icon: '🖥️' },
|
||||
{ key: 'critical_alerts', label: 'Alertes CRITICAL', icon: '🔴' },
|
||||
] as { key: keyof BaselineData; label: string; icon: string }[]).map(({ key, label, icon }) => {
|
||||
const m = baseline[key];
|
||||
const up = m.pct_change > 0;
|
||||
const neutral = m.pct_change === 0;
|
||||
return (
|
||||
<div key={key} className="bg-background-card border border-border rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-text-disabled uppercase tracking-wide">{label}</div>
|
||||
<div className="text-xl font-bold text-text-primary">{m.today.toLocaleString('fr-FR')}</div>
|
||||
<div className="text-xs text-text-secondary">hier: {m.yesterday.toLocaleString('fr-FR')}</div>
|
||||
</div>
|
||||
<div className={`text-sm font-bold px-2 py-1 rounded ${
|
||||
neutral ? 'text-text-disabled' :
|
||||
up ? 'text-threat-critical bg-threat-critical/10' :
|
||||
'text-threat-low bg-threat-low/10'
|
||||
}`}>
|
||||
{neutral ? '=' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Critical Metrics */}
|
||||
{metrics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
|
||||
@ -8,7 +8,163 @@ import { CorrelationSummary } from './analysis/CorrelationSummary';
|
||||
import { CorrelationGraph } from './CorrelationGraph';
|
||||
import { ReputationPanel } from './ReputationPanel';
|
||||
|
||||
// ─── Spoofing Coherence Widget ─────────────────────────────────────────────
|
||||
// ─── Multi-source Activity Summary Widget ─────────────────────────────────────
|
||||
|
||||
interface IPSummary {
|
||||
ip: string;
|
||||
risk_score: number;
|
||||
ml: { max_score: number; threat_level: string; attack_type: string; total_detections: number; distinct_hosts: number; distinct_ja4: number };
|
||||
bruteforce: { active: boolean; hosts_attacked: number; total_hits: number; total_params: number; top_hosts: string[] };
|
||||
tcp_spoofing: { detected: boolean; tcp_ttl: number | null; suspected_os: string | null; declared_os: string | null };
|
||||
ja4_rotation: { rotating: boolean; distinct_ja4_count: number; total_hits?: number };
|
||||
persistence: { persistent: boolean; recurrence: number; worst_score?: number; worst_threat_level?: string; first_seen?: string; last_seen?: string };
|
||||
timeline_24h: { hour: number; hits: number; ja4s: string[] }[];
|
||||
}
|
||||
|
||||
function RiskGauge({ score }: { score: number }) {
|
||||
const color = score >= 75 ? '#ef4444' : score >= 50 ? '#f97316' : score >= 25 ? '#eab308' : '#22c55e';
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<svg width="80" height="80" viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke="rgba(100,116,139,0.2)" strokeWidth="8" />
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke={color} strokeWidth="8"
|
||||
strokeDasharray={`${(score / 100) * 213.6} 213.6`}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)" />
|
||||
<text x="40" y="44" textAnchor="middle" fontSize="18" fontWeight="bold" fill={color}>{score}</text>
|
||||
</svg>
|
||||
<span className="text-xs text-text-secondary">Risk Score</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityBadge({ active, label, color }: { active: boolean; label: string; color: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium ${
|
||||
active ? `border-${color}/40 bg-${color}/10 text-${color}` : 'border-border bg-background-card text-text-disabled'
|
||||
}`}>
|
||||
<span>{active ? '●' : '○'}</span>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
|
||||
if (!data.length) return <span className="text-text-disabled text-xs">Pas d'activité 24h</span>;
|
||||
const max = Math.max(...data.map(d => d.hits), 1);
|
||||
return (
|
||||
<div className="flex items-end gap-0.5 h-8">
|
||||
{Array.from({ length: 24 }, (_, h) => {
|
||||
const d = data.find(x => x.hour === h);
|
||||
const pct = d ? (d.hits / max) * 100 : 0;
|
||||
return (
|
||||
<div key={h} className="flex-1 flex flex-col justify-end" title={d ? `${h}h: ${d.hits} hits` : `${h}h: 0`}>
|
||||
<div className={`w-full rounded-sm ${pct > 0 ? 'bg-accent-primary' : 'bg-background-card'}`}
|
||||
style={{ height: `${Math.max(pct, 2)}%` }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IPActivitySummary({ ip }: { ip: string }) {
|
||||
const [data, setData] = useState<IPSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/investigation/${encodeURIComponent(ip)}/summary`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => setData(d))
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false));
|
||||
}, [ip]);
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg border border-border">
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 hover:bg-background-card/50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-text-primary flex items-center gap-2">
|
||||
🔎 Synthèse multi-sources
|
||||
{data && <span className={`text-xs px-2 py-0.5 rounded-full font-bold ${
|
||||
data.risk_score >= 75 ? 'bg-threat-critical/20 text-threat-critical' :
|
||||
data.risk_score >= 50 ? 'bg-threat-high/20 text-threat-high' :
|
||||
data.risk_score >= 25 ? 'bg-threat-medium/20 text-threat-medium' :
|
||||
'bg-threat-low/20 text-threat-low'
|
||||
}`}>Score: {data.risk_score}</span>}
|
||||
</span>
|
||||
<span className="text-text-secondary">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-5 pb-5">
|
||||
{loading && <div className="text-text-disabled text-sm py-4">Chargement des données multi-sources…</div>}
|
||||
{!loading && !data && <div className="text-text-disabled text-sm py-4">Données insuffisantes pour cette IP.</div>}
|
||||
{data && (
|
||||
<div className="space-y-4">
|
||||
{/* Risk + badges row */}
|
||||
<div className="flex items-start gap-6">
|
||||
<RiskGauge score={data.risk_score} />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ActivityBadge active={data.ml.total_detections > 0} label={`ML: ${data.ml.total_detections} détections`} color="threat-critical" />
|
||||
<ActivityBadge active={data.bruteforce.active} label={`Brute Force: ${data.bruteforce.hosts_attacked} hosts`} color="threat-high" />
|
||||
<ActivityBadge active={data.tcp_spoofing.detected} label={`TCP Spoof: TTL ${data.tcp_spoofing.tcp_ttl ?? '—'}`} color="threat-medium" />
|
||||
<ActivityBadge active={data.ja4_rotation.rotating} label={`JA4 Rotation: ${data.ja4_rotation.distinct_ja4_count} signatures`} color="threat-medium" />
|
||||
<ActivityBadge active={data.persistence.persistent} label={`Persistance: ${data.persistence.recurrence}x récurrences`} color="threat-high" />
|
||||
</div>
|
||||
{/* Detail grid */}
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
{data.ml.total_detections > 0 && (
|
||||
<div className="bg-background-card rounded p-2">
|
||||
<div className="text-text-disabled mb-1">ML Detection</div>
|
||||
<div className="text-text-primary font-medium">{data.ml.threat_level || '—'} · {data.ml.attack_type || '—'}</div>
|
||||
<div className="text-text-secondary">Score: {data.ml.max_score} · {data.ml.distinct_ja4} JA4(s)</div>
|
||||
</div>
|
||||
)}
|
||||
{data.bruteforce.active && (
|
||||
<div className="bg-background-card rounded p-2">
|
||||
<div className="text-text-disabled mb-1">Brute Force</div>
|
||||
<div className="text-threat-high font-medium">{data.bruteforce.total_hits.toLocaleString('fr-FR')} hits</div>
|
||||
<div className="text-text-secondary truncate" title={data.bruteforce.top_hosts.join(', ')}>
|
||||
{data.bruteforce.top_hosts[0] ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.tcp_spoofing.detected && (
|
||||
<div className="bg-background-card rounded p-2">
|
||||
<div className="text-text-disabled mb-1">TCP Spoofing</div>
|
||||
<div className="text-threat-medium font-medium">TTL {data.tcp_spoofing.tcp_ttl} → {data.tcp_spoofing.suspected_os}</div>
|
||||
<div className="text-text-secondary">UA déclare: {data.tcp_spoofing.declared_os}</div>
|
||||
</div>
|
||||
)}
|
||||
{data.persistence.persistent && (
|
||||
<div className="bg-background-card rounded p-2">
|
||||
<div className="text-text-disabled mb-1">Persistance</div>
|
||||
<div className="text-threat-high font-medium">{data.persistence.recurrence}× sessions</div>
|
||||
<div className="text-text-secondary">{data.persistence.first_seen?.substring(0, 10)} → {data.persistence.last_seen?.substring(0, 10)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mini timeline */}
|
||||
<div>
|
||||
<div className="text-xs text-text-disabled mb-1 font-medium uppercase tracking-wide">Activité dernières 24h</div>
|
||||
<MiniTimeline data={data.timeline_24h} />
|
||||
<div className="flex justify-between text-xs text-text-disabled mt-0.5"><span>0h</span><span>12h</span><span>23h</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CoherenceData {
|
||||
verdict: string;
|
||||
@ -188,6 +344,9 @@ export function InvestigationView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ligne 0 : Synthèse multi-sources */}
|
||||
<IPActivitySummary ip={ip} />
|
||||
|
||||
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
|
||||
<div className="grid grid-cols-3 gap-6 items-start">
|
||||
<div className="bg-background-secondary rounded-lg p-6 h-full">
|
||||
|
||||
@ -26,7 +26,27 @@ interface JA4HistoryEntry {
|
||||
window_start: string;
|
||||
}
|
||||
|
||||
type ActiveTab = 'rotators' | 'persistent';
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -53,6 +73,15 @@ function threatLevelBadge(level: string): { bg: string; text: string } {
|
||||
}
|
||||
}
|
||||
|
||||
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 }) {
|
||||
@ -188,6 +217,16 @@ export function RotationView() {
|
||||
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);
|
||||
@ -212,7 +251,6 @@ export function RotationView() {
|
||||
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();
|
||||
// Sort by persistence_score DESC
|
||||
const sorted = [...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score);
|
||||
setPersistent(sorted);
|
||||
setPersistentLoaded(true);
|
||||
@ -223,9 +261,43 @@ export function RotationView() {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@ -234,6 +306,8 @@ export function RotationView() {
|
||||
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 (
|
||||
@ -365,6 +439,154 @@ export function RotationView() {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user