feat(web): Identities + Campaigns list pages + THREAT DATA nav group
Adds proper /identities and /campaigns list pages following the Bounty/Attackers convention (page-header + page-title-group + controls-row + logs-section + logs-table + EmptyState). Both pages live-update via the existing identity / campaign SSE streams. Sidebar: Attackers, Identities, Campaigns now group under a THREAT DATA NavGroup, matching the SWARM grouping pattern. CampaignDetail and IdentityDetail rewritten to use the house class system (page-header / logs-section / chip / dim-chip) instead of inline styles. The campaign chip on IdentityDetail navigates to /campaigns/:uuid; both pages share a small fp-group helper for fingerprint listings (added to Dashboard.css).
This commit is contained in:
@@ -19,7 +19,9 @@ const LiveLogs = lazy(() => import('./components/LiveLogs'));
|
||||
const Webhooks = lazy(() => import('./components/Webhooks'));
|
||||
const Attackers = lazy(() => import('./components/Attackers'));
|
||||
const AttackerDetail = lazy(() => import('./components/AttackerDetail'));
|
||||
const Identities = lazy(() => import('./components/Identities'));
|
||||
const IdentityDetail = lazy(() => import('./components/IdentityDetail'));
|
||||
const Campaigns = lazy(() => import('./components/Campaigns'));
|
||||
const CampaignDetail = lazy(() => import('./components/CampaignDetail'));
|
||||
const Config = lazy(() => import('./components/Config'));
|
||||
const Bounty = lazy(() => import('./components/Bounty'));
|
||||
@@ -115,7 +117,9 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
|
||||
<Route path="/credentials" element={<Credentials />} />
|
||||
<Route path="/attackers" element={<Attackers />} />
|
||||
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||
<Route path="/identities" element={<Identities />} />
|
||||
<Route path="/identities/:id" element={<IdentityDetail />} />
|
||||
<Route path="/campaigns" element={<Campaigns />} />
|
||||
<Route path="/campaigns/:id" element={<CampaignDetail />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/swarm-updates" element={<RemoteUpdates />} />
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Crosshair, Fingerprint, Globe, Radio } from '../icons';
|
||||
import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons';
|
||||
import api from '../utils/api';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
import { useCampaignStream } from './useCampaignStream';
|
||||
import './Dashboard.css';
|
||||
|
||||
/*
|
||||
* CampaignDetail — read-only view of a campaign-clustered operation.
|
||||
*
|
||||
* The layer above identity resolution. Member identities are visible
|
||||
* here as rows that link back to IdentityDetail. Same visual vocabulary
|
||||
* as IdentityDetail by design — the substrate (soft merges, schema
|
||||
* version, JSON fingerprint summaries, live SSE updates) is identical
|
||||
* one layer up.
|
||||
* Layer above identity resolution. Member identities link back to
|
||||
* IdentityDetail; same visual vocabulary as the rest of the app
|
||||
* (page-header / sections / chips), no inline-style drift.
|
||||
*/
|
||||
|
||||
interface CampaignData {
|
||||
@@ -97,26 +96,23 @@ const CampaignDetail: React.FC = () => {
|
||||
fetchIdentities();
|
||||
}, [id]);
|
||||
|
||||
// Live updates: refetch when a campaign event references this uuid.
|
||||
// Refetch when a campaign event references this uuid.
|
||||
useCampaignStream({
|
||||
enabled: !!id,
|
||||
onEvent: (ev) => {
|
||||
if (!id) return;
|
||||
const payload = ev.payload || {};
|
||||
const refs = new Set<string>();
|
||||
const addUuid = (v: unknown) => {
|
||||
if (typeof v === 'string') refs.add(v);
|
||||
};
|
||||
const payload = ev.payload || {};
|
||||
addUuid(payload.campaign_uuid);
|
||||
addUuid(payload.winner_uuid);
|
||||
addUuid(payload.loser_uuid);
|
||||
addUuid(payload.resurrected_uuid);
|
||||
addUuid(payload.former_winner_uuid);
|
||||
|
||||
if (refs.has(id)) {
|
||||
api.get(`/campaigns/${id}`)
|
||||
.then((res) => setCampaign(res.data))
|
||||
.catch(() => {});
|
||||
api.get(`/campaigns/${id}`).then((res) => setCampaign(res.data)).catch(() => {});
|
||||
api.get(`/campaigns/${id}/identities?limit=50&offset=0`)
|
||||
.then((res) => {
|
||||
setIdentities(res.data.data ?? []);
|
||||
@@ -129,24 +125,20 @@ const CampaignDetail: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
|
||||
LOADING CAMPAIGN…
|
||||
</div>
|
||||
<div className="bounty-root">
|
||||
<EmptyState icon={Crosshair} title="LOADING CAMPAIGN…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !campaign) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<button onClick={() => navigate('/attackers')} className="back-button">
|
||||
<div className="bounty-root">
|
||||
<button onClick={() => navigate('/campaigns')} className="back-button">
|
||||
<ArrowLeft size={18} />
|
||||
<span>BACK TO ATTACKERS</span>
|
||||
<span>BACK TO CAMPAIGNS</span>
|
||||
</button>
|
||||
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
|
||||
{error || 'CAMPAIGN NOT FOUND'}
|
||||
</div>
|
||||
<EmptyState icon={Crosshair} title={error || 'CAMPAIGN NOT FOUND'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,122 +149,117 @@ const CampaignDetail: React.FC = () => {
|
||||
const c2List = safeParseJsonList(campaign.c2_endpoints);
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<button onClick={() => navigate('/attackers')} className="back-button">
|
||||
<div className="bounty-root">
|
||||
<button onClick={() => navigate('/campaigns')} className="back-button">
|
||||
<ArrowLeft size={18} />
|
||||
<span>BACK TO ATTACKERS</span>
|
||||
<span>BACK TO CAMPAIGNS</span>
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Fingerprint size={32} className="violet-accent" />
|
||||
<h1 className="matrix-text" style={{ fontSize: '1.4rem', letterSpacing: '2px' }}>
|
||||
CAMPAIGN · {campaign.uuid}
|
||||
</h1>
|
||||
{campaign.merged_into_uuid && (
|
||||
<span
|
||||
className="traversal-badge"
|
||||
style={{ fontSize: '0.8rem', cursor: 'pointer', letterSpacing: '2px', opacity: 0.7 }}
|
||||
title="This campaign was soft-merged into another. Click to view the canonical winner."
|
||||
onClick={() => navigate(`/campaigns/${campaign.merged_into_uuid}`)}
|
||||
>
|
||||
MERGED INTO {campaign.merged_into_uuid.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
<div className="stat-card" title="Live count of identities FK'd to this campaign">
|
||||
<div className="stat-value matrix-text">{campaign.identity_count_live}</div>
|
||||
<div className="stat-label">IDENTITIES</div>
|
||||
</div>
|
||||
<div className="stat-card" title="Distinct JA3 fingerprints across member identities">
|
||||
<div className="stat-value violet-accent">{ja3List.length}</div>
|
||||
<div className="stat-label">JA3</div>
|
||||
</div>
|
||||
<div className="stat-card" title="Distinct HASSH fingerprints">
|
||||
<div className="stat-value violet-accent">{hasshList.length}</div>
|
||||
<div className="stat-label">HASSH</div>
|
||||
</div>
|
||||
<div className="stat-card" title="Distinct payload SimHashes aggregated across identities">
|
||||
<div className="stat-value matrix-text">{payloadList.length}</div>
|
||||
<div className="stat-label">PAYLOADS</div>
|
||||
</div>
|
||||
<div className="stat-card" title="C2 callback endpoints aggregated across identities">
|
||||
<div className="stat-value matrix-text">{c2List.length}</div>
|
||||
<div className="stat-label">C2 ENDPOINTS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(campaign.confidence !== null || campaign.schema_version > 1) && (
|
||||
<div style={{ display: 'flex', gap: '24px', padding: '12px 0', opacity: 0.7, fontSize: '0.85rem' }}>
|
||||
{campaign.confidence !== null && (
|
||||
<span title="Campaign-cohesion score from the clusterer (0–1)">
|
||||
CONFIDENCE · {campaign.confidence.toFixed(3)}
|
||||
</span>
|
||||
)}
|
||||
<span title="Federation gossip schema version">
|
||||
SCHEMA · v{campaign.schema_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ja3List.length > 0 && (
|
||||
<FingerprintList icon={<Globe size={18} />} label="JA3" items={ja3List} />
|
||||
)}
|
||||
{hasshList.length > 0 && (
|
||||
<FingerprintList icon={<Globe size={18} />} label="HASSH" items={hasshList} />
|
||||
)}
|
||||
{c2List.length > 0 && (
|
||||
<FingerprintList icon={<Radio size={18} />} label="C2 ENDPOINTS" items={c2List} />
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<Crosshair size={20} className="violet-accent" />
|
||||
<h2 className="matrix-text" style={{ fontSize: '1.0rem', letterSpacing: '2px' }}>
|
||||
IDENTITIES · {identityTotal}
|
||||
</h2>
|
||||
</div>
|
||||
{identities.length === 0 ? (
|
||||
<div style={{ padding: '24px', opacity: 0.5, fontFamily: 'var(--font-mono)' }}>
|
||||
No identities linked yet. The campaign clusterer assigns
|
||||
identities asynchronously; they should appear shortly after
|
||||
the next clusterer pass.
|
||||
<div className="page-header">
|
||||
<div className="page-title-group">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Crosshair size={22} className="violet-accent" />
|
||||
<h1>CAMPAIGN · {campaign.uuid.slice(0, 12)}…</h1>
|
||||
{campaign.merged_into_uuid && (
|
||||
<span
|
||||
className="chip dim-chip"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/campaigns/${campaign.merged_into_uuid}`)}
|
||||
title="Soft-merged. Click to view canonical winner."
|
||||
>
|
||||
MERGED → {campaign.merged_into_uuid.slice(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IDENTITY</th>
|
||||
<th>FIRST SEEN</th>
|
||||
<th>LAST SEEN</th>
|
||||
<th style={{ textAlign: 'right' }}>OBSERVATIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{identities.map((ident) => (
|
||||
<tr
|
||||
key={ident.uuid}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/identities/${ident.uuid}`)}
|
||||
>
|
||||
<td>{ident.uuid.slice(0, 12)}…</td>
|
||||
<td style={{ opacity: 0.7 }}>{ident.first_seen_at ?? '—'}</td>
|
||||
<td style={{ opacity: 0.7 }}>{ident.last_seen_at ?? '—'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{ident.observation_count}</td>
|
||||
<span className="page-sub">
|
||||
{campaign.identity_count_live} IDENTITIES ·
|
||||
{' '}{ja3List.length} JA3 · {hasshList.length} HASSH ·
|
||||
{' '}{payloadList.length} PAYLOAD · {c2List.length} C2
|
||||
{campaign.confidence !== null && (
|
||||
<> · CONFIDENCE {campaign.confidence.toFixed(3)}</>
|
||||
)}
|
||||
{' '}· SCHEMA v{campaign.schema_version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && (
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<Fingerprint size={14} />
|
||||
<span>AGGREGATED FINGERPRINTS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="logs-table-container" style={{ padding: 12 }}>
|
||||
{ja3List.length > 0 && (
|
||||
<FingerprintGroup icon={<Globe size={14} />} label="JA3" items={ja3List} />
|
||||
)}
|
||||
{hasshList.length > 0 && (
|
||||
<FingerprintGroup icon={<Globe size={14} />} label="HASSH" items={hasshList} />
|
||||
)}
|
||||
{c2List.length > 0 && (
|
||||
<FingerprintGroup icon={<Radio size={14} />} label="C2 ENDPOINTS" items={c2List} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<Filter size={14} />
|
||||
<span>{identityTotal} IDENTITIES IN THIS CAMPAIGN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="logs-table-container">
|
||||
{identities.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Crosshair}
|
||||
title="NO IDENTITIES LINKED YET"
|
||||
hint="the campaign clusterer assigns identities asynchronously"
|
||||
/>
|
||||
) : (
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IDENTITY</th>
|
||||
<th>FIRST SEEN</th>
|
||||
<th>LAST SEEN</th>
|
||||
<th style={{ textAlign: 'right' }}>OBSERVATIONS</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{identities.map((ident) => (
|
||||
<tr
|
||||
key={ident.uuid}
|
||||
className="clickable"
|
||||
onClick={() => navigate(`/identities/${ident.uuid}`)}
|
||||
>
|
||||
<td className="matrix-text" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{ident.uuid.slice(0, 12)}…
|
||||
</td>
|
||||
<td className="dim">{ident.first_seen_at ?? '—'}</td>
|
||||
<td className="dim">{ident.last_seen_at ?? '—'}</td>
|
||||
<td className="matrix-text" style={{ textAlign: 'right' }}>
|
||||
{ident.observation_count}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{campaign.notes && (
|
||||
<div style={{ marginTop: '24px', padding: '12px', borderLeft: '2px solid var(--violet)', opacity: 0.85 }}>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.7, letterSpacing: '2px', marginBottom: '4px' }}>
|
||||
ANALYST NOTES
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<span>ANALYST NOTES</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
|
||||
<div className="logs-table-container" style={{ padding: 12, fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
|
||||
{campaign.notes}
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,32 +268,19 @@ const CampaignDetail: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const FingerprintList: React.FC<{
|
||||
const FingerprintGroup: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
items: string[];
|
||||
}> = ({ icon, label, items }) => (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<div className="fp-group">
|
||||
<div className="fp-group-label">
|
||||
{icon}
|
||||
<span className="matrix-text" style={{ fontSize: '0.85rem', letterSpacing: '2px' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
<div className="fp-group-items">
|
||||
{items.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '4px 8px',
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</code>
|
||||
<span key={v} className="chip dim-chip">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
205
decnet_web/src/components/Campaigns.tsx
Normal file
205
decnet_web/src/components/Campaigns.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, ChevronRight as ChevR, Filter, Crosshair, Search,
|
||||
} from '../icons';
|
||||
import api from '../utils/api';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
import { useFocusSearch } from '../hooks/useFocusSearch';
|
||||
import { useCampaignStream } from './useCampaignStream';
|
||||
import './Dashboard.css';
|
||||
|
||||
interface CampaignEntry {
|
||||
uuid: string;
|
||||
schema_version: number;
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
updated_at: string;
|
||||
confidence: number | null;
|
||||
identity_count: number;
|
||||
ja3_hashes: string | null;
|
||||
hassh_hashes: string | null;
|
||||
payload_simhashes: string | null;
|
||||
c2_endpoints: string | null;
|
||||
merged_into_uuid: string | null;
|
||||
}
|
||||
|
||||
const safeListLen = (raw: string | null): number => {
|
||||
if (!raw) return 0;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const timeAgo = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '—';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
};
|
||||
|
||||
const Campaigns: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = (searchParams.get('q') || '').toLowerCase();
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
|
||||
const [campaigns, setCampaigns] = useState<CampaignEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get('q') || '');
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
useFocusSearch(searchRef);
|
||||
|
||||
const limit = 50;
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const offset = (page - 1) * limit;
|
||||
const res = await api.get(`/campaigns?limit=${limit}&offset=${offset}`);
|
||||
setCampaigns(res.data.data ?? []);
|
||||
setTotal(res.data.total ?? 0);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch campaigns', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchCampaigns(); }, [page]);
|
||||
|
||||
useCampaignStream({
|
||||
enabled: true,
|
||||
onEvent: () => { fetchCampaigns(); },
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearchParams({ q: searchInput, page: '1' });
|
||||
};
|
||||
const setPage = (p: number) => setSearchParams({ q: searchParams.get('q') || '', page: p.toString() });
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
const visible = query
|
||||
? campaigns.filter((c) => c.uuid.toLowerCase().includes(query))
|
||||
: campaigns;
|
||||
|
||||
const totalIdentities = campaigns.reduce((sum, c) => sum + c.identity_count, 0);
|
||||
|
||||
return (
|
||||
<div className="bounty-root">
|
||||
<div className="page-header">
|
||||
<div className="page-title-group">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Crosshair size={22} className="violet-accent" />
|
||||
<h1>CAMPAIGN CLUSTERING</h1>
|
||||
</div>
|
||||
<span className="page-sub">
|
||||
{total.toLocaleString()} CAMPAIGNS · {totalIdentities} IDENTITIES GROUPED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="controls-row" onSubmit={handleSearch}>
|
||||
<div className="search-container">
|
||||
<Search size={14} className="search-icon" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder="Filter by UUID..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<Filter size={14} />
|
||||
<span>{visible.length.toLocaleString()} CAMPAIGNS SHOWN</span>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<div className="pager">
|
||||
<span className="dim">Page {page} of {totalPages}</span>
|
||||
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logs-table-container">
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>UUID</th>
|
||||
<th>FIRST SEEN</th>
|
||||
<th>LAST SEEN</th>
|
||||
<th>FINGERPRINTS</th>
|
||||
<th>INFRA</th>
|
||||
<th>IDENTITIES</th>
|
||||
<th>CONFIDENCE</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.length > 0 ? visible.map((c) => (
|
||||
<tr
|
||||
key={c.uuid}
|
||||
className="clickable"
|
||||
onClick={() => navigate(`/campaigns/${c.uuid}`)}
|
||||
>
|
||||
<td className="matrix-text" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{c.uuid.slice(0, 12)}…
|
||||
</td>
|
||||
<td className="dim">{timeAgo(c.first_seen_at)}</td>
|
||||
<td className="dim">{timeAgo(c.last_seen_at)}</td>
|
||||
<td>
|
||||
<span className="chip dim-chip">{safeListLen(c.ja3_hashes)} JA3</span>{' '}
|
||||
<span className="chip dim-chip">{safeListLen(c.hassh_hashes)} HASSH</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="chip dim-chip">{safeListLen(c.payload_simhashes)} PAYLOAD</span>{' '}
|
||||
<span className="chip dim-chip">{safeListLen(c.c2_endpoints)} C2</span>
|
||||
</td>
|
||||
<td className="matrix-text">{c.identity_count}</td>
|
||||
<td className="violet-accent">
|
||||
{c.confidence !== null ? c.confidence.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', opacity: 0.4 }}>
|
||||
<ChevR size={14} />
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<EmptyState
|
||||
icon={Crosshair}
|
||||
title={loading ? 'CLUSTERING CAMPAIGNS…' : 'NO CAMPAIGNS YET'}
|
||||
hint={loading ? undefined : 'the campaign clusterer groups identities into operations as they correlate'}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Campaigns;
|
||||
@@ -528,3 +528,25 @@
|
||||
.fp-card-body {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Identity / Campaign fingerprint groupings — used inside a logs-section */
|
||||
.fp-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fp-group:last-child { margin-bottom: 0; }
|
||||
|
||||
.fp-group-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.fp-group-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
223
decnet_web/src/components/Identities.tsx
Normal file
223
decnet_web/src/components/Identities.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, ChevronRight as ChevR, Filter, Fingerprint, Search,
|
||||
} from '../icons';
|
||||
import api from '../utils/api';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
import { useFocusSearch } from '../hooks/useFocusSearch';
|
||||
import { useIdentityStream } from './useIdentityStream';
|
||||
import './Dashboard.css';
|
||||
|
||||
interface IdentityEntry {
|
||||
uuid: string;
|
||||
schema_version: number;
|
||||
campaign_id: string | null;
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
updated_at: string;
|
||||
confidence: number | null;
|
||||
observation_count: number;
|
||||
ja3_hashes: string | null;
|
||||
hassh_hashes: string | null;
|
||||
payload_simhashes: string | null;
|
||||
c2_endpoints: string | null;
|
||||
merged_into_uuid: string | null;
|
||||
}
|
||||
|
||||
const safeListLen = (raw: string | null): number => {
|
||||
if (!raw) return 0;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const timeAgo = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '—';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
};
|
||||
|
||||
const Identities: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = (searchParams.get('q') || '').toLowerCase();
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
|
||||
const [identities, setIdentities] = useState<IdentityEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get('q') || '');
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
useFocusSearch(searchRef);
|
||||
|
||||
const limit = 50;
|
||||
|
||||
const fetchIdentities = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const offset = (page - 1) * limit;
|
||||
const res = await api.get(`/identities?limit=${limit}&offset=${offset}`);
|
||||
setIdentities(res.data.data ?? []);
|
||||
setTotal(res.data.total ?? 0);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch identities', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchIdentities(); }, [page]);
|
||||
|
||||
// Live updates: refetch on any clusterer event so the list stays
|
||||
// current without polling.
|
||||
useIdentityStream({
|
||||
enabled: true,
|
||||
onEvent: () => { fetchIdentities(); },
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearchParams({ q: searchInput, page: '1' });
|
||||
};
|
||||
const setPage = (p: number) => setSearchParams({ q: searchParams.get('q') || '', page: p.toString() });
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
const visible = query
|
||||
? identities.filter((i) =>
|
||||
i.uuid.toLowerCase().includes(query)
|
||||
|| (i.campaign_id || '').toLowerCase().includes(query),
|
||||
)
|
||||
: identities;
|
||||
|
||||
const assignedCount = identities.filter((i) => i.campaign_id).length;
|
||||
|
||||
return (
|
||||
<div className="bounty-root">
|
||||
<div className="page-header">
|
||||
<div className="page-title-group">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Fingerprint size={22} className="violet-accent" />
|
||||
<h1>IDENTITY RESOLUTION</h1>
|
||||
</div>
|
||||
<span className="page-sub">
|
||||
{total.toLocaleString()} IDENTITIES · {assignedCount} CAMPAIGN-ASSIGNED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="controls-row" onSubmit={handleSearch}>
|
||||
<div className="search-container">
|
||||
<Search size={14} className="search-icon" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder="Filter by UUID or campaign..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<Filter size={14} />
|
||||
<span>{visible.length.toLocaleString()} IDENTITIES SHOWN</span>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<div className="pager">
|
||||
<span className="dim">Page {page} of {totalPages}</span>
|
||||
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="logs-table-container">
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>UUID</th>
|
||||
<th>FIRST SEEN</th>
|
||||
<th>LAST SEEN</th>
|
||||
<th>JA3 / HASSH</th>
|
||||
<th>PAYLOADS / C2</th>
|
||||
<th>OBS</th>
|
||||
<th>CAMPAIGN</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.length > 0 ? visible.map((i) => (
|
||||
<tr
|
||||
key={i.uuid}
|
||||
className="clickable"
|
||||
onClick={() => navigate(`/identities/${i.uuid}`)}
|
||||
>
|
||||
<td className="matrix-text" style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{i.uuid.slice(0, 12)}…
|
||||
</td>
|
||||
<td className="dim">{timeAgo(i.first_seen_at)}</td>
|
||||
<td className="dim">{timeAgo(i.last_seen_at)}</td>
|
||||
<td>
|
||||
<span className="chip dim-chip">{safeListLen(i.ja3_hashes)} JA3</span>{' '}
|
||||
<span className="chip dim-chip">{safeListLen(i.hassh_hashes)} HASSH</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="chip dim-chip">{safeListLen(i.payload_simhashes)} PAYLOAD</span>{' '}
|
||||
<span className="chip dim-chip">{safeListLen(i.c2_endpoints)} C2</span>
|
||||
</td>
|
||||
<td className="matrix-text">{i.observation_count}</td>
|
||||
<td>
|
||||
{i.campaign_id ? (
|
||||
<span
|
||||
className="chip violet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/campaigns/${i.campaign_id}`);
|
||||
}}
|
||||
>
|
||||
{i.campaign_id.slice(0, 8)}…
|
||||
</span>
|
||||
) : (
|
||||
<span className="dim">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', opacity: 0.4 }}>
|
||||
<ChevR size={14} />
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<EmptyState
|
||||
icon={Fingerprint}
|
||||
title={loading ? 'RESOLVING IDENTITIES…' : 'NO IDENTITIES YET'}
|
||||
hint={loading ? undefined : 'the clusterer populates this view as observations correlate'}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Identities;
|
||||
@@ -1,24 +1,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Crosshair, Fingerprint, Globe, Radio } from '../icons';
|
||||
import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons';
|
||||
import api from '../utils/api';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
import { useIdentityStream } from './useIdentityStream';
|
||||
import './Dashboard.css';
|
||||
|
||||
/*
|
||||
* IdentityDetail — read-only view of a resolved attacker identity.
|
||||
*
|
||||
* The clusterer worker that populates these rows is a separate
|
||||
* downstream effort; until it ships, /identities/* responses are
|
||||
* empty and this page renders the not-found state. See
|
||||
* development/IDENTITY_RESOLUTION.md.
|
||||
*
|
||||
* The page is intentionally narrow at v1: header (uuid + campaign
|
||||
* link if assigned), aggregated stats (observation count, fingerprint
|
||||
* counts), and a list of linked observations that link back to
|
||||
* AttackerDetail. Bigger surfaces (intel summary, kd_digraph_simhash
|
||||
* neighbors, federation gossip status) ship after the clusterer
|
||||
* lands and there's data to render.
|
||||
* Header (page-header), aggregated stats in the sub-line, fingerprint
|
||||
* groups in their own section, observations in a logs-section table.
|
||||
* Same vocabulary as CampaignDetail one layer up.
|
||||
*/
|
||||
|
||||
interface IdentityData {
|
||||
@@ -106,29 +99,22 @@ const IdentityDetail: React.FC = () => {
|
||||
fetchObservations();
|
||||
}, [id]);
|
||||
|
||||
// Live updates: when the clusterer fires an identity event that
|
||||
// touches this identity (links a fresh observation, soft-merges,
|
||||
// resurrects on unmerge), refetch both the row and the observations
|
||||
// list so the page reflects current truth without a manual refresh.
|
||||
useIdentityStream({
|
||||
enabled: !!id,
|
||||
onEvent: (ev) => {
|
||||
if (!id) return;
|
||||
const payload = ev.payload || {};
|
||||
const refs = new Set<string>();
|
||||
const addUuid = (v: unknown) => {
|
||||
if (typeof v === 'string') refs.add(v);
|
||||
};
|
||||
const payload = ev.payload || {};
|
||||
addUuid(payload.identity_uuid);
|
||||
addUuid(payload.winner_uuid);
|
||||
addUuid(payload.loser_uuid);
|
||||
addUuid(payload.resurrected_uuid);
|
||||
addUuid(payload.former_winner_uuid);
|
||||
|
||||
if (refs.has(id)) {
|
||||
api.get(`/identities/${id}`)
|
||||
.then((res) => setIdentity(res.data))
|
||||
.catch(() => {});
|
||||
api.get(`/identities/${id}`).then((res) => setIdentity(res.data)).catch(() => {});
|
||||
api.get(`/identities/${id}/observations?limit=50&offset=0`)
|
||||
.then((res) => {
|
||||
setObservations(res.data.data ?? []);
|
||||
@@ -141,24 +127,20 @@ const IdentityDetail: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
|
||||
LOADING IDENTITY…
|
||||
</div>
|
||||
<div className="bounty-root">
|
||||
<EmptyState icon={Fingerprint} title="LOADING IDENTITY…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !identity) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<button onClick={() => navigate('/attackers')} className="back-button">
|
||||
<div className="bounty-root">
|
||||
<button onClick={() => navigate('/identities')} className="back-button">
|
||||
<ArrowLeft size={18} />
|
||||
<span>BACK TO ATTACKERS</span>
|
||||
<span>BACK TO IDENTITIES</span>
|
||||
</button>
|
||||
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
|
||||
{error || 'IDENTITY NOT FOUND'}
|
||||
</div>
|
||||
<EmptyState icon={Fingerprint} title={error || 'IDENTITY NOT FOUND'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,137 +151,125 @@ const IdentityDetail: React.FC = () => {
|
||||
const c2List = safeParseJsonList(identity.c2_endpoints);
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<button onClick={() => navigate('/attackers')} className="back-button">
|
||||
<div className="bounty-root">
|
||||
<button onClick={() => navigate('/identities')} className="back-button">
|
||||
<ArrowLeft size={18} />
|
||||
<span>BACK TO ATTACKERS</span>
|
||||
<span>BACK TO IDENTITIES</span>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Fingerprint size={32} className="violet-accent" />
|
||||
<h1 className="matrix-text" style={{ fontSize: '1.4rem', letterSpacing: '2px' }}>
|
||||
IDENTITY · {identity.uuid}
|
||||
</h1>
|
||||
{identity.campaign_id && (
|
||||
<span
|
||||
className="traversal-badge"
|
||||
style={{ fontSize: '0.8rem', cursor: 'pointer', letterSpacing: '2px' }}
|
||||
title="Campaign assignment from the campaign clusterer. Click to view campaign."
|
||||
onClick={() => navigate(`/campaigns/${identity.campaign_id}`)}
|
||||
>
|
||||
CAMPAIGN · {identity.campaign_id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
{identity.merged_into_uuid && (
|
||||
<span
|
||||
className="traversal-badge"
|
||||
style={{ fontSize: '0.8rem', cursor: 'pointer', letterSpacing: '2px', opacity: 0.7 }}
|
||||
title="This identity was soft-merged into another. Click to view the canonical winner."
|
||||
onClick={() => navigate(`/identities/${identity.merged_into_uuid}`)}
|
||||
>
|
||||
MERGED INTO {identity.merged_into_uuid.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
<div className="stat-card" title="Live count of attacker observations FK'd to this identity">
|
||||
<div className="stat-value matrix-text">{identity.observation_count_live}</div>
|
||||
<div className="stat-label">OBSERVATIONS</div>
|
||||
</div>
|
||||
<div className="stat-card" title="Distinct JA3 TLS fingerprints across this identity's tooling">
|
||||
<div className="stat-value violet-accent">{ja3List.length}</div>
|
||||
<div className="stat-label">JA3</div>
|
||||
</div>
|
||||
<div className="stat-card" title="Distinct HASSH SSH-client fingerprints">
|
||||
<div className="stat-value violet-accent">{hasshList.length}</div>
|
||||
<div className="stat-label">HASSH</div>
|
||||
</div>
|
||||
<div className="stat-card" title="Distinct payload SimHashes (Hamming-comparable)">
|
||||
<div className="stat-value matrix-text">{payloadList.length}</div>
|
||||
<div className="stat-label">PAYLOADS</div>
|
||||
</div>
|
||||
<div className="stat-card" title="C2 callback endpoints observed">
|
||||
<div className="stat-value matrix-text">{c2List.length}</div>
|
||||
<div className="stat-label">C2 ENDPOINTS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confidence + schema version, only show if populated */}
|
||||
{(identity.confidence !== null || identity.schema_version > 1) && (
|
||||
<div style={{ display: 'flex', gap: '24px', padding: '12px 0', opacity: 0.7, fontSize: '0.85rem' }}>
|
||||
{identity.confidence !== null && (
|
||||
<span title="Identity-cohesion score from the clusterer (0–1)">
|
||||
CONFIDENCE · {identity.confidence.toFixed(3)}
|
||||
</span>
|
||||
)}
|
||||
<span title="Federation gossip schema version">
|
||||
SCHEMA · v{identity.schema_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fingerprint detail rows */}
|
||||
{ja3List.length > 0 && (
|
||||
<FingerprintList icon={<Globe size={18} />} label="JA3" items={ja3List} />
|
||||
)}
|
||||
{hasshList.length > 0 && (
|
||||
<FingerprintList icon={<Globe size={18} />} label="HASSH" items={hasshList} />
|
||||
)}
|
||||
{c2List.length > 0 && (
|
||||
<FingerprintList icon={<Radio size={18} />} label="C2 ENDPOINTS" items={c2List} />
|
||||
)}
|
||||
|
||||
{/* Observations table */}
|
||||
<div style={{ marginTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<Crosshair size={20} className="violet-accent" />
|
||||
<h2 className="matrix-text" style={{ fontSize: '1.0rem', letterSpacing: '2px' }}>
|
||||
OBSERVATIONS · {observationTotal}
|
||||
</h2>
|
||||
</div>
|
||||
{observations.length === 0 ? (
|
||||
<div style={{ padding: '24px', opacity: 0.5, fontFamily: 'var(--font-mono)' }}>
|
||||
No observations linked yet. The clusterer assigns observations
|
||||
asynchronously; they should appear shortly after the next
|
||||
clusterer pass.
|
||||
<div className="page-header">
|
||||
<div className="page-title-group">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Fingerprint size={22} className="violet-accent" />
|
||||
<h1>IDENTITY · {identity.uuid.slice(0, 12)}…</h1>
|
||||
{identity.campaign_id && (
|
||||
<span
|
||||
className="chip violet"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/campaigns/${identity.campaign_id}`)}
|
||||
title="Campaign assignment from the campaign clusterer"
|
||||
>
|
||||
CAMPAIGN · {identity.campaign_id.slice(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
{identity.merged_into_uuid && (
|
||||
<span
|
||||
className="chip dim-chip"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/identities/${identity.merged_into_uuid}`)}
|
||||
title="Soft-merged. Click to view canonical winner."
|
||||
>
|
||||
MERGED → {identity.merged_into_uuid.slice(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table" style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>FIRST SEEN</th>
|
||||
<th>LAST SEEN</th>
|
||||
<th style={{ textAlign: 'right' }}>EVENTS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{observations.map((obs) => (
|
||||
<tr
|
||||
key={obs.uuid}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/attackers/${obs.uuid}`)}
|
||||
>
|
||||
<td>{obs.ip}</td>
|
||||
<td style={{ opacity: 0.7 }}>{obs.first_seen}</td>
|
||||
<td style={{ opacity: 0.7 }}>{obs.last_seen}</td>
|
||||
<td style={{ textAlign: 'right' }}>{obs.event_count}</td>
|
||||
<span className="page-sub">
|
||||
{identity.observation_count_live} OBSERVATIONS ·
|
||||
{' '}{ja3List.length} JA3 · {hasshList.length} HASSH ·
|
||||
{' '}{payloadList.length} PAYLOAD · {c2List.length} C2
|
||||
{identity.confidence !== null && (
|
||||
<> · CONFIDENCE {identity.confidence.toFixed(3)}</>
|
||||
)}
|
||||
{' '}· SCHEMA v{identity.schema_version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && (
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<Fingerprint size={14} />
|
||||
<span>FINGERPRINTS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="logs-table-container" style={{ padding: 12 }}>
|
||||
{ja3List.length > 0 && (
|
||||
<FingerprintGroup icon={<Globe size={14} />} label="JA3" items={ja3List} />
|
||||
)}
|
||||
{hasshList.length > 0 && (
|
||||
<FingerprintGroup icon={<Globe size={14} />} label="HASSH" items={hasshList} />
|
||||
)}
|
||||
{c2List.length > 0 && (
|
||||
<FingerprintGroup icon={<Radio size={14} />} label="C2 ENDPOINTS" items={c2List} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<Filter size={14} />
|
||||
<span>{observationTotal} OBSERVATIONS LINKED</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="logs-table-container">
|
||||
{observations.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Crosshair}
|
||||
title="NO OBSERVATIONS LINKED YET"
|
||||
hint="the clusterer assigns observations asynchronously"
|
||||
/>
|
||||
) : (
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>FIRST SEEN</th>
|
||||
<th>LAST SEEN</th>
|
||||
<th style={{ textAlign: 'right' }}>EVENTS</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{observations.map((obs) => (
|
||||
<tr
|
||||
key={obs.uuid}
|
||||
className="clickable"
|
||||
onClick={() => navigate(`/attackers/${obs.uuid}`)}
|
||||
>
|
||||
<td className="matrix-text">{obs.ip}</td>
|
||||
<td className="dim">{obs.first_seen}</td>
|
||||
<td className="dim">{obs.last_seen}</td>
|
||||
<td className="matrix-text" style={{ textAlign: 'right' }}>
|
||||
{obs.event_count}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{identity.notes && (
|
||||
<div style={{ marginTop: '24px', padding: '12px', borderLeft: '2px solid var(--violet)', opacity: 0.85 }}>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.7, letterSpacing: '2px', marginBottom: '4px' }}>
|
||||
ANALYST NOTES
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<span>ANALYST NOTES</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
|
||||
<div className="logs-table-container" style={{ padding: 12, fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
|
||||
{identity.notes}
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,32 +278,19 @@ const IdentityDetail: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const FingerprintList: React.FC<{
|
||||
const FingerprintGroup: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
items: string[];
|
||||
}> = ({ icon, label, items }) => (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<div className="fp-group">
|
||||
<div className="fp-group-label">
|
||||
{icon}
|
||||
<span className="matrix-text" style={{ fontSize: '0.85rem', letterSpacing: '2px' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
<div className="fp-group-items">
|
||||
{items.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '4px 8px',
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</code>
|
||||
<span key={v} className="chip dim-chip">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
|
||||
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
|
||||
ShieldAlert, Bell, Webhook, Lock,
|
||||
ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint,
|
||||
} from '../icons';
|
||||
import { prefetchRoute } from '../routePrefetch';
|
||||
import './Layout.css';
|
||||
@@ -31,6 +31,8 @@ const ROUTE_LABELS: Record<string, string> = {
|
||||
'/bounty': 'BOUNTY',
|
||||
'/credentials': 'CREDENTIALS',
|
||||
'/attackers': 'ATTACKERS',
|
||||
'/identities': 'IDENTITIES',
|
||||
'/campaigns': 'CAMPAIGNS',
|
||||
'/config': 'CONFIG',
|
||||
'/swarm-updates': 'REMOTE UPDATES',
|
||||
'/swarm/hosts': 'SWARM HOSTS',
|
||||
@@ -126,7 +128,11 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
</NavGroup>
|
||||
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
|
||||
<NavItem to="/credentials" icon={<Lock size={20} />} label="Credentials" open={sidebarOpen} />
|
||||
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
|
||||
<NavGroup label="THREAT DATA" icon={<Activity size={20} />} open={sidebarOpen}>
|
||||
<NavItem to="/attackers" icon={<Activity size={18} />} label="Attackers" open={sidebarOpen} indent />
|
||||
<NavItem to="/identities" icon={<Fingerprint size={18} />} label="Identities" open={sidebarOpen} indent />
|
||||
<NavItem to="/campaigns" icon={<Crosshair size={18} />} label="Campaigns" open={sidebarOpen} indent />
|
||||
</NavGroup>
|
||||
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
|
||||
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
|
||||
<NavItem to="/swarm-updates" icon={<Package size={18} />} label="Remote Updates" open={sidebarOpen} indent />
|
||||
|
||||
Reference in New Issue
Block a user