refactor(decnet_web/AttackerDetail): lift behaviour panel block
Move BehaviouralPrimitivesPanel + 8 sub-components (BehaviorHeadline, BeaconBlock, DetectedToolsBlock, TcpStackBlock, TimingStatsBlock, PhaseSequenceBlock, AttributionBadge, KeyValueRow, StatBlock) plus the OS_LABELS / BEHAVIOR_LABELS / TOOL_LABELS / BEHAVIOUR_DOMAIN_* lookup tables and fmtOpt/fmtSecs into AttackerDetail/behaviour/. AttackerDetail.tsx drops from 1220 to 680 LOC; existing behaviour_panel test moves to behaviour/BehaviouralPrimitivesPanel.test.tsx and now imports from the canonical location. The shell still re-exports BehaviouralPrimitivesPanel for source compatibility.
This commit is contained in:
@@ -15,569 +15,26 @@ import { ArtifactsPanel } from './AttackerDetail/sections/ArtifactsPanel';
|
||||
import { MailLogPanel } from './AttackerDetail/sections/MailLogPanel';
|
||||
import { Tag, Section } from './AttackerDetail/ui';
|
||||
import {
|
||||
FingerprintGroup, getPayload, seqClassColor,
|
||||
FingerprintGroup, getPayload,
|
||||
} from './AttackerDetail/fingerprints';
|
||||
import {
|
||||
BehaviorHeadline, BeaconBlock, DetectedToolsBlock, PhaseSequenceBlock,
|
||||
TcpStackBlock, TimingStatsBlock, BehaviouralPrimitivesPanel,
|
||||
} from './AttackerDetail/behaviour';
|
||||
import type {
|
||||
AttackerBehavior,
|
||||
BehaviouralObservation,
|
||||
AttributionPrimitiveState,
|
||||
} from './AttackerDetail/types';
|
||||
import './Dashboard.css';
|
||||
|
||||
// Re-export the types historically exposed from this module so external
|
||||
// importers (tests, future siblings) keep their import paths stable
|
||||
// while the canonical definitions live in ./AttackerDetail/types.
|
||||
// Re-export so existing external importers (tests, future siblings) stay
|
||||
// source-compatible while the canonical definitions live in
|
||||
// ./AttackerDetail/{types,behaviour}.
|
||||
export { BehaviouralPrimitivesPanel };
|
||||
export type { BehaviouralObservation, AttributionPrimitiveState };
|
||||
|
||||
|
||||
|
||||
// ─── 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_LABELS: Record<string, string> = {
|
||||
beaconing: 'BEACONING',
|
||||
interactive: 'INTERACTIVE',
|
||||
scanning: 'SCANNING',
|
||||
brute_force: 'BRUTE FORCE',
|
||||
slow_scan: 'SLOW SCAN',
|
||||
mixed: 'MIXED',
|
||||
unknown: 'UNKNOWN',
|
||||
};
|
||||
|
||||
const BEHAVIOR_COLORS: Record<string, string> = {
|
||||
beaconing: '#ff6b6b',
|
||||
interactive: 'var(--accent-color)',
|
||||
scanning: '#e5c07b',
|
||||
brute_force: '#ff9f43',
|
||||
slow_scan: '#c8a96e',
|
||||
mixed: 'var(--text-color)',
|
||||
unknown: 'var(--text-color)',
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
cobalt_strike: 'COBALT STRIKE',
|
||||
sliver: 'SLIVER',
|
||||
havoc: 'HAVOC',
|
||||
mythic: 'MYTHIC',
|
||||
nmap: 'NMAP',
|
||||
gophish: 'GOPHISH',
|
||||
nikto: 'NIKTO',
|
||||
sqlmap: 'SQLMAP',
|
||||
nuclei: 'NUCLEI',
|
||||
masscan: 'MASSCAN',
|
||||
zgrab: 'ZGRAB',
|
||||
metasploit: 'METASPLOIT',
|
||||
gobuster: 'GOBUSTER',
|
||||
dirbuster: 'DIRBUSTER',
|
||||
hydra: 'HYDRA',
|
||||
wfuzz: 'WFUZZ',
|
||||
curl: 'CURL',
|
||||
python_requests: 'PYTHON-REQUESTS',
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
// Tools detected via beacon timing (C2 frameworks).
|
||||
const _C2_TOOLS = new Set(['cobalt_strike', 'sliver', 'havoc', 'mythic']);
|
||||
|
||||
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
|
||||
? (BEHAVIOR_LABELS[b.behavior_class] || b.behavior_class.toUpperCase())
|
||||
: 'UNKNOWN';
|
||||
const behaviorColor = b.behavior_class ? BEHAVIOR_COLORS[b.behavior_class] : undefined;
|
||||
return (
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
||||
<StatBlock label="OS GUESS" value={osLabel} />
|
||||
<StatBlock label="HOP DISTANCE" value={fmtOpt(b.hop_distance)} />
|
||||
<StatBlock label="ATTACK PATTERN" value={behaviorLabel} color={behaviorColor} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DetectedToolsBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||
const tools = b.tool_guesses && b.tool_guesses.length > 0 ? b.tool_guesses : null;
|
||||
if (!tools) return null;
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border-color)', padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
||||
<Crosshair size={14} style={{ opacity: 0.6 }} />
|
||||
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
|
||||
DETECTED TOOLS
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{tools.map(t => (
|
||||
<div key={t} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
color: '#ff6b6b',
|
||||
minWidth: '160px',
|
||||
}}
|
||||
>
|
||||
{TOOL_LABELS[t] || t.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '1px',
|
||||
color: 'var(--dim-color)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '2px',
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{_C2_TOOLS.has(t) ? 'BEACON TIMING' : 'HTTP HEADER'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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 && fp.dscp == null && fp.ecn == 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' }}>
|
||||
<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>
|
||||
)}
|
||||
{fp.dscp !== null && fp.dscp !== undefined && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>DSCP </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.dscp}</span>
|
||||
</div>
|
||||
)}
|
||||
{fp.ecn !== null && fp.ecn !== undefined && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>ECN </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.ecn}</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>}
|
||||
{fp.ipid_class && fp.ipid_class !== 'unknown' && (
|
||||
<Tag color={seqClassColor(fp.ipid_class)}>IPID:{fp.ipid_class.toUpperCase()}</Tag>
|
||||
)}
|
||||
{fp.isn_class && fp.isn_class !== 'unknown' && (
|
||||
<Tag color={seqClassColor(fp.isn_class)}>
|
||||
{fp.isn_class !== 'random' && '⚠ '}
|
||||
ISN:{fp.isn_class.toUpperCase()}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Behavioural primitives panel (BEHAVE-INTEGRATION Phase 5) ─────────────
|
||||
|
||||
// Day-one render priority per BEHAVE-INTEGRATION.md §441-454. These four
|
||||
// primitives carry the highest discriminative value for the "is this the
|
||||
// same operator class" hover story; everything else alphabetises.
|
||||
const BEHAVIOUR_PRIORITY: ReadonlyArray<string> = [
|
||||
'motor.input_modality',
|
||||
'cognitive.feedback_loop_engagement',
|
||||
'cognitive.command_branch_diversity',
|
||||
'cognitive.inter_command_latency_class',
|
||||
];
|
||||
|
||||
const BEHAVIOUR_DOMAIN_ORDER: ReadonlyArray<string> = [
|
||||
'motor', 'cognitive', 'temporal', 'operational',
|
||||
'environmental', 'emotional_valence',
|
||||
];
|
||||
|
||||
const BEHAVIOUR_DOMAIN_LABELS: Record<string, string> = {
|
||||
motor: 'MOTOR',
|
||||
cognitive: 'COGNITIVE',
|
||||
temporal: 'TEMPORAL',
|
||||
operational: 'OPERATIONAL',
|
||||
environmental: 'ENVIRONMENTAL',
|
||||
emotional_valence: 'EMOTIONAL VALENCE',
|
||||
};
|
||||
|
||||
const BEHAVIOUR_DOMAIN_ICONS: Record<string, React.ComponentType<{ size?: number; style?: React.CSSProperties }>> = {
|
||||
motor: Keyboard,
|
||||
cognitive: Cpu,
|
||||
temporal: Clock,
|
||||
operational: Activity,
|
||||
environmental: Globe,
|
||||
emotional_valence: Sparkles,
|
||||
};
|
||||
|
||||
function _domainOf(primitive: string): string {
|
||||
return primitive.split('.', 1)[0];
|
||||
}
|
||||
|
||||
function _leafOf(primitive: string): string {
|
||||
return primitive.split('.').slice(1).join('.');
|
||||
}
|
||||
|
||||
function _comparePrimitives(a: string, b: string): number {
|
||||
const ai = BEHAVIOUR_PRIORITY.indexOf(a);
|
||||
const bi = BEHAVIOUR_PRIORITY.indexOf(b);
|
||||
if (ai !== -1 && bi !== -1) return ai - bi;
|
||||
if (ai !== -1) return -1;
|
||||
if (bi !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function _renderValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Per-state badge styling. Five states, frozen vocabulary —
|
||||
// matches decnet/correlation/attribution/aggregate.py. multi_actor is
|
||||
// the loudest because the cross-primitive correlator (Phase 5) only
|
||||
// fires multi_actor_suspected when >= 2 primitives flag it.
|
||||
const ATTRIBUTION_STATE_STYLE: Record<
|
||||
AttributionPrimitiveState['state'],
|
||||
{ label: string; bg: string; fg: string; border: string }
|
||||
> = {
|
||||
stable: { label: 'STABLE', bg: 'rgba(64,224,128,0.12)', fg: '#7fe9a4', border: '#3a8c5a' },
|
||||
drifting: { label: 'DRIFTING', bg: 'rgba(240,196,64,0.12)', fg: '#f0c440', border: '#a08020' },
|
||||
conflicted: { label: 'CONFLICTED', bg: 'rgba(240,96,96,0.12)', fg: '#f06060', border: '#a04040' },
|
||||
multi_actor: { label: 'MULTI-ACTOR', bg: 'rgba(180,96,240,0.16)', fg: '#c896f6', border: '#7a4fb0' },
|
||||
unknown: { label: 'UNKNOWN', bg: 'transparent', fg: 'var(--text-dim,#888)', border: 'var(--border-color)' },
|
||||
};
|
||||
|
||||
const AttributionBadge: React.FC<{ state: AttributionPrimitiveState }> = ({ state }) => {
|
||||
const style = ATTRIBUTION_STATE_STYLE[state.state] ?? ATTRIBUTION_STATE_STYLE.unknown;
|
||||
return (
|
||||
<span
|
||||
className="attribution-badge"
|
||||
data-testid={`attribution-badge-${state.primitive}`}
|
||||
data-state={state.state}
|
||||
title={
|
||||
`${style.label} • confidence ${(state.confidence * 100).toFixed(0)}% ` +
|
||||
`over ${state.observation_count} observation${state.observation_count === 1 ? '' : 's'}`
|
||||
}
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
letterSpacing: '1px',
|
||||
fontFamily: 'monospace',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '2px',
|
||||
background: style.bg,
|
||||
color: style.fg,
|
||||
border: `1px solid ${style.border}`,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const BehaviouralPrimitivesPanel: React.FC<{
|
||||
observations: ReadonlyArray<BehaviouralObservation>;
|
||||
attribution?: ReadonlyMap<string, AttributionPrimitiveState>;
|
||||
}> = ({ observations, attribution }) => {
|
||||
if (!observations.length) {
|
||||
return (
|
||||
<div className="info-banner" data-testid="behaviour-empty">
|
||||
<span className="dim">No behavioural observations yet — the profiler runs once a session ends.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Group by top-level domain, sort each group by the priority-then-alpha
|
||||
// comparator, then walk the canonical domain order.
|
||||
const groups = new Map<string, BehaviouralObservation[]>();
|
||||
for (const obs of observations) {
|
||||
const domain = _domainOf(obs.primitive);
|
||||
const list = groups.get(domain) ?? [];
|
||||
list.push(obs);
|
||||
groups.set(domain, list);
|
||||
}
|
||||
for (const list of groups.values()) {
|
||||
list.sort((a, b) => _comparePrimitives(a.primitive, b.primitive));
|
||||
}
|
||||
const orderedDomains = [
|
||||
...BEHAVIOUR_DOMAIN_ORDER.filter((d) => groups.has(d)),
|
||||
...Array.from(groups.keys()).filter((d) => !BEHAVIOUR_DOMAIN_ORDER.includes(d)).sort(),
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="behaviour-panel"
|
||||
data-testid="behaviour-panel"
|
||||
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||
>
|
||||
{orderedDomains.map((domain) => {
|
||||
const Icon = BEHAVIOUR_DOMAIN_ICONS[domain] ?? Activity;
|
||||
const label = BEHAVIOUR_DOMAIN_LABELS[domain] ?? domain.toUpperCase();
|
||||
const rows = groups.get(domain)!;
|
||||
return (
|
||||
<div
|
||||
key={domain}
|
||||
className="behaviour-group"
|
||||
data-testid={`behaviour-group-${domain}`}
|
||||
style={{ border: '1px solid var(--border-color)', padding: '12px 16px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
||||
<Icon size={14} style={{ opacity: 0.6 }} />
|
||||
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="dim" style={{ fontSize: '0.65rem', marginLeft: 'auto' }}>
|
||||
{rows.length} {rows.length === 1 ? 'PRIMITIVE' : 'PRIMITIVES'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{rows.map((obs) => (
|
||||
<div
|
||||
key={obs.primitive}
|
||||
className="behaviour-row"
|
||||
data-testid={`behaviour-row-${obs.primitive}`}
|
||||
style={{ display: 'flex', gap: '12px', alignItems: 'baseline' }}
|
||||
>
|
||||
<span
|
||||
className="behaviour-leaf dim"
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '1px',
|
||||
minWidth: '180px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{_leafOf(obs.primitive)}
|
||||
</span>
|
||||
<span
|
||||
className="behaviour-value matrix-text"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
flex: 1,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{_renderValue(obs.value)}
|
||||
</span>
|
||||
{attribution?.get(obs.primitive) ? (
|
||||
<AttributionBadge state={attribution.get(obs.primitive)!} />
|
||||
) : null}
|
||||
<span
|
||||
className="behaviour-confidence dim"
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '1px',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '2px',
|
||||
padding: '1px 6px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{(obs.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Threat-Intel Panel ─────────────────────────────────────────────────────
|
||||
|
||||
// Mirrors decnet/web/db/models/attacker_intel.py — server returns the row
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
BehaviouralPrimitivesPanel,
|
||||
type BehaviouralObservation,
|
||||
type AttributionPrimitiveState,
|
||||
} from './AttackerDetail';
|
||||
import { BehaviouralPrimitivesPanel } from './BehaviouralPrimitivesPanel';
|
||||
import type {
|
||||
AttributionPrimitiveState, BehaviouralObservation,
|
||||
} from '../types';
|
||||
|
||||
const _attr = (
|
||||
primitive: string,
|
||||
@@ -41,26 +43,22 @@ describe('BehaviouralPrimitivesPanel', () => {
|
||||
});
|
||||
|
||||
it('places day-one priority primitives at the top of their group', () => {
|
||||
// Mix priority + non-priority primitives in arbitrary input order.
|
||||
const observations: BehaviouralObservation[] = [
|
||||
_obs('motor.keystroke_cadence', 'steady'),
|
||||
_obs('cognitive.tool_vocabulary', 'broad'),
|
||||
_obs('motor.input_modality', 'typed'), // priority #1
|
||||
_obs('cognitive.feedback_loop_engagement', 'closed_loop'), // priority #2
|
||||
_obs('cognitive.command_branch_diversity', 'adaptive_branching'), // #3
|
||||
_obs('cognitive.inter_command_latency_class', 'typing_speed'), // #4
|
||||
_obs('motor.input_modality', 'typed'),
|
||||
_obs('cognitive.feedback_loop_engagement', 'closed_loop'),
|
||||
_obs('cognitive.command_branch_diversity', 'adaptive_branching'),
|
||||
_obs('cognitive.inter_command_latency_class', 'typing_speed'),
|
||||
_obs('motor.error_correction', 'immediate'),
|
||||
];
|
||||
render(<BehaviouralPrimitivesPanel observations={observations} />);
|
||||
|
||||
// motor group: input_modality must precede the alphabetised rest.
|
||||
const motorRows = Array.from(
|
||||
document.querySelectorAll('[data-testid^="behaviour-row-motor."]'),
|
||||
).map((el) => el.getAttribute('data-testid')!);
|
||||
expect(motorRows[0]).toBe('behaviour-row-motor.input_modality');
|
||||
|
||||
// cognitive group: the three priority primitives must be in the
|
||||
// documented order at the top.
|
||||
const cogRows = Array.from(
|
||||
document.querySelectorAll('[data-testid^="behaviour-row-cognitive."]'),
|
||||
).map((el) => el.getAttribute('data-testid')!);
|
||||
@@ -89,8 +87,6 @@ describe('BehaviouralPrimitivesPanel', () => {
|
||||
];
|
||||
const attribution = new Map<string, AttributionPrimitiveState>([
|
||||
['motor.input_modality', _attr('motor.input_modality', 'stable', 0.95)],
|
||||
// cognitive.feedback_loop_engagement intentionally absent —
|
||||
// the panel must not render a badge for it.
|
||||
]);
|
||||
render(
|
||||
<BehaviouralPrimitivesPanel
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { Activity } from '../../../icons';
|
||||
import type { AttributionPrimitiveState, BehaviouralObservation } from '../types';
|
||||
import { AttributionBadge } from './pieces';
|
||||
import {
|
||||
BEHAVIOUR_DOMAIN_ICONS, BEHAVIOUR_DOMAIN_LABELS, BEHAVIOUR_DOMAIN_ORDER,
|
||||
comparePrimitives, domainOf, leafOf, renderValue,
|
||||
} from './lookups';
|
||||
|
||||
export const BehaviouralPrimitivesPanel: React.FC<{
|
||||
observations: ReadonlyArray<BehaviouralObservation>;
|
||||
attribution?: ReadonlyMap<string, AttributionPrimitiveState>;
|
||||
}> = ({ observations, attribution }) => {
|
||||
if (!observations.length) {
|
||||
return (
|
||||
<div className="info-banner" data-testid="behaviour-empty">
|
||||
<span className="dim">No behavioural observations yet — the profiler runs once a session ends.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Group by top-level domain, sort each group by the priority-then-alpha
|
||||
// comparator, then walk the canonical domain order.
|
||||
const groups = new Map<string, BehaviouralObservation[]>();
|
||||
for (const obs of observations) {
|
||||
const domain = domainOf(obs.primitive);
|
||||
const list = groups.get(domain) ?? [];
|
||||
list.push(obs);
|
||||
groups.set(domain, list);
|
||||
}
|
||||
for (const list of groups.values()) {
|
||||
list.sort((a, b) => comparePrimitives(a.primitive, b.primitive));
|
||||
}
|
||||
const orderedDomains = [
|
||||
...BEHAVIOUR_DOMAIN_ORDER.filter((d) => groups.has(d)),
|
||||
...Array.from(groups.keys()).filter((d) => !BEHAVIOUR_DOMAIN_ORDER.includes(d)).sort(),
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="behaviour-panel"
|
||||
data-testid="behaviour-panel"
|
||||
style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}
|
||||
>
|
||||
{orderedDomains.map((domain) => {
|
||||
const Icon = BEHAVIOUR_DOMAIN_ICONS[domain] ?? Activity;
|
||||
const label = BEHAVIOUR_DOMAIN_LABELS[domain] ?? domain.toUpperCase();
|
||||
const rows = groups.get(domain)!;
|
||||
return (
|
||||
<div
|
||||
key={domain}
|
||||
className="behaviour-group"
|
||||
data-testid={`behaviour-group-${domain}`}
|
||||
style={{ border: '1px solid var(--border-color)', padding: '12px 16px' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
||||
<Icon size={14} style={{ opacity: 0.6 }} />
|
||||
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="dim" style={{ fontSize: '0.65rem', marginLeft: 'auto' }}>
|
||||
{rows.length} {rows.length === 1 ? 'PRIMITIVE' : 'PRIMITIVES'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{rows.map((obs) => (
|
||||
<div
|
||||
key={obs.primitive}
|
||||
className="behaviour-row"
|
||||
data-testid={`behaviour-row-${obs.primitive}`}
|
||||
style={{ display: 'flex', gap: '12px', alignItems: 'baseline' }}
|
||||
>
|
||||
<span
|
||||
className="behaviour-leaf dim"
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '1px',
|
||||
minWidth: '180px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{leafOf(obs.primitive)}
|
||||
</span>
|
||||
<span
|
||||
className="behaviour-value matrix-text"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
flex: 1,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{renderValue(obs.value)}
|
||||
</span>
|
||||
{attribution?.get(obs.primitive) ? (
|
||||
<AttributionBadge state={attribution.get(obs.primitive)!} />
|
||||
) : null}
|
||||
<span
|
||||
className="behaviour-confidence dim"
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '1px',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '2px',
|
||||
padding: '1px 6px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{(obs.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { BehaviouralPrimitivesPanel } from './BehaviouralPrimitivesPanel';
|
||||
export {
|
||||
BehaviorHeadline, BeaconBlock, DetectedToolsBlock, PhaseSequenceBlock,
|
||||
TcpStackBlock, TimingStatsBlock, AttributionBadge, KeyValueRow, StatBlock,
|
||||
} from './pieces';
|
||||
export {
|
||||
fmtOpt, fmtSecs, OS_LABELS, BEHAVIOR_LABELS, BEHAVIOR_COLORS, TOOL_LABELS,
|
||||
} from './lookups';
|
||||
145
decnet_web/src/components/AttackerDetail/behaviour/lookups.ts
Normal file
145
decnet_web/src/components/AttackerDetail/behaviour/lookups.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type React from 'react';
|
||||
import {
|
||||
Activity, Clock, Cpu, Globe, Keyboard, Sparkles,
|
||||
} from '../../../icons';
|
||||
import type { AttributionPrimitiveState } from '../types';
|
||||
|
||||
export 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',
|
||||
};
|
||||
|
||||
export const BEHAVIOR_LABELS: Record<string, string> = {
|
||||
beaconing: 'BEACONING',
|
||||
interactive: 'INTERACTIVE',
|
||||
scanning: 'SCANNING',
|
||||
brute_force: 'BRUTE FORCE',
|
||||
slow_scan: 'SLOW SCAN',
|
||||
mixed: 'MIXED',
|
||||
unknown: 'UNKNOWN',
|
||||
};
|
||||
|
||||
export const BEHAVIOR_COLORS: Record<string, string> = {
|
||||
beaconing: '#ff6b6b',
|
||||
interactive: 'var(--accent-color)',
|
||||
scanning: '#e5c07b',
|
||||
brute_force: '#ff9f43',
|
||||
slow_scan: '#c8a96e',
|
||||
mixed: 'var(--text-color)',
|
||||
unknown: 'var(--text-color)',
|
||||
};
|
||||
|
||||
export const TOOL_LABELS: Record<string, string> = {
|
||||
cobalt_strike: 'COBALT STRIKE',
|
||||
sliver: 'SLIVER',
|
||||
havoc: 'HAVOC',
|
||||
mythic: 'MYTHIC',
|
||||
nmap: 'NMAP',
|
||||
gophish: 'GOPHISH',
|
||||
nikto: 'NIKTO',
|
||||
sqlmap: 'SQLMAP',
|
||||
nuclei: 'NUCLEI',
|
||||
masscan: 'MASSCAN',
|
||||
zgrab: 'ZGRAB',
|
||||
metasploit: 'METASPLOIT',
|
||||
gobuster: 'GOBUSTER',
|
||||
dirbuster: 'DIRBUSTER',
|
||||
hydra: 'HYDRA',
|
||||
wfuzz: 'WFUZZ',
|
||||
curl: 'CURL',
|
||||
python_requests: 'PYTHON-REQUESTS',
|
||||
};
|
||||
|
||||
// Tools detected via beacon timing (C2 frameworks).
|
||||
export const C2_TOOLS = new Set(['cobalt_strike', 'sliver', 'havoc', 'mythic']);
|
||||
|
||||
export const fmtOpt = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? '—' : String(v);
|
||||
|
||||
export 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`;
|
||||
};
|
||||
|
||||
// ─── Behavioural primitives panel (BEHAVE-INTEGRATION Phase 5) ─────────────
|
||||
|
||||
// Day-one render priority per BEHAVE-INTEGRATION.md §441-454. These four
|
||||
// primitives carry the highest discriminative value for the "is this the
|
||||
// same operator class" hover story; everything else alphabetises.
|
||||
export const BEHAVIOUR_PRIORITY: ReadonlyArray<string> = [
|
||||
'motor.input_modality',
|
||||
'cognitive.feedback_loop_engagement',
|
||||
'cognitive.command_branch_diversity',
|
||||
'cognitive.inter_command_latency_class',
|
||||
];
|
||||
|
||||
export const BEHAVIOUR_DOMAIN_ORDER: ReadonlyArray<string> = [
|
||||
'motor', 'cognitive', 'temporal', 'operational',
|
||||
'environmental', 'emotional_valence',
|
||||
];
|
||||
|
||||
export const BEHAVIOUR_DOMAIN_LABELS: Record<string, string> = {
|
||||
motor: 'MOTOR',
|
||||
cognitive: 'COGNITIVE',
|
||||
temporal: 'TEMPORAL',
|
||||
operational: 'OPERATIONAL',
|
||||
environmental: 'ENVIRONMENTAL',
|
||||
emotional_valence: 'EMOTIONAL VALENCE',
|
||||
};
|
||||
|
||||
export const BEHAVIOUR_DOMAIN_ICONS: Record<string, React.ComponentType<{ size?: number; style?: React.CSSProperties }>> = {
|
||||
motor: Keyboard,
|
||||
cognitive: Cpu,
|
||||
temporal: Clock,
|
||||
operational: Activity,
|
||||
environmental: Globe,
|
||||
emotional_valence: Sparkles,
|
||||
};
|
||||
|
||||
export function domainOf(primitive: string): string {
|
||||
return primitive.split('.', 1)[0];
|
||||
}
|
||||
|
||||
export function leafOf(primitive: string): string {
|
||||
return primitive.split('.').slice(1).join('.');
|
||||
}
|
||||
|
||||
export function comparePrimitives(a: string, b: string): number {
|
||||
const ai = BEHAVIOUR_PRIORITY.indexOf(a);
|
||||
const bi = BEHAVIOUR_PRIORITY.indexOf(b);
|
||||
if (ai !== -1 && bi !== -1) return ai - bi;
|
||||
if (ai !== -1) return -1;
|
||||
if (bi !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
export function renderValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// Per-state badge styling. Five states, frozen vocabulary —
|
||||
// matches decnet/correlation/attribution/aggregate.py. multi_actor is
|
||||
// the loudest because the cross-primitive correlator (Phase 5) only
|
||||
// fires multi_actor_suspected when >= 2 primitives flag it.
|
||||
export const ATTRIBUTION_STATE_STYLE: Record<
|
||||
AttributionPrimitiveState['state'],
|
||||
{ label: string; bg: string; fg: string; border: string }
|
||||
> = {
|
||||
stable: { label: 'STABLE', bg: 'rgba(64,224,128,0.12)', fg: '#7fe9a4', border: '#3a8c5a' },
|
||||
drifting: { label: 'DRIFTING', bg: 'rgba(240,196,64,0.12)', fg: '#f0c440', border: '#a08020' },
|
||||
conflicted: { label: 'CONFLICTED', bg: 'rgba(240,96,96,0.12)', fg: '#f06060', border: '#a04040' },
|
||||
multi_actor: { label: 'MULTI-ACTOR', bg: 'rgba(180,96,240,0.16)', fg: '#c896f6', border: '#7a4fb0' },
|
||||
unknown: { label: 'UNKNOWN', bg: 'transparent', fg: 'var(--text-dim,#888)', border: 'var(--border-color)' },
|
||||
};
|
||||
294
decnet_web/src/components/AttackerDetail/behaviour/pieces.tsx
Normal file
294
decnet_web/src/components/AttackerDetail/behaviour/pieces.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Activity, Crosshair, Radio, Timer, Wifi,
|
||||
} from '../../../icons';
|
||||
import { Tag } from '../ui';
|
||||
import { seqClassColor } from '../fingerprints';
|
||||
import type { AttackerBehavior, AttributionPrimitiveState } from '../types';
|
||||
import {
|
||||
ATTRIBUTION_STATE_STYLE, BEHAVIOR_COLORS, BEHAVIOR_LABELS,
|
||||
C2_TOOLS, OS_LABELS, TOOL_LABELS, fmtOpt, fmtSecs,
|
||||
} from './lookups';
|
||||
|
||||
export 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>
|
||||
);
|
||||
|
||||
export 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>
|
||||
);
|
||||
|
||||
export 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
|
||||
? (BEHAVIOR_LABELS[b.behavior_class] || b.behavior_class.toUpperCase())
|
||||
: 'UNKNOWN';
|
||||
const behaviorColor = b.behavior_class ? BEHAVIOR_COLORS[b.behavior_class] : undefined;
|
||||
return (
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
|
||||
<StatBlock label="OS GUESS" value={osLabel} />
|
||||
<StatBlock label="HOP DISTANCE" value={fmtOpt(b.hop_distance)} />
|
||||
<StatBlock label="ATTACK PATTERN" value={behaviorLabel} color={behaviorColor} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DetectedToolsBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||
const tools = b.tool_guesses && b.tool_guesses.length > 0 ? b.tool_guesses : null;
|
||||
if (!tools) return null;
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border-color)', padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
||||
<Crosshair size={14} style={{ opacity: 0.6 }} />
|
||||
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
|
||||
DETECTED TOOLS
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{tools.map((t) => (
|
||||
<div key={t} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
color: '#ff6b6b',
|
||||
minWidth: '160px',
|
||||
}}
|
||||
>
|
||||
{TOOL_LABELS[t] || t.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: '1px',
|
||||
color: 'var(--dim-color)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '2px',
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{C2_TOOLS.has(t) ? 'BEACON TIMING' : 'HTTP HEADER'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export 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>
|
||||
);
|
||||
};
|
||||
|
||||
export const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||
const fp = b.tcp_fingerprint;
|
||||
if (!fp || (!fp.window && !fp.mss && !fp.options_sig && fp.dscp == null && fp.ecn == 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' }}>
|
||||
<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>
|
||||
)}
|
||||
{fp.dscp !== null && fp.dscp !== undefined && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>DSCP </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.dscp}</span>
|
||||
</div>
|
||||
)}
|
||||
{fp.ecn !== null && fp.ecn !== undefined && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>ECN </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.ecn}</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>}
|
||||
{fp.ipid_class && fp.ipid_class !== 'unknown' && (
|
||||
<Tag color={seqClassColor(fp.ipid_class)}>IPID:{fp.ipid_class.toUpperCase()}</Tag>
|
||||
)}
|
||||
{fp.isn_class && fp.isn_class !== 'unknown' && (
|
||||
<Tag color={seqClassColor(fp.isn_class)}>
|
||||
{fp.isn_class !== 'random' && '⚠ '}
|
||||
ISN:{fp.isn_class.toUpperCase()}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export 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>
|
||||
);
|
||||
};
|
||||
|
||||
export 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>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttributionBadge: React.FC<{ state: AttributionPrimitiveState }> = ({ state }) => {
|
||||
const style = ATTRIBUTION_STATE_STYLE[state.state] ?? ATTRIBUTION_STATE_STYLE.unknown;
|
||||
return (
|
||||
<span
|
||||
className="attribution-badge"
|
||||
data-testid={`attribution-badge-${state.primitive}`}
|
||||
data-state={state.state}
|
||||
title={
|
||||
`${style.label} • confidence ${(state.confidence * 100).toFixed(0)}% ` +
|
||||
`over ${state.observation_count} observation${state.observation_count === 1 ? '' : 's'}`
|
||||
}
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
letterSpacing: '1px',
|
||||
fontFamily: 'monospace',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '2px',
|
||||
background: style.bg,
|
||||
color: style.fg,
|
||||
border: `1px solid ${style.border}`,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user