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 { MailLogPanel } from './AttackerDetail/sections/MailLogPanel';
|
||||||
import { Tag, Section } from './AttackerDetail/ui';
|
import { Tag, Section } from './AttackerDetail/ui';
|
||||||
import {
|
import {
|
||||||
FingerprintGroup, getPayload, seqClassColor,
|
FingerprintGroup, getPayload,
|
||||||
} from './AttackerDetail/fingerprints';
|
} from './AttackerDetail/fingerprints';
|
||||||
|
import {
|
||||||
|
BehaviorHeadline, BeaconBlock, DetectedToolsBlock, PhaseSequenceBlock,
|
||||||
|
TcpStackBlock, TimingStatsBlock, BehaviouralPrimitivesPanel,
|
||||||
|
} from './AttackerDetail/behaviour';
|
||||||
import type {
|
import type {
|
||||||
AttackerBehavior,
|
|
||||||
BehaviouralObservation,
|
BehaviouralObservation,
|
||||||
AttributionPrimitiveState,
|
AttributionPrimitiveState,
|
||||||
} from './AttackerDetail/types';
|
} from './AttackerDetail/types';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
|
|
||||||
// Re-export the types historically exposed from this module so external
|
// Re-export so existing external importers (tests, future siblings) stay
|
||||||
// importers (tests, future siblings) keep their import paths stable
|
// source-compatible while the canonical definitions live in
|
||||||
// while the canonical definitions live in ./AttackerDetail/types.
|
// ./AttackerDetail/{types,behaviour}.
|
||||||
|
export { BehaviouralPrimitivesPanel };
|
||||||
export type { BehaviouralObservation, AttributionPrimitiveState };
|
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 ─────────────────────────────────────────────────────
|
// ─── Threat-Intel Panel ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Mirrors decnet/web/db/models/attacker_intel.py — server returns the row
|
// 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 { describe, it, expect } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
import {
|
import { BehaviouralPrimitivesPanel } from './BehaviouralPrimitivesPanel';
|
||||||
BehaviouralPrimitivesPanel,
|
import type {
|
||||||
type BehaviouralObservation,
|
AttributionPrimitiveState, BehaviouralObservation,
|
||||||
type AttributionPrimitiveState,
|
} from '../types';
|
||||||
} from './AttackerDetail';
|
|
||||||
|
|
||||||
const _attr = (
|
const _attr = (
|
||||||
primitive: string,
|
primitive: string,
|
||||||
@@ -41,26 +43,22 @@ describe('BehaviouralPrimitivesPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('places day-one priority primitives at the top of their group', () => {
|
it('places day-one priority primitives at the top of their group', () => {
|
||||||
// Mix priority + non-priority primitives in arbitrary input order.
|
|
||||||
const observations: BehaviouralObservation[] = [
|
const observations: BehaviouralObservation[] = [
|
||||||
_obs('motor.keystroke_cadence', 'steady'),
|
_obs('motor.keystroke_cadence', 'steady'),
|
||||||
_obs('cognitive.tool_vocabulary', 'broad'),
|
_obs('cognitive.tool_vocabulary', 'broad'),
|
||||||
_obs('motor.input_modality', 'typed'), // priority #1
|
_obs('motor.input_modality', 'typed'),
|
||||||
_obs('cognitive.feedback_loop_engagement', 'closed_loop'), // priority #2
|
_obs('cognitive.feedback_loop_engagement', 'closed_loop'),
|
||||||
_obs('cognitive.command_branch_diversity', 'adaptive_branching'), // #3
|
_obs('cognitive.command_branch_diversity', 'adaptive_branching'),
|
||||||
_obs('cognitive.inter_command_latency_class', 'typing_speed'), // #4
|
_obs('cognitive.inter_command_latency_class', 'typing_speed'),
|
||||||
_obs('motor.error_correction', 'immediate'),
|
_obs('motor.error_correction', 'immediate'),
|
||||||
];
|
];
|
||||||
render(<BehaviouralPrimitivesPanel observations={observations} />);
|
render(<BehaviouralPrimitivesPanel observations={observations} />);
|
||||||
|
|
||||||
// motor group: input_modality must precede the alphabetised rest.
|
|
||||||
const motorRows = Array.from(
|
const motorRows = Array.from(
|
||||||
document.querySelectorAll('[data-testid^="behaviour-row-motor."]'),
|
document.querySelectorAll('[data-testid^="behaviour-row-motor."]'),
|
||||||
).map((el) => el.getAttribute('data-testid')!);
|
).map((el) => el.getAttribute('data-testid')!);
|
||||||
expect(motorRows[0]).toBe('behaviour-row-motor.input_modality');
|
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(
|
const cogRows = Array.from(
|
||||||
document.querySelectorAll('[data-testid^="behaviour-row-cognitive."]'),
|
document.querySelectorAll('[data-testid^="behaviour-row-cognitive."]'),
|
||||||
).map((el) => el.getAttribute('data-testid')!);
|
).map((el) => el.getAttribute('data-testid')!);
|
||||||
@@ -89,8 +87,6 @@ describe('BehaviouralPrimitivesPanel', () => {
|
|||||||
];
|
];
|
||||||
const attribution = new Map<string, AttributionPrimitiveState>([
|
const attribution = new Map<string, AttributionPrimitiveState>([
|
||||||
['motor.input_modality', _attr('motor.input_modality', 'stable', 0.95)],
|
['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(
|
render(
|
||||||
<BehaviouralPrimitivesPanel
|
<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