feat: enhance UI components with config management and RBAC gating

- Add Config.tsx component for admin configuration management
- Update AttackerDetail, DeckyFleet components to use server-side RBAC gating
- Remove client-side role checks per memory: server-side UI gating is mandatory
- Add Config.css for configuration UI styling
This commit is contained in:
2026-04-15 12:51:08 -04:00
parent 0ee23b8700
commit a78126b1ba
4 changed files with 1139 additions and 34 deletions

View File

@@ -1,9 +1,44 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey } from 'lucide-react';
import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer } from 'lucide-react';
import api from '../utils/api';
import './Dashboard.css';
interface AttackerBehavior {
os_guess: string | null;
hop_distance: number | null;
tcp_fingerprint: {
window?: number | null;
wscale?: number | null;
mss?: number | null;
options_sig?: string;
has_sack?: boolean;
has_timestamps?: boolean;
} | null;
retransmit_count: number;
behavior_class: string | null;
beacon_interval_s: number | null;
beacon_jitter_pct: number | null;
tool_guess: string | null;
timing_stats: {
event_count?: number;
duration_s?: number;
mean_iat_s?: number | null;
median_iat_s?: number | null;
stdev_iat_s?: number | null;
min_iat_s?: number | null;
max_iat_s?: number | null;
cv?: number | null;
} | null;
phase_sequence: {
recon_end_ts?: string | null;
exfil_start_ts?: string | null;
exfil_latency_s?: number | null;
large_payload_count?: number;
} | null;
updated_at?: string;
}
interface AttackerData {
uuid: string;
ip: string;
@@ -21,6 +56,7 @@ interface AttackerData {
fingerprints: any[];
commands: { service: string; decky: string; command: string; timestamp: string }[];
updated_at: string;
behavior: AttackerBehavior | null;
}
// ─── Fingerprint rendering ───────────────────────────────────────────────────
@@ -312,6 +348,250 @@ const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ fpType,
);
};
// ─── Behavioral profile blocks ──────────────────────────────────────────────
const OS_LABELS: Record<string, string> = {
linux: 'LINUX',
windows: 'WINDOWS',
macos_ios: 'macOS / iOS',
freebsd: 'FREEBSD',
openbsd: 'OPENBSD',
embedded: 'EMBEDDED',
nmap: 'NMAP (SCANNER)',
unknown: 'UNKNOWN',
};
const BEHAVIOR_COLORS: Record<string, string> = {
beaconing: '#ff6b6b',
interactive: 'var(--accent-color)',
scanning: '#e5c07b',
mixed: 'var(--text-color)',
unknown: 'var(--text-color)',
};
const TOOL_LABELS: Record<string, string> = {
cobalt_strike: 'COBALT STRIKE',
sliver: 'SLIVER',
havoc: 'HAVOC',
mythic: 'MYTHIC',
};
const fmtOpt = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : String(v);
const fmtSecs = (v: number | null | undefined): string => {
if (v === null || v === undefined) return '—';
if (v < 1) return `${(v * 1000).toFixed(0)} ms`;
if (v < 60) return `${v.toFixed(2)} s`;
if (v < 3600) return `${(v / 60).toFixed(2)} m`;
return `${(v / 3600).toFixed(2)} h`;
};
const StatBlock: React.FC<{ label: string; value: React.ReactNode; color?: string }> = ({
label, value, color,
}) => (
<div className="stat-card">
<div className="stat-value" style={{ color: color || 'var(--text-color)' }}>
{value}
</div>
<div className="stat-label">{label}</div>
</div>
);
const KeyValueRow: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div style={{ display: 'flex', gap: '12px', alignItems: 'baseline' }}>
<span className="dim" style={{ fontSize: '0.7rem', letterSpacing: '1px', minWidth: '120px' }}>
{label}
</span>
<span className="matrix-text" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
{value}
</span>
</div>
);
const BehaviorHeadline: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
const osLabel = b.os_guess ? (OS_LABELS[b.os_guess] || b.os_guess.toUpperCase()) : '—';
const behaviorLabel = b.behavior_class ? b.behavior_class.toUpperCase() : 'UNKNOWN';
const behaviorColor = b.behavior_class ? BEHAVIOR_COLORS[b.behavior_class] : undefined;
const toolLabel = b.tool_guess ? (TOOL_LABELS[b.tool_guess] || b.tool_guess.toUpperCase()) : '—';
return (
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
<StatBlock label="OS GUESS" value={osLabel} />
<StatBlock label="HOP DISTANCE" value={fmtOpt(b.hop_distance)} />
<StatBlock label="BEHAVIOR" value={behaviorLabel} color={behaviorColor} />
<StatBlock
label="TOOL ATTRIBUTION"
value={toolLabel}
color={b.tool_guess ? '#ff6b6b' : undefined}
/>
</div>
);
};
const BeaconBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
if (b.behavior_class !== 'beaconing' || b.beacon_interval_s === null) return null;
return (
<div style={{
border: '1px solid var(--border-color)', padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<Radio size={14} style={{ opacity: 0.6 }} />
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
BEACON CADENCE
</span>
</div>
<div style={{ display: 'flex', gap: '32px', alignItems: 'baseline' }}>
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>INTERVAL </span>
<span className="matrix-text" style={{ fontSize: '1.3rem', fontWeight: 'bold' }}>
{fmtSecs(b.beacon_interval_s)}
</span>
</div>
{b.beacon_jitter_pct !== null && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>JITTER </span>
<span className="matrix-text" style={{ fontSize: '1.3rem', fontWeight: 'bold' }}>
{b.beacon_jitter_pct.toFixed(1)}%
</span>
</div>
)}
</div>
</div>
);
};
const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
const fp = b.tcp_fingerprint;
if (!fp || (!fp.window && !fp.mss && !fp.options_sig)) return null;
return (
<div style={{
border: '1px solid var(--border-color)', padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<Wifi size={14} style={{ opacity: 0.6 }} />
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
TCP STACK (PASSIVE)
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div style={{ display: 'flex', gap: '24px', flexWrap: 'wrap' }}>
{fp.window !== null && fp.window !== undefined && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>WIN </span>
<span className="matrix-text" style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>
{fp.window}
</span>
</div>
)}
{fp.wscale !== null && fp.wscale !== undefined && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>WSCALE </span>
<span className="matrix-text" style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>
{fp.wscale}
</span>
</div>
)}
{fp.mss !== null && fp.mss !== undefined && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>MSS </span>
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.mss}</span>
</div>
)}
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>RETRANSMITS </span>
<span
className="matrix-text"
style={{
fontSize: '1.1rem',
fontWeight: 'bold',
color: b.retransmit_count > 0 ? '#e5c07b' : undefined,
}}
>
{b.retransmit_count}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{fp.has_sack && <Tag>SACK</Tag>}
{fp.has_timestamps && <Tag>TS</Tag>}
</div>
{fp.options_sig && (
<div>
<span className="dim" style={{ fontSize: '0.7rem' }}>OPTS: </span>
<span className="matrix-text" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{fp.options_sig}
</span>
</div>
)}
</div>
</div>
);
};
const TimingStatsBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
const s = b.timing_stats;
if (!s || !s.event_count || s.event_count < 2) return null;
return (
<div style={{
border: '1px solid var(--border-color)', padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<Timer size={14} style={{ opacity: 0.6 }} />
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
INTER-EVENT TIMING
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<KeyValueRow label="EVENT COUNT" value={s.event_count ?? '—'} />
<KeyValueRow label="DURATION" value={fmtSecs(s.duration_s)} />
<KeyValueRow label="MEAN IAT" value={fmtSecs(s.mean_iat_s)} />
<KeyValueRow label="MEDIAN IAT" value={fmtSecs(s.median_iat_s)} />
<KeyValueRow label="STDEV IAT" value={fmtSecs(s.stdev_iat_s)} />
<KeyValueRow
label="MIN / MAX IAT"
value={`${fmtSecs(s.min_iat_s)} / ${fmtSecs(s.max_iat_s)}`}
/>
<KeyValueRow
label="CV (JITTER)"
value={s.cv !== null && s.cv !== undefined ? s.cv.toFixed(3) : '—'}
/>
</div>
</div>
);
};
const PhaseSequenceBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
const p = b.phase_sequence;
if (!p || (!p.recon_end_ts && !p.exfil_start_ts && !p.large_payload_count)) return null;
return (
<div style={{
border: '1px solid var(--border-color)', padding: '12px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
<Activity size={14} style={{ opacity: 0.6 }} />
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
PHASE SEQUENCE
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<KeyValueRow
label="RECON END"
value={p.recon_end_ts ? new Date(p.recon_end_ts).toLocaleString() : '—'}
/>
<KeyValueRow
label="EXFIL START"
value={p.exfil_start_ts ? new Date(p.exfil_start_ts).toLocaleString() : '—'}
/>
<KeyValueRow label="RECON→EXFIL LATENCY" value={fmtSecs(p.exfil_latency_s)} />
<KeyValueRow
label="LARGE PAYLOADS"
value={p.large_payload_count ?? 0}
/>
</div>
</div>
);
};
// ─── Collapsible section ────────────────────────────────────────────────────
const Section: React.FC<{
@@ -352,6 +632,7 @@ const AttackerDetail: React.FC = () => {
timeline: true,
services: true,
deckies: true,
behavior: true,
commands: true,
fingerprints: true,
});
@@ -543,6 +824,29 @@ const AttackerDetail: React.FC = () => {
</div>
</Section>
{/* Behavioral Profile */}
<Section
title="BEHAVIORAL PROFILE"
open={openSections.behavior}
onToggle={() => toggle('behavior')}
>
{attacker.behavior ? (
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<BehaviorHeadline b={attacker.behavior} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<BeaconBlock b={attacker.behavior} />
<TcpStackBlock b={attacker.behavior} />
<TimingStatsBlock b={attacker.behavior} />
<PhaseSequenceBlock b={attacker.behavior} />
</div>
</div>
) : (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
NO BEHAVIORAL DATA YET PROFILER HAS NOT RUN FOR THIS ATTACKER
</div>
)}
</Section>
{/* Commands */}
{(() => {
const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);