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:
SOC Analyst
2026-03-16 00:43:27 +01:00
parent 8032ebaab8
commit d4c3512572
11 changed files with 815 additions and 6 deletions

View File

@ -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 {

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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">

View File

@ -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 &lt; 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>
);
}