diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 163b27e6..24baca38 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -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 = { - linux: 'LINUX', - windows: 'WINDOWS', - macos_ios: 'macOS / iOS', - freebsd: 'FREEBSD', - openbsd: 'OPENBSD', - embedded: 'EMBEDDED', - nmap: 'NMAP (SCANNER)', - unknown: 'UNKNOWN', -}; - -const BEHAVIOR_LABELS: Record = { - beaconing: 'BEACONING', - interactive: 'INTERACTIVE', - scanning: 'SCANNING', - brute_force: 'BRUTE FORCE', - slow_scan: 'SLOW SCAN', - mixed: 'MIXED', - unknown: 'UNKNOWN', -}; - -const BEHAVIOR_COLORS: Record = { - 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 = { - 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, -}) => ( -
-
- {value} -
-
{label}
-
-); - -const KeyValueRow: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( -
- - {label} - - - {value} - -
-); - -// 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 ( -
- - - -
- ); -}; - -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 ( -
-
- - - DETECTED TOOLS - -
-
- {tools.map(t => ( -
- - {TOOL_LABELS[t] || t.toUpperCase()} - - - {_C2_TOOLS.has(t) ? 'BEACON TIMING' : 'HTTP HEADER'} - -
- ))} -
-
- ); -}; - -const BeaconBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { - if (b.behavior_class !== 'beaconing' || b.beacon_interval_s === null) return null; - return ( -
-
- - - BEACON CADENCE - -
-
-
- INTERVAL - - {fmtSecs(b.beacon_interval_s)} - -
- {b.beacon_jitter_pct !== null && ( -
- JITTER - - {b.beacon_jitter_pct.toFixed(1)}% - -
- )} -
-
- ); -}; - -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 ( -
-
- - - TCP STACK (PASSIVE) - -
-
-
- {fp.window !== null && fp.window !== undefined && ( -
- WIN - - {fp.window} - -
- )} - {fp.wscale !== null && fp.wscale !== undefined && ( -
- WSCALE - - {fp.wscale} - -
- )} - {fp.mss !== null && fp.mss !== undefined && ( -
- MSS - {fp.mss} -
- )} - {fp.dscp !== null && fp.dscp !== undefined && ( -
- DSCP - {fp.dscp} -
- )} - {fp.ecn !== null && fp.ecn !== undefined && ( -
- ECN - {fp.ecn} -
- )} -
- RETRANSMITS - 0 ? '#e5c07b' : undefined, - }} - > - {b.retransmit_count} - -
-
-
- {fp.has_sack && SACK} - {fp.has_timestamps && TS} - {fp.ipid_class && fp.ipid_class !== 'unknown' && ( - IPID:{fp.ipid_class.toUpperCase()} - )} - {fp.isn_class && fp.isn_class !== 'unknown' && ( - - {fp.isn_class !== 'random' && '⚠ '} - ISN:{fp.isn_class.toUpperCase()} - - )} -
- {fp.options_sig && ( -
- OPTS: - - {fp.options_sig} - -
- )} -
-
- ); -}; - -const TimingStatsBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { - const s = b.timing_stats; - if (!s || !s.event_count || s.event_count < 2) return null; - return ( -
-
- - - INTER-EVENT TIMING - -
-
- - - - - - - -
-
- ); -}; - -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 ( -
-
- - - PHASE SEQUENCE - -
-
- - - - -
-
- ); -}; - -// ─── 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 = [ - 'motor.input_modality', - 'cognitive.feedback_loop_engagement', - 'cognitive.command_branch_diversity', - 'cognitive.inter_command_latency_class', -]; - -const BEHAVIOUR_DOMAIN_ORDER: ReadonlyArray = [ - 'motor', 'cognitive', 'temporal', 'operational', - 'environmental', 'emotional_valence', -]; - -const BEHAVIOUR_DOMAIN_LABELS: Record = { - motor: 'MOTOR', - cognitive: 'COGNITIVE', - temporal: 'TEMPORAL', - operational: 'OPERATIONAL', - environmental: 'ENVIRONMENTAL', - emotional_valence: 'EMOTIONAL VALENCE', -}; - -const BEHAVIOUR_DOMAIN_ICONS: Record> = { - 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 ( - - {style.label} - - ); -}; - -export const BehaviouralPrimitivesPanel: React.FC<{ - observations: ReadonlyArray; - attribution?: ReadonlyMap; -}> = ({ observations, attribution }) => { - if (!observations.length) { - return ( -
- No behavioural observations yet — the profiler runs once a session ends. -
- ); - } - // Group by top-level domain, sort each group by the priority-then-alpha - // comparator, then walk the canonical domain order. - const groups = new Map(); - 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 ( -
- {orderedDomains.map((domain) => { - const Icon = BEHAVIOUR_DOMAIN_ICONS[domain] ?? Activity; - const label = BEHAVIOUR_DOMAIN_LABELS[domain] ?? domain.toUpperCase(); - const rows = groups.get(domain)!; - return ( -
-
- - - {label} - - - {rows.length} {rows.length === 1 ? 'PRIMITIVE' : 'PRIMITIVES'} - -
-
- {rows.map((obs) => ( -
- - {_leafOf(obs.primitive)} - - - {_renderValue(obs.value)} - - {attribution?.get(obs.primitive) ? ( - - ) : null} - - {(obs.confidence * 100).toFixed(0)}% - -
- ))} -
-
- ); - })} -
- ); -}; - // ─── Threat-Intel Panel ───────────────────────────────────────────────────── // Mirrors decnet/web/db/models/attacker_intel.py — server returns the row diff --git a/decnet_web/src/components/AttackerDetail.behaviour_panel.test.tsx b/decnet_web/src/components/AttackerDetail/behaviour/BehaviouralPrimitivesPanel.test.tsx similarity index 90% rename from decnet_web/src/components/AttackerDetail.behaviour_panel.test.tsx rename to decnet_web/src/components/AttackerDetail/behaviour/BehaviouralPrimitivesPanel.test.tsx index 05ba5a6b..7e8407a8 100644 --- a/decnet_web/src/components/AttackerDetail.behaviour_panel.test.tsx +++ b/decnet_web/src/components/AttackerDetail/behaviour/BehaviouralPrimitivesPanel.test.tsx @@ -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(); - // 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([ ['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( ; + attribution?: ReadonlyMap; +}> = ({ observations, attribution }) => { + if (!observations.length) { + return ( +
+ No behavioural observations yet — the profiler runs once a session ends. +
+ ); + } + // Group by top-level domain, sort each group by the priority-then-alpha + // comparator, then walk the canonical domain order. + const groups = new Map(); + 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 ( +
+ {orderedDomains.map((domain) => { + const Icon = BEHAVIOUR_DOMAIN_ICONS[domain] ?? Activity; + const label = BEHAVIOUR_DOMAIN_LABELS[domain] ?? domain.toUpperCase(); + const rows = groups.get(domain)!; + return ( +
+
+ + + {label} + + + {rows.length} {rows.length === 1 ? 'PRIMITIVE' : 'PRIMITIVES'} + +
+
+ {rows.map((obs) => ( +
+ + {leafOf(obs.primitive)} + + + {renderValue(obs.value)} + + {attribution?.get(obs.primitive) ? ( + + ) : null} + + {(obs.confidence * 100).toFixed(0)}% + +
+ ))} +
+
+ ); + })} +
+ ); +}; diff --git a/decnet_web/src/components/AttackerDetail/behaviour/index.ts b/decnet_web/src/components/AttackerDetail/behaviour/index.ts new file mode 100644 index 00000000..872206e1 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/behaviour/index.ts @@ -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'; diff --git a/decnet_web/src/components/AttackerDetail/behaviour/lookups.ts b/decnet_web/src/components/AttackerDetail/behaviour/lookups.ts new file mode 100644 index 00000000..2d6a88af --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/behaviour/lookups.ts @@ -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 = { + linux: 'LINUX', + windows: 'WINDOWS', + macos_ios: 'macOS / iOS', + freebsd: 'FREEBSD', + openbsd: 'OPENBSD', + embedded: 'EMBEDDED', + nmap: 'NMAP (SCANNER)', + unknown: 'UNKNOWN', +}; + +export const BEHAVIOR_LABELS: Record = { + beaconing: 'BEACONING', + interactive: 'INTERACTIVE', + scanning: 'SCANNING', + brute_force: 'BRUTE FORCE', + slow_scan: 'SLOW SCAN', + mixed: 'MIXED', + unknown: 'UNKNOWN', +}; + +export const BEHAVIOR_COLORS: Record = { + 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 = { + 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 = [ + 'motor.input_modality', + 'cognitive.feedback_loop_engagement', + 'cognitive.command_branch_diversity', + 'cognitive.inter_command_latency_class', +]; + +export const BEHAVIOUR_DOMAIN_ORDER: ReadonlyArray = [ + 'motor', 'cognitive', 'temporal', 'operational', + 'environmental', 'emotional_valence', +]; + +export const BEHAVIOUR_DOMAIN_LABELS: Record = { + motor: 'MOTOR', + cognitive: 'COGNITIVE', + temporal: 'TEMPORAL', + operational: 'OPERATIONAL', + environmental: 'ENVIRONMENTAL', + emotional_valence: 'EMOTIONAL VALENCE', +}; + +export const BEHAVIOUR_DOMAIN_ICONS: Record> = { + 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)' }, +}; diff --git a/decnet_web/src/components/AttackerDetail/behaviour/pieces.tsx b/decnet_web/src/components/AttackerDetail/behaviour/pieces.tsx new file mode 100644 index 00000000..66af7954 --- /dev/null +++ b/decnet_web/src/components/AttackerDetail/behaviour/pieces.tsx @@ -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, +}) => ( +
+
+ {value} +
+
{label}
+
+); + +export const KeyValueRow: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( +
+ + {label} + + + {value} + +
+); + +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 ( +
+ + + +
+ ); +}; + +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 ( +
+
+ + + DETECTED TOOLS + +
+
+ {tools.map((t) => ( +
+ + {TOOL_LABELS[t] || t.toUpperCase()} + + + {C2_TOOLS.has(t) ? 'BEACON TIMING' : 'HTTP HEADER'} + +
+ ))} +
+
+ ); +}; + +export const BeaconBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { + if (b.behavior_class !== 'beaconing' || b.beacon_interval_s === null) return null; + return ( +
+
+ + + BEACON CADENCE + +
+
+
+ INTERVAL + + {fmtSecs(b.beacon_interval_s)} + +
+ {b.beacon_jitter_pct !== null && ( +
+ JITTER + + {b.beacon_jitter_pct.toFixed(1)}% + +
+ )} +
+
+ ); +}; + +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 ( +
+
+ + + TCP STACK (PASSIVE) + +
+
+
+ {fp.window !== null && fp.window !== undefined && ( +
+ WIN + {fp.window} +
+ )} + {fp.wscale !== null && fp.wscale !== undefined && ( +
+ WSCALE + {fp.wscale} +
+ )} + {fp.mss !== null && fp.mss !== undefined && ( +
+ MSS + {fp.mss} +
+ )} + {fp.dscp !== null && fp.dscp !== undefined && ( +
+ DSCP + {fp.dscp} +
+ )} + {fp.ecn !== null && fp.ecn !== undefined && ( +
+ ECN + {fp.ecn} +
+ )} +
+ RETRANSMITS + 0 ? '#e5c07b' : undefined, + }} + > + {b.retransmit_count} + +
+
+
+ {fp.has_sack && SACK} + {fp.has_timestamps && TS} + {fp.ipid_class && fp.ipid_class !== 'unknown' && ( + IPID:{fp.ipid_class.toUpperCase()} + )} + {fp.isn_class && fp.isn_class !== 'unknown' && ( + + {fp.isn_class !== 'random' && '⚠ '} + ISN:{fp.isn_class.toUpperCase()} + + )} +
+ {fp.options_sig && ( +
+ OPTS: + + {fp.options_sig} + +
+ )} +
+
+ ); +}; + +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 ( +
+
+ + + INTER-EVENT TIMING + +
+
+ + + + + + + +
+
+ ); +}; + +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 ( +
+
+ + + PHASE SEQUENCE + +
+
+ + + + +
+
+ ); +}; + +export const AttributionBadge: React.FC<{ state: AttributionPrimitiveState }> = ({ state }) => { + const style = ATTRIBUTION_STATE_STYLE[state.state] ?? ATTRIBUTION_STATE_STYLE.unknown; + return ( + + {style.label} + + ); +};