/* ============================================================ CyberSOC Dashboard โ Main Application Controller ============================================================ */ // ============================================================ // StateStore โ single source of truth for all dashboard state // ============================================================ class StateStore { constructor() { this._state = StateStore._emptyState(); this._subscribers = new Map(); } static _emptyState() { return { episodeActive: false, episodeId: null, taskId: null, stepCount: 0, maxSteps: 30, totalReward: 0, reward: 0, done: false, alerts: [], topology: null, timeline: [], activeThreats: [], businessImpactScore: 0, liveScores: { threat_containment: 0, ioc_blocking: 0, forensic_investigation: 0, siem_correlation: 0, threat_intel_usage: 0, vuln_root_cause: 0, business_impact: 0, step_efficiency: 0, plan_coverage: 0, plan_evidence_quality: 0, }, threatGraph: { nodes: [], links: [] }, gradeBreakdown: null, finalScore: null, hostForensics: null, iocEnrichment: null, vulnerabilityResults: null, playbookResult: null, correlationResults: null, threatGraphSummary: null, activeTurn: null, }; } get(key) { return this._state[key]; } subscribe(event, callback) { if (!this._subscribers.has(event)) this._subscribers.set(event, []); this._subscribers.get(event).push(callback); return () => { const subs = this._subscribers.get(event); if (subs) { const i = subs.indexOf(callback); if (i >= 0) subs.splice(i, 1); } }; } emit(event, data) { (this._subscribers.get(event) || []).forEach(cb => { try { cb(data); } catch (e) { console.error(`[StateStore] "${event}" handler error:`, e); } }); } // Called by the API after every reset() or step() response. // Merges the parsed observation into state and fires events. applyObservation(obs, action) { if (!obs) return; const wasActive = this._state.episodeActive; Object.assign(this._state, { episodeActive: true, episodeId: obs.episode_id ?? this._state.episodeId, taskId: obs.task_id ?? this._state.taskId, stepCount: obs.step_count ?? this._state.stepCount, maxSteps: obs.max_steps ?? this._state.maxSteps, totalReward: obs.total_reward ?? this._state.totalReward, reward: obs.reward ?? 0, done: obs.done ?? false, alerts: obs.alert_queue ?? this._state.alerts, topology: obs.network_topology ?? this._state.topology, timeline: obs.timeline ?? this._state.timeline, activeThreats: obs.active_threats ?? this._state.activeThreats, businessImpactScore: obs.business_impact_score ?? this._state.businessImpactScore, gradeBreakdown: obs.grade_breakdown ?? this._state.gradeBreakdown, finalScore: obs.final_score ?? this._state.finalScore, hostForensics: obs.host_forensics ?? null, iocEnrichment: obs.ioc_enrichment ?? null, vulnerabilityResults: obs.vulnerability_results ?? null, playbookResult: obs.playbook_result ?? null, correlationResults: obs.correlation_results ?? null, threatGraphSummary: obs.threat_graph_summary ?? this._state.threatGraphSummary, activeTurn: obs.active_turn ?? this._state.activeTurn, }); if (!wasActive) this.emit('episode:started', { obs, action }); this.emit('episode:step', { obs, action }); if (this._state.done) this.emit('episode:ended', { obs, action }); } updateLiveScores(scores) { this._state.liveScores = { ...scores }; this.emit('state:scores', scores); } updateThreatGraph(graphData) { this._state.threatGraph = graphData; this.emit('state:threatGraph', graphData); } reset() { this._state = StateStore._emptyState(); this.emit('episode:reset', {}); } } // ============================================================ // RedTeamController โ manages the Red Team Toolkit UI panel // ============================================================ class RedTeamController { constructor(dashboard) { this.dashboard = dashboard; this._actionFields = ['lateral_pivot', 'deploy_payload', 'evade_detection']; this._bindEvents(); this._onActionTypeChange(); } _bindEvents() { const select = document.getElementById('red-action-select'); const btn = document.getElementById('btn-execute-red'); if (select) select.addEventListener('change', () => this._onActionTypeChange()); if (btn) btn.addEventListener('click', () => this._onExecuteClick()); } _onActionTypeChange() { const actionType = document.getElementById('red-action-select')?.value; this._actionFields.forEach(t => { const el = document.getElementById(`rt-fields-${t}`); if (el) el.classList.toggle('hidden', t !== actionType); }); } async _onExecuteClick() { const actionType = document.getElementById('red-action-select')?.value || 'pass_turn'; let action = { type: actionType }; if (actionType === 'lateral_pivot') { action.source_host = document.getElementById('red-src')?.value?.trim() || ''; action.target_host = document.getElementById('red-dst')?.value?.trim() || ''; } else if (actionType === 'deploy_payload') { action.hostname = document.getElementById('red-payload-host')?.value?.trim() || ''; action.payload_type = document.getElementById('red-payload-type')?.value || 'ransomware'; } else if (actionType === 'evade_detection') { action.hostname = document.getElementById('red-evade-host')?.value?.trim() || ''; action.technique = document.getElementById('red-evade-technique')?.value || 'migrate_pid'; } const btn = document.getElementById('btn-execute-red'); if (btn) btn.disabled = true; try { const obs = await this.dashboard.api.step(action); AnimationUtils.showNotification(`๐ด Red: ${actionType.replace(/_/g, ' ')}`, 'red', 2000); if (!obs.done && obs.active_turn !== 'red' && !this.dashboard.isPaused) { this.dashboard._scheduleNextStep(2000); } } catch (err) { AnimationUtils.showNotification(`โ Red action failed: ${err.message.substring(0, 60)}`, 'red', 3000); if (btn) btn.disabled = false; } } setEnabled(enabled) { const btn = document.getElementById('btn-execute-red'); const select = document.getElementById('red-action-select'); if (btn) btn.disabled = !enabled; if (select) select.disabled = !enabled; document.querySelectorAll('.rt-input').forEach(f => { f.disabled = !enabled; }); document.querySelectorAll('.rt-fields select').forEach(f => { f.disabled = !enabled; }); const section = document.querySelector('.red-team-section'); if (section) section.classList.toggle('rt-disabled', !enabled); } } // ============================================================ // CyberSOCDashboard โ orchestrates episode flow and UI bindings // ============================================================ class CyberSOCDashboard { constructor() { this.api = API; this.store = new StateStore(); this.threatGraph = new ClientThreatGraph(); this.graphViz = new ThreatGraphViz('threat-graph-container'); this.radarChart = new RadarChart('radar-chart'); this.rewardTimeline = new RewardTimeline('reward-timeline'); this.demoActions = []; this.currentStepIndex = 0; this.autoPlayTimer = null; this.episodeStartTime = null; this.timerInterval = null; this.isPaused = false; this.episodeRunning = false; this.prevAlertIds = new Set(); this.currentAction = null; this.redTeamController = null; } async init() { AnimationUtils.init(); this.graphViz.init(); this.radarChart.init(); this.rewardTimeline.init(); this.api.setStore(this.store); this._subscribeToStore(); this._bindButtons(); this.redTeamController = new RedTeamController(this); this._showConnectionOverlay('Connecting to CyberSOC Server...'); await this._waitForServer(); } // Wire UI components to store events โ the only place DOM updates are triggered. _subscribeToStore() { this.store.subscribe('episode:started', ({ obs }) => this._onEpisodeStarted(obs)); this.store.subscribe('episode:step', ({ obs, action }) => this._onStep(obs, action)); this.store.subscribe('episode:ended', ({ obs }) => this._handleEpisodeComplete(obs)); this.store.subscribe('episode:reset', () => this._resetUI()); this.store.subscribe('state:scores', scores => this.radarChart.update(scores)); this.store.subscribe('state:threatGraph', graphData => this.graphViz.update(graphData)); } async _waitForServer() { let attempts = 0; const maxAttempts = 30; const check = async () => { attempts++; const ok = await this.api.checkConnection(); if (ok) { this._hideConnectionOverlay(); AnimationUtils.showNotification('โ Connected to CyberSOC Server', 'green', 2000); document.getElementById('btn-start').disabled = false; } else if (attempts < maxAttempts) { this._showConnectionOverlay(`Connecting to CyberSOC Server... (${attempts}/${maxAttempts})`); setTimeout(check, 2000); } else { this._showConnectionOverlay('โ ๏ธ Server not available. Start with: uvicorn dashboard_server:app --port 8000'); document.getElementById('btn-start').disabled = false; } }; await check(); } _bindButtons() { document.getElementById('btn-start').addEventListener('click', () => this._onStartClick()); document.getElementById('btn-pause').addEventListener('click', () => this._onPauseClick()); document.getElementById('btn-next').addEventListener('click', () => this._onNextClick()); document.getElementById('btn-reset').addEventListener('click', () => this._onResetClick()); } async _onStartClick() { const taskId = document.getElementById('task-select').value; await this.startEpisode(taskId); } _onPauseClick() { this.isPaused = !this.isPaused; const btn = document.getElementById('btn-pause'); if (this.isPaused) { btn.textContent = 'โถ Resume'; if (this.autoPlayTimer) { clearTimeout(this.autoPlayTimer); this.autoPlayTimer = null; } } else { btn.textContent = 'โธ Pause'; this._scheduleNextStep(); } } _onNextClick() { if (this.autoPlayTimer) { clearTimeout(this.autoPlayTimer); this.autoPlayTimer = null; } this.isPaused = true; document.getElementById('btn-pause').textContent = 'โถ Resume'; this._executeNextStep(); } async _onResetClick() { this.isPaused = false; this.episodeRunning = false; if (this.autoPlayTimer) { clearTimeout(this.autoPlayTimer); this.autoPlayTimer = null; } if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval = null; } document.getElementById('btn-pause').classList.add('hidden'); document.getElementById('btn-next').classList.add('hidden'); document.getElementById('btn-reset').classList.add('hidden'); // Resets data model โ emits episode:reset โ _resetUI() subscriber fires this.store.reset(); } // ============================================================ // Episode Management // ============================================================ async startEpisode(taskId) { document.getElementById('btn-start').classList.add('hidden'); document.getElementById('task-select').disabled = true; document.getElementById('btn-pause').classList.remove('hidden'); document.getElementById('btn-next').classList.remove('hidden'); document.getElementById('btn-reset').classList.remove('hidden'); AnimationUtils.showNotification(`๐ Starting ${taskId.toUpperCase()} episode...`, 'blue', 2000); // Reset alert tracking before api.reset() so _onEpisodeStarted starts clean this.prevAlertIds = new Set(); try { // api.reset() calls store.applyObservation() internally, which fires // episode:started โ _onEpisodeStarted() and episode:step โ _onStep(obs, null) const obs = await this.api.reset(taskId); this.episodeRunning = true; this.currentStepIndex = 0; this.episodeStartTime = Date.now(); if (this.timerInterval) clearInterval(this.timerInterval); this.timerInterval = setInterval(() => this._updateTimer(), 1000); this.demoActions = this.buildDemoActions(obs); this._scheduleNextStep(2500); } catch (err) { console.error('Episode start failed:', err); AnimationUtils.showNotification(`โ Failed to start episode: ${err.message}`, 'red', 5000); document.getElementById('btn-start').classList.remove('hidden'); document.getElementById('task-select').disabled = false; document.getElementById('btn-pause').classList.add('hidden'); document.getElementById('btn-next').classList.add('hidden'); document.getElementById('btn-reset').classList.add('hidden'); } } // ============================================================ // Store event handlers โ the reactive UI layer // ============================================================ // Fired once when a new episode observation arrives (wasActive was false). _onEpisodeStarted(obs) { this._resetUIForEpisode(); // Zero out scores โ scores must not carry over from a previous episode this.store.updateLiveScores({ threat_containment: 0, ioc_blocking: 0, forensic_investigation: 0, siem_correlation: 0, threat_intel_usage: 0, vuln_root_cause: 0, business_impact: 0, step_efficiency: 0, plan_coverage: 0, plan_evidence_quality: 0, }); this._updateHeader(obs); this._updatePhase(0, obs.max_steps); this._updateTurnIndicator(obs.active_turn); this._populateInitialAlerts(obs.alert_queue || []); // async fire-and-forget this._updateNetworkTopology(obs.network_topology); this._updateBusinessImpact(obs.business_impact_score ?? 0); this._updateActiveThreats(obs.active_threats); this._updateContainmentProgress(obs, obs.task_id); // Seed the client-side threat graph from the initial observation this.threatGraph = new ClientThreatGraph(); this.threatGraph.updateFromObservation(obs, null); this.store.updateThreatGraph(this.threatGraph.getGraphData()); (obs.alert_queue || []).forEach(a => this.prevAlertIds.add(a.alert_id)); } // Fired for every step response (including the initial reset). // When action is null this is the initial observation โ _onEpisodeStarted already handled it. _onStep(obs, action) { if (!action) return; this._updateHeader(obs); this._updatePhase(obs.step_count, obs.max_steps); this._updateTurnIndicator(obs.active_turn); this._updateActionLog(obs.timeline, action, obs); this._updateNetworkTopology(obs.network_topology); this._updateBusinessImpact(obs.business_impact_score ?? 0); this._updateActiveThreats(obs.active_threats); this._updateContainmentProgress(obs, obs.task_id); this._updateRewardBadge(obs.total_reward ?? 0); // Detect pivot alerts that just appeared const newPivots = []; (obs.alert_queue || []).forEach(alert => { if (alert.alert_id.startsWith('PIVOT-') && !this.prevAlertIds.has(alert.alert_id)) { newPivots.push(alert); } }); this._updateAlertQueue(obs.alert_queue || [], newPivots.length > 0); // async fire-and-forget // Update threat graph state and push to graphViz via store event this.threatGraph.updateFromObservation(obs, action); this.store.updateThreatGraph(this.threatGraph.getGraphData()); // Per-action graph animations if (action.type === 'run_forensics' && obs.host_forensics) { this.graphViz.flashNode(obs.host_forensics.hostname, obs.host_forensics.is_compromised ? 'red' : 'green'); } if (action.type === 'block_ioc') { this.graphViz.flashNode(action.ioc_value, 'green'); } if (action.type === 'kill_process') { const procId = `${action.hostname}:${action.process_name}`; this.graphViz.flashNode(procId, 'green'); AnimationUtils.showNotification(`๐ Killed: ${action.process_name} on ${action.hostname}`, 'amber', 2000); } // Red team lateral pivot handling if (newPivots.length > 0) { this._handleRedTeamPivot(newPivots[0]); newPivots.forEach(p => { this.prevAlertIds.add(p.alert_id); const pivotLinks = this.threatGraph.links.filter(l => l.edgeType === 'pivoted_from'); if (pivotLinks.length > 0) { const pl = pivotLinks[pivotLinks.length - 1]; const srcId = pl.source.id || pl.source; const tgtId = pl.target.id || pl.target; this.graphViz.animatePivot(srcId, tgtId); } }); } if (obs.step_count > 0) { this.rewardTimeline.addPoint(obs.step_count, obs.total_reward ?? 0, action.type); } if (obs.threat_graph_summary) this._updateGraphSummary(obs.threat_graph_summary); // _updateLiveScores writes back to the store โ emits state:scores โ radarChart.update() this._updateLiveScores(obs, action); (obs.alert_queue || []).forEach(a => this.prevAlertIds.add(a.alert_id)); } buildDemoActions(obs) { const alerts = obs.alert_queue || []; const alertIds = alerts.map(a => a.alert_id); const taskId = obs.task_id; const allIocs = []; const ipIocs = []; const domainIocs = []; const hashIocs = []; const hostsSeen = new Set(); alerts.forEach(a => { if (!hostsSeen.has(a.source_host)) hostsSeen.add(a.source_host); (a.ioc_indicators || []).forEach(ioc => { if (!allIocs.includes(ioc)) { allIocs.push(ioc); if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ioc)) ipIocs.push(ioc); else if (/[a-f0-9]{32,64}/i.test(ioc)) hashIocs.push(ioc); else domainIocs.push(ioc); } }); }); const hosts = [...hostsSeen]; if (taskId === 'hard') return this._hardDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs); if (taskId === 'medium') return this._mediumDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs); return this._easyDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs); } _hardDemoActions(alertIds, _hosts, ipIocs, domainIocs, _hashIocs) { return [ { type: 'correlate_alerts', alert_ids: [alertIds[0] || 'ALERT-H001', alertIds[1] || 'ALERT-H002'] }, { type: 'query_host', hostname: 'EXEC-003' }, { type: 'run_forensics', hostname: 'EXEC-003' }, { type: 'query_host', hostname: 'WS-088' }, { type: 'run_forensics', hostname: 'WS-088' }, { type: 'enrich_ioc', ioc_value: ipIocs[0] || '198.51.100.77', ioc_type: 'ip' }, { type: 'scan_host_vulnerabilities', hostname: 'SRV-002' }, { type: 'run_forensics', hostname: 'SRV-002' }, { type: 'kill_process', hostname: 'EXEC-003', process_name: 'outlook_macro.exe' }, { type: 'kill_process', hostname: 'EXEC-003', process_name: 'svchost_c2.exe' }, { type: 'kill_process', hostname: 'WS-088', process_name: 'svchost_c2.exe' }, { type: 'block_ioc', ioc_value: ipIocs[0] || '198.51.100.77', ioc_type: 'ip' }, { type: 'block_ioc', ioc_value: domainIocs[0] || 'cdn-update.malware-c2.net', ioc_type: 'domain' }, { type: 'isolate_segment', subnet: 'executive', reason: 'APT lateral movement detected โ executive subnet compromised' }, { type: 'trigger_playbook', playbook_name: 'c2_disruption', target: 'EXEC-003' }, { type: 'kill_process', hostname: 'SRV-002', process_name: 'exploit_kernel.exe' }, { type: 'kill_process', hostname: 'SRV-002', process_name: 'data_pump.exe' }, { type: 'kill_process', hostname: 'FIN-008', process_name: 'data_pump.exe' }, { type: 'kill_process', hostname: 'SRV-010', process_name: 'blackcat_ransom.exe' }, { type: 'submit_containment_plan', plan: [ { threat_id: 'T-HARD-001', actions_taken: ['query_host', 'run_forensics', 'kill_process', 'correlate_alerts'], root_cause: 'Spear-phishing email with malicious macro attachment targeting executive VP. Macro executed outlook_macro.exe establishing initial foothold.', confidence: 0.95, }, { threat_id: 'T-HARD-002', actions_taken: ['run_forensics', 'kill_process', 'block_ioc', 'trigger_playbook', 'isolate_segment'], root_cause: 'C2 beacon (svchost_c2.exe) established on EXEC-003 and WS-088 communicating to 198.51.100.77 every 60s. C2 infrastructure disrupted.', confidence: 0.92, }, { threat_id: 'T-HARD-003', actions_taken: ['scan_host_vulnerabilities', 'run_forensics', 'kill_process'], root_cause: 'Kernel privilege escalation on SRV-002 via exploit_kernel.exe. CVE leveraged for SYSTEM-level access to database server.', confidence: 0.88, }, { threat_id: 'T-HARD-004', actions_taken: ['run_forensics', 'kill_process', 'block_ioc'], root_cause: 'Data exfiltration campaign using data_pump.exe on SRV-002 and FIN-008. 2.3GB customer PII transferred to 203.0.113.99. Exfil channel blocked.', confidence: 0.90, }, { threat_id: 'T-HARD-005', actions_taken: ['kill_process'], root_cause: 'BlackCat ransomware deployment on datacenter servers SRV-010 and SRV-015. Encryption halted before full deployment. Recovery recommended.', confidence: 0.85, }, ], executive_summary: 'APT campaign fully contained. C2 infrastructure at 198.51.100.77 disrupted and blocked. Executive subnet isolated. All 5 threat chains neutralized: phishing entry point, C2 beaconing, kernel privilege escalation, data exfiltration, and ransomware deployment. Estimated data exposure: 2.3GB PII. Recommend: patch SRV-002 kernel CVE, reset all executive credentials, deploy EDR signatures for BlackCat variants.', }, ]; } _mediumDemoActions(alertIds, hosts, ipIocs, domainIocs, _hashIocs) { return [ { type: 'correlate_alerts', alert_ids: alertIds.slice(0, 2) }, { type: 'query_host', hostname: hosts[0] || 'WS-017' }, { type: 'run_forensics', hostname: hosts[0] || 'WS-017' }, { type: 'enrich_ioc', ioc_value: domainIocs[0] || ipIocs[0] || '203.0.113.50', ioc_type: domainIocs.length > 0 ? 'domain' : 'ip' }, { type: 'query_host', hostname: hosts[1] || 'DEV-033' }, { type: 'run_forensics', hostname: hosts[1] || 'DEV-033' }, { type: 'kill_process', hostname: hosts[0] || 'WS-017', process_name: 'powershell.exe' }, { type: 'kill_process', hostname: hosts[0] || 'WS-017', process_name: 'mimikatz.exe' }, { type: 'block_ioc', ioc_value: domainIocs[0] || ipIocs[0] || '203.0.113.50', ioc_type: domainIocs.length > 0 ? 'domain' : 'ip' }, { type: 'kill_process', hostname: hosts[1] || 'DEV-033', process_name: 'svchost_backdoor.exe' }, { type: 'isolate_segment', subnet: 'corporate', reason: 'Credential theft and lateral movement detected' }, { type: 'trigger_playbook', playbook_name: 'phishing_response', target: hosts[0] || 'WS-017' }, { type: 'submit_containment_plan', plan: [ { threat_id: 'T-MED-001', actions_taken: ['query_host', 'run_forensics', 'kill_process'], root_cause: 'Phishing email led to PowerShell execution downloading payload from evil-login.example.com', confidence: 0.9 }, { threat_id: 'T-MED-002', actions_taken: ['run_forensics', 'kill_process', 'block_ioc'], root_cause: 'Credential dumping via Mimikatz. LSASS memory access detected.', confidence: 0.88 }, { threat_id: 'T-MED-003', actions_taken: ['run_forensics', 'kill_process', 'isolate_segment'], root_cause: 'Lateral movement via compromised admin credentials to DEV-033 and FIN-012', confidence: 0.85 }, ], executive_summary: 'Multi-stage phishing โ credential theft โ lateral movement campaign contained. Three compromised hosts remediated. Corporate subnet isolated pending investigation.', }, ]; } _easyDemoActions(alertIds, hosts, ipIocs, _domainIocs, hashIocs) { const host = hosts[0] || 'WS-042'; const hash = hashIocs[0] || ipIocs[0] || 'e99a18c428cb38d5f260853678922e03'; return [ { type: 'correlate_alerts', alert_ids: alertIds.slice(0, 2) }, { type: 'query_host', hostname: host }, { type: 'run_forensics', hostname: host }, { type: 'enrich_ioc', ioc_value: hash, ioc_type: hashIocs.length > 0 ? 'hash' : 'ip' }, { type: 'kill_process', hostname: host, process_name: 'cryptolocker.exe' }, { type: 'block_ioc', ioc_value: hash, ioc_type: hashIocs.length > 0 ? 'hash' : 'ip' }, { type: 'scan_host_vulnerabilities', hostname: host }, { type: 'trigger_playbook', playbook_name: 'ransomware_containment', target: host }, { type: 'submit_containment_plan', plan: [{ threat_id: 'T-EASY-001', actions_taken: ['query_host', 'run_forensics', 'kill_process', 'block_ioc'], root_cause: 'Ransomware (cryptolocker.exe) executing on WS-042, encrypting user documents. Delivered via phishing email attachment.', confidence: 0.95, }], executive_summary: 'Single ransomware endpoint contained. Process killed, IOC hash blocked. Recommend disk restore from backup for WS-042.', }, ]; } // ============================================================ // Step Execution // ============================================================ _scheduleNextStep(delay = 2000) { if (this.isPaused || !this.episodeRunning) return; if (this.autoPlayTimer) clearTimeout(this.autoPlayTimer); this.autoPlayTimer = setTimeout(() => this._executeNextStep(), delay); } async _executeNextStep() { if (!this.episodeRunning || this.store.get('done')) { this.episodeRunning = false; return; } if (this.currentStepIndex >= this.demoActions.length) { AnimationUtils.showNotification('โน๏ธ Demo sequence complete. Episode still running.', 'blue', 3000); return; } const action = this.demoActions[this.currentStepIndex]; this.currentAction = action; this.currentStepIndex++; try { // api.step() calls store.applyObservation() internally. // episode:step fires synchronously โ _onStep() updates UI before we resume here. const obs = await this.api.step(action); if (obs.done) { this.episodeRunning = false; if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval = null; } // episode:ended was already emitted by store โ _handleEpisodeComplete() fired } else if (!this.isPaused && obs.active_turn !== 'red') { let delay = 2000; if (action.type === 'isolate_segment') delay = 3000; if (action.type === 'submit_containment_plan') delay = 500; this._scheduleNextStep(delay); } // When active_turn === 'red', auto-play pauses โ RedTeamController resumes it } catch (err) { console.error('Step failed:', err); AnimationUtils.showNotification(`โ Step failed: ${err.message.substring(0, 80)}`, 'red', 4000); if (!this.isPaused) this._scheduleNextStep(3000); } } // ============================================================ // Live Score Calculation โ writes back through store โ radarChart // ============================================================ _updateLiveScores(obs, action) { if (!action) return; const scores = { ...this.store.get('liveScores') }; if (action.type === 'correlate_alerts') scores.siem_correlation = 1.0; if (action.type === 'run_forensics') { scores.forensic_investigation = Math.min(1, scores.forensic_investigation + 0.2); } if (action.type === 'enrich_ioc') { scores.threat_intel_usage = Math.min(1, scores.threat_intel_usage + 0.25); } if (action.type === 'scan_host_vulnerabilities' && (obs.vulnerability_results?.length ?? 0) > 0) { scores.vuln_root_cause = 1.0; } if (action.type === 'block_ioc') { scores.ioc_blocking = Math.min(1, scores.ioc_blocking + 0.2); } if (action.type === 'kill_process') { scores.threat_containment = Math.min(1, scores.threat_containment + 0.15); } if (action.type === 'submit_containment_plan') { scores.plan_coverage = 0.9; scores.plan_evidence_quality = 0.85; } scores.business_impact = Math.max(0, 1.0 - (obs.business_impact_score ?? 0)); const ratio = (obs.step_count ?? 0) / (obs.max_steps || 30); scores.step_efficiency = Math.max(0.3, 1.0 - Math.max(0, ratio - 0.5) * 1.5); // Emits state:scores โ radarChart.update() subscriber fires this.store.updateLiveScores(scores); } // ============================================================ // UI Update Helpers // ============================================================ _updateTurnIndicator(activeTurn) { const el = document.getElementById('turn-indicator'); const text = document.getElementById('turn-text'); if (!el) return; if (!activeTurn) { el.classList.add('hidden'); this.redTeamController?.setEnabled(false); return; } el.classList.remove('hidden'); const isRed = activeTurn === 'red'; el.className = `turn-indicator ${isRed ? 'red-turn' : 'blue-turn'}`; if (text) text.textContent = isRed ? '๐ด RED TEAM TURN' : '๐ต BLUE TEAM TURN'; this.redTeamController?.setEnabled(isRed); } _updateHeader(obs) { const stepEl = document.getElementById('header-step'); const episodeEl = document.getElementById('header-episode'); const diffBadge = document.getElementById('difficulty-badge'); if (stepEl) { const old = parseInt(stepEl.textContent.split('/')[0]) || 0; AnimationUtils.countUpInt(stepEl, old, obs.step_count, 300); setTimeout(() => { if (stepEl) stepEl.textContent = `${obs.step_count}/${obs.max_steps}`; }, 350); } if (episodeEl && obs.episode_id) { episodeEl.textContent = obs.episode_id.substring(0, 8) + 'โฆ'; } if (diffBadge && obs.task_id) { diffBadge.textContent = obs.task_id.toUpperCase(); diffBadge.className = `difficulty-badge ${obs.task_id}`; } } _updateTimer() { if (!this.episodeStartTime) return; const elapsed = Math.floor((Date.now() - this.episodeStartTime) / 1000); const mm = String(Math.floor(elapsed / 60)).padStart(2, '0'); const ss = String(elapsed % 60).padStart(2, '0'); const el = document.getElementById('header-timer'); if (el) el.textContent = `${mm}:${ss}`; } _updatePhase(_step, _maxSteps) { const dots = document.querySelectorAll('.phase-dot'); const connectors = document.querySelectorAll('.phase-connector'); let phaseIdx = 0; if (this.currentAction) { const t = this.currentAction.type; if (['correlate_alerts'].includes(t)) phaseIdx = 0; else if (['query_host', 'run_forensics', 'enrich_ioc', 'scan_host_vulnerabilities'].includes(t)) phaseIdx = 1; else if (['kill_process', 'block_ioc', 'isolate_segment', 'trigger_playbook'].includes(t)) phaseIdx = 2; else if (['submit_containment_plan'].includes(t)) phaseIdx = 3; } dots.forEach((dot, i) => { dot.classList.remove('active', 'completed'); if (i < phaseIdx) dot.classList.add('completed'); else if (i === phaseIdx) dot.classList.add('active'); }); connectors.forEach((conn, i) => conn.classList.toggle('completed', i < phaseIdx)); } async _populateInitialAlerts(alerts) { const list = document.getElementById('alert-list'); if (!list) return; list.innerHTML = ''; for (let i = 0; i < alerts.length; i++) { await new Promise(r => setTimeout(r, 180 * i)); this._addAlertCard(alerts[i], list, false); } this._updateAlertBadge(alerts.length); } async _updateAlertQueue(alerts, _hasPivot = false) { const list = document.getElementById('alert-list'); if (!list) return; const existingIds = new Set([...list.querySelectorAll('.alert-card')].map(c => c.dataset.alertId)); const newAlerts = alerts.filter(a => !existingIds.has(a.alert_id)); newAlerts.forEach(alert => this._addAlertCard(alert, list, alert.alert_id.startsWith('PIVOT-'))); const resolvedCount = alerts.filter(a => a.is_acknowledged).length; this._updateAlertBadge(alerts.length - resolvedCount); } _addAlertCard(alert, container, isPivot = false) { const card = document.createElement('div'); card.className = `alert-card${isPivot ? ' pivot' : ''}`; card.dataset.alertId = alert.alert_id; const severityClass = `severity-${alert.severity}`; const iocText = (alert.ioc_indicators || []).join(', '); const iocHtml = iocText ? `