- 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>
368 lines
15 KiB
TypeScript
368 lines
15 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|