- CampaignsView: update ClusterData interface to match real API response
(severity/unique_ips/score instead of threat_level/total_ips/confidence_range)
Fix fetch to use data.items, rewrite ClusterCard and BehavioralTab
Remove unused getClassificationColor and THREAT_ORDER constants
- analysis.py: fix IPv4Address object has no attribute 'split' on line 322
Add str() conversion before calling .split('.')
- entities.py: fix Date vs DateTime comparison — log_date is a Date column,
comparing against now()-INTERVAL HOUR caused yesterday's entries to be excluded
Use toDate(now() - INTERVAL X HOUR) for correct Date-level comparison
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|