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:
2026-05-09 06:25:53 -04:00
parent 1f3f58c42c
commit e92d415304
6 changed files with 585 additions and 567 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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';

View 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)' },
};

View 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>
);
};