Files
dashboard/frontend/src/components/PivotView.tsx
SOC Analyst 1455e04303 fix: correct CampaignsView, analysis.py IPv4 split, entities date filter
- 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>
2026-03-15 23:10:35 +01:00

368 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PivotView — Cross-entity correlation matrix
*
* SOC analysts add multiple IPs or JA4 fingerprints.
* The page fetches variability data for each entity and renders a
* comparison matrix highlighting shared values (correlations).
*
* Columns = entities added by the analyst
* Rows = attribute categories (JA4, User-Agent, Country, ASN, Subnet)
* ★ = value shared by 2+ entities → possible campaign link
*/
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
// ─── Types ────────────────────────────────────────────────────────────────────
type EntityType = 'ip' | 'ja4';
interface EntityCol {
id: string;
type: EntityType;
value: string;
loading: boolean;
error: string | null;
data: VariabilityData | null;
}
interface AttrItem {
value: string;
count: number;
percentage: number;
}
interface VariabilityData {
total_detections: number;
unique_ips?: number;
attributes: {
ja4?: AttrItem[];
user_agents?: AttrItem[];
countries?: AttrItem[];
asns?: AttrItem[];
hosts?: AttrItem[];
subnets?: AttrItem[];
};
}
type AttrKey = keyof VariabilityData['attributes'];
const ATTR_ROWS: { key: AttrKey; label: string; icon: string }[] = [
{ key: 'ja4', label: 'JA4 Fingerprint', icon: '🔐' },
{ key: 'user_agents', label: 'User-Agents', icon: '🤖' },
{ key: 'countries', label: 'Pays', icon: '🌍' },
{ key: 'asns', label: 'ASN', icon: '🏢' },
{ key: 'hosts', label: 'Hosts cibles', icon: '🖥️' },
{ key: 'subnets', label: 'Subnets', icon: '🔷' },
];
const MAX_VALUES_PER_CELL = 4;
function detectType(input: string): EntityType {
// JA4 fingerprints are ~36 chars containing letters, digits, underscores
// IP addresses match IPv4 pattern
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(input.trim())) return 'ip';
return 'ja4';
}
function getCountryFlag(code: string): string {
return (code || '').toUpperCase().replace(/./g, c =>
String.fromCodePoint(c.charCodeAt(0) + 127397)
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export function PivotView() {
const navigate = useNavigate();
const [cols, setCols] = useState<EntityCol[]>([]);
const [input, setInput] = useState('');
const fetchEntity = useCallback(async (col: EntityCol): Promise<EntityCol> => {
try {
const encoded = encodeURIComponent(col.value);
const url = col.type === 'ip'
? `/api/variability/ip/${encoded}`
: `/api/variability/ja4/${encoded}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: VariabilityData = await res.json();
return { ...col, loading: false, data };
} catch (e) {
return { ...col, loading: false, error: (e as Error).message };
}
}, []);
const addEntity = useCallback(async () => {
const val = input.trim();
if (!val) return;
// Support batch: comma-separated
const values = val.split(/[,\s]+/).map(v => v.trim()).filter(Boolean);
setInput('');
for (const v of values) {
const id = `${Date.now()}-${v}`;
const type = detectType(v);
const pending: EntityCol = { id, type, value: v, loading: true, error: null, data: null };
setCols(prev => {
if (prev.some(c => c.value === v)) return prev;
return [...prev, pending];
});
const resolved = await fetchEntity(pending);
setCols(prev => prev.map(c => c.id === id ? resolved : c));
}
}, [input, fetchEntity]);
const removeCol = (id: string) => setCols(prev => prev.filter(c => c.id !== id));
// Find shared values across all loaded cols for a given attribute key
const getSharedValues = (key: AttrKey): Set<string> => {
const loaded = cols.filter(c => c.data);
if (loaded.length < 2) return new Set();
const valueCounts = new Map<string, number>();
for (const col of loaded) {
const items = col.data?.attributes[key] ?? [];
for (const item of items.slice(0, 10)) {
valueCounts.set(item.value, (valueCounts.get(item.value) ?? 0) + 1);
}
}
const shared = new Set<string>();
valueCounts.forEach((count, val) => { if (count >= 2) shared.add(val); });
return shared;
};
const sharedByKey = Object.fromEntries(
ATTR_ROWS.map(r => [r.key, getSharedValues(r.key)])
) as Record<AttrKey, Set<string>>;
const totalCorrelations = ATTR_ROWS.reduce(
(sum, r) => sum + sharedByKey[r.key].size, 0
);
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary">🔗 Pivot Corrélation Multi-Entités</h1>
<p className="text-text-secondary text-sm mt-1">
Ajoutez des IPs ou JA4. Les valeurs partagées <span className="text-yellow-400 font-bold"></span> révèlent des campagnes coordonnées.
</p>
</div>
{/* Input bar */}
<div className="flex gap-3">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addEntity(); }}
placeholder="IP (ex: 1.2.3.4) ou JA4, séparés par des virgules…"
className="flex-1 bg-background-secondary border border-background-card rounded-lg px-4 py-2.5 text-text-primary placeholder-text-disabled font-mono text-sm focus:outline-none focus:border-accent-primary"
/>
<button
onClick={addEntity}
disabled={!input.trim()}
className="px-5 py-2.5 bg-accent-primary text-white rounded-lg text-sm font-medium hover:bg-accent-primary/80 disabled:opacity-40 transition-colors"
>
+ Ajouter
</button>
{cols.length > 0 && (
<button
onClick={() => setCols([])}
className="px-4 py-2.5 bg-background-card text-text-secondary rounded-lg text-sm hover:text-text-primary transition-colors"
>
Tout effacer
</button>
)}
</div>
{/* Empty state */}
{cols.length === 0 && (
<div className="bg-background-secondary rounded-xl p-12 text-center space-y-3">
<div className="text-5xl">🔗</div>
<div className="text-lg font-medium text-text-primary">Aucune entité ajoutée</div>
<div className="text-sm text-text-secondary max-w-md mx-auto">
Entrez plusieurs IPs ou fingerprints JA4 pour identifier des corrélations JA4 partagés, même pays d'origine, mêmes cibles, même ASN.
</div>
<div className="text-xs text-text-disabled pt-2">
Exemple : <span className="font-mono text-text-secondary">1.2.3.4, 5.6.7.8, 9.10.11.12</span>
</div>
</div>
)}
{/* Correlation summary badge */}
{cols.length >= 2 && cols.some(c => c.data) && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border ${
totalCorrelations > 0
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
: 'bg-background-secondary border-background-card text-text-secondary'
}`}>
<span className="text-xl">{totalCorrelations > 0 ? '' : ''}</span>
<div>
{totalCorrelations > 0 ? (
<>
<span className="font-bold">{totalCorrelations} corrélation{totalCorrelations > 1 ? 's' : ''} détectée{totalCorrelations > 1 ? 's' : ''}</span>
{' '}— attributs partagés par 2+ entités. Possible campagne coordonnée.
</>
) : (
'Aucune corrélation détectée entre les entités analysées.'
)}
</div>
</div>
)}
{/* Matrix */}
{cols.length > 0 && (
<div className="overflow-x-auto rounded-xl border border-background-card">
<table className="w-full min-w-max">
{/* Column headers */}
<thead>
<tr className="bg-background-secondary border-b border-background-card">
<th className="px-4 py-3 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider w-40">
Attribut
</th>
{cols.map(col => (
<th key={col.id} className="px-4 py-3 text-left w-56">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-text-disabled">
{col.type === 'ip' ? '🌐' : '🔐'}
</span>
<button
onClick={() => navigate(
col.type === 'ip'
? `/investigation/${col.value}`
: `/investigation/ja4/${col.value}`
)}
className="font-mono text-sm text-accent-primary hover:underline truncate max-w-[160px] text-left"
title={col.value}
>
{col.value.length > 20 ? col.value.slice(0, 20) + '' : col.value}
</button>
</div>
{col.data && (
<div className="text-xs text-text-disabled mt-0.5">
{col.data.total_detections.toLocaleString()} det.
{col.type === 'ja4' && col.data.unique_ips !== undefined && (
<> · {col.data.unique_ips} IPs</>
)}
</div>
)}
{col.loading && (
<div className="text-xs text-text-disabled mt-0.5 animate-pulse">Chargement…</div>
)}
{col.error && (
<div className="text-xs text-red-400 mt-0.5">⚠ {col.error}</div>
)}
</div>
<button
onClick={() => removeCol(col.id)}
className="text-text-disabled hover:text-red-400 transition-colors text-base leading-none shrink-0 mt-0.5"
title="Retirer"
>
×
</button>
</div>
</th>
))}
</tr>
</thead>
{/* Attribute rows */}
<tbody className="divide-y divide-background-card">
{ATTR_ROWS.map(row => {
const shared = sharedByKey[row.key];
const hasAnyData = cols.some(c => (c.data?.attributes[row.key]?.length ?? 0) > 0);
if (!hasAnyData && cols.every(c => c.data !== null && !c.loading)) return null;
return (
<tr key={row.key} className="hover:bg-background-card/20 transition-colors">
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-1.5">
<span>{row.icon}</span>
<span className="text-xs font-medium text-text-secondary">{row.label}</span>
</div>
{shared.size > 0 && (
<div className="mt-1">
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-1.5 py-0.5 rounded">
★ {shared.size} commun{shared.size > 1 ? 's' : ''}
</span>
</div>
)}
</td>
{cols.map(col => {
const items = col.data?.attributes[row.key] ?? [];
return (
<td key={col.id} className="px-3 py-3 align-top">
{col.loading ? (
<div className="h-16 bg-background-card/30 rounded animate-pulse" />
) : items.length === 0 ? (
<span className="text-xs text-text-disabled">—</span>
) : (
<div className="space-y-1.5">
{items.slice(0, MAX_VALUES_PER_CELL).map((item, i) => {
const isShared = shared.has(item.value);
return (
<div
key={i}
className={`rounded px-2 py-1.5 text-xs ${
isShared
? 'bg-yellow-500/15 border border-yellow-500/30'
: 'bg-background-card/40'
}`}
>
<div className="flex items-start justify-between gap-1">
<div className={`font-mono break-all leading-tight ${
isShared ? 'text-yellow-300' : 'text-text-primary'
}`}>
{isShared && <span className="mr-1 text-yellow-400">★</span>}
{row.key === 'countries'
? `${getCountryFlag(item.value)} ${item.value}`
: item.value.length > 60
? item.value.slice(0, 60) + ''
: item.value}
</div>
</div>
<div className="text-text-disabled mt-0.5 flex gap-2">
<span>{item.count.toLocaleString()}</span>
<span>{item.percentage.toFixed(1)}%</span>
</div>
</div>
);
})}
{items.length > MAX_VALUES_PER_CELL && (
<div className="text-xs text-text-disabled px-2">
+{items.length - MAX_VALUES_PER_CELL} autres
</div>
)}
</div>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Legend */}
{cols.length >= 2 && (
<div className="flex items-center gap-4 text-xs text-text-disabled">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded bg-yellow-500/20 border border-yellow-500/30 inline-block" />
Valeur partagée par 2+ entités
</span>
<span>|</span>
<span>Cliquer sur une entité Investigation complète</span>
</div>
)}
</div>
);
}