Ajayyy00 commited on
Commit
57e71f8
·
0 Parent(s):

Initial commit of CyberSOC upgraded RLVR environment

Browse files
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Create user with home directory
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
+ WORKDIR /app
9
+
10
+ # Copy requirements and install
11
+ COPY --chown=user ./requirements.txt requirements.txt
12
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
13
+
14
+ # Copy all environment files
15
+ COPY --chown=user . /app
16
+
17
+ # The hackathon expects the OpenEnv Server to run on 7860 for Spaces Gradio endpoints
18
+ # We will use uvicorn to host the app which complies with the spec
19
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,531 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CyberSOC: Complete Project Review
2
+
3
+ ## 1. Project Overview — RLVR Positioning
4
+
5
+ CyberSOC (CyberSOCEnv) is an **RLVR-stage reinforcement learning environment** that sits at the final rung of the model-maturation arc: **Random Init → Pretraining → SFT/IFT → Preference FT → RLVR**. It does not pretrain, supervise, or preference-align — it consumes a base model that has already been through those stages and turns its agentic actions into a dense, verifiable, 10-dimensional reward signal that GRPO can train on.
6
+
7
+ It assumes a base model that has already been SFT-aligned. The environment itself does not perform SFT; it is an **RL-only artifact**. This satisfies **Daniel's "Law of RL"**: *The base model must get non-zero reward on Easy before it can meaningfully learn Hard.*
8
+
9
+ **Built for**: The OpenEnv Hackathon (Meta Platforms)
10
+ **Framework**: OpenEnv (Meta's RL environment framework)
11
+ **License**: BSD-style (Meta Platforms, Inc.)
12
+
13
+ ### Guide Alignment Summary
14
+
15
+ | Guide Section | Requirement | Our Implementation |
16
+ |---|---|---|
17
+ | §1 | Step-by-step, programmatic verification, hard-but-possible | 10 typed actions, 10-dim deterministic grader, Easy→Hard curriculum |
18
+ | §4 | Design env before trainer | Env designed first; reset/step/state as first-class artifacts |
19
+ | §6 | Keep task simple at first | 1000+ scenarios across 3 difficulty tiers enable curriculum learning |
20
+ | §7 | Multiple independent reward functions | 10 dimensions consumed as `reward_funcs=[...]` by GRPOTrainer |
21
+ | §8 | Protect against reward hacking | 8 distinct defenses mapped to guide's attack vectors |
22
+ | §10 | Right training stack | Unsloth (QLoRA) + TRL (GRPO) + OpenEnv (transport) |
23
+ | §11 | Prefer GRPO/RLVR | RLVR throughout; every reward is deterministic code (zero LLM-as-judge) |
24
+ | §12 | Keep inference fast | Graph-delta injection + sparse nodes = rollout-latency optimizations |
25
+ | §14 | Scale only after stable | All 9 components passed integration before any GRPO rollout |
26
+
27
+ ---
28
+
29
+ ## 2. Core Idea & Innovation
30
+
31
+ ### The Problem
32
+ Traditional cybersecurity training environments use static puzzles with fixed answers. Real SOC work requires dynamic reasoning under time pressure with incomplete information.
33
+
34
+ ### The Solution
35
+ CyberSOC creates a **fully dynamic, deterministic SOC simulation** with:
36
+
37
+ 1. **Procedural Scenario Generation** — 1,003 unique attack scenarios (3 curated + 1,000 generated) from seed-based deterministic generation. Same seed = same scenario, enabling reproducible RL training.
38
+ 2. **13 Threat Categories** — Ransomware, Phishing, Credential Theft, Lateral Movement, C2 Communication, Privilege Escalation, Data Exfiltration, Cryptomining, Supply Chain, Insider Threat, Webshell, Botnet, Malware.
39
+ 3. **Adaptive Red Team** — An adversary that reacts to agent actions: if you isolate a host, the attacker may pivot laterally. If you kill a process without blocking IOCs, it may reinfect with a `_v2` variant.
40
+ 4. **10-Dimensional Grading** — Not a binary pass/fail. Agents are scored across 10 weighted dimensions for nuanced RL credit assignment. **Zero LLM-as-a-judge.**
41
+ 5. **Business Continuity Constraints** — Rash actions (isolating clean subnets, killing legitimate processes) cause business downtime penalties.
42
+ 6. **TRL GRPO Integration** — 10 reward functions that plug directly into Hugging Face's TRL `GRPOTrainer` for RL fine-tuning.
43
+
44
+ ---
45
+
46
+ ## 3. Architecture
47
+
48
+ ```
49
+ MetaRound2/
50
+ ├── models.py # Pydantic data models (Observation, Action, State)
51
+ ├── client.py # WebSocket client for agent interaction
52
+ ├── __init__.py # Package exports
53
+ ├── inference.py # LLM baseline inference script
54
+ ├── dashboard_server.py # Dashboard + API server launcher
55
+ ├── pyproject.toml # Python package config
56
+ ├── Dockerfile # HuggingFace Spaces deployment
57
+ ├── openenv.yaml # 1003 task manifest
58
+ ├── validate_submission.sh # Hackathon submission validator
59
+
60
+ ├── server/ # Backend environment engine
61
+ │ ├── app.py # FastAPI application entry point
62
+ │ ├── play_environment.py # Core environment (1284 lines)
63
+ │ ├── tasks.py # Hand-crafted task definitions (easy/medium/hard)
64
+ │ ├── task_generator.py # Procedural generation engine (1000+ tasks)
65
+ │ ├── graders.py # 10-dimensional grading system
66
+ │ ├── threat_graph.py # Typed knowledge graph
67
+ │ ├── soar_playbooks.py # 5 SOAR playbook definitions
68
+ │ ├── action_validation.py # 3-gate action validation middleware
69
+ │ ├── tool_router.py # Phase state machine + triage solver
70
+ │ ├── episode_sandbox.py # Wall-clock + step-limit guard
71
+ │ ├── visualize_graph.py # PNG graph renderer (matplotlib/networkx)
72
+ │ └── Dockerfile # Multi-stage Docker build
73
+
74
+ ├── training/ # RL training integration
75
+ │ └── reward_funcs.py # 10 TRL GRPO reward functions
76
+
77
+ ├── dashboard/ # Real-time web dashboard
78
+ │ ├── index.html # Main HTML (6 panels)
79
+ │ ├── css/styles.css # Dark theme CSS (25KB)
80
+ │ └── js/
81
+ │ ├── app.js # Main dashboard logic (45KB)
82
+ │ ├── graphs.js # D3.js threat graph + Chart.js (31KB)
83
+ │ ├── api.js # REST API client
84
+ │ └── animations.js # Micro-animations & effects
85
+
86
+ └── tests/ # 10 test files + integration suite
87
+ ├── test_integration.py
88
+ └── test_task1.py ... test_task9.py
89
+ ```
90
+
91
+ ---
92
+
93
+ ## 4. Backend (Server)
94
+
95
+ ### 4.1 Core Environment — `play_environment.py`
96
+
97
+ The heart of the project. `CyberSOCEnvironment` extends OpenEnv's `Environment` interface.
98
+
99
+ **Key features:**
100
+ - **`reset(task_id)`** — Builds the network, injects attack chains, initializes alert queue, seeds the ThreatGraph
101
+ - **`step(action)`** — Processes one agent action, computes rewards, updates state, triggers adaptive adversary
102
+ - **Concurrent sessions** — Each WebSocket connection gets its own environment instance
103
+ - **ActionMiddleware** — Pre-flight validation (phase violations, graph-groundedness) before consuming a step
104
+
105
+ **10 Agent Actions:**
106
+
107
+ | # | Action | Purpose | Reward Range |
108
+ |---|--------|---------|-------------|
109
+ | 1 | `query_host` | Map architecture, get endpoint info | -0.05 to +0.05 |
110
+ | 2 | `run_forensics` | Deep system artifact extraction | -0.02 to +0.10 |
111
+ | 3 | `kill_process` | Terminate malicious execution | -0.08 to +0.25 |
112
+ | 4 | `block_ioc` | Blacklist IOCs network-wide | -0.03 to +0.15 |
113
+ | 5 | `isolate_segment` | Quarantine subnet or host | -0.10 to +0.15 |
114
+ | 6 | `correlate_alerts` | Find shared entities across alerts | ±0.05 |
115
+ | 7 | `enrich_ioc` | Threat-intel enrichment (actor, TTPs) | ±0.05 |
116
+ | 8 | `scan_host_vulnerabilities` | Discover CVEs on a host | ±0.05 |
117
+ | 9 | `trigger_playbook` | Execute SOAR automated response | ±0.10 |
118
+ | 10 | `submit_containment_plan` | Final report — ends episode | 0.0 to 1.0 |
119
+
120
+ ### 4.2 Data Models — `models.py`
121
+
122
+ All data flows through strict **Pydantic models** (429 lines):
123
+
124
+ - **Enums**: `Severity`, `ThreatType` (13 types), `HostStatus`, `SubnetRole` (6 roles)
125
+ - **Sub-models**: `Alert`, `HostInfo`, `NetworkTopology`, `ForensicsResult`, `TimelineEntry`
126
+ - **`SOCObservation`** (extends OpenEnv `Observation`): 20+ fields including `alert_queue`, `network_topology`, `host_forensics`, `threat_graph_summary`, `reward_dimensions`, `available_playbooks`
127
+ - **Actions**: Discriminated union of 10 action types via `SOCActionWrapper`
128
+ - **`SOCState`** (internal): Tracks all episode state — killed processes, blocked IOCs, isolated subnets, etc.
129
+
130
+ ### 4.3 Task Definitions — `tasks.py`
131
+
132
+ Three hand-crafted benchmark scenarios:
133
+
134
+ | Task | Threats | Hosts | Max Steps | Description |
135
+ |------|---------|-------|-----------|-------------|
136
+ | **Easy** | 1 | 1 | 15 | Single ransomware on WS-042 |
137
+ | **Medium** | 3 | 4 | 25 | Phishing → credential theft → lateral movement across 3 subnets |
138
+ | **Hard** | 5 | 7 | 30 | Full APT: phishing → C2 → privesc → exfil → ransomware |
139
+
140
+ **Network**: ~75 active hosts across 6 subnets (corporate, engineering, finance, DMZ, datacenter, executive) with realistic processes, ports, and criticality scores.
141
+
142
+ ### 4.4 Procedural Task Generator — `task_generator.py`
143
+
144
+ Generates **1,000+ unique deterministic scenarios** from a seed:
145
+
146
+ - `hash(task_id)` → deterministic `random.Random` seed → drives ALL choices
147
+ - **Template pools**: 90+ malware process names, 40 C2 domains, 36 C2 IPs, 12 ransomware extensions, 12 data types
148
+ - **3 difficulty tiers**: Easy (1 threat), Medium (2-3 threats, multi-stage chains), Hard (3-6 threats, APT campaigns)
149
+ - **Alert generation**: Templated descriptions with randomized details (timestamps, file counts, data sizes)
150
+
151
+ ### 4.5 Grading System — `graders.py`
152
+
153
+ **10-dimensional weighted grading:**
154
+
155
+ | Dimension | Weight | What It Measures |
156
+ |-----------|--------|-----------------|
157
+ | `threat_containment` | 0.20 | Fraction of required process kills completed |
158
+ | `ioc_blocking` | 0.12 | Fraction of known IOCs blocked (penalizes blind blocking) |
159
+ | `forensic_investigation` | 0.10 | Compromised hosts examined |
160
+ | `siem_correlation` | 0.08 | Whether alerts were correlated (bonus for early correlation) |
161
+ | `threat_intel_usage` | 0.08 | IOCs enriched with threat intel |
162
+ | `vuln_root_cause` | 0.08 | CVE root causes discovered (bonus if cited in plan) |
163
+ | `business_impact` | 0.10 | Penalizes unnecessary isolation and over-isolation (>20% = -0.30) |
164
+ | `step_efficiency` | 0.07 | Rewards SOAR playbook usage, penalizes step overrun |
165
+ | `plan_coverage` | 0.10 | Threats addressed in final plan |
166
+ | `plan_evidence_quality` | 0.07 | Evidence confidence from ThreatGraph |
167
+
168
+ **Anti-gaming**: Per-occurrence penalty cap (±0.15), blind-blocking penalties, normalized evidence confidence.
169
+
170
+ ### 4.6 Threat Graph — `threat_graph.py`
171
+
172
+ A **typed knowledge graph** tracking all SOC entities:
173
+
174
+ - **5 Node Types**: `HostNode`, `ProcessNode`, `IOCNode`, `VulnerabilityNode`, `AlertNode`
175
+ - **6 Edge Types**: `runs_on`, `involves`, `communicates_with`, `pivoted_from`, `part_of_chain`, `exploits`
176
+ - **200-node cap** with LRU IOC pruning
177
+ - **Version tracking** with changelog for delta queries
178
+ - **Evidence confidence** computation for plan quality scoring
179
+ - **Context summary** generation for LLM injection
180
+
181
+ ### 4.7 SOAR Playbooks — `soar_playbooks.py`
182
+
183
+ 5 automated response playbooks with prerequisite validation:
184
+
185
+ | Playbook | Prerequisites | Sub-Actions |
186
+ |----------|--------------|-------------|
187
+ | `ransomware_containment` | Forensics run, process identified | kill_process, block_ioc |
188
+ | `c2_disruption` | IOC enriched, C2 IP identified | block_ioc, isolate_segment |
189
+ | `lateral_movement_lockdown` | Forensics run, lateral movement detected | kill_process, isolate_segment |
190
+ | `phishing_response` | Phishing vector confirmed | enrich_ioc, block_ioc |
191
+ | `data_exfil_stop` | Forensics run, exfil destination identified | block_ioc, kill_process |
192
+
193
+ ### 4.8 Action Validation — `action_validation.py`
194
+
195
+ **3-gate middleware:**
196
+ 1. **Phase whitelist** — Actions restricted by phase (triage/investigation/remediation/report)
197
+ 2. **Schema validation** — Required arguments checked
198
+ 3. **Graph groundedness** — Actions must reference discovered entities (can't block an IOC you haven't seen)
199
+
200
+ ### 4.9 Tool Router — `tool_router.py`
201
+
202
+ **Deterministic phase state machine:**
203
+ - Phases: `triage` → `investigation` → `remediation` → `report` → `done`
204
+ - Loop limits: max 4 investigation loops, 3 remediation loops
205
+ - Supports **pushback** — agent can justify staying in a phase with graph references
206
+
207
+ **Triage Solver**: Priority = `severity_weight × criticality_weight × (1 + blast_radius/10)`
208
+
209
+ ### 4.10 Episode Sandbox — `episode_sandbox.py`
210
+
211
+ **Safety guardrails:**
212
+ - **120-second wall-clock timeout** per episode
213
+ - **20-step hard limit** per episode
214
+ - **State integrity protection** — Protected fields (`_task_def`, `_live_requirements`, `_threat_graph`) are snapshot-hashed; mutations are detected and rolled back
215
+ - **Hacking detection** — Reports any external state tampering
216
+
217
+ ### 4.11 Adaptive Red Team
218
+
219
+ Two mechanisms in `play_environment.py`:
220
+
221
+ 1. **Reinfection** (`_maybe_reinfect`): 30% chance when killing a process if IOCs in the chain are unblocked → spawns `process_v2` variant + CRITICAL alert
222
+ 2. **Lateral Pivot** (`_execute_lateral_pivot`): Triggered by isolate/kill actions on hard tasks → copies malware to adjacent healthy host, adds `pivoted_from` edge, emits PIVOT alert, updates live requirements
223
+
224
+ **Escalation**: Probability increases when agent is slow (step > 10 with 0 containments).
225
+
226
+ ### 4.12 Server Application — `app.py`
227
+
228
+ FastAPI app created via OpenEnv's `create_app()`:
229
+ - **POST /reset** — Reset environment with task_id
230
+ - **POST /step** — Execute an action
231
+ - **GET /state** — Get current state
232
+ - **WS /ws** — WebSocket for persistent sessions
233
+ - CORS enabled for dashboard communication
234
+ - Supports 4 concurrent environment instances
235
+
236
+ ---
237
+
238
+ ## 5. Frontend (Dashboard)
239
+
240
+ ### 5.1 Overview
241
+
242
+ A real-time **"CyberSOC Command Center"** web dashboard with 6 panels, built with vanilla HTML/CSS/JS + D3.js + Chart.js.
243
+
244
+ ### 5.2 Six Dashboard Panels
245
+
246
+ 1. **Alert Queue** — Live SIEM/EDR alerts with severity badges and IOC indicators
247
+ 2. **Live Threat Graph** — D3.js force-directed graph with 5 node types, drag/zoom, glow effects, pivot animation
248
+ 3. **Agent Actions** — Chronological action log with reward tracking
249
+ 4. **Network Topology** — Visual subnet map with compromised/isolated counts
250
+ 5. **Performance Metrics** — Chart.js radar chart (10 dimensions) + cumulative reward timeline
251
+ 6. **Mission Status** — Containment progress bars, business impact gauge, active threat list, episode controls
252
+
253
+ ### 5.3 Visual Design
254
+
255
+ - **Dark theme** with glassmorphism panels
256
+ - **Typography**: Inter (UI) + JetBrains Mono (data)
257
+ - **Color system**: Accent colors for cyan, green, amber, red, purple
258
+ - **Animations**: Count-up numbers, scale bounces, pulse glows, screen flashes
259
+ - **Red Team pivot**: Screen border flash, toast notification, traveling dot animation on pivot edges
260
+
261
+ ### 5.4 Key Frontend Components
262
+
263
+ **`graphs.js` (881 lines)**:
264
+ - `ClientThreatGraph` — Client-side graph state manager synced from observations
265
+ - `ThreatGraphViz` — D3.js v7 force simulation with SVG glow filters, curved edges, node symbols (circle/diamond/triangle/square/wye), click-to-highlight, drag behavior
266
+ - `RadarChart` — Chart.js 10-axis radar for live grading dimensions
267
+ - `RewardTimeline` — Gradient-filled cumulative reward line chart
268
+
269
+ **`app.js` (45KB)** — Main orchestrator handling episode lifecycle, API calls, UI updates, phase indicator tracking
270
+
271
+ **`api.js`** — REST client with auto-detection of server origin, session management, response parsing
272
+
273
+ **`animations.js`** — Utility library for count-up, screen flash, toast notifications, scale bounce, pulse glow, dramatic final score reveal
274
+
275
+ ### 5.5 Dashboard Server — `dashboard_server.py`
276
+
277
+ Wraps the FastAPI app to also serve the dashboard as static files at `/dashboard/`. Prints a styled ASCII banner on startup.
278
+
279
+ ---
280
+
281
+ ## 6. Inference & Training
282
+
283
+ ### 6.1 Inference Script — `inference.py`
284
+
285
+ LLM baseline agent using **OpenAI-compatible API**:
286
+ - System prompt defines SOC analyst role with all 6 core actions
287
+ - Formats observations into structured text for the LLM
288
+ - Parses JSON actions from LLM responses (with fallback extraction)
289
+ - Runs episodes across easy/medium/hard tasks
290
+ - Emits structured stdout logs: `[START]`, `[STEP]`, `[END]` (hackathon requirement)
291
+ - Default model: `Qwen/Qwen2.5-72B-Instruct` via HuggingFace Router
292
+
293
+ ### 6.2 GRPO Reward Functions — `training/reward_funcs.py`
294
+
295
+ 10 TRL-compatible reward functions for **Group Relative Policy Optimization**:
296
+
297
+ ```python
298
+ from training.reward_funcs import make_soc_reward_funcs
299
+ reward_fns = make_soc_reward_funcs("http://localhost:8000")
300
+ trainer = GRPOTrainer(model=model, reward_funcs=reward_fns, args=GRPOConfig(...))
301
+ ```
302
+
303
+ Each function:
304
+ 1. Parses completion as JSON action list
305
+ 2. Replays actions against live environment server
306
+ 3. Returns the specific dimension's score from `grade_breakdown`
307
+ 4. Non-parseable completions return 0.0
308
+
309
+ ### 6.3 Per-Step Reward Dimensions
310
+
311
+ The environment computes **live partial scores** every step (`_compute_reward_dimensions`) for GRPO credit assignment without waiting for the terminal grade. These are exposed in `SOCObservation.reward_dimensions`.
312
+
313
+ ---
314
+
315
+ ## 7. Testing
316
+
317
+ **11 test files** covering all major components:
318
+
319
+ | File | Focus |
320
+ |------|-------|
321
+ | `test_integration.py` | Full episode flows, phase violations, adaptive pivots, 10-dim grading, sandbox limits |
322
+ | `test_task1.py` - `test_task9.py` | Individual task-specific validations |
323
+
324
+ Key integration tests:
325
+ - Easy/medium episodes complete without crashes
326
+ - All 10 action types can be exercised in a single episode
327
+ - Phase violations return negative reward (not crash)
328
+ - Adaptive pivot fires on hard tasks
329
+ - Step rewards accumulate correctly and are idempotent
330
+ - Grader returns exactly 10 dimensions
331
+ - Sandbox step limit raises `EpisodeTimeout`
332
+
333
+ ---
334
+
335
+ ## 8. Deployment & DevOps
336
+
337
+ ### Docker
338
+ - **Root Dockerfile** — Slim Python 3.10, serves on port 7860 (HuggingFace Spaces)
339
+ - **Server Dockerfile** — Multi-stage build from `ghcr.io/meta-pytorch/openenv-base`, uses `uv` for dependency management, health check on `/health`
340
+
341
+ ### Validation
342
+ `validate_submission.sh` — 3-step validator:
343
+ 1. Ping HF Space `/reset` endpoint
344
+ 2. Docker build succeeds
345
+ 3. `openenv validate` passes
346
+
347
+ ### OpenEnv Manifest
348
+ `openenv.yaml` — 1,003 task definitions with descriptions, max steps, and difficulty tags. Used by the OpenEnv framework for task discovery and benchmarking.
349
+
350
+ ---
351
+
352
+ ## 9. Environment Variables
353
+
354
+ | Variable | Purpose | Default |
355
+ |----------|---------|---------|
356
+ | `API_BASE_URL` | LLM API endpoint | `https://router.huggingface.co/v1` |
357
+ | `MODEL_NAME` | Model identifier | `Qwen/Qwen2.5-72B-Instruct` |
358
+ | `HF_TOKEN` | HuggingFace API key | — |
359
+
360
+ ---
361
+
362
+ ## 10. Data Flow
363
+
364
+ ```mermaid
365
+ sequenceDiagram
366
+ participant Agent as LLM Agent
367
+ participant Inf as inference.py
368
+ participant Env as CyberSOCEnvironment
369
+ participant TG as ThreatGraph
370
+ participant Gr as Grader
371
+
372
+ Inf->>Env: reset(task_id="hard")
373
+ Env->>TG: populate from task_def
374
+ Env-->>Inf: SOCObservation (alerts, topology)
375
+
376
+ loop Each Step
377
+ Inf->>Agent: format_observation → LLM prompt
378
+ Agent-->>Inf: JSON action
379
+ Inf->>Env: step(SOCActionWrapper)
380
+ Env->>Env: ActionMiddleware.validate()
381
+ Env->>Env: Handle action (query/forensics/kill/etc)
382
+ Env->>TG: Update graph nodes/edges
383
+ Env->>Env: _adversary_react() (adaptive pivot)
384
+ Env->>Env: _compute_reward_dimensions()
385
+ Env-->>Inf: SOCObservation (updated state)
386
+ end
387
+
388
+ Inf->>Env: step(submit_containment_plan)
389
+ Env->>Gr: grade_episode(actions, plan, graph, task_def, state)
390
+ Gr-->>Env: {final_score, breakdown[10], penalties, bonuses}
391
+ Env-->>Inf: SOCObservation (done=true, final_score)
392
+ ```
393
+
394
+ ---
395
+
396
+ ## 11. Red Team Design Philosophy
397
+
398
+ The Red Team is NOT a separate LLM agent. It is a **deterministic adversarial dynamics engine** that defines the environment's state transition function.
399
+
400
+ ### 6 Behavioral Mechanisms
401
+ 1. **Reactive Pivoting**: Triggers on `isolate_segment` and `kill_process` (copy-not-move spread)
402
+ 2. **Persistence**: Reinfection triggers when a process is killed but its root IOC remains unblocked (teaches causal reasoning)
403
+ 3. **Time Pressure**: Pivot probability escalates +0.2 after step 10 if zero containments are achieved
404
+ 4. **Controlled Randomness**: Uses an episode-scoped `self._rng` (seeded by `task_id`) to ensure deterministic rollouts
405
+ 5. **Noisy Observations**: Benign processes mixed in host data
406
+ 6. **Escalation**: Pivot probabilities scale with difficulty (`Easy: 0.0`, `Medium: 0.3`, `Hard: 0.8`)
407
+
408
+ ### Attack Lifecycle Model (MITRE-aligned)
409
+ `Phase 1: Compromise` → `Phase 2: Lateral Movement` → `Phase 3: Persistence` → `Phase 4: Escalation` → `Phase 5: Impact`
410
+
411
+ ---
412
+
413
+ ## 12. Reward-Hacking Defense Map
414
+
415
+ Per guide §8, we implemented specific defenses against the known RL exploit vectors:
416
+
417
+ | Guide Attack Vector | Our Defense |
418
+ |---|---|
419
+ | Editing timers | `EpisodeSandbox` wall-clock enforcement |
420
+ | Caching results | Idempotent step rewards via `_fired_step_rewards` |
421
+ | Abusing globals | Instance-scoped RNG + episode-scoped `self._rng` |
422
+ | Mutating protected state | Sandbox hash-snapshot + rollback |
423
+ | Exploiting env bugs | 3-gate validation middleware |
424
+ | Reward-function gaming | Evidence confidence normalization |
425
+ | Cheating via blind remediation | Graph-groundedness gate |
426
+ | Blind IOC blocking | Enrichment-before-block penalty |
427
+
428
+ ---
429
+
430
+ ## 13. Curriculum Learning Strategy
431
+
432
+ The 1000+ deterministic scenarios generated by `task_generator.py` are explicitly divided into three difficulty tiers to support Curriculum Learning (Guide §6).
433
+
434
+ This exists precisely to satisfy **Daniel's Law of RL**: *The base model must get non-zero reward on Easy before it can meaningfully learn Hard.*
435
+
436
+ - **Phase 1 (Warm-Start)**: `gen_0001`–`gen_0333` (Easy). Single threat, 15 max steps, 0.0 pivot probability.
437
+ - **Phase 2 (Scaling)**: `gen_0334`–`gen_0666` (Medium). Multi-stage, 25 max steps, 0.3 pivot probability.
438
+ - **Phase 3 (Stress-Test)**: `gen_0667`–`gen_1000` (Hard). APT, 30 max steps, 0.8 pivot probability.
439
+
440
+ The adaptive pivot probability is itself a curriculum signal; the environment gets harder as the agent gets better.
441
+
442
+ ---
443
+
444
+ ## 14. Intended Training Stack
445
+
446
+ CyberSOCEnv is designed for the canonical stack specified in Guide §10:
447
+
448
+ 1. **Unsloth**: 4-bit QLoRA loading and efficient inference
449
+ 2. **TRL**: `GRPOTrainer` consuming our 10 independent callable functions via `reward_funcs=[...]`
450
+ 3. **OpenEnv**: WebSocket transport and session isolation
451
+ 4. **vLLM**: Serving the rollout workers for maximum throughput
452
+
453
+ A reference adapter module exists at `training/reward_funcs.py` that mirrors the Unsloth 2048 notebook structure 1:1, allowing plug-and-play GRPO training.
454
+
455
+ ---
456
+
457
+ ## 15. Anti-Patterns Avoided
458
+
459
+ How we avoided the 7 common mistakes listed in Guide §21:
460
+
461
+ 1. **Building before designing env**: Env, types, and sandbox were built and tested completely offline before any trainer was attached.
462
+ 2. **LLM-as-a-judge**: CyberSOCEnv uses zero LLM-as-judge signals. Everything is deterministic code against the ThreatGraph.
463
+ 3. **Single monolithic reward**: We use a 10-dimensional verifiable rubric, fed independently into TRL.
464
+ 4. **Ignoring inference latency**: We implemented Graph Delta Injection (~10x fewer tokens) and a sparse-node generation strategy (~75 active nodes) specifically to optimize GRPO rollout latency.
465
+ 5. **No abuse prevention**: 3-gate middleware + EpisodeSandbox explicitly prevent out-of-band cheating.
466
+ 6. **Delayed deployment**: Environment was packaged with Docker and deployed to HF Spaces early.
467
+ 7. **Scaling prematurely**: All 9 components passed integration testing (`test_integration.py` through `test_task9.py`) before scaling to 1000 tasks.
468
+
469
+ ---
470
+
471
+ ## 16. Known Deviations & Alignment Items
472
+
473
+ While we strive to match the OpenEnv canonical scaffolding (Guide §5), there are a few intentional architectural differences:
474
+
475
+ 1. **Action Dispatch**: We use a discriminated union wrapper (`SOCActionWrapper` with a `type` field) rather than a single flat action class. This matches the MCP ToolCall pattern and real SOC work better than a flat action space.
476
+ 2. **Decoupled Engine**: The core logic lives in `server/play_environment.py`, completely separate from the FastAPI transport layer in `server/app.py`. This ensures we can run headless parallel environments during GRPO without HTTP overhead if needed.
477
+
478
+ ---
479
+
480
+ ## 17. Team Structure & Role Split
481
+
482
+ Per guide §17, responsibilities are split across three functional roles to execute the RL pipeline effectively.
483
+
484
+ ### Role 1: Environment Engineer
485
+ **Mission**: Build a deterministic, unhackable, fast environment.
486
+ **Owns**: `play_environment.py`, `tasks.py`, `threat_graph.py`, `episode_sandbox.py`, `action_validation.py`, `tests/`
487
+ - **Scope**: Implements the state machine, Red Team behavior, and validates actions. Owns the core `step()` and `reset()` loops. Ensures the environment parses valid inputs and securely handles invalid ones.
488
+ - **Hackathon Focus**: Bug fixes, latency optimization (graph deltas), sandbox integrity, and procedural scenario generation.
489
+
490
+ ### Role 2: Reward Engineer
491
+ **Mission**: Design the mathematical signals that shape model behavior.
492
+ **Owns**: `graders.py`, `training/reward_funcs.py`, `models.py`
493
+ - **Scope**: Creates the 10-dimensional verifiable grading logic. Plumbs the environment outputs into TRL-compatible `reward_funcs`. Tunes the penalties to prevent reward hacking (e.g., punishing blind IOC blocking).
494
+ - **Hackathon Focus**: Ensuring the model gets positive step-rewards early on to prevent it from collapsing, while preventing it from finding "lazy" exploits.
495
+
496
+ ### Role 3: Training Engineer
497
+ **Mission**: Execute the GRPO curriculum and produce the final model.
498
+ **Owns**: `training/` directory, Colab notebooks, `inference.py`
499
+ - **Scope**: Sets up the actual training loops using Unsloth and TRL. Manages the hyperparameter tuning, LoRA checkpointing, and vLLM inference configuration. Runs the curriculum from Easy to Hard.
500
+ - **Hackathon Focus**: Capturing the before/after learning curves on held-out tasks to prove to the judges that the environment actually works to train a model.
501
+
502
+ ---
503
+
504
+ ## 18. Key Innovations Summary
505
+
506
+ | Innovation | Description |
507
+ |-----------|-------------|
508
+ | **Procedural Generation** | SHA-256 seeded RNG generates 1000+ unique deterministic scenarios |
509
+ | **ThreatGraph** | Typed knowledge graph with version tracking, evidence confidence, and LRU pruning |
510
+ | **10-Dim Grading** | Weighted multi-dimensional scoring replacing binary pass/fail |
511
+ | **Adaptive Red Team** | Attacker reacts to defender actions — lateral pivots and reinfection |
512
+ | **SOAR Playbooks** | Prerequisite-gated automated response workflows |
513
+ | **3-Gate Validation** | Phase whitelist + schema + graph-groundedness prevents invalid actions |
514
+ | **Episode Sandbox** | State integrity protection with hash-based tampering detection |
515
+ | **Live GRPO Signals** | Per-step reward dimensions for RL credit assignment |
516
+ | **Anti-Gaming** | Blind-blocking penalties, over-isolation cap, idempotent step rewards (0.40 cap) |
517
+ | **Real-time Dashboard** | D3.js threat graph with pivot animations and 10-dim radar chart |
518
+
519
+ ---
520
+
521
+ ## 19. Technology Stack
522
+
523
+ | Layer | Technologies |
524
+ |-------|-------------|
525
+ | **Backend** | Python 3.10+, FastAPI, Uvicorn, Pydantic v2, OpenEnv Core |
526
+ | **Frontend** | Vanilla HTML/CSS/JS, D3.js v7, Chart.js v4, Inter/JetBrains Mono fonts |
527
+ | **Inference** | OpenAI Python SDK, asyncio |
528
+ | **Training** | TRL (Hugging Face), GRPO |
529
+ | **DevOps** | Docker (multi-stage), uv package manager, pytest |
530
+ | **Deployment** | HuggingFace Spaces (Docker SDK) |
531
+ | **Visualization** | NetworkX + Matplotlib (server-side PNG), D3.js (client-side interactive) |
__init__.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """CyberSOCEnv — Enterprise Cybersecurity Operations Center Environment."""
8
+
9
+ from .client import CyberSOCClient
10
+ from .models import (
11
+ SOCObservation,
12
+ SOCActionWrapper,
13
+ SOCState,
14
+ QueryHost,
15
+ IsolateSegment,
16
+ BlockIOC,
17
+ RunForensics,
18
+ KillProcess,
19
+ SubmitContainmentPlan,
20
+ )
21
+
22
+ __all__ = [
23
+ "CyberSOCClient",
24
+ "SOCObservation",
25
+ "SOCActionWrapper",
26
+ "SOCState",
27
+ "QueryHost",
28
+ "IsolateSegment",
29
+ "BlockIOC",
30
+ "RunForensics",
31
+ "KillProcess",
32
+ "SubmitContainmentPlan",
33
+ ]
client.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """CyberSOCEnv Client — connects to the SOC environment server."""
8
+
9
+ from typing import Dict
10
+
11
+ from openenv.core import EnvClient
12
+ from openenv.core.client_types import StepResult
13
+
14
+ from .models import (
15
+ SOCObservation,
16
+ SOCActionWrapper,
17
+ SOCState,
18
+ Alert,
19
+ Severity,
20
+ ThreatType,
21
+ NetworkTopology,
22
+ ForensicsResult,
23
+ TimelineEntry,
24
+ )
25
+
26
+
27
+ class CyberSOCClient(
28
+ EnvClient[SOCActionWrapper, SOCObservation, SOCState]
29
+ ):
30
+ """
31
+ Client for the CyberSOCEnv environment.
32
+
33
+ Connects via WebSocket to the SOC environment server for
34
+ low-latency, persistent-session interaction.
35
+
36
+ Example:
37
+ >>> with CyberSOCClient(base_url="http://localhost:8000") as client:
38
+ ... result = client.reset()
39
+ ... print(result.observation.alert_queue)
40
+ ...
41
+ ... from play.models import QueryHost
42
+ ... result = client.step(SOCActionWrapper(type="query_host", hostname="WS-001"))
43
+ ... print(result.observation.host_forensics)
44
+ """
45
+
46
+ def _step_payload(self, action: SOCActionWrapper) -> Dict:
47
+ """Convert SOCActionWrapper to JSON payload for step message."""
48
+ return action.model_dump(exclude_none=True)
49
+
50
+ def _parse_result(self, payload: Dict) -> StepResult[SOCObservation]:
51
+ """Parse server response into StepResult[SOCObservation]."""
52
+ obs_data = payload.get("observation", {})
53
+
54
+ # Parse alerts
55
+ alerts = [Alert(**a) for a in obs_data.get("alert_queue", [])]
56
+
57
+ # Parse network topology
58
+ topo_data = obs_data.get("network_topology", {})
59
+ topology = NetworkTopology(**topo_data) if topo_data else NetworkTopology()
60
+
61
+ # Parse forensics (may be None)
62
+ forensics_data = obs_data.get("host_forensics")
63
+ forensics = ForensicsResult(**forensics_data) if forensics_data else None
64
+
65
+ # Parse timeline
66
+ timeline = [TimelineEntry(**t) for t in obs_data.get("timeline", [])]
67
+
68
+ observation = SOCObservation(
69
+ episode_id=obs_data.get("episode_id", ""),
70
+ alert_queue=alerts,
71
+ network_topology=topology,
72
+ host_forensics=forensics,
73
+ timeline=timeline,
74
+ business_impact_score=obs_data.get("business_impact_score", 0.0),
75
+ step_count=obs_data.get("step_count", 0),
76
+ active_threats=obs_data.get("active_threats", []),
77
+ max_steps=obs_data.get("max_steps", 30),
78
+ task_id=obs_data.get("task_id", "easy"),
79
+ total_reward=obs_data.get("total_reward", 0.0),
80
+ final_score=obs_data.get("final_score"),
81
+ grade_breakdown=obs_data.get("grade_breakdown"),
82
+ done=payload.get("done", False),
83
+ reward=payload.get("reward"),
84
+ )
85
+
86
+ result = StepResult(
87
+ observation=observation,
88
+ reward=payload.get("reward"),
89
+ done=payload.get("done", False),
90
+ )
91
+ # Attach episode_id directly on the result for easy RL loop access
92
+ result.episode_id = observation.episode_id # type: ignore[attr-defined]
93
+ return result
94
+
95
+ def _parse_state(self, payload: Dict) -> SOCState:
96
+ """Parse server response into SOCState."""
97
+ return SOCState(
98
+ episode_id=payload.get("episode_id"),
99
+ step_count=payload.get("step_count", 0),
100
+ task_id=payload.get("task_id", "easy"),
101
+ total_reward=payload.get("total_reward", 0.0),
102
+ business_impact=payload.get("business_impact", 0.0),
103
+ )
dashboard/css/styles.css ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ CyberSOC Command Center — Dark SOC Theme
3
+ ============================================================ */
4
+
5
+ :root {
6
+ --bg-primary: #0a0e17;
7
+ --bg-secondary: #111827;
8
+ --bg-card: #1a1f2e;
9
+ --border: #2a3040;
10
+ --border-hover: #3d4a60;
11
+
12
+ --accent-blue: #3b82f6;
13
+ --accent-cyan: #06b6d4;
14
+ --accent-green: #10b981;
15
+ --accent-red: #ef4444;
16
+ --accent-amber: #f59e0b;
17
+ --accent-orange: #f97316;
18
+ --accent-purple: #8b5cf6;
19
+
20
+ --text-primary: #f1f5f9;
21
+ --text-secondary: #94a3b8;
22
+ --text-muted: #4b5563;
23
+
24
+ --glow-red: 0 0 20px rgba(239,68,68,0.45);
25
+ --glow-blue: 0 0 20px rgba(59,130,246,0.45);
26
+ --glow-green: 0 0 15px rgba(16,185,129,0.35);
27
+ --glow-amber: 0 0 15px rgba(245,158,11,0.35);
28
+ --glow-purple: 0 0 15px rgba(139,92,246,0.35);
29
+ --glow-cyan: 0 0 15px rgba(6,182,212,0.35);
30
+
31
+ --header-h: 60px;
32
+ --gap: 7px;
33
+ --r: 10px;
34
+ --r-sm: 5px;
35
+ }
36
+
37
+ /* ============================================================ Reset */
38
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
39
+ html, body { height: 100%; overflow: hidden; font-family: 'Inter', sans-serif;
40
+ background: var(--bg-primary); color: var(--text-primary); font-size: 13px; line-height: 1.5; }
41
+
42
+ .mono { font-family: 'JetBrains Mono', monospace; }
43
+
44
+ /* ============================================================ Header */
45
+ .header {
46
+ position: fixed; top: 0; left: 0; right: 0;
47
+ height: var(--header-h); z-index: 100;
48
+ background: var(--bg-primary);
49
+ border-bottom: 1px solid var(--border);
50
+ display: flex; align-items: center; justify-content: space-between;
51
+ padding: 0 16px; gap: 12px;
52
+ backdrop-filter: blur(4px);
53
+ }
54
+
55
+ .header-left { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
56
+
57
+ .shield-icon { font-size: 22px; filter: drop-shadow(0 0 8px rgba(59,130,246,0.7)); }
58
+
59
+ .header-title { font-size: 15px; font-weight: 700; letter-spacing: 0.04em; white-space: nowrap; }
60
+
61
+ /* Phase Indicator */
62
+ .header-center { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; }
63
+
64
+ .phase-indicator { display: flex; align-items: center; }
65
+
66
+ .phase-dot { display: flex; flex-direction: column; align-items: center; gap: 3px; }
67
+
68
+ .phase-dot-circle {
69
+ width: 11px; height: 11px; border-radius: 50%;
70
+ background: var(--text-muted); border: 2px solid var(--border);
71
+ transition: all 0.4s ease;
72
+ }
73
+
74
+ .phase-dot.active .phase-dot-circle { background: var(--accent-blue); border-color: var(--accent-blue); box-shadow: var(--glow-blue); animation: phasePulse 1.6s ease infinite; }
75
+ .phase-dot.completed .phase-dot-circle { background: var(--accent-green); border-color: var(--accent-green); box-shadow: var(--glow-green); }
76
+
77
+ .phase-dot[data-phase="triage"].active .phase-dot-circle { background: var(--accent-amber); border-color: var(--accent-amber); box-shadow: var(--glow-amber); }
78
+ .phase-dot[data-phase="remediation"].active .phase-dot-circle { background: var(--accent-orange); border-color: var(--accent-orange); }
79
+ .phase-dot[data-phase="report"].active .phase-dot-circle { background: var(--accent-green); border-color: var(--accent-green); box-shadow: var(--glow-green); }
80
+
81
+ @keyframes phasePulse { 0%,100%{box-shadow:0 0 6px rgba(59,130,246,.4)} 50%{box-shadow:0 0 18px rgba(59,130,246,.9)} }
82
+
83
+ .phase-label { font-size: 8px; font-weight: 600; letter-spacing: .08em; color: var(--text-muted); transition: color .3s; white-space: nowrap; }
84
+ .phase-dot.active .phase-label { color: var(--accent-blue); }
85
+ .phase-dot.completed .phase-label { color: var(--accent-green); }
86
+ .phase-dot[data-phase="triage"].active .phase-label { color: var(--accent-amber); }
87
+ .phase-dot[data-phase="remediation"].active .phase-label { color: var(--accent-orange); }
88
+
89
+ .phase-connector { width: 36px; height: 1px; background: var(--border); margin: 0 4px 12px; transition: background .4s; }
90
+ .phase-connector.completed { background: var(--accent-green); }
91
+
92
+ .red-team-alert {
93
+ font-size: 10px; font-weight: 700; letter-spacing: .1em;
94
+ color: var(--accent-red); padding: 2px 8px;
95
+ border: 1px solid var(--accent-red); border-radius: 4px;
96
+ animation: blink 0.7s ease infinite;
97
+ }
98
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:.25} }
99
+
100
+ /* Header right */
101
+ .header-right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
102
+
103
+ .header-stat { display: flex; flex-direction: column; align-items: center; }
104
+ .header-stat-label { font-size: 8px; font-weight: 600; letter-spacing: .1em; color: var(--text-muted); }
105
+ .header-stat-value { font-size: 12px; font-weight: 700; }
106
+ .header-stat-value.mono { font-family: 'JetBrains Mono', monospace; font-size: 10px; }
107
+
108
+ .difficulty-badge {
109
+ padding: 3px 9px; border-radius: 4px; font-size: 9px; font-weight: 700; letter-spacing: .1em;
110
+ background: rgba(59,130,246,.15); color: var(--accent-blue); border: 1px solid rgba(59,130,246,.4);
111
+ }
112
+ .difficulty-badge.easy { background:rgba(16,185,129,.15); color:var(--accent-green); border-color:rgba(16,185,129,.4); }
113
+ .difficulty-badge.medium { background:rgba(245,158,11,.15); color:var(--accent-amber); border-color:rgba(245,158,11,.4); }
114
+ .difficulty-badge.hard { background:rgba(239,68,68,.15); color:var(--accent-red); border-color:rgba(239,68,68,.4); }
115
+
116
+ /* ============================================================ Grid */
117
+ .main-grid {
118
+ position: fixed; top: var(--header-h); left: 0; right: 0; bottom: 0;
119
+ display: grid;
120
+ grid-template-columns: 272px 1fr 256px;
121
+ grid-template-rows: 56% 44%;
122
+ gap: var(--gap); padding: var(--gap);
123
+ }
124
+
125
+ /* ============================================================ Panel */
126
+ .panel {
127
+ background: var(--bg-card);
128
+ border: 1px solid var(--border);
129
+ border-radius: var(--r);
130
+ box-shadow: 0 4px 24px rgba(0,0,0,.35);
131
+ display: flex; flex-direction: column; overflow: hidden;
132
+ transition: border-color .25s;
133
+ }
134
+ .panel:hover { border-color: var(--border-hover); }
135
+
136
+ .panel-header {
137
+ padding: 8px 12px; border-bottom: 1px solid var(--border);
138
+ display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
139
+ min-height: 36px;
140
+ }
141
+ .panel-title { font-size: 11px; font-weight: 600; letter-spacing: .05em; }
142
+ .panel-body { flex: 1; overflow: hidden; position: relative; }
143
+
144
+ .panel-1 { grid-column:1; grid-row:1; }
145
+ .panel-2 { grid-column:2; grid-row:1; }
146
+ .panel-3 { grid-column:3; grid-row:1; }
147
+ .panel-4 { grid-column:1; grid-row:2; }
148
+ .panel-5 { grid-column:2; grid-row:2; }
149
+ .panel-6 { grid-column:3; grid-row:2; display:flex; flex-direction:column; }
150
+
151
+ /* ============================================================ Badges */
152
+ .badge {
153
+ display:inline-flex; align-items:center; justify-content:center;
154
+ min-width:18px; height:18px; padding:0 5px; border-radius:9px;
155
+ font-size:10px; font-weight:700; font-family:'JetBrains Mono',monospace;
156
+ background:rgba(239,68,68,.2); color:var(--accent-red); border:1px solid rgba(239,68,68,.4);
157
+ }
158
+
159
+ .reward-badge { font-family:'JetBrains Mono',monospace; font-size:12px; font-weight:700; color:var(--accent-green); }
160
+ .reward-badge.negative { color:var(--accent-red); }
161
+
162
+ /* Panel 2 graph summary subtitle */
163
+ .graph-summary {
164
+ width:100%; font-size:9px; font-family:'JetBrains Mono',monospace;
165
+ color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
166
+ padding-top:1px;
167
+ }
168
+
169
+ /* Panel 2 legend */
170
+ .graph-legend { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
171
+ .legend-item { display:flex; align-items:center; gap:3px; font-size:9px; color:var(--text-secondary); }
172
+ .legend-dot { width:7px; height:7px; border-radius:50%; }
173
+ .legend-dot.host { background:var(--accent-blue); }
174
+ .legend-dot.process { background:var(--accent-amber); border-radius:1px; transform:rotate(45deg); }
175
+ .legend-dot.ioc { background:var(--accent-red); }
176
+ .legend-dot.alert-node { background:var(--accent-orange); }
177
+ .legend-dot.vuln { background:var(--accent-green); border-radius:1px; }
178
+
179
+ /* ============================================================ Alert List (Panel 1) */
180
+ .alert-list {
181
+ height:100%; overflow-y:auto; padding:7px;
182
+ display:flex; flex-direction:column; gap:5px;
183
+ scrollbar-width:thin; scrollbar-color:var(--border) transparent;
184
+ }
185
+
186
+ .alert-card {
187
+ background:var(--bg-secondary); border:1px solid var(--border);
188
+ border-radius:var(--r-sm); padding:7px 9px; cursor:pointer;
189
+ transition:border-color .2s, box-shadow .2s;
190
+ animation:slideInRight .3s ease forwards;
191
+ }
192
+ .alert-card:hover { border-color:var(--accent-blue); }
193
+
194
+ .alert-card.pivot { border-color:var(--accent-red) !important; animation:pivotPulse 1s ease 4; }
195
+ @keyframes pivotPulse { 0%,100%{box-shadow:none} 50%{box-shadow:var(--glow-red)} }
196
+
197
+ .alert-card-header { display:flex; align-items:center; justify-content:space-between; gap:5px; margin-bottom:3px; }
198
+
199
+ .severity-badge { padding:1px 5px; border-radius:3px; font-size:8px; font-weight:700; letter-spacing:.07em; flex-shrink:0; }
200
+ .severity-low { background:rgba(16,185,129,.2); color:var(--accent-green); }
201
+ .severity-medium { background:rgba(245,158,11,.2); color:var(--accent-amber); }
202
+ .severity-high { background:rgba(249,115,22,.2); color:var(--accent-orange); }
203
+ .severity-critical { background:rgba(239,68,68,.2); color:var(--accent-red); border:1px solid rgba(239,68,68,.4); }
204
+
205
+ .alert-host { font-family:'JetBrains Mono',monospace; font-size:10px; color:var(--accent-cyan); }
206
+ .alert-description { font-size:10px; color:var(--text-secondary); line-height:1.4; margin-bottom:3px; }
207
+ .alert-iocs { font-family:'JetBrains Mono',monospace; font-size:9px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
208
+ .alert-footer { display:flex; align-items:center; gap:5px; margin-top:3px; }
209
+
210
+ .pivot-badge {
211
+ display:inline-flex; align-items:center; gap:2px; padding:1px 5px;
212
+ border-radius:3px; font-size:8px; font-weight:700;
213
+ background:rgba(239,68,68,.2); color:var(--accent-red); border:1px solid rgba(239,68,68,.5);
214
+ animation:blink .7s ease infinite;
215
+ }
216
+ .correlated-badge {
217
+ display:inline-flex; align-items:center; gap:2px; padding:1px 5px;
218
+ border-radius:3px; font-size:8px; font-weight:700;
219
+ background:rgba(139,92,246,.2); color:var(--accent-purple); border:1px solid rgba(139,92,246,.4);
220
+ }
221
+
222
+ /* ============================================================ Threat Graph (Panel 2) */
223
+ .graph-body {
224
+ padding:0; background:radial-gradient(ellipse at center, #0d1422 0%, var(--bg-card) 100%);
225
+ }
226
+ #threat-graph-svg { width:100%; height:100%; }
227
+
228
+ .graph-tooltip {
229
+ position:absolute; background:var(--bg-secondary); border:1px solid var(--border);
230
+ border-radius:var(--r-sm); padding:7px 9px; font-size:10px;
231
+ pointer-events:none; z-index:10; max-width:190px;
232
+ box-shadow:0 4px 16px rgba(0,0,0,.5); transition:opacity .15s;
233
+ }
234
+ .graph-tooltip.hidden { opacity:0; pointer-events:none; }
235
+ .graph-tooltip-title { font-weight:600; font-family:'JetBrains Mono',monospace; margin-bottom:4px; color:var(--accent-cyan); font-size:11px; }
236
+ .graph-tooltip-row { display:flex; justify-content:space-between; gap:8px; margin-bottom:1px; }
237
+ .graph-tooltip-key { color:var(--text-muted); }
238
+ .graph-tooltip-value { color:var(--text-primary); font-family:'JetBrains Mono',monospace; }
239
+
240
+ /* D3 SVG styles */
241
+ .graph-link { fill:none; stroke-width:1.5; }
242
+ .graph-link.runs_on { stroke:rgba(59,130,246,.5); }
243
+ .graph-link.involves { stroke:rgba(148,163,184,.35); stroke-dasharray:4,4; }
244
+ .graph-link.communicates_with { stroke:rgba(245,158,11,.5); stroke-dasharray:6,3; }
245
+ .graph-link.exploits { stroke:rgba(239,68,68,.6); }
246
+ .graph-link.pivoted_from { stroke:var(--accent-red); stroke-width:3; filter:drop-shadow(0 0 4px rgba(239,68,68,.8)); }
247
+ .graph-link.part_of_chain { stroke:rgba(100,116,139,.4); stroke-dasharray:3,5; }
248
+
249
+ .node-label { font-family:'JetBrains Mono',monospace; font-size:9px; fill:rgba(148,163,184,.7); pointer-events:none; user-select:none; }
250
+
251
+ /* ============================================================ Action Log (Panel 3) */
252
+ .action-log {
253
+ height:100%; overflow-y:auto; padding:7px;
254
+ display:flex; flex-direction:column; gap:3px;
255
+ font-family:'JetBrains Mono',monospace; font-size:10.5px;
256
+ scrollbar-width:thin; scrollbar-color:var(--border) transparent;
257
+ }
258
+
259
+ .log-entry {
260
+ padding:5px 7px; border-radius:4px;
261
+ border-left:2.5px solid var(--border);
262
+ background:rgba(255,255,255,.02);
263
+ animation:slideInRight .25s ease forwards;
264
+ line-height:1.5;
265
+ }
266
+ .log-entry.investigation { border-left-color:var(--accent-cyan); }
267
+ .log-entry.remediation { border-left-color:var(--accent-amber); }
268
+ .log-entry.triage { border-left-color:var(--accent-purple); }
269
+ .log-entry.report { border-left-color:var(--accent-green); }
270
+
271
+ .log-step { color:var(--text-muted); font-size:9px; }
272
+ .log-action { color:var(--accent-cyan); font-weight:700; }
273
+ .log-entry.remediation .log-action { color:var(--accent-amber); }
274
+ .log-entry.triage .log-action { color:var(--accent-purple); }
275
+ .log-entry.report .log-action { color:var(--accent-green); }
276
+ .log-target { color:var(--text-primary); }
277
+ .log-result { font-size:9px; color:var(--text-secondary); margin-top:1px; }
278
+ .log-details {
279
+ font-size:9px; color:var(--text-muted); margin-top:3px; padding:3px 6px;
280
+ background:rgba(0,0,0,.2); border-radius:3px; border-left:2px solid var(--border);
281
+ line-height:1.6;
282
+ }
283
+ .log-reward { font-size:10px; font-weight:700; }
284
+ .log-reward.positive { color:var(--accent-green); }
285
+ .log-reward.negative { color:var(--accent-red); }
286
+
287
+ /* ============================================================ Network Topology (Panel 4) */
288
+ .network-topology {
289
+ height:100%; overflow-y:auto; padding:7px;
290
+ display:flex; flex-direction:column; gap:5px;
291
+ scrollbar-width:thin; scrollbar-color:var(--border) transparent;
292
+ }
293
+
294
+ .subnet-section {
295
+ background:var(--bg-secondary); border:1px solid var(--border);
296
+ border-radius:var(--r-sm); padding:6px 8px;
297
+ transition:border-color .3s; position:relative; overflow:hidden;
298
+ }
299
+ .subnet-section.has-compromised { border-color:rgba(239,68,68,.4); }
300
+ .subnet-section.isolated { border-color:rgba(245,158,11,.6); background:rgba(245,158,11,.04); }
301
+
302
+ .subnet-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:5px; }
303
+ .subnet-name { font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.08em; }
304
+ .subnet-stats { font-size:9px; font-family:'JetBrains Mono',monospace; color:var(--text-muted); }
305
+ .subnet-stats .compromised { color:var(--accent-red); }
306
+ .subnet-stats .isolated { color:var(--accent-amber); }
307
+
308
+ .host-grid { display:flex; flex-wrap:wrap; gap:3px; }
309
+
310
+ .host-dot {
311
+ width:9px; height:9px; border-radius:2px;
312
+ cursor:pointer; transition:transform .15s;
313
+ }
314
+ .host-dot:hover { transform:scale(1.6); z-index:1; }
315
+ .host-dot.online { background:rgba(59,130,246,.5); }
316
+ .host-dot.compromised { background:var(--accent-red); animation:hostGlow 1.2s ease infinite; }
317
+ .host-dot.isolated { background:rgba(100,116,139,.5); border:1px solid var(--accent-amber); }
318
+ .host-dot.queried { background:var(--accent-cyan); }
319
+ .host-dot.healthy { background:rgba(16,185,129,.5); }
320
+
321
+ @keyframes hostGlow { 0%,100%{box-shadow:none} 50%{box-shadow:0 0 5px rgba(239,68,68,.9)} }
322
+
323
+ .isolated-overlay {
324
+ position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
325
+ background:rgba(245,158,11,.06); border:1.5px solid rgba(245,158,11,.5);
326
+ border-radius:var(--r-sm); font-size:9px; font-weight:700;
327
+ color:var(--accent-amber); letter-spacing:.08em; pointer-events:none;
328
+ }
329
+
330
+ .topology-stats { display:flex; gap:6px; }
331
+ .topo-stat { font-size:9px; font-family:'JetBrains Mono',monospace; font-weight:600; padding:2px 5px; border-radius:3px; }
332
+ .topo-stat.compromised { background:rgba(239,68,68,.15); color:var(--accent-red); }
333
+ .topo-stat.isolated { background:rgba(245,158,11,.15); color:var(--accent-amber); }
334
+
335
+ /* ============================================================ Scores Panel (Panel 5) */
336
+ .scores-body {
337
+ display:flex; flex-direction:column; gap:6px; padding:8px; overflow:hidden; height:100%;
338
+ }
339
+ .radar-container { flex:1; position:relative; min-height:0; }
340
+ .timeline-container { flex:0 0 90px; position:relative; }
341
+
342
+ /* ============================================================ Mission Status (Panel 6) */
343
+ .panel-6 .panel-body {
344
+ display:flex; flex-direction:column; overflow-y:auto;
345
+ scrollbar-width:thin; scrollbar-color:var(--border) transparent;
346
+ }
347
+
348
+ .containment-section,
349
+ .impact-section,
350
+ .threats-section,
351
+ .control-section {
352
+ padding:7px 11px; border-bottom:1px solid var(--border); flex-shrink:0;
353
+ }
354
+ .containment-section:last-child,
355
+ .impact-section:last-child,
356
+ .threats-section:last-child,
357
+ .control-section:last-child { border-bottom:none; }
358
+
359
+ .section-label {
360
+ font-size:9px; font-weight:600; letter-spacing:.08em;
361
+ color:var(--text-muted); text-transform:uppercase; margin-bottom:5px;
362
+ }
363
+
364
+ /* Containment bars */
365
+ .containment-bars { display:flex; flex-direction:column; gap:4px; }
366
+
367
+ .containment-bar-item { display:flex; flex-direction:column; gap:2px; }
368
+ .containment-bar-label {
369
+ display:flex; justify-content:space-between;
370
+ font-size:9px; color:var(--text-secondary);
371
+ }
372
+ .containment-bar-label .complete { color:var(--accent-green); }
373
+ .containment-bar-track { height:5px; background:var(--bg-secondary); border-radius:3px; overflow:hidden; border:1px solid var(--border); }
374
+ .containment-bar-fill { height:100%; background:var(--accent-cyan); border-radius:3px; transition:width .5s ease; width:0%; }
375
+ .containment-bar-fill.complete { background:var(--accent-green); box-shadow:var(--glow-green); }
376
+
377
+ /* Business Impact Gauge */
378
+ .impact-gauge { padding:2px 0; }
379
+
380
+ .impact-gauge-track {
381
+ position:relative; height:11px; border-radius:6px; overflow:hidden;
382
+ display:flex; margin-bottom:3px;
383
+ }
384
+ .impact-zone-green { flex:0 0 40%; background:linear-gradient(90deg,rgba(16,185,129,.4),rgba(16,185,129,.2)); }
385
+ .impact-zone-amber { flex:0 0 35%; background:linear-gradient(90deg,rgba(245,158,11,.25),rgba(245,158,11,.5)); }
386
+ .impact-zone-red { flex:0 0 25%; background:linear-gradient(90deg,rgba(239,68,68,.35),rgba(239,68,68,.7)); }
387
+
388
+ .impact-marker {
389
+ position:absolute; top:-2px; width:3px; height:15px;
390
+ background:white; border-radius:2px; box-shadow:0 0 8px rgba(255,255,255,.8);
391
+ transition:left .5s ease; left:0%; transform:translateX(-50%);
392
+ }
393
+
394
+ .impact-labels { display:flex; justify-content:space-between; font-size:8px; color:var(--text-muted); margin-bottom:3px; }
395
+ .impact-value-display { display:flex; align-items:center; justify-content:space-between; }
396
+ .impact-label { font-size:9px; color:var(--text-muted); }
397
+ .impact-value { font-size:18px; font-weight:700; font-family:'JetBrains Mono',monospace; color:var(--accent-green); transition:color .5s; }
398
+ .impact-value.high { color:var(--accent-amber); }
399
+ .impact-value.critical { color:var(--accent-red); }
400
+
401
+ /* Active threats */
402
+ .active-threats-list { display:flex; flex-wrap:wrap; gap:3px; }
403
+
404
+ .threat-tag {
405
+ padding:2px 5px; border-radius:3px; font-size:9px; font-weight:600;
406
+ font-family:'JetBrains Mono',monospace;
407
+ background:rgba(239,68,68,.15); color:var(--accent-red); border:1px solid rgba(239,68,68,.3);
408
+ }
409
+ .threat-tag.contained {
410
+ background:rgba(16,185,129,.15); color:var(--accent-green);
411
+ border-color:rgba(16,185,129,.3); text-decoration:line-through; opacity:.6;
412
+ }
413
+
414
+ /* Controls */
415
+ .control-section { display:flex; flex-wrap:wrap; align-items:center; gap:5px; padding:8px 11px !important; }
416
+
417
+ .btn {
418
+ padding:5px 12px; border-radius:var(--r-sm); font-size:11px; font-weight:600;
419
+ cursor:pointer; border:1px solid transparent; transition:all .2s;
420
+ display:inline-flex; align-items:center; gap:3px;
421
+ }
422
+ .btn:disabled { opacity:.5; cursor:not-allowed; }
423
+ .btn-primary { background:var(--accent-blue); color:#fff; border-color:var(--accent-blue); }
424
+ .btn-primary:hover:not(:disabled) { background:#2563eb; box-shadow:var(--glow-blue); }
425
+ .btn-secondary { background:var(--bg-secondary); color:var(--text-primary); border-color:var(--border); }
426
+ .btn-secondary:hover { border-color:var(--accent-blue); color:var(--accent-blue); }
427
+
428
+ .task-selector { display:flex; align-items:center; gap:5px; font-size:11px; color:var(--text-secondary); margin-left:auto; }
429
+ .task-select {
430
+ background:var(--bg-secondary); color:var(--text-primary);
431
+ border:1px solid var(--border); border-radius:4px; padding:3px 6px;
432
+ font-size:11px; cursor:pointer; outline:none;
433
+ }
434
+ .task-select:focus { border-color:var(--accent-blue); }
435
+
436
+ /* ============================================================ Overlays */
437
+ .overlay {
438
+ position:fixed; inset:0; z-index:200;
439
+ display:flex; align-items:center; justify-content:center;
440
+ background:rgba(10,14,23,.92); backdrop-filter:blur(10px);
441
+ }
442
+ .overlay.hidden { display:none; }
443
+
444
+ .overlay-content {
445
+ text-align:center; display:flex; flex-direction:column; align-items:center; gap:14px;
446
+ }
447
+ .overlay-icon { font-size:44px; filter:drop-shadow(0 0 20px rgba(59,130,246,.7)); }
448
+ .overlay h2 { font-size:22px; font-weight:700; }
449
+
450
+ #connection-status { font-size:13px; color:var(--text-secondary); }
451
+
452
+ .connection-spinner {
453
+ width:30px; height:30px; border:2.5px solid var(--border);
454
+ border-top-color:var(--accent-blue); border-radius:50%; animation:spin 1s linear infinite;
455
+ }
456
+ @keyframes spin { to { transform:rotate(360deg); } }
457
+
458
+ /* Final Score Overlay */
459
+ .final-score-content {
460
+ text-align:center; display:flex; flex-direction:column; align-items:center; gap:14px;
461
+ max-width:480px; width:90%; padding:28px;
462
+ background:var(--bg-card); border:1px solid var(--accent-green);
463
+ border-radius:var(--r); box-shadow:var(--glow-green), 0 24px 60px rgba(0,0,0,.7);
464
+ animation:revealPop .5s cubic-bezier(.175,.885,.32,1.275) forwards;
465
+ }
466
+ @keyframes revealPop { from{opacity:0;transform:scale(.8)} to{opacity:1;transform:scale(1)} }
467
+
468
+ .mission-complete-banner {
469
+ font-size:12px; font-weight:700; letter-spacing:.2em; color:var(--accent-green);
470
+ border:1px solid var(--accent-green); padding:3px 14px; border-radius:3px;
471
+ animation:bannerGlow 1.5s ease infinite;
472
+ }
473
+ @keyframes bannerGlow { 0%,100%{box-shadow:none} 50%{box-shadow:var(--glow-green)} }
474
+
475
+ .final-score-label { font-size:10px; font-weight:600; letter-spacing:.15em; color:var(--text-muted); }
476
+ .final-score-number {
477
+ font-family:'JetBrains Mono',monospace; font-size:64px; font-weight:700;
478
+ color:var(--accent-green); line-height:1;
479
+ }
480
+
481
+ .final-grade-bars { width:100%; display:flex; flex-direction:column; gap:3px; }
482
+ .final-grade-bar-item { display:grid; grid-template-columns:140px 1fr 38px; align-items:center; gap:6px; }
483
+ .final-grade-bar-label { font-size:9px; color:var(--text-secondary); text-align:right; }
484
+ .final-grade-bar-track { height:5px; background:var(--bg-secondary); border-radius:3px; overflow:hidden; }
485
+ .final-grade-bar-fill { height:100%; background:var(--accent-cyan); border-radius:3px; transition:width 1s ease; }
486
+ .final-grade-bar-value { font-family:'JetBrains Mono',monospace; font-size:9px; color:var(--text-primary); }
487
+
488
+ .final-penalties-bonuses { width:100%; display:flex; flex-direction:column; gap:2px; max-height:80px; overflow-y:auto; }
489
+ .penalty-item { font-size:10px; color:var(--accent-red); font-family:'JetBrains Mono',monospace; }
490
+ .bonus-item { font-size:10px; color:var(--accent-green); font-family:'JetBrains Mono',monospace; }
491
+
492
+ /* ============================================================ Screen Flash */
493
+ .screen-flash { position:fixed; inset:0; pointer-events:none; z-index:999; opacity:0; border-radius:0; }
494
+ .screen-flash.red-flash { background:rgba(239,68,68,.08); border:5px solid rgba(239,68,68,.6); }
495
+ .screen-flash.green-flash { background:rgba(16,185,129,.07); border:5px solid rgba(16,185,129,.5); }
496
+
497
+ /* ============================================================ Notifications */
498
+ #notification-container {
499
+ position:fixed; top:calc(var(--header-h) + 10px); right:10px; z-index:150;
500
+ display:flex; flex-direction:column; gap:5px; pointer-events:none;
501
+ }
502
+ .notification-toast {
503
+ padding:7px 13px; border-radius:var(--r-sm); font-size:11px; font-weight:600;
504
+ color:#fff; animation:toastIn .3s ease forwards; pointer-events:none;
505
+ max-width:340px;
506
+ }
507
+ .notification-toast.red { background:rgba(239,68,68,.9); border:1px solid var(--accent-red); box-shadow:var(--glow-red); }
508
+ .notification-toast.green { background:rgba(16,185,129,.9); border:1px solid var(--accent-green); }
509
+ .notification-toast.blue { background:rgba(59,130,246,.9); border:1px solid var(--accent-blue); }
510
+ .notification-toast.amber { background:rgba(245,158,11,.9); border:1px solid var(--accent-amber); }
511
+ @keyframes toastIn { from{opacity:0;transform:translateX(100%)} to{opacity:1;transform:translateX(0)} }
512
+
513
+ /* ============================================================ Utility */
514
+ .hidden { display:none !important; }
515
+
516
+ .empty-state { color:var(--text-muted); font-size:11px; text-align:center; padding:16px; font-style:italic; }
517
+
518
+ /* ============================================================ Animations */
519
+ @keyframes slideInRight { from{opacity:0;transform:translateX(16px)} to{opacity:1;transform:translateX(0)} }
520
+ @keyframes fadeIn { from{opacity:0} to{opacity:1} }
521
+ @keyframes scaleBounce { 0%{transform:scale(1)} 50%{transform:scale(1.14)} 100%{transform:scale(1)} }
522
+
523
+ /* ============================================================ Scrollbars */
524
+ ::-webkit-scrollbar { width:3px; height:3px; }
525
+ ::-webkit-scrollbar-track { background:transparent; }
526
+ ::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
527
+ ::-webkit-scrollbar-thumb:hover { background:var(--border-hover); }
dashboard/index.html ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CyberSOC Command Center</title>
7
+
8
+ <!-- Fonts -->
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
12
+
13
+ <!-- D3.js v7 -->
14
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
15
+
16
+ <!-- Chart.js v4 -->
17
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
18
+
19
+ <!-- Styles -->
20
+ <link rel="stylesheet" href="css/styles.css">
21
+ </head>
22
+ <body>
23
+
24
+ <!-- ==========================================
25
+ CONNECTION OVERLAY
26
+ ========================================== -->
27
+ <div id="connection-overlay" class="overlay">
28
+ <div class="overlay-content">
29
+ <div class="overlay-icon">🛡️</div>
30
+ <h2>CyberSOC Command Center</h2>
31
+ <div id="connection-status">Connecting to CyberSOC Server...</div>
32
+ <div class="connection-spinner"></div>
33
+ <div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
34
+ Server: <span style="font-family:monospace;color:var(--accent-cyan)">http://localhost:8000</span>
35
+ </div>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- ==========================================
40
+ FINAL SCORE OVERLAY
41
+ ========================================== -->
42
+ <div id="final-score-overlay" class="overlay hidden">
43
+ <div class="final-score-content">
44
+ <div class="mission-complete-banner">MISSION COMPLETE</div>
45
+ <div class="final-score-label">EPISODE FINAL SCORE</div>
46
+ <div id="final-score-number" class="final-score-number">0.000</div>
47
+ <div id="final-grade-bars" class="final-grade-bars"></div>
48
+ <div id="final-penalties-bonuses" class="final-penalties-bonuses"></div>
49
+ <button class="btn btn-primary" onclick="window.dashboard.closeScoreOverlay()">📊 View Full Report</button>
50
+ </div>
51
+ </div>
52
+
53
+ <!-- ==========================================
54
+ HEADER
55
+ ========================================== -->
56
+ <header class="header">
57
+ <div class="header-left">
58
+ <span class="shield-icon">🛡️</span>
59
+ <span class="header-title">CyberSOC Command Center</span>
60
+ </div>
61
+
62
+ <div class="header-center">
63
+ <div class="phase-indicator" id="phase-indicator">
64
+ <div class="phase-dot" data-phase="triage">
65
+ <span class="phase-dot-circle"></span>
66
+ <span class="phase-label">TRIAGE</span>
67
+ </div>
68
+ <div class="phase-connector"></div>
69
+ <div class="phase-dot" data-phase="investigation">
70
+ <span class="phase-dot-circle"></span>
71
+ <span class="phase-label">INVESTIGATE</span>
72
+ </div>
73
+ <div class="phase-connector"></div>
74
+ <div class="phase-dot" data-phase="remediation">
75
+ <span class="phase-dot-circle"></span>
76
+ <span class="phase-label">REMEDIATE</span>
77
+ </div>
78
+ <div class="phase-connector"></div>
79
+ <div class="phase-dot" data-phase="report">
80
+ <span class="phase-dot-circle"></span>
81
+ <span class="phase-label">REPORT</span>
82
+ </div>
83
+ </div>
84
+ <div id="red-team-alert" class="red-team-alert hidden">
85
+ 🔴 RED TEAM ACTIVE — ADAPTIVE PIVOT DETECTED
86
+ </div>
87
+ </div>
88
+
89
+ <div class="header-right">
90
+ <div class="header-stat">
91
+ <span class="header-stat-label">STEP</span>
92
+ <span class="header-stat-value mono" id="header-step">0/30</span>
93
+ </div>
94
+ <div class="header-stat">
95
+ <span class="header-stat-label">EPISODE</span>
96
+ <span class="header-stat-value mono" id="header-episode">—</span>
97
+ </div>
98
+ <div class="header-stat">
99
+ <span class="header-stat-label">ELAPSED</span>
100
+ <span class="header-stat-value mono" id="header-timer">00:00</span>
101
+ </div>
102
+ <div id="difficulty-badge" class="difficulty-badge">—</div>
103
+ </div>
104
+ </header>
105
+
106
+ <!-- ==========================================
107
+ MAIN GRID
108
+ ========================================== -->
109
+ <main class="main-grid">
110
+
111
+ <!-- PANEL 1: Alert Queue -->
112
+ <section class="panel panel-1" id="panel-alerts">
113
+ <div class="panel-header">
114
+ <h2 class="panel-title">🚨 Alert Queue</h2>
115
+ <span class="badge" id="alert-count-badge">0</span>
116
+ </div>
117
+ <div class="panel-body">
118
+ <div id="alert-list" class="alert-list">
119
+ <div class="empty-state">Awaiting alerts…</div>
120
+ </div>
121
+ </div>
122
+ </section>
123
+
124
+ <!-- PANEL 2: Live Threat Graph -->
125
+ <section class="panel panel-2" id="panel-graph">
126
+ <div class="panel-header" style="flex-wrap:wrap;gap:4px;">
127
+ <div style="display:flex;align-items:center;justify-content:space-between;width:100%;">
128
+ <h2 class="panel-title">🕸️ Live Threat Graph</h2>
129
+ <div class="graph-legend">
130
+ <span class="legend-item"><span class="legend-dot host"></span>Host</span>
131
+ <span class="legend-item"><span class="legend-dot process"></span>Process</span>
132
+ <span class="legend-item"><span class="legend-dot ioc"></span>IOC</span>
133
+ <span class="legend-item"><span class="legend-dot alert-node"></span>Alert</span>
134
+ <span class="legend-item"><span class="legend-dot vuln"></span>Vuln</span>
135
+ </div>
136
+ </div>
137
+ <div id="graph-summary" class="graph-summary"></div>
138
+ </div>
139
+ <div class="panel-body graph-body" id="threat-graph-container">
140
+ <svg id="threat-graph-svg"></svg>
141
+ <div id="graph-tooltip" class="graph-tooltip hidden"></div>
142
+ </div>
143
+ </section>
144
+
145
+ <!-- PANEL 3: Agent Action Log -->
146
+ <section class="panel panel-3" id="panel-actions">
147
+ <div class="panel-header">
148
+ <h2 class="panel-title">📋 Agent Actions</h2>
149
+ <span id="total-reward-badge" class="reward-badge">+0.00</span>
150
+ </div>
151
+ <div class="panel-body">
152
+ <div id="action-log" class="action-log">
153
+ <div class="empty-state">Awaiting agent actions…</div>
154
+ </div>
155
+ </div>
156
+ </section>
157
+
158
+ <!-- PANEL 4: Network Topology -->
159
+ <section class="panel panel-4" id="panel-network">
160
+ <div class="panel-header">
161
+ <h2 class="panel-title">🌐 Network Topology</h2>
162
+ <div class="topology-stats">
163
+ <span class="topo-stat compromised" id="topo-compromised">0 compromised</span>
164
+ <span class="topo-stat isolated" id="topo-isolated">0 isolated</span>
165
+ </div>
166
+ </div>
167
+ <div class="panel-body">
168
+ <div id="network-topology" class="network-topology"></div>
169
+ </div>
170
+ </section>
171
+
172
+ <!-- PANEL 5: Performance Metrics -->
173
+ <section class="panel panel-5" id="panel-scores">
174
+ <div class="panel-header">
175
+ <h2 class="panel-title">📡 Performance Metrics</h2>
176
+ <span style="font-size:9px;color:var(--text-muted)">10-dim grading · live estimate</span>
177
+ </div>
178
+ <div class="panel-body scores-body">
179
+ <div class="radar-container">
180
+ <canvas id="radar-chart"></canvas>
181
+ </div>
182
+ <div class="timeline-container">
183
+ <canvas id="reward-timeline"></canvas>
184
+ </div>
185
+ </div>
186
+ </section>
187
+
188
+ <!-- PANEL 6: Mission Status -->
189
+ <section class="panel panel-6" id="panel-mission">
190
+ <div class="panel-header">
191
+ <h2 class="panel-title">📊 Mission Status</h2>
192
+ </div>
193
+ <div class="panel-body">
194
+
195
+ <!-- Containment Progress -->
196
+ <div class="containment-section">
197
+ <h3 class="section-label">Containment Progress</h3>
198
+ <div id="containment-bars" class="containment-bars">
199
+ <!-- Injected by JS -->
200
+ </div>
201
+ </div>
202
+
203
+ <!-- Business Impact -->
204
+ <div class="impact-section">
205
+ <h3 class="section-label">Business Impact</h3>
206
+ <div class="impact-gauge">
207
+ <div class="impact-gauge-track">
208
+ <div class="impact-zone-green"></div>
209
+ <div class="impact-zone-amber"></div>
210
+ <div class="impact-zone-red"></div>
211
+ <div class="impact-marker" id="impact-marker"></div>
212
+ </div>
213
+ <div class="impact-labels">
214
+ <span>Minimal</span>
215
+ <span>Moderate</span>
216
+ <span>Severe</span>
217
+ </div>
218
+ <div class="impact-value-display">
219
+ <span class="impact-label">Score:</span>
220
+ <span class="impact-value mono" id="impact-value">0.00</span>
221
+ </div>
222
+ </div>
223
+ </div>
224
+
225
+ <!-- Active Threats -->
226
+ <div class="threats-section">
227
+ <h3 class="section-label">Active Threats</h3>
228
+ <div id="active-threats-list" class="active-threats-list">
229
+ <span class="empty-state" style="padding:6px 0">No threats detected</span>
230
+ </div>
231
+ </div>
232
+
233
+ <!-- Controls -->
234
+ <div class="control-section">
235
+ <button class="btn btn-primary" id="btn-start">▶ Start Episode</button>
236
+ <button class="btn btn-secondary hidden" id="btn-pause">⏸ Pause</button>
237
+ <button class="btn btn-secondary hidden" id="btn-next">⏭ Step</button>
238
+ <button class="btn btn-secondary hidden" id="btn-reset">↺ Reset</button>
239
+ <div class="task-selector">
240
+ <label for="task-select">Task:</label>
241
+ <select id="task-select" class="task-select">
242
+ <option value="easy">Easy</option>
243
+ <option value="medium">Medium</option>
244
+ <option value="hard" selected>Hard</option>
245
+ </select>
246
+ </div>
247
+ </div>
248
+
249
+ </div>
250
+ </section>
251
+
252
+ </main><!-- /main-grid -->
253
+
254
+ <!-- Scripts (order matters) -->
255
+ <script src="js/animations.js"></script>
256
+ <script src="js/api.js"></script>
257
+ <script src="js/graphs.js"></script>
258
+ <script src="js/app.js"></script>
259
+
260
+ </body>
261
+ </html>
dashboard/js/animations.js ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ CyberSOC Dashboard — Animation Utilities
3
+ ============================================================ */
4
+
5
+ const AnimationUtils = {
6
+ _notifContainer: null,
7
+ _flashEl: null,
8
+
9
+ init() {
10
+ // Notification container
11
+ let nc = document.getElementById('notification-container');
12
+ if (!nc) {
13
+ nc = document.createElement('div');
14
+ nc.id = 'notification-container';
15
+ document.body.appendChild(nc);
16
+ }
17
+ this._notifContainer = nc;
18
+
19
+ // Screen flash element
20
+ let sf = document.getElementById('screen-flash');
21
+ if (!sf) {
22
+ sf = document.createElement('div');
23
+ sf.id = 'screen-flash';
24
+ sf.className = 'screen-flash';
25
+ document.body.appendChild(sf);
26
+ }
27
+ this._flashEl = sf;
28
+ },
29
+
30
+ // Animate a number counting up/down
31
+ countUp(element, from, to, duration = 600, decimals = 2) {
32
+ if (!element) return;
33
+ const start = performance.now();
34
+ const range = to - from;
35
+ const update = (now) => {
36
+ const elapsed = now - start;
37
+ const progress = Math.min(elapsed / duration, 1);
38
+ const ease = 1 - Math.pow(1 - progress, 3); // cubic ease-out
39
+ const current = from + range * ease;
40
+ element.textContent = current.toFixed(decimals);
41
+ if (progress < 1) requestAnimationFrame(update);
42
+ };
43
+ requestAnimationFrame(update);
44
+ },
45
+
46
+ // Count up an integer (no decimals)
47
+ countUpInt(element, from, to, duration = 400) {
48
+ if (!element) return;
49
+ const start = performance.now();
50
+ const range = to - from;
51
+ const update = (now) => {
52
+ const elapsed = now - start;
53
+ const progress = Math.min(elapsed / duration, 1);
54
+ const ease = 1 - Math.pow(1 - progress, 3);
55
+ element.textContent = Math.round(from + range * ease);
56
+ if (progress < 1) requestAnimationFrame(update);
57
+ };
58
+ requestAnimationFrame(update);
59
+ },
60
+
61
+ // Flash the screen border (for Red Team pivots)
62
+ flashScreen(color = 'red', duration = 800) {
63
+ if (!this._flashEl) return;
64
+ this._flashEl.className = `screen-flash ${color}-flash`;
65
+ this._flashEl.style.opacity = '1';
66
+ setTimeout(() => {
67
+ this._flashEl.style.transition = `opacity ${duration}ms ease`;
68
+ this._flashEl.style.opacity = '0';
69
+ setTimeout(() => {
70
+ this._flashEl.style.transition = '';
71
+ this._flashEl.className = 'screen-flash';
72
+ }, duration);
73
+ }, 150);
74
+ },
75
+
76
+ // Show a toast notification
77
+ showNotification(text, type = 'blue', duration = 3000) {
78
+ if (!this._notifContainer) return;
79
+ const toast = document.createElement('div');
80
+ toast.className = `notification-toast ${type}`;
81
+ toast.textContent = text;
82
+ this._notifContainer.appendChild(toast);
83
+ setTimeout(() => {
84
+ toast.style.transition = 'opacity 0.4s ease, transform 0.4s ease';
85
+ toast.style.opacity = '0';
86
+ toast.style.transform = 'translateX(100%)';
87
+ setTimeout(() => toast.remove(), 400);
88
+ }, duration);
89
+ },
90
+
91
+ // Scale bounce for score changes
92
+ scaleBounce(element, intensity = 1.12) {
93
+ if (!element) return;
94
+ element.style.transition = 'transform 0.15s ease';
95
+ element.style.transform = `scale(${intensity})`;
96
+ setTimeout(() => {
97
+ element.style.transform = 'scale(1)';
98
+ }, 150);
99
+ },
100
+
101
+ // Pulse glow on an element
102
+ pulseGlow(element, color = 'blue', times = 3) {
103
+ if (!element) return;
104
+ const glowMap = {
105
+ blue: '0 0 20px rgba(59,130,246,0.8)',
106
+ red: '0 0 20px rgba(239,68,68,0.8)',
107
+ green: '0 0 15px rgba(16,185,129,0.7)',
108
+ amber: '0 0 15px rgba(245,158,11,0.7)',
109
+ purple: '0 0 15px rgba(139,92,246,0.7)',
110
+ };
111
+ const glow = glowMap[color] || glowMap.blue;
112
+ let count = 0;
113
+ const pulse = () => {
114
+ if (count >= times * 2) return;
115
+ const isOn = count % 2 === 0;
116
+ element.style.transition = 'box-shadow 0.3s ease';
117
+ element.style.boxShadow = isOn ? glow : 'none';
118
+ count++;
119
+ setTimeout(pulse, 300);
120
+ };
121
+ pulse();
122
+ },
123
+
124
+ // Checkmark completion animation for progress bars
125
+ checkmarkComplete(labelElement) {
126
+ if (!labelElement) return;
127
+ const check = document.createElement('span');
128
+ check.textContent = ' ✓';
129
+ check.style.color = 'var(--accent-green)';
130
+ check.style.opacity = '0';
131
+ check.style.transition = 'opacity 0.3s ease';
132
+ labelElement.appendChild(check);
133
+ requestAnimationFrame(() => {
134
+ check.style.opacity = '1';
135
+ });
136
+ },
137
+
138
+ // Reveal final score with dramatic animation
139
+ revealFinalScore(finalScore, breakdown, penalties, bonuses) {
140
+ const overlay = document.getElementById('final-score-overlay');
141
+ if (!overlay) return;
142
+ overlay.classList.remove('hidden');
143
+
144
+ // Animate the main score number
145
+ const scoreEl = document.getElementById('final-score-number');
146
+ if (scoreEl) {
147
+ scoreEl.textContent = '0.000';
148
+ setTimeout(() => this.countUp(scoreEl, 0, finalScore, 1500, 3), 300);
149
+ }
150
+
151
+ // Build dimension bars
152
+ const barsContainer = document.getElementById('final-grade-bars');
153
+ if (barsContainer && breakdown) {
154
+ barsContainer.innerHTML = '';
155
+ const dimLabels = {
156
+ threat_containment: 'Threat Containment',
157
+ ioc_blocking: 'IOC Blocking',
158
+ forensic_investigation: 'Forensic Investigation',
159
+ siem_correlation: 'SIEM Correlation',
160
+ threat_intel_usage: 'Threat Intel',
161
+ vuln_root_cause: 'Vuln Root Cause',
162
+ business_impact: 'Business Impact',
163
+ step_efficiency: 'Step Efficiency',
164
+ plan_coverage: 'Plan Coverage',
165
+ plan_evidence_quality: 'Plan Evidence',
166
+ };
167
+ let delay = 500;
168
+ Object.entries(breakdown).forEach(([key, value]) => {
169
+ const item = document.createElement('div');
170
+ item.className = 'final-grade-bar-item';
171
+ item.innerHTML = `
172
+ <span class="final-grade-bar-label">${dimLabels[key] || key}</span>
173
+ <div class="final-grade-bar-track">
174
+ <div class="final-grade-bar-fill" data-value="${value}" style="width:0%"></div>
175
+ </div>
176
+ <span class="final-grade-bar-value">0.00</span>
177
+ `;
178
+ barsContainer.appendChild(item);
179
+ const fill = item.querySelector('.final-grade-bar-fill');
180
+ const valEl = item.querySelector('.final-grade-bar-value');
181
+ setTimeout(() => {
182
+ fill.style.transition = 'width 0.8s ease';
183
+ fill.style.width = `${value * 100}%`;
184
+ this.countUp(valEl, 0, value, 800, 2);
185
+ if (value >= 0.8) fill.style.background = 'var(--accent-green)';
186
+ else if (value >= 0.5) fill.style.background = 'var(--accent-cyan)';
187
+ else fill.style.background = 'var(--accent-amber)';
188
+ }, delay);
189
+ delay += 120;
190
+ });
191
+ }
192
+
193
+ // Build penalties/bonuses
194
+ const pbContainer = document.getElementById('final-penalties-bonuses');
195
+ if (pbContainer) {
196
+ pbContainer.innerHTML = '';
197
+ (penalties || []).forEach(p => {
198
+ const el = document.createElement('div');
199
+ el.className = 'penalty-item';
200
+ el.textContent = `❌ ${p.type}: ${p.delta.toFixed(2)} (${p.detail})`;
201
+ pbContainer.appendChild(el);
202
+ });
203
+ (bonuses || []).forEach(b => {
204
+ const el = document.createElement('div');
205
+ el.className = 'bonus-item';
206
+ el.textContent = `✅ ${b.type}: +${b.delta.toFixed(2)} (${b.detail})`;
207
+ pbContainer.appendChild(el);
208
+ });
209
+ }
210
+
211
+ // Color the score based on value
212
+ if (scoreEl) {
213
+ setTimeout(() => {
214
+ if (finalScore >= 0.7) scoreEl.style.color = 'var(--accent-green)';
215
+ else if (finalScore >= 0.4) scoreEl.style.color = 'var(--accent-amber)';
216
+ else scoreEl.style.color = 'var(--accent-red)';
217
+ this.scaleBounce(scoreEl, 1.05);
218
+ }, 1600);
219
+ }
220
+ },
221
+
222
+ // Red team pivot wow moment
223
+ triggerRedTeamPivot() {
224
+ this.flashScreen('red', 600);
225
+ this.showNotification('⚡ RED TEAM PIVOT DETECTED — Adaptive adversary spreading!', 'red', 4000);
226
+ const rtAlert = document.getElementById('red-team-alert');
227
+ if (rtAlert) {
228
+ rtAlert.classList.remove('hidden');
229
+ setTimeout(() => rtAlert.classList.add('hidden'), 8000);
230
+ }
231
+ },
232
+ };
dashboard/js/api.js ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ CyberSOC Dashboard — API Client
3
+ Talks to the FastAPI CyberSOCEnv server at localhost:8000
4
+ ============================================================ */
5
+
6
+ const API = {
7
+ // When served from the dashboard_server.py at /dashboard/ use same-origin relative URLs.
8
+ // When opened as file:// or from any other origin, fall back to absolute localhost:8000.
9
+ baseUrl: (() => {
10
+ if (typeof window === 'undefined') return 'http://localhost:8000';
11
+ const { protocol, hostname, port } = window.location;
12
+ if (protocol === 'file:') return 'http://localhost:8000';
13
+ if (hostname === 'localhost' && (port === '8000' || !port)) return '';
14
+ return 'http://localhost:8000';
15
+ })(),
16
+ sessionId: null,
17
+
18
+ // Parse the server response — handles both wrapped {observation: {...}}
19
+ // and flat observation formats
20
+ _parseResponse(data) {
21
+ if (!data) return null;
22
+ // Prefer wrapped format (per client.py's _parse_result)
23
+ const obs = data.observation || data;
24
+ return {
25
+ // Core observation fields
26
+ episode_id: obs.episode_id || '',
27
+ alert_queue: obs.alert_queue || [],
28
+ network_topology: obs.network_topology || { total_hosts: 0, subnets: {}, compromised_count: 0, isolated_count: 0, online_count: 0 },
29
+ host_forensics: obs.host_forensics || null,
30
+ timeline: obs.timeline || [],
31
+ business_impact_score: obs.business_impact_score ?? 0,
32
+ step_count: obs.step_count ?? 0,
33
+ active_threats: obs.active_threats || [],
34
+ max_steps: obs.max_steps || 30,
35
+ task_id: obs.task_id || 'hard',
36
+ total_reward: obs.total_reward ?? 0,
37
+ final_score: obs.final_score ?? null,
38
+ grade_breakdown: obs.grade_breakdown || null,
39
+ correlation_results: obs.correlation_results || null,
40
+ ioc_enrichment: obs.ioc_enrichment || null,
41
+ vulnerability_results: obs.vulnerability_results || null,
42
+ playbook_result: obs.playbook_result || null,
43
+ threat_graph_summary: obs.threat_graph_summary || null,
44
+ available_playbooks: obs.available_playbooks || [],
45
+ // Done/reward can be at top level or in obs
46
+ done: data.done ?? obs.done ?? false,
47
+ reward: data.reward ?? obs.reward ?? 0,
48
+ };
49
+ },
50
+
51
+ async reset(taskId = 'hard') {
52
+ const url = `${this.baseUrl}/reset`;
53
+ const response = await fetch(url, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ task_id: taskId }),
57
+ });
58
+ if (!response.ok) {
59
+ const errText = await response.text();
60
+ throw new Error(`Reset failed: ${response.status} — ${errText.substring(0, 200)}`);
61
+ }
62
+ const data = await response.json();
63
+ // Store session ID if the server provides one
64
+ const sessionHeader = response.headers.get('X-Session-Id') || response.headers.get('x-session-id');
65
+ if (sessionHeader) this.sessionId = sessionHeader;
66
+ if (data.session_id) this.sessionId = data.session_id;
67
+ return this._parseResponse(data);
68
+ },
69
+
70
+ async step(action) {
71
+ const url = `${this.baseUrl}/step`;
72
+ const headers = { 'Content-Type': 'application/json' };
73
+ if (this.sessionId) headers['X-Session-Id'] = this.sessionId;
74
+
75
+ const response = await fetch(url, {
76
+ method: 'POST',
77
+ headers,
78
+ body: JSON.stringify(action),
79
+ });
80
+ if (!response.ok) {
81
+ // Try alternate format: {action: {...}}
82
+ const altResponse = await fetch(url, {
83
+ method: 'POST',
84
+ headers,
85
+ body: JSON.stringify({ action }),
86
+ });
87
+ if (!altResponse.ok) {
88
+ const errText = await altResponse.text();
89
+ throw new Error(`Step failed: ${altResponse.status} — ${errText.substring(0, 200)}`);
90
+ }
91
+ const data = await altResponse.json();
92
+ return this._parseResponse(data);
93
+ }
94
+ const data = await response.json();
95
+ return this._parseResponse(data);
96
+ },
97
+
98
+ async getState() {
99
+ const url = `${this.baseUrl}/state`;
100
+ const headers = {};
101
+ if (this.sessionId) headers['X-Session-Id'] = this.sessionId;
102
+ const response = await fetch(url, { headers });
103
+ if (!response.ok) return null;
104
+ return response.json();
105
+ },
106
+
107
+ async checkConnection() {
108
+ try {
109
+ // Try /state first, fall back to root
110
+ const r = await fetch(`${this.baseUrl}/state`, {
111
+ signal: AbortSignal.timeout(3000),
112
+ });
113
+ return r.ok || r.status === 404; // 404 = server alive, no state yet
114
+ } catch {
115
+ try {
116
+ const r2 = await fetch(this.baseUrl, { signal: AbortSignal.timeout(3000) });
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+ },
123
+ };
dashboard/js/app.js ADDED
@@ -0,0 +1,1083 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ CyberSOC Dashboard — Main Application Controller
3
+ ============================================================ */
4
+
5
+ class CyberSOCDashboard {
6
+ constructor() {
7
+ this.api = API;
8
+ this.threatGraph = new ClientThreatGraph();
9
+ this.graphViz = new ThreatGraphViz('threat-graph-container');
10
+ this.radarChart = new RadarChart('radar-chart');
11
+ this.rewardTimeline = new RewardTimeline('reward-timeline');
12
+
13
+ this.currentObs = null;
14
+ this.demoActions = [];
15
+ this.currentStepIndex = 0;
16
+ this.autoPlayTimer = null;
17
+ this.episodeStartTime = null;
18
+ this.timerInterval = null;
19
+ this.isPaused = false;
20
+ this.episodeRunning = false;
21
+ this.prevAlertIds = new Set();
22
+ this.currentAction = null;
23
+
24
+ // Live score estimates (updated heuristically during episode)
25
+ this.liveScores = {
26
+ threat_containment: 0, ioc_blocking: 0, forensic_investigation: 0,
27
+ siem_correlation: 0, threat_intel_usage: 0, vuln_root_cause: 0,
28
+ business_impact: 1.0, step_efficiency: 0.5, plan_coverage: 0, plan_evidence_quality: 0,
29
+ };
30
+ this.containmentState = {
31
+ processed_killed: 0, processes_total: 0,
32
+ iocs_blocked: 0, iocs_total: 0,
33
+ hosts_investigated: 0, hosts_total: 0,
34
+ playbooks_triggered: 0, playbooks_total: 2,
35
+ };
36
+ }
37
+
38
+ async init() {
39
+ AnimationUtils.init();
40
+ this.graphViz.init();
41
+ this.radarChart.init();
42
+ this.rewardTimeline.init();
43
+ this._bindButtons();
44
+ this._showConnectionOverlay('Connecting to CyberSOC Server...');
45
+ await this._waitForServer();
46
+ }
47
+
48
+ async _waitForServer() {
49
+ let attempts = 0;
50
+ const maxAttempts = 30;
51
+ const check = async () => {
52
+ attempts++;
53
+ const ok = await this.api.checkConnection();
54
+ if (ok) {
55
+ this._hideConnectionOverlay();
56
+ AnimationUtils.showNotification('✅ Connected to CyberSOC Server', 'green', 2000);
57
+ document.getElementById('btn-start').disabled = false;
58
+ } else if (attempts < maxAttempts) {
59
+ this._showConnectionOverlay(`Connecting to CyberSOC Server... (${attempts}/${maxAttempts})`);
60
+ setTimeout(check, 2000);
61
+ } else {
62
+ this._showConnectionOverlay('⚠️ Server not available. Start with: uvicorn server.app:app --port 8000');
63
+ document.getElementById('btn-start').disabled = false; // Allow manual start attempt
64
+ }
65
+ };
66
+ await check();
67
+ }
68
+
69
+ _bindButtons() {
70
+ document.getElementById('btn-start').addEventListener('click', () => this._onStartClick());
71
+ document.getElementById('btn-pause').addEventListener('click', () => this._onPauseClick());
72
+ document.getElementById('btn-next').addEventListener('click', () => this._onNextClick());
73
+ document.getElementById('btn-reset').addEventListener('click', () => this._onResetClick());
74
+ }
75
+
76
+ async _onStartClick() {
77
+ const taskId = document.getElementById('task-select').value;
78
+ await this.startEpisode(taskId);
79
+ }
80
+
81
+ _onPauseClick() {
82
+ this.isPaused = !this.isPaused;
83
+ const btn = document.getElementById('btn-pause');
84
+ if (this.isPaused) {
85
+ btn.textContent = '▶ Resume';
86
+ if (this.autoPlayTimer) { clearTimeout(this.autoPlayTimer); this.autoPlayTimer = null; }
87
+ } else {
88
+ btn.textContent = '⏸ Pause';
89
+ this._scheduleNextStep();
90
+ }
91
+ }
92
+
93
+ _onNextClick() {
94
+ if (this.autoPlayTimer) { clearTimeout(this.autoPlayTimer); this.autoPlayTimer = null; }
95
+ this.isPaused = true;
96
+ document.getElementById('btn-pause').textContent = '▶ Resume';
97
+ this._executeNextStep();
98
+ }
99
+
100
+ async _onResetClick() {
101
+ this.isPaused = false;
102
+ this.episodeRunning = false;
103
+ if (this.autoPlayTimer) { clearTimeout(this.autoPlayTimer); this.autoPlayTimer = null; }
104
+ if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval = null; }
105
+
106
+ // Reset UI
107
+ document.getElementById('btn-start').classList.remove('hidden');
108
+ document.getElementById('btn-pause').classList.add('hidden');
109
+ document.getElementById('btn-next').classList.add('hidden');
110
+ document.getElementById('btn-reset').classList.add('hidden');
111
+ document.getElementById('task-select').disabled = false;
112
+
113
+ this._resetUI();
114
+ }
115
+
116
+ // ============================================================
117
+ // Episode Management
118
+ // ============================================================
119
+
120
+ async startEpisode(taskId) {
121
+ document.getElementById('btn-start').classList.add('hidden');
122
+ document.getElementById('task-select').disabled = true;
123
+ document.getElementById('btn-pause').classList.remove('hidden');
124
+ document.getElementById('btn-next').classList.remove('hidden');
125
+ document.getElementById('btn-reset').classList.remove('hidden');
126
+
127
+ AnimationUtils.showNotification(`🚀 Starting ${taskId.toUpperCase()} episode...`, 'blue', 2000);
128
+
129
+ try {
130
+ const obs = await this.api.reset(taskId);
131
+ this.currentObs = obs;
132
+ this.episodeRunning = true;
133
+ this.currentStepIndex = 0;
134
+ this.episodeStartTime = Date.now();
135
+ this.prevAlertIds = new Set();
136
+
137
+ // Start timer
138
+ if (this.timerInterval) clearInterval(this.timerInterval);
139
+ this.timerInterval = setInterval(() => this._updateTimer(), 1000);
140
+
141
+ // Build demo action sequence from initial observation
142
+ this.demoActions = this.buildDemoActions(obs);
143
+
144
+ // Initial UI update (staggered alerts)
145
+ this._resetUIForEpisode();
146
+ this._updateHeader(obs);
147
+ this._updatePhase(0, obs.max_steps);
148
+ await this._populateInitialAlerts(obs.alert_queue);
149
+ this._updateNetworkTopology(obs.network_topology);
150
+ this._updateBusinessImpact(obs.business_impact_score);
151
+ this._updateActiveThreats(obs.active_threats);
152
+ this._updateContainmentProgress(obs, taskId);
153
+
154
+ // Populate threat graph from initial observation
155
+ this.threatGraph = new ClientThreatGraph();
156
+ this.threatGraph.updateFromObservation(obs, null);
157
+ obs.alert_queue.forEach(a => this.prevAlertIds.add(a.alert_id));
158
+ this.graphViz.update(this.threatGraph.getGraphData());
159
+
160
+ // Start auto-play
161
+ this._scheduleNextStep(2500);
162
+
163
+ } catch (err) {
164
+ console.error('Episode start failed:', err);
165
+ AnimationUtils.showNotification(`❌ Failed to start episode: ${err.message}`, 'red', 5000);
166
+ document.getElementById('btn-start').classList.remove('hidden');
167
+ document.getElementById('task-select').disabled = false;
168
+ document.getElementById('btn-pause').classList.add('hidden');
169
+ document.getElementById('btn-next').classList.add('hidden');
170
+ document.getElementById('btn-reset').classList.add('hidden');
171
+ }
172
+ }
173
+
174
+ buildDemoActions(obs) {
175
+ // Parse initial observation to get real data
176
+ const alerts = obs.alert_queue || [];
177
+ const alertIds = alerts.map(a => a.alert_id);
178
+ const taskId = obs.task_id;
179
+
180
+ // Collect all IOCs from alerts
181
+ const allIocs = [];
182
+ const ipIocs = [];
183
+ const domainIocs = [];
184
+ const hashIocs = [];
185
+ const hostsSeen = new Set();
186
+ const subnetsSeen = new Set();
187
+
188
+ alerts.forEach(a => {
189
+ if (!hostsSeen.has(a.source_host)) {
190
+ hostsSeen.add(a.source_host);
191
+ }
192
+ if (a.subnet) subnetsSeen.add(a.subnet);
193
+ (a.ioc_indicators || []).forEach(ioc => {
194
+ if (!allIocs.includes(ioc)) {
195
+ allIocs.push(ioc);
196
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ioc)) ipIocs.push(ioc);
197
+ else if (/[a-f0-9]{32,64}/i.test(ioc)) hashIocs.push(ioc);
198
+ else domainIocs.push(ioc);
199
+ }
200
+ });
201
+ });
202
+
203
+ const hosts = [...hostsSeen];
204
+ const firstHost = hosts[0] || 'WS-042';
205
+ const secondHost = hosts[1] || firstHost;
206
+ const thirdHost = hosts[2] || firstHost;
207
+ const firstIp = ipIocs[0] || allIocs[0] || '192.168.1.1';
208
+ const firstDomain = domainIocs[0] || allIocs[1] || firstIp;
209
+ const firstHash = hashIocs[0] || allIocs[2] || firstIp;
210
+
211
+ // Task-specific action sequences using known data
212
+ if (taskId === 'hard') {
213
+ return this._hardDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs);
214
+ } else if (taskId === 'medium') {
215
+ return this._mediumDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs);
216
+ } else {
217
+ return this._easyDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs);
218
+ }
219
+ }
220
+
221
+ _hardDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs) {
222
+ return [
223
+ // TRIAGE: correlate alerts about the C2 activity
224
+ { type: 'correlate_alerts', alert_ids: [alertIds[0] || 'ALERT-H001', alertIds[1] || 'ALERT-H002'] },
225
+
226
+ // INVESTIGATION: executive host (entry point)
227
+ { type: 'query_host', hostname: 'EXEC-003' },
228
+ { type: 'run_forensics', hostname: 'EXEC-003' },
229
+
230
+ // INVESTIGATION: corporate host with C2 beacon
231
+ { type: 'query_host', hostname: 'WS-088' },
232
+ { type: 'run_forensics', hostname: 'WS-088' },
233
+
234
+ // THREAT INTEL: enrich the C2 IP
235
+ { type: 'enrich_ioc', ioc_value: ipIocs[0] || '198.51.100.77', ioc_type: 'ip' },
236
+
237
+ // INVESTIGATION: datacenter server (privilege escalation target)
238
+ { type: 'scan_host_vulnerabilities', hostname: 'SRV-002' },
239
+ { type: 'run_forensics', hostname: 'SRV-002' },
240
+
241
+ // REMEDIATION: kill C2-related processes
242
+ { type: 'kill_process', hostname: 'EXEC-003', process_name: 'outlook_macro.exe' },
243
+ { type: 'kill_process', hostname: 'EXEC-003', process_name: 'svchost_c2.exe' },
244
+ { type: 'kill_process', hostname: 'WS-088', process_name: 'svchost_c2.exe' },
245
+
246
+ // REMEDIATION: block C2 infrastructure
247
+ { type: 'block_ioc', ioc_value: ipIocs[0] || '198.51.100.77', ioc_type: 'ip' },
248
+ { type: 'block_ioc', ioc_value: domainIocs[0] || 'cdn-update.malware-c2.net', ioc_type: 'domain' },
249
+
250
+ // REMEDIATION: isolate executive subnet (triggers pivot if adaptive=true)
251
+ { type: 'isolate_segment', subnet: 'executive', reason: 'APT lateral movement detected — executive subnet compromised' },
252
+
253
+ // REMEDIATION: SOAR playbook for C2 disruption
254
+ { type: 'trigger_playbook', playbook_name: 'c2_disruption', target: 'EXEC-003' },
255
+
256
+ // REMEDIATION: kill exfil and ransomware processes
257
+ { type: 'kill_process', hostname: 'SRV-002', process_name: 'exploit_kernel.exe' },
258
+ { type: 'kill_process', hostname: 'SRV-002', process_name: 'data_pump.exe' },
259
+ { type: 'kill_process', hostname: 'FIN-008', process_name: 'data_pump.exe' },
260
+ { type: 'kill_process', hostname: 'SRV-010', process_name: 'blackcat_ransom.exe' },
261
+
262
+ // REPORT: submit comprehensive containment plan
263
+ {
264
+ type: 'submit_containment_plan',
265
+ plan: [
266
+ {
267
+ threat_id: 'T-HARD-001',
268
+ actions_taken: ['query_host', 'run_forensics', 'kill_process', 'correlate_alerts'],
269
+ root_cause: 'Spear-phishing email with malicious macro attachment targeting executive VP. Macro executed outlook_macro.exe establishing initial foothold.',
270
+ confidence: 0.95,
271
+ },
272
+ {
273
+ threat_id: 'T-HARD-002',
274
+ actions_taken: ['run_forensics', 'kill_process', 'block_ioc', 'trigger_playbook', 'isolate_segment'],
275
+ 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.',
276
+ confidence: 0.92,
277
+ },
278
+ {
279
+ threat_id: 'T-HARD-003',
280
+ actions_taken: ['scan_host_vulnerabilities', 'run_forensics', 'kill_process'],
281
+ root_cause: 'Kernel privilege escalation on SRV-002 via exploit_kernel.exe. CVE leveraged for SYSTEM-level access to database server.',
282
+ confidence: 0.88,
283
+ },
284
+ {
285
+ threat_id: 'T-HARD-004',
286
+ actions_taken: ['run_forensics', 'kill_process', 'block_ioc'],
287
+ 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.',
288
+ confidence: 0.90,
289
+ },
290
+ {
291
+ threat_id: 'T-HARD-005',
292
+ actions_taken: ['kill_process'],
293
+ root_cause: 'BlackCat ransomware deployment on datacenter servers SRV-010 and SRV-015. Encryption halted before full deployment. Recovery recommended.',
294
+ confidence: 0.85,
295
+ },
296
+ ],
297
+ 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.',
298
+ },
299
+ ];
300
+ }
301
+
302
+ _mediumDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs) {
303
+ return [
304
+ { type: 'correlate_alerts', alert_ids: alertIds.slice(0, 2) },
305
+ { type: 'query_host', hostname: hosts[0] || 'WS-017' },
306
+ { type: 'run_forensics', hostname: hosts[0] || 'WS-017' },
307
+ { type: 'enrich_ioc', ioc_value: domainIocs[0] || ipIocs[0] || '203.0.113.50', ioc_type: domainIocs.length > 0 ? 'domain' : 'ip' },
308
+ { type: 'query_host', hostname: hosts[1] || 'DEV-033' },
309
+ { type: 'run_forensics', hostname: hosts[1] || 'DEV-033' },
310
+ { type: 'kill_process', hostname: hosts[0] || 'WS-017', process_name: 'powershell.exe' },
311
+ { type: 'kill_process', hostname: hosts[0] || 'WS-017', process_name: 'mimikatz.exe' },
312
+ { type: 'block_ioc', ioc_value: domainIocs[0] || ipIocs[0] || '203.0.113.50', ioc_type: domainIocs.length > 0 ? 'domain' : 'ip' },
313
+ { type: 'kill_process', hostname: hosts[1] || 'DEV-033', process_name: 'svchost_backdoor.exe' },
314
+ { type: 'isolate_segment', subnet: 'corporate', reason: 'Credential theft and lateral movement detected' },
315
+ { type: 'trigger_playbook', playbook_name: 'phishing_response', target: hosts[0] || 'WS-017' },
316
+ {
317
+ type: 'submit_containment_plan',
318
+ plan: [
319
+ { 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 },
320
+ { 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 },
321
+ { 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 },
322
+ ],
323
+ executive_summary: 'Multi-stage phishing → credential theft → lateral movement campaign contained. Three compromised hosts remediated. Corporate subnet isolated pending investigation.',
324
+ },
325
+ ];
326
+ }
327
+
328
+ _easyDemoActions(alertIds, hosts, ipIocs, domainIocs, hashIocs) {
329
+ const host = hosts[0] || 'WS-042';
330
+ const hash = hashIocs[0] || ipIocs[0] || 'e99a18c428cb38d5f260853678922e03';
331
+ return [
332
+ { type: 'correlate_alerts', alert_ids: alertIds.slice(0, 2) },
333
+ { type: 'query_host', hostname: host },
334
+ { type: 'run_forensics', hostname: host },
335
+ { type: 'enrich_ioc', ioc_value: hash, ioc_type: hashIocs.length > 0 ? 'hash' : 'ip' },
336
+ { type: 'kill_process', hostname: host, process_name: 'cryptolocker.exe' },
337
+ { type: 'block_ioc', ioc_value: hash, ioc_type: hashIocs.length > 0 ? 'hash' : 'ip' },
338
+ { type: 'scan_host_vulnerabilities', hostname: host },
339
+ { type: 'trigger_playbook', playbook_name: 'ransomware_containment', target: host },
340
+ {
341
+ type: 'submit_containment_plan',
342
+ plan: [{
343
+ threat_id: 'T-EASY-001',
344
+ actions_taken: ['query_host', 'run_forensics', 'kill_process', 'block_ioc'],
345
+ root_cause: 'Ransomware (cryptolocker.exe) executing on WS-042, encrypting user documents. Delivered via phishing email attachment.',
346
+ confidence: 0.95,
347
+ }],
348
+ executive_summary: 'Single ransomware endpoint contained. Process killed, IOC hash blocked. Recommend disk restore from backup for WS-042.',
349
+ },
350
+ ];
351
+ }
352
+
353
+ // ============================================================
354
+ // Step Execution
355
+ // ============================================================
356
+
357
+ _scheduleNextStep(delay = 2000) {
358
+ if (this.isPaused || !this.episodeRunning) return;
359
+ if (this.autoPlayTimer) clearTimeout(this.autoPlayTimer);
360
+ this.autoPlayTimer = setTimeout(() => this._executeNextStep(), delay);
361
+ }
362
+
363
+ async _executeNextStep() {
364
+ if (!this.episodeRunning || this.currentObs?.done) {
365
+ this.episodeRunning = false;
366
+ return;
367
+ }
368
+ if (this.currentStepIndex >= this.demoActions.length) {
369
+ AnimationUtils.showNotification('ℹ️ Demo sequence complete. Episode still running.', 'blue', 3000);
370
+ return;
371
+ }
372
+
373
+ const action = this.demoActions[this.currentStepIndex];
374
+ this.currentAction = action;
375
+ this.currentStepIndex++;
376
+
377
+ try {
378
+ const obs = await this.api.step(action);
379
+ this.currentObs = obs;
380
+ await this.updateUI(obs, action);
381
+
382
+ if (obs.done) {
383
+ this.episodeRunning = false;
384
+ if (this.timerInterval) clearInterval(this.timerInterval);
385
+ this._handleEpisodeComplete(obs);
386
+ } else if (!this.isPaused) {
387
+ // Adjust delay based on action type for dramatic effect
388
+ let delay = 2000;
389
+ if (action.type === 'isolate_segment') delay = 3000; // Longer for pivot drama
390
+ if (action.type === 'submit_containment_plan') delay = 500;
391
+ this._scheduleNextStep(delay);
392
+ }
393
+ } catch (err) {
394
+ console.error('Step failed:', err);
395
+ AnimationUtils.showNotification(`❌ Step failed: ${err.message.substring(0, 80)}`, 'red', 4000);
396
+ // Try to continue on error
397
+ if (!this.isPaused) this._scheduleNextStep(3000);
398
+ }
399
+ }
400
+
401
+ // ============================================================
402
+ // UI Updates
403
+ // ============================================================
404
+
405
+ async updateUI(obs, action) {
406
+ this._updateHeader(obs);
407
+ this._updatePhase(obs.step_count, obs.max_steps);
408
+ this._updateActionLog(obs.timeline, action, obs);
409
+ this._updateNetworkTopology(obs.network_topology);
410
+ this._updateBusinessImpact(obs.business_impact_score);
411
+ this._updateActiveThreats(obs.active_threats);
412
+ this._updateContainmentProgress(obs, obs.task_id);
413
+ this._updateRewardBadge(obs.total_reward);
414
+
415
+ // Check for new pivot alerts
416
+ const newPivots = [];
417
+ (obs.alert_queue || []).forEach(alert => {
418
+ if (alert.alert_id.startsWith('PIVOT-') && !this.prevAlertIds.has(alert.alert_id)) {
419
+ newPivots.push(alert);
420
+ }
421
+ });
422
+
423
+ // Update alert queue (add new alerts with animation)
424
+ await this._updateAlertQueue(obs.alert_queue, newPivots.length > 0);
425
+
426
+ // Update threat graph
427
+ this.threatGraph.updateFromObservation(obs, action);
428
+ this.graphViz.update(this.threatGraph.getGraphData());
429
+
430
+ // Animate forensics result on graph
431
+ if (action?.type === 'run_forensics' && obs.host_forensics) {
432
+ this.graphViz.flashNode(obs.host_forensics.hostname, obs.host_forensics.is_compromised ? 'red' : 'green');
433
+ }
434
+ if (action?.type === 'block_ioc') {
435
+ this.graphViz.flashNode(action.ioc_value, 'green');
436
+ }
437
+ if (action?.type === 'kill_process') {
438
+ const procId = `${action.hostname}:${action.process_name}`;
439
+ this.graphViz.flashNode(procId, 'green');
440
+ AnimationUtils.showNotification(`💀 Killed: ${action.process_name} on ${action.hostname}`, 'amber', 2000);
441
+ }
442
+
443
+ // Red Team pivot moment
444
+ if (newPivots.length > 0) {
445
+ this._handleRedTeamPivot(newPivots[0]);
446
+ newPivots.forEach(p => {
447
+ this.prevAlertIds.add(p.alert_id);
448
+ // Animate pivot edge in graph
449
+ const pivotLinks = this.threatGraph.links.filter(l => l.edgeType === 'pivoted_from');
450
+ if (pivotLinks.length > 0) {
451
+ const pl = pivotLinks[pivotLinks.length - 1];
452
+ const srcId = pl.source.id || pl.source;
453
+ const tgtId = pl.target.id || pl.target;
454
+ this.graphViz.animatePivot(srcId, tgtId);
455
+ }
456
+ });
457
+ }
458
+
459
+ // Update reward timeline chart
460
+ if (obs.step_count > 0) {
461
+ this.rewardTimeline.addPoint(obs.step_count, obs.total_reward, action?.type);
462
+ }
463
+
464
+ // Update threat graph summary subtitle
465
+ if (obs.threat_graph_summary) this._updateGraphSummary(obs.threat_graph_summary);
466
+
467
+ // Update live score estimates
468
+ this._updateLiveScores(obs, action);
469
+
470
+ // Sync alert IDs
471
+ (obs.alert_queue || []).forEach(a => this.prevAlertIds.add(a.alert_id));
472
+ }
473
+
474
+ _updateLiveScores(obs, action) {
475
+ if (!action) return;
476
+ const scores = { ...this.liveScores };
477
+
478
+ if (action.type === 'correlate_alerts') scores.siem_correlation = 1.0;
479
+ if (action.type === 'run_forensics') {
480
+ scores.forensic_investigation = Math.min(1, scores.forensic_investigation + 0.2);
481
+ }
482
+ if (action.type === 'enrich_ioc') {
483
+ scores.threat_intel_usage = Math.min(1, scores.threat_intel_usage + 0.25);
484
+ }
485
+ if (action.type === 'scan_host_vulnerabilities' && obs.vulnerability_results?.length > 0) {
486
+ scores.vuln_root_cause = 1.0;
487
+ }
488
+ if (action.type === 'block_ioc') {
489
+ scores.ioc_blocking = Math.min(1, scores.ioc_blocking + 0.2);
490
+ }
491
+ if (action.type === 'kill_process') {
492
+ scores.threat_containment = Math.min(1, scores.threat_containment + 0.15);
493
+ }
494
+ if (action.type === 'submit_containment_plan') {
495
+ scores.plan_coverage = 0.9;
496
+ scores.plan_evidence_quality = 0.85;
497
+ }
498
+
499
+ // Business impact inversely related to actual business_impact_score
500
+ scores.business_impact = Math.max(0, 1.0 - obs.business_impact_score);
501
+
502
+ // Step efficiency based on step count vs max
503
+ const ratio = obs.step_count / (obs.max_steps || 30);
504
+ scores.step_efficiency = Math.max(0.3, 1.0 - Math.max(0, ratio - 0.5) * 1.5);
505
+
506
+ this.liveScores = scores;
507
+ this.radarChart.update(scores);
508
+ }
509
+
510
+ _updateHeader(obs) {
511
+ const stepEl = document.getElementById('header-step');
512
+ const episodeEl = document.getElementById('header-episode');
513
+ const diffBadge = document.getElementById('difficulty-badge');
514
+
515
+ if (stepEl) {
516
+ const old = parseInt(stepEl.textContent.split('/')[0]) || 0;
517
+ AnimationUtils.countUpInt(stepEl, old, obs.step_count, 300);
518
+ // Reformat with max_steps after animation
519
+ setTimeout(() => {
520
+ if (stepEl) stepEl.textContent = `${obs.step_count}/${obs.max_steps}`;
521
+ }, 350);
522
+ }
523
+
524
+ if (episodeEl && obs.episode_id) {
525
+ episodeEl.textContent = obs.episode_id.substring(0, 8) + '…';
526
+ }
527
+
528
+ if (diffBadge && obs.task_id) {
529
+ diffBadge.textContent = obs.task_id.toUpperCase();
530
+ diffBadge.className = `difficulty-badge ${obs.task_id}`;
531
+ }
532
+ }
533
+
534
+ _updateTimer() {
535
+ if (!this.episodeStartTime) return;
536
+ const elapsed = Math.floor((Date.now() - this.episodeStartTime) / 1000);
537
+ const mm = String(Math.floor(elapsed / 60)).padStart(2, '0');
538
+ const ss = String(elapsed % 60).padStart(2, '0');
539
+ const el = document.getElementById('header-timer');
540
+ if (el) el.textContent = `${mm}:${ss}`;
541
+ }
542
+
543
+ _updatePhase(step, maxSteps) {
544
+ // Phase progression based on step count and action types
545
+ const dots = document.querySelectorAll('.phase-dot');
546
+ const connectors = document.querySelectorAll('.phase-connector');
547
+
548
+ // Determine current phase
549
+ let phaseIdx = 0;
550
+ if (this.currentAction) {
551
+ const t = this.currentAction.type;
552
+ if (['correlate_alerts'].includes(t)) phaseIdx = 0; // TRIAGE
553
+ else if (['query_host', 'run_forensics', 'enrich_ioc', 'scan_host_vulnerabilities'].includes(t)) phaseIdx = 1; // INVESTIGATION
554
+ else if (['kill_process', 'block_ioc', 'isolate_segment', 'trigger_playbook'].includes(t)) phaseIdx = 2; // REMEDIATION
555
+ else if (['submit_containment_plan'].includes(t)) phaseIdx = 3; // REPORT
556
+ }
557
+
558
+ dots.forEach((dot, i) => {
559
+ dot.classList.remove('active', 'completed');
560
+ if (i < phaseIdx) dot.classList.add('completed');
561
+ else if (i === phaseIdx) dot.classList.add('active');
562
+ });
563
+
564
+ connectors.forEach((conn, i) => {
565
+ conn.classList.toggle('completed', i < phaseIdx);
566
+ });
567
+ }
568
+
569
+ async _populateInitialAlerts(alerts) {
570
+ const list = document.getElementById('alert-list');
571
+ if (!list) return;
572
+ list.innerHTML = '';
573
+
574
+ // Stagger alert cards sliding in
575
+ for (let i = 0; i < alerts.length; i++) {
576
+ await new Promise(r => setTimeout(r, 180 * i));
577
+ this._addAlertCard(alerts[i], list, false);
578
+ }
579
+
580
+ this._updateAlertBadge(alerts.length);
581
+ }
582
+
583
+ async _updateAlertQueue(alerts, hasPivot = false) {
584
+ const list = document.getElementById('alert-list');
585
+ if (!list) return;
586
+
587
+ // Find new alerts
588
+ const existingIds = new Set([...list.querySelectorAll('.alert-card')].map(c => c.dataset.alertId));
589
+ const newAlerts = alerts.filter(a => !existingIds.has(a.alert_id));
590
+
591
+ newAlerts.forEach(alert => {
592
+ this._addAlertCard(alert, list, alert.alert_id.startsWith('PIVOT-'));
593
+ });
594
+
595
+ // Update resolved state
596
+ const resolvedCount = alerts.filter(a => a.is_acknowledged).length;
597
+ this._updateAlertBadge(alerts.length - resolvedCount);
598
+ }
599
+
600
+ _addAlertCard(alert, container, isPivot = false) {
601
+ const card = document.createElement('div');
602
+ card.className = `alert-card${isPivot ? ' pivot' : ''}`;
603
+ card.dataset.alertId = alert.alert_id;
604
+
605
+ const severityClass = `severity-${alert.severity}`;
606
+ const iocText = (alert.ioc_indicators || []).join(', ');
607
+ const iocHtml = iocText ? `<div class="alert-iocs">IOCs: ${iocText}</div>` : '';
608
+ const pivotBadge = isPivot ? `<span class="pivot-badge">⚡ PIVOT</span>` : '';
609
+ const correlBadge = alert.is_acknowledged ? `<span class="correlated-badge">🔗 CORR</span>` : '';
610
+
611
+ card.innerHTML = `
612
+ <div class="alert-card-header">
613
+ <span class="${severityClass} severity-badge">${alert.severity.toUpperCase()}</span>
614
+ <span class="alert-host">${alert.source_host}</span>
615
+ </div>
616
+ <div class="alert-description">${alert.description}</div>
617
+ ${iocHtml}
618
+ <div class="alert-footer">
619
+ <span style="font-size:9px;color:var(--text-muted);font-family:monospace">${alert.threat_type}</span>
620
+ ${pivotBadge}${correlBadge}
621
+ </div>
622
+ `;
623
+
624
+ card.title = alert.description;
625
+ container.insertBefore(card, container.firstChild);
626
+ }
627
+
628
+ _updateAlertBadge(count) {
629
+ const badge = document.getElementById('alert-count-badge');
630
+ if (badge) badge.textContent = count;
631
+ }
632
+
633
+ _updateActionLog(timeline, lastAction, obs) {
634
+ const log = document.getElementById('action-log');
635
+ if (!log || !timeline?.length) return;
636
+
637
+ const lastEntry = timeline[timeline.length - 1];
638
+ if (!lastEntry) return;
639
+
640
+ // Remove empty state
641
+ const empty = log.querySelector('.empty-state');
642
+ if (empty) empty.remove();
643
+
644
+ const category = this._getActionCategory(lastEntry.action_type);
645
+ const isPos = lastEntry.reward >= 0;
646
+ const rewardText = `${isPos ? '+' : ''}${lastEntry.reward.toFixed(2)}`;
647
+ const rewardClass = isPos ? 'positive' : 'negative';
648
+ const icon = this._getActionIcon(lastEntry.action_type);
649
+
650
+ const detailsHtml = this._buildLogDetails(lastEntry.action_type, obs);
651
+
652
+ const entry = document.createElement('div');
653
+ entry.className = `log-entry ${category}`;
654
+ entry.innerHTML = `
655
+ <div>
656
+ <span class="log-step">[Step ${String(lastEntry.step).padStart(2, '0')}]</span>
657
+ <span class="log-action"> ${icon} ${lastEntry.action_type}</span>
658
+ <span class="log-target"> → ${lastEntry.target}</span>
659
+ <span class="log-reward ${rewardClass}" style="float:right">${isPos ? '✅' : '❌'} ${rewardText}</span>
660
+ </div>
661
+ <div class="log-result">${lastEntry.result.substring(0, 120)}</div>
662
+ ${detailsHtml}
663
+ `;
664
+
665
+ log.insertBefore(entry, log.firstChild);
666
+
667
+ // Update total reward badge
668
+ this._updateRewardBadge(obs?.total_reward ?? 0);
669
+ }
670
+
671
+ _buildLogDetails(actionType, obs) {
672
+ if (!obs) return '';
673
+ const lines = [];
674
+
675
+ if (actionType === 'run_forensics' && obs.host_forensics) {
676
+ const f = obs.host_forensics;
677
+ const status = f.is_compromised
678
+ ? `<span style="color:var(--accent-red)">⚠ COMPROMISED</span>`
679
+ : `<span style="color:var(--accent-green)">✓ CLEAN</span>`;
680
+ lines.push(`Status: ${status}`);
681
+ if (f.malicious_processes?.length) {
682
+ lines.push(`Procs: <span style="color:var(--accent-amber)">${f.malicious_processes.join(' · ')}</span>`);
683
+ }
684
+ if (f.network_connections?.length) {
685
+ lines.push(`Conns: <span style="color:var(--accent-red)">${f.network_connections.slice(0, 3).join(' · ')}</span>`);
686
+ }
687
+ }
688
+
689
+ if (actionType === 'enrich_ioc' && obs.ioc_enrichment) {
690
+ const e = obs.ioc_enrichment;
691
+ if (e.threat_actor) lines.push(`Actor: <span style="color:var(--accent-red)">${e.threat_actor}</span>`);
692
+ if (e.reputation !== undefined) lines.push(`Reputation: <span style="color:var(--accent-amber)">${e.reputation}</span>`);
693
+ if (e.mitre_ttps?.length) lines.push(`TTPs: <span style="color:var(--accent-purple)">${e.mitre_ttps.slice(0, 3).join(' · ')}</span>`);
694
+ }
695
+
696
+ if (actionType === 'scan_host_vulnerabilities' && obs.vulnerability_results?.length) {
697
+ const vulns = obs.vulnerability_results;
698
+ const critical = vulns.filter(v => v.cvss_score >= 9).length;
699
+ const cves = vulns.slice(0, 3).map(v => v.cve_id).join(' · ');
700
+ lines.push(`Found ${vulns.length} CVEs${critical ? ` (<span style="color:var(--accent-red)">${critical} critical</span>)` : ''}`);
701
+ if (cves) lines.push(`<span style="color:var(--accent-amber)">${cves}</span>`);
702
+ }
703
+
704
+ if (actionType === 'trigger_playbook' && obs.playbook_result) {
705
+ const p = obs.playbook_result;
706
+ const success = p.success ?? p.status === 'success';
707
+ lines.push(`Result: <span style="color:${success ? 'var(--accent-green)' : 'var(--accent-red)'}">
708
+ ${success ? '✓ Executed' : '✗ Failed'}</span>`);
709
+ if (p.actions_taken?.length) lines.push(`Steps: ${p.actions_taken.slice(0, 3).join(' → ')}`);
710
+ }
711
+
712
+ if (actionType === 'correlate_alerts' && obs.correlation_results) {
713
+ const c = obs.correlation_results;
714
+ const count = Array.isArray(c) ? c.length : (c.correlated_count ?? 1);
715
+ lines.push(`Correlated <span style="color:var(--accent-purple)">${count} alerts</span> into chain`);
716
+ if (c.threat_chain) lines.push(`Chain: <span style="color:var(--accent-cyan)">${c.threat_chain}</span>`);
717
+ }
718
+
719
+ if (!lines.length) return '';
720
+ return `<div class="log-details">${lines.join('<br>')}</div>`;
721
+ }
722
+
723
+ _updateGraphSummary(summary) {
724
+ const el = document.getElementById('graph-summary');
725
+ if (el && summary) el.textContent = summary;
726
+ }
727
+
728
+ _updateRewardBadge(totalReward) {
729
+ const badge = document.getElementById('total-reward-badge');
730
+ if (!badge) return;
731
+ const isPos = totalReward >= 0;
732
+ badge.textContent = `${isPos ? '+' : ''}${totalReward.toFixed(2)}`;
733
+ badge.className = `reward-badge${isPos ? '' : ' negative'}`;
734
+ AnimationUtils.scaleBounce(badge);
735
+ }
736
+
737
+ _updateNetworkTopology(topology) {
738
+ const container = document.getElementById('network-topology');
739
+ if (!container) return;
740
+
741
+ const subnetConfig = {
742
+ corporate: { label: 'Corporate', color: '#3b82f6' },
743
+ engineering: { label: 'Engineering', color: '#06b6d4' },
744
+ finance: { label: 'Finance', color: '#f59e0b' },
745
+ dmz: { label: 'DMZ', color: '#94a3b8' },
746
+ datacenter: { label: 'Datacenter', color: '#8b5cf6' },
747
+ executive: { label: 'Executive', color: '#ef4444' },
748
+ };
749
+
750
+ // Build or update subnet section skeletons
751
+ Object.entries(topology.subnets || {}).forEach(([subnet, count]) => {
752
+ const cfg = subnetConfig[subnet] || { label: subnet, color: '#94a3b8' };
753
+ let section = document.getElementById(`subnet-${subnet}`);
754
+ if (!section) {
755
+ section = document.createElement('div');
756
+ section.className = 'subnet-section';
757
+ section.id = `subnet-${subnet}`;
758
+ section.innerHTML = `
759
+ <div class="subnet-header">
760
+ <span class="subnet-name" style="color:${cfg.color}">${cfg.label}</span>
761
+ <span class="subnet-stats" id="stats-${subnet}">${count} hosts</span>
762
+ </div>
763
+ <div class="host-grid" id="grid-${subnet}"></div>
764
+ `;
765
+ container.appendChild(section);
766
+ }
767
+
768
+ // Populate grid with placeholder dots (count-based, no known names yet)
769
+ const grid = document.getElementById(`grid-${subnet}`);
770
+ if (grid && grid.children.length === 0) {
771
+ for (let i = 0; i < count; i++) {
772
+ const dot = document.createElement('div');
773
+ dot.className = 'host-dot online';
774
+ dot.dataset.idx = i;
775
+ grid.appendChild(dot);
776
+ }
777
+ }
778
+ });
779
+
780
+ // Overlay known-host statuses from the threat graph onto the dots
781
+ // Group graph hosts by subnet
782
+ const subnetHostStatus = {}; // subnet -> [{hostname, status}]
783
+ this.threatGraph.nodes.forEach((node) => {
784
+ if (node.nodeType !== 'host' || !node.subnet) return;
785
+ if (!subnetHostStatus[node.subnet]) subnetHostStatus[node.subnet] = [];
786
+ subnetHostStatus[node.subnet].push({ hostname: node.id, status: node.status || 'online' });
787
+ });
788
+
789
+ // For each subnet, update the first N dots with known statuses
790
+ Object.entries(subnetHostStatus).forEach(([subnet, hosts]) => {
791
+ const grid = document.getElementById(`grid-${subnet}`);
792
+ if (!grid) return;
793
+ const dots = [...grid.querySelectorAll('.host-dot')];
794
+ hosts.forEach((h, i) => {
795
+ if (i < dots.length) {
796
+ dots[i].className = `host-dot ${h.status}`;
797
+ dots[i].title = `${h.hostname} [${h.status}]`;
798
+ dots[i].id = `hostdot-${h.hostname}`;
799
+ }
800
+ });
801
+ });
802
+
803
+ // Update header stats
804
+ const topoStatComp = document.getElementById('topo-compromised');
805
+ const topoStatIso = document.getElementById('topo-isolated');
806
+ if (topoStatComp) topoStatComp.textContent = `${topology.compromised_count} compromised`;
807
+ if (topoStatIso) topoStatIso.textContent = `${topology.isolated_count} isolated`;
808
+
809
+ // Mark isolated subnets
810
+ Object.keys(topology.subnets || {}).forEach(subnet => {
811
+ const section = document.getElementById(`subnet-${subnet}`);
812
+ if (!section) return;
813
+ const hosts = subnetHostStatus[subnet] || [];
814
+ const isIsolated = hosts.some(h => h.status === 'isolated');
815
+ const hasCompromised = hosts.some(h => h.status === 'compromised');
816
+ section.classList.toggle('isolated', isIsolated);
817
+ section.classList.toggle('has-compromised', hasCompromised && !isIsolated);
818
+
819
+ let overlay = section.querySelector('.isolated-overlay');
820
+ if (isIsolated && !overlay) {
821
+ overlay = document.createElement('div');
822
+ overlay.className = 'isolated-overlay';
823
+ overlay.textContent = '🔒 ISOLATED';
824
+ section.appendChild(overlay);
825
+ } else if (!isIsolated && overlay) {
826
+ overlay.remove();
827
+ }
828
+ });
829
+ }
830
+
831
+ _updateBusinessImpact(score) {
832
+ const marker = document.getElementById('impact-marker');
833
+ const value = document.getElementById('impact-value');
834
+ if (marker) marker.style.left = `${score * 100}%`;
835
+ if (value) {
836
+ const prev = parseFloat(value.textContent) || 0;
837
+ AnimationUtils.countUp(value, prev, score, 400, 2);
838
+ value.className = 'impact-value mono' + (score > 0.6 ? ' critical' : score > 0.35 ? ' high' : '');
839
+ }
840
+
841
+ if (score > 0.5) {
842
+ const impactSection = document.querySelector('.impact-section');
843
+ if (impactSection) AnimationUtils.pulseGlow(impactSection, score > 0.7 ? 'red' : 'amber', 2);
844
+ }
845
+ }
846
+
847
+ _updateActiveThreats(threats) {
848
+ const list = document.getElementById('active-threats-list');
849
+ if (!list) return;
850
+ if (!threats || threats.length === 0) {
851
+ list.innerHTML = '<span style="color:var(--accent-green);font-size:11px;">✅ All threats contained</span>';
852
+ return;
853
+ }
854
+ list.innerHTML = threats.map(t => `<span class="threat-tag">${t}</span>`).join('');
855
+ }
856
+
857
+ _updateContainmentProgress(obs, taskId) {
858
+ const container = document.getElementById('containment-bars');
859
+ if (!container) return;
860
+
861
+ // Derive progress from timeline
862
+ const timeline = obs.timeline || [];
863
+ const killed = timeline.filter(t => t.action_type === 'kill_process');
864
+ const blocked = timeline.filter(t => t.action_type === 'block_ioc');
865
+ const forensics = timeline.filter(t => t.action_type === 'run_forensics');
866
+ const playbooks = timeline.filter(t => t.action_type === 'trigger_playbook');
867
+ const correlated = timeline.some(t => t.action_type === 'correlate_alerts');
868
+
869
+ // Task-specific totals
870
+ const totals = {
871
+ hard: { kill: 8, block: 6, forensics: 5, playbooks: 1 },
872
+ medium: { kill: 4, block: 5, forensics: 3, playbooks: 1 },
873
+ easy: { kill: 1, block: 1, forensics: 1, playbooks: 1 },
874
+ };
875
+ const t = totals[taskId] || totals.hard;
876
+
877
+ const bars = [
878
+ { id: 'bar-kill', label: 'Processes Killed', current: Math.min(killed.length, t.kill), total: t.kill },
879
+ { id: 'bar-block', label: 'IOCs Blocked', current: Math.min(blocked.length, t.block), total: t.block },
880
+ { id: 'bar-forensics', label: 'Hosts Investigated', current: Math.min(forensics.length, t.forensics), total: t.forensics },
881
+ { id: 'bar-playbooks', label: 'Playbooks Triggered', current: Math.min(playbooks.length, t.playbooks), total: t.playbooks },
882
+ ];
883
+
884
+ bars.forEach(bar => {
885
+ let item = document.getElementById(bar.id);
886
+ if (!item) {
887
+ item = document.createElement('div');
888
+ item.id = bar.id;
889
+ item.className = 'containment-bar-item';
890
+ container.appendChild(item);
891
+ }
892
+ const pct = bar.total > 0 ? Math.round((bar.current / bar.total) * 100) : 0;
893
+ const isComplete = bar.current >= bar.total;
894
+ item.innerHTML = `
895
+ <div class="containment-bar-label">
896
+ <span>${bar.label}</span>
897
+ <span class="${isComplete ? 'complete' : ''}">${bar.current}/${bar.total}${isComplete ? ' ✓' : ''}</span>
898
+ </div>
899
+ <div class="containment-bar-track">
900
+ <div class="containment-bar-fill ${isComplete ? 'complete' : ''}" style="width:${pct}%"></div>
901
+ </div>
902
+ `;
903
+ });
904
+ }
905
+
906
+ _handleRedTeamPivot(pivotAlert) {
907
+ AnimationUtils.triggerRedTeamPivot();
908
+ // Add the pivot alert to the queue with special styling
909
+ const list = document.getElementById('alert-list');
910
+ if (list) {
911
+ const card = document.createElement('div');
912
+ card.className = 'alert-card pivot';
913
+ card.dataset.alertId = pivotAlert.alert_id;
914
+ card.innerHTML = `
915
+ <div class="alert-card-header">
916
+ <span class="severity-critical severity-badge">CRITICAL</span>
917
+ <span class="alert-host">${pivotAlert.source_host || 'UNKNOWN'}</span>
918
+ </div>
919
+ <div class="alert-description" style="color:var(--accent-red)">⚡ ADAPTIVE THREAT — LATERAL PIVOT DETECTED. Adversary moved to new host.</div>
920
+ <div class="alert-footer">
921
+ <span class="pivot-badge">⚡ PIVOT</span>
922
+ </div>
923
+ `;
924
+ list.insertBefore(card, list.firstChild);
925
+ }
926
+ // Recalculate containment (requirements grew)
927
+ AnimationUtils.showNotification('⚡ Red Team lateral pivot! Containment requirements updated.', 'red', 5000);
928
+ }
929
+
930
+ _handleEpisodeComplete(obs) {
931
+ if (this.timerInterval) clearInterval(this.timerInterval);
932
+ document.getElementById('btn-pause').classList.add('hidden');
933
+ document.getElementById('btn-next').classList.add('hidden');
934
+
935
+ // Update phase to REPORT
936
+ this.currentAction = { type: 'submit_containment_plan' };
937
+ this._updatePhase(obs.step_count, obs.max_steps);
938
+
939
+ // Update radar with final scores
940
+ if (obs.grade_breakdown) {
941
+ this.radarChart.update(obs.grade_breakdown);
942
+ }
943
+
944
+ // Brief delay then show final score overlay
945
+ setTimeout(() => {
946
+ const finalScore = obs.final_score ?? obs.total_reward ?? 0;
947
+ AnimationUtils.revealFinalScore(
948
+ finalScore,
949
+ obs.grade_breakdown,
950
+ [], // penalties would come from grade_result
951
+ [] // bonuses
952
+ );
953
+ AnimationUtils.showNotification(`🏆 Episode complete! Score: ${finalScore.toFixed(3)}`, 'green', 6000);
954
+ }, 1000);
955
+ }
956
+
957
+ // ============================================================
958
+ // Helpers
959
+ // ============================================================
960
+
961
+ _getActionCategory(actionType) {
962
+ if (['correlate_alerts'].includes(actionType)) return 'triage';
963
+ if (['query_host', 'run_forensics', 'enrich_ioc', 'scan_host_vulnerabilities'].includes(actionType)) return 'investigation';
964
+ if (['kill_process', 'block_ioc', 'isolate_segment', 'trigger_playbook'].includes(actionType)) return 'remediation';
965
+ if (['submit_containment_plan'].includes(actionType)) return 'report';
966
+ return 'investigation';
967
+ }
968
+
969
+ _getActionIcon(actionType) {
970
+ const icons = {
971
+ query_host: '🔍',
972
+ run_forensics: '🧬',
973
+ enrich_ioc: '🔎',
974
+ scan_host_vulnerabilities: '🩺',
975
+ kill_process: '⚔️',
976
+ block_ioc: '🚫',
977
+ isolate_segment: '🔒',
978
+ trigger_playbook: '▶️',
979
+ correlate_alerts: '🔗',
980
+ submit_containment_plan: '📝',
981
+ };
982
+ return icons[actionType] || '➤';
983
+ }
984
+
985
+ _showConnectionOverlay(msg) {
986
+ const overlay = document.getElementById('connection-overlay');
987
+ const status = document.getElementById('connection-status');
988
+ if (overlay) overlay.classList.remove('hidden');
989
+ if (status) status.textContent = msg;
990
+ }
991
+
992
+ _hideConnectionOverlay() {
993
+ const overlay = document.getElementById('connection-overlay');
994
+ if (overlay) {
995
+ overlay.style.transition = 'opacity 0.5s ease';
996
+ overlay.style.opacity = '0';
997
+ setTimeout(() => {
998
+ overlay.classList.add('hidden');
999
+ overlay.style.opacity = '';
1000
+ overlay.style.transition = '';
1001
+ }, 500);
1002
+ }
1003
+ }
1004
+
1005
+ _resetUI() {
1006
+ // Clear all panels to initial state
1007
+ document.getElementById('alert-list').innerHTML = '<div class="empty-state">Awaiting alerts...</div>';
1008
+ document.getElementById('action-log').innerHTML = '<div class="empty-state">Awaiting agent actions...</div>';
1009
+ document.getElementById('active-threats-list').innerHTML = '<span class="empty-state">No threats detected</span>';
1010
+ document.getElementById('containment-bars').innerHTML = '';
1011
+ document.getElementById('network-topology').innerHTML = '';
1012
+ document.getElementById('network-topology').dataset.built = 'false';
1013
+ document.getElementById('header-step').textContent = '0/30';
1014
+ document.getElementById('header-episode').textContent = '—';
1015
+ document.getElementById('header-timer').textContent = '00:00';
1016
+ document.getElementById('impact-value').textContent = '0.00';
1017
+ document.getElementById('impact-marker').style.left = '0%';
1018
+ document.getElementById('total-reward-badge').textContent = '+0.00';
1019
+ document.getElementById('alert-count-badge').textContent = '0';
1020
+ document.getElementById('difficulty-badge').textContent = '—';
1021
+ document.getElementById('difficulty-badge').className = 'difficulty-badge';
1022
+
1023
+ // Reset phase
1024
+ document.querySelectorAll('.phase-dot').forEach(d => d.classList.remove('active', 'completed'));
1025
+ document.querySelectorAll('.phase-connector').forEach(c => c.classList.remove('completed'));
1026
+
1027
+ // Reset charts
1028
+ if (this.rewardTimeline) this.rewardTimeline.reset();
1029
+ if (this.radarChart) this.radarChart.update({
1030
+ threat_containment: 0, ioc_blocking: 0, forensic_investigation: 0,
1031
+ siem_correlation: 0, threat_intel_usage: 0, vuln_root_cause: 0,
1032
+ business_impact: 1.0, step_efficiency: 0.5, plan_coverage: 0, plan_evidence_quality: 0,
1033
+ });
1034
+
1035
+ // Reset threat graph
1036
+ this.threatGraph = new ClientThreatGraph();
1037
+ if (this.graphViz._initialized) {
1038
+ this.graphViz.update({ nodes: [], links: [] });
1039
+ }
1040
+
1041
+ // Show start button
1042
+ document.getElementById('btn-start').classList.remove('hidden');
1043
+ document.getElementById('task-select').disabled = false;
1044
+ }
1045
+
1046
+ _resetUIForEpisode() {
1047
+ document.getElementById('alert-list').innerHTML = '';
1048
+ document.getElementById('action-log').innerHTML = '';
1049
+ document.getElementById('containment-bars').innerHTML = '';
1050
+ document.getElementById('network-topology').innerHTML = '';
1051
+ document.getElementById('network-topology').dataset.built = 'false';
1052
+ if (this.rewardTimeline) this.rewardTimeline.reset();
1053
+ this.liveScores = {
1054
+ threat_containment: 0, ioc_blocking: 0, forensic_investigation: 0,
1055
+ siem_correlation: 0, threat_intel_usage: 0, vuln_root_cause: 0,
1056
+ business_impact: 1.0, step_efficiency: 0.5, plan_coverage: 0, plan_evidence_quality: 0,
1057
+ };
1058
+ if (this.radarChart) this.radarChart.update(this.liveScores);
1059
+ }
1060
+
1061
+ closeScoreOverlay() {
1062
+ const overlay = document.getElementById('final-score-overlay');
1063
+ if (overlay) {
1064
+ overlay.style.transition = 'opacity 0.4s ease';
1065
+ overlay.style.opacity = '0';
1066
+ setTimeout(() => {
1067
+ overlay.classList.add('hidden');
1068
+ overlay.style.opacity = '';
1069
+ }, 400);
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+
1075
+ // ============================================================
1076
+ // Bootstrap
1077
+ // ============================================================
1078
+ let dashboard;
1079
+ document.addEventListener('DOMContentLoaded', () => {
1080
+ dashboard = new CyberSOCDashboard();
1081
+ window.dashboard = dashboard;
1082
+ dashboard.init();
1083
+ });
dashboard/js/graphs.js ADDED
@@ -0,0 +1,880 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ CyberSOC Dashboard — Graph Visualizations
3
+ D3.js v7 Threat Graph + Chart.js Radar + Timeline
4
+ ============================================================ */
5
+
6
+ // ============================================================
7
+ // Client-side Threat Graph State
8
+ // ============================================================
9
+ class ClientThreatGraph {
10
+ constructor() {
11
+ this.nodes = new Map(); // id -> node object
12
+ this.links = []; // {source, target, edgeType, id}
13
+ this._linkSet = new Set(); // "source|target|type" for dedup
14
+ this._pivotLinks = new Set();
15
+ }
16
+
17
+ addNode(node) {
18
+ if (!this.nodes.has(node.id)) {
19
+ this.nodes.set(node.id, { ...node, _new: true });
20
+ }
21
+ return this;
22
+ }
23
+
24
+ updateNode(id, updates) {
25
+ if (this.nodes.has(id)) {
26
+ Object.assign(this.nodes.get(id), updates, { _updated: true });
27
+ }
28
+ return this;
29
+ }
30
+
31
+ addLink(sourceId, targetId, edgeType) {
32
+ const key = `${sourceId}|${targetId}|${edgeType}`;
33
+ const rkey = `${targetId}|${sourceId}|${edgeType}`;
34
+ if (this._linkSet.has(key) || this._linkSet.has(rkey)) return this;
35
+ this._linkSet.add(key);
36
+ const link = { source: sourceId, target: targetId, edgeType, id: key };
37
+ this.links.push(link);
38
+ if (edgeType === 'pivoted_from') this._pivotLinks.add(key);
39
+ return this;
40
+ }
41
+
42
+ hasPivotLinks() { return this._pivotLinks.size > 0; }
43
+
44
+ getGraphData() {
45
+ return {
46
+ nodes: [...this.nodes.values()],
47
+ links: [...this.links],
48
+ };
49
+ }
50
+
51
+ // Update from a full SOCObservation
52
+ updateFromObservation(obs, lastAction) {
53
+ // Always sync alert nodes and hosts from alert_queue
54
+ (obs.alert_queue || []).forEach(alert => {
55
+ const existing = this.nodes.get(alert.alert_id);
56
+ if (!existing) {
57
+ this.addNode({
58
+ id: alert.alert_id,
59
+ nodeType: 'alert',
60
+ label: alert.alert_id,
61
+ severity: alert.severity,
62
+ sourceHost: alert.source_host,
63
+ threatType: alert.threat_type,
64
+ description: alert.description,
65
+ isPivot: alert.alert_id.startsWith('PIVOT-'),
66
+ subnet: alert.subnet,
67
+ });
68
+ }
69
+
70
+ // Add source host
71
+ if (!this.nodes.has(alert.source_host)) {
72
+ this.addNode({
73
+ id: alert.source_host,
74
+ nodeType: 'host',
75
+ label: alert.source_host,
76
+ status: 'online',
77
+ subnet: alert.subnet || this._subnetFromHostname(alert.source_host),
78
+ });
79
+ } else if (!this.nodes.get(alert.source_host).subnet) {
80
+ this.updateNode(alert.source_host, { subnet: alert.subnet || this._subnetFromHostname(alert.source_host) });
81
+ }
82
+
83
+ // Link alert to host
84
+ this.addLink(alert.source_host, alert.alert_id, 'involves');
85
+
86
+ // Add IOC nodes
87
+ (alert.ioc_indicators || []).forEach(ioc => {
88
+ if (!this.nodes.has(ioc)) {
89
+ this.addNode({
90
+ id: ioc,
91
+ nodeType: 'ioc',
92
+ label: ioc.length > 22 ? ioc.substring(0, 20) + '…' : ioc,
93
+ fullLabel: ioc,
94
+ iocType: this._guessIocType(ioc),
95
+ blocked: false,
96
+ enriched: false,
97
+ });
98
+ }
99
+ this.addLink(alert.alert_id, ioc, 'involves');
100
+ });
101
+ });
102
+
103
+ // Process based on last action
104
+ if (!lastAction) return;
105
+
106
+ const actionType = lastAction.type;
107
+
108
+ if (actionType === 'run_forensics' && obs.host_forensics) {
109
+ const f = obs.host_forensics;
110
+ this.updateNode(f.hostname, {
111
+ status: f.is_compromised ? 'compromised' : 'online',
112
+ forensicsRun: true,
113
+ maliciousProcs: f.malicious_processes,
114
+ });
115
+
116
+ (f.malicious_processes || []).forEach(proc => {
117
+ const procId = `${f.hostname}:${proc}`;
118
+ this.addNode({
119
+ id: procId,
120
+ nodeType: 'process',
121
+ label: proc,
122
+ hostname: f.hostname,
123
+ processName: proc,
124
+ killed: false,
125
+ });
126
+ this.addLink(procId, f.hostname, 'runs_on');
127
+ });
128
+
129
+ (f.network_connections || []).forEach(conn => {
130
+ const ip = conn.split(':')[0];
131
+ if (!this.nodes.has(ip)) {
132
+ this.addNode({ id: ip, nodeType: 'ioc', label: ip, iocType: 'ip', blocked: false, enriched: false });
133
+ }
134
+ this.addLink(f.hostname, ip, 'communicates_with');
135
+ });
136
+ }
137
+
138
+ if (actionType === 'query_host') {
139
+ const hn = lastAction.hostname;
140
+ const subnet = this._subnetFromHostname(hn);
141
+ const existing = this.nodes.get(hn);
142
+ if (existing) {
143
+ this.updateNode(hn, {
144
+ status: existing.status === 'compromised' ? 'compromised' : 'queried',
145
+ subnet: existing.subnet || subnet,
146
+ });
147
+ } else {
148
+ this.addNode({ id: hn, nodeType: 'host', label: hn, status: 'queried', subnet });
149
+ }
150
+ }
151
+
152
+ if (actionType === 'enrich_ioc') {
153
+ const iocVal = lastAction.ioc_value;
154
+ if (!this.nodes.has(iocVal)) {
155
+ this.addNode({
156
+ id: iocVal,
157
+ nodeType: 'ioc',
158
+ label: iocVal.length > 22 ? iocVal.substring(0, 20) + '…' : iocVal,
159
+ fullLabel: iocVal,
160
+ iocType: lastAction.ioc_type || this._guessIocType(iocVal),
161
+ blocked: false,
162
+ enriched: false,
163
+ });
164
+ }
165
+ if (obs.ioc_enrichment) {
166
+ this.updateNode(iocVal, {
167
+ enriched: true,
168
+ threatActor: obs.ioc_enrichment.threat_actor,
169
+ mitreTTPs: obs.ioc_enrichment.mitre_ttps || [],
170
+ reputation: obs.ioc_enrichment.reputation,
171
+ });
172
+ }
173
+ }
174
+
175
+ if (actionType === 'block_ioc') {
176
+ this.updateNode(lastAction.ioc_value, { blocked: true });
177
+ }
178
+
179
+ if (actionType === 'kill_process') {
180
+ const procId = `${lastAction.hostname}:${lastAction.process_name}`;
181
+ this.updateNode(procId, { killed: true });
182
+ // Also add process node if not seen before
183
+ if (!this.nodes.has(procId)) {
184
+ this.addNode({
185
+ id: procId, nodeType: 'process', label: lastAction.process_name,
186
+ hostname: lastAction.hostname, killed: true,
187
+ });
188
+ }
189
+ }
190
+
191
+ if (actionType === 'isolate_segment') {
192
+ const subnet = lastAction.subnet;
193
+ this.nodes.forEach((node, id) => {
194
+ if (node.nodeType === 'host' && node.subnet === subnet) {
195
+ this.updateNode(id, { status: 'isolated' });
196
+ }
197
+ });
198
+ }
199
+
200
+ if (actionType === 'scan_host_vulnerabilities' && obs.vulnerability_results) {
201
+ (obs.vulnerability_results || []).forEach(vuln => {
202
+ const vid = vuln.cve_id || 'CVE-UNKNOWN';
203
+ this.addNode({
204
+ id: vid, nodeType: 'vulnerability', label: vid,
205
+ cvssScore: vuln.cvss_score, exploitability: vuln.exploitability,
206
+ hostname: lastAction.hostname,
207
+ });
208
+ this.addLink(vid, lastAction.hostname, 'exploits');
209
+ });
210
+ }
211
+
212
+ if (actionType === 'correlate_alerts' && obs.correlation_results) {
213
+ const aids = lastAction.alert_ids || [];
214
+ for (let i = 0; i < aids.length - 1; i++) {
215
+ this.addLink(aids[i], aids[i + 1], 'part_of_chain');
216
+ }
217
+ // Mark alerts as correlated
218
+ aids.forEach(id => this.updateNode(id, { correlated: true }));
219
+ }
220
+ }
221
+
222
+ _guessIocType(ioc) {
223
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ioc)) return 'ip';
224
+ if (/[a-f0-9]{32,64}/i.test(ioc)) return 'hash';
225
+ return 'domain';
226
+ }
227
+
228
+ _subnetFromHostname(hostname) {
229
+ if (!hostname) return null;
230
+ const prefix = hostname.split('-')[0].toUpperCase();
231
+ const map = { WS: 'corporate', DEV: 'engineering', FIN: 'finance', DMZ: 'dmz', SRV: 'datacenter', EXEC: 'executive' };
232
+ return map[prefix] || null;
233
+ }
234
+
235
+ // Detect new pivot alerts (PIVOT-xxx)
236
+ getNewPivotAlerts(prevAlertIds) {
237
+ const pivots = [];
238
+ this.nodes.forEach((node, id) => {
239
+ if (node.nodeType === 'alert' && node.isPivot && !prevAlertIds.has(id)) {
240
+ pivots.push(node);
241
+ }
242
+ });
243
+ return pivots;
244
+ }
245
+ }
246
+
247
+
248
+ // ============================================================
249
+ // D3.js Force-Directed Threat Graph Visualization
250
+ // ============================================================
251
+ class ThreatGraphViz {
252
+ constructor(containerId) {
253
+ this.containerId = containerId;
254
+ this.svg = null;
255
+ this.simulation = null;
256
+ this.graphGroup = null;
257
+ this.linkGroup = null;
258
+ this.nodeGroup = null;
259
+ this.labelGroup = null;
260
+ this.tooltip = null;
261
+ this.width = 0;
262
+ this.height = 0;
263
+ this._pivotTimers = [];
264
+ this._nodeData = [];
265
+ this._linkData = [];
266
+ this._initialized = false;
267
+ }
268
+
269
+ init() {
270
+ const container = document.getElementById(this.containerId);
271
+ if (!container) return;
272
+ const svg = d3.select('#threat-graph-svg');
273
+ const rect = container.getBoundingClientRect();
274
+ this.width = rect.width || 600;
275
+ this.height = rect.height || 400;
276
+
277
+ svg.attr('width', this.width).attr('height', this.height);
278
+
279
+ // Add SVG filters for glow effects
280
+ const defs = svg.append('defs');
281
+ this._addGlowFilters(defs);
282
+
283
+ // Background
284
+ svg.append('rect')
285
+ .attr('width', this.width).attr('height', this.height)
286
+ .attr('fill', 'transparent');
287
+
288
+ // Zoom behavior
289
+ const zoom = d3.zoom()
290
+ .scaleExtent([0.2, 4])
291
+ .on('zoom', (event) => {
292
+ this.graphGroup.attr('transform', event.transform);
293
+ });
294
+ svg.call(zoom);
295
+
296
+ // Main group
297
+ this.graphGroup = svg.append('g').attr('class', 'graph-group');
298
+ this.linkGroup = this.graphGroup.append('g').attr('class', 'links');
299
+ this.nodeGroup = this.graphGroup.append('g').attr('class', 'nodes');
300
+ this.labelGroup = this.graphGroup.append('g').attr('class', 'labels');
301
+
302
+ // Tooltip
303
+ this.tooltip = d3.select('#graph-tooltip');
304
+
305
+ // Force simulation
306
+ this.simulation = d3.forceSimulation()
307
+ .force('link', d3.forceLink().id(d => d.id).distance(d => {
308
+ if (d.edgeType === 'pivoted_from') return 120;
309
+ if (d.edgeType === 'runs_on') return 50;
310
+ return 80;
311
+ }).strength(0.7))
312
+ .force('charge', d3.forceManyBody().strength(-180).distanceMax(300))
313
+ .force('center', d3.forceCenter(this.width / 2, this.height / 2))
314
+ .force('collision', d3.forceCollide().radius(d => this._nodeRadius(d) + 8))
315
+ .force('x', d3.forceX(this.width / 2).strength(0.04))
316
+ .force('y', d3.forceY(this.height / 2).strength(0.04));
317
+
318
+ this._initialized = true;
319
+ }
320
+
321
+ _addGlowFilters(defs) {
322
+ const glows = [
323
+ { id: 'glow-red', color: '#ef4444', stdDev: 4 },
324
+ { id: 'glow-blue', color: '#3b82f6', stdDev: 3 },
325
+ { id: 'glow-green', color: '#10b981', stdDev: 3 },
326
+ { id: 'glow-amber', color: '#f59e0b', stdDev: 3 },
327
+ { id: 'glow-purple', color: '#8b5cf6', stdDev: 3 },
328
+ { id: 'glow-cyan', color: '#06b6d4', stdDev: 3 },
329
+ ];
330
+ glows.forEach(g => {
331
+ const filter = defs.append('filter').attr('id', g.id);
332
+ filter.append('feGaussianBlur').attr('in', 'SourceGraphic').attr('stdDeviation', g.stdDev).attr('result', 'blur');
333
+ const merge = filter.append('feMerge');
334
+ merge.append('feMergeNode').attr('in', 'blur');
335
+ merge.append('feMergeNode').attr('in', 'SourceGraphic');
336
+ });
337
+
338
+ // Arrowhead markers for directed edges
339
+ const arrows = [
340
+ { id: 'arrow-default', color: 'rgba(148,163,184,0.6)' },
341
+ { id: 'arrow-pivot', color: '#ef4444' },
342
+ { id: 'arrow-c2', color: 'rgba(245,158,11,0.7)' },
343
+ { id: 'arrow-exploit', color: 'rgba(239,68,68,0.7)' },
344
+ { id: 'arrow-chain', color: 'rgba(139,92,246,0.7)' },
345
+ ];
346
+ arrows.forEach(a => {
347
+ defs.append('marker')
348
+ .attr('id', a.id)
349
+ .attr('viewBox', '0 -5 10 10')
350
+ .attr('refX', 14)
351
+ .attr('refY', 0)
352
+ .attr('markerWidth', 5)
353
+ .attr('markerHeight', 5)
354
+ .attr('orient', 'auto')
355
+ .append('path')
356
+ .attr('d', 'M0,-5L10,0L0,5')
357
+ .attr('fill', a.color);
358
+ });
359
+ }
360
+
361
+ _edgeMarker(edgeType) {
362
+ if (edgeType === 'pivoted_from') return 'url(#arrow-pivot)';
363
+ if (edgeType === 'communicates_with') return 'url(#arrow-c2)';
364
+ if (edgeType === 'exploits') return 'url(#arrow-exploit)';
365
+ if (edgeType === 'part_of_chain') return 'url(#arrow-chain)';
366
+ if (edgeType === 'runs_on') return 'url(#arrow-default)';
367
+ return null;
368
+ }
369
+
370
+ update(graphData) {
371
+ if (!this._initialized) return;
372
+
373
+ const nodes = graphData.nodes;
374
+ const links = graphData.links.map(l => ({
375
+ ...l,
376
+ source: l.source.id || l.source,
377
+ target: l.target.id || l.target,
378
+ }));
379
+
380
+ this._nodeData = nodes;
381
+ this._linkData = links;
382
+
383
+ // Update links (curved quadratic bezier paths)
384
+ const linkSel = this.linkGroup.selectAll('.graph-link')
385
+ .data(links, d => d.id);
386
+
387
+ const linkEnter = linkSel.enter().append('path')
388
+ .attr('class', d => `graph-link ${d.edgeType}`)
389
+ .attr('fill', 'none')
390
+ .attr('marker-end', d => this._edgeMarker(d.edgeType))
391
+ .attr('stroke-opacity', 0)
392
+ .transition().duration(600)
393
+ .attr('stroke-opacity', d => d.edgeType === 'pivoted_from' ? 0.9 : 0.6);
394
+
395
+ linkSel
396
+ .attr('class', d => `graph-link ${d.edgeType}`)
397
+ .attr('marker-end', d => this._edgeMarker(d.edgeType));
398
+ linkSel.exit().transition().duration(300).attr('stroke-opacity', 0).remove();
399
+
400
+ // Update nodes
401
+ const nodeSel = this.nodeGroup.selectAll('.graph-node')
402
+ .data(nodes, d => d.id);
403
+
404
+ const nodeEnter = nodeSel.enter().append('path')
405
+ .attr('class', 'graph-node')
406
+ .attr('d', d => this._nodeSymbol(d))
407
+ .attr('transform', d => `translate(${this.width / 2},${this.height / 2}) scale(0)`)
408
+ .attr('fill', d => this._nodeColor(d))
409
+ .attr('stroke', d => this._nodeStroke(d))
410
+ .attr('stroke-width', d => d.nodeType === 'host' ? 2 : 1.5)
411
+ .attr('filter', d => this._nodeFilter(d))
412
+ .attr('cursor', 'pointer')
413
+ .on('mouseover', (event, d) => this._showTooltip(event, d))
414
+ .on('mouseout', () => this._hideTooltip())
415
+ .on('click', (event, d) => this._highlightNode(d));
416
+
417
+ nodeEnter.transition().duration(500).ease(d3.easeBounceOut)
418
+ .attr('transform', d => `translate(${d.x || this.width/2},${d.y || this.height/2}) scale(1)`);
419
+
420
+ nodeSel.transition().duration(300)
421
+ .attr('fill', d => this._nodeColor(d))
422
+ .attr('stroke', d => this._nodeStroke(d))
423
+ .attr('filter', d => this._nodeFilter(d));
424
+
425
+ nodeSel.exit().transition().duration(300)
426
+ .attr('transform', d => `translate(${d.x},${d.y}) scale(0)`)
427
+ .remove();
428
+
429
+ // Update labels
430
+ const labelSel = this.labelGroup.selectAll('.node-label')
431
+ .data(nodes.filter(d => d.nodeType === 'host' || d.nodeType === 'vulnerability'), d => d.id);
432
+
433
+ const labelEnter = labelSel.enter().append('text')
434
+ .attr('class', 'node-label')
435
+ .attr('text-anchor', 'middle')
436
+ .attr('dy', d => this._nodeRadius(d) + 12)
437
+ .attr('opacity', 0)
438
+ .text(d => d.label);
439
+
440
+ labelEnter.transition().duration(500).attr('opacity', 0.7);
441
+ labelSel.text(d => d.label);
442
+ labelSel.exit().remove();
443
+
444
+ // Drag behavior
445
+ const drag = d3.drag()
446
+ .on('start', (event, d) => {
447
+ if (!event.active) this.simulation.alphaTarget(0.3).restart();
448
+ d.fx = d.x; d.fy = d.y;
449
+ })
450
+ .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
451
+ .on('end', (event, d) => {
452
+ if (!event.active) this.simulation.alphaTarget(0);
453
+ d.fx = null; d.fy = null;
454
+ });
455
+
456
+ this.nodeGroup.selectAll('.graph-node').call(drag);
457
+
458
+ // Restart simulation
459
+ this.simulation.nodes(nodes).on('tick', () => this._tick());
460
+ this.simulation.force('link').links(links);
461
+ this.simulation.alpha(0.5).restart();
462
+ }
463
+
464
+ _tick() {
465
+ this.linkGroup.selectAll('.graph-link')
466
+ .attr('d', d => {
467
+ const sx = d.source.x || 0, sy = d.source.y || 0;
468
+ const tx = d.target.x || 0, ty = d.target.y || 0;
469
+ const dx = tx - sx, dy = ty - sy;
470
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
471
+ // Perpendicular offset — bigger curve for pivot edges
472
+ const offset = d.edgeType === 'pivoted_from' ? 50 : (d.edgeType === 'part_of_chain' ? 8 : 20);
473
+ const cx = (sx + tx) / 2 - (dy / len) * offset;
474
+ const cy = (sy + ty) / 2 + (dx / len) * offset;
475
+ return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}`;
476
+ });
477
+
478
+ this.nodeGroup.selectAll('.graph-node')
479
+ .attr('transform', d => `translate(${d.x || 0},${d.y || 0})`);
480
+
481
+ this.labelGroup.selectAll('.node-label')
482
+ .attr('x', d => d.x || 0)
483
+ .attr('y', d => d.y || 0);
484
+ }
485
+
486
+ _nodeRadius(d) {
487
+ if (d.nodeType === 'host') return 12;
488
+ if (d.nodeType === 'process') return 7;
489
+ if (d.nodeType === 'alert') return 9;
490
+ if (d.nodeType === 'vulnerability') return 8;
491
+ return 8; // ioc
492
+ }
493
+
494
+ _nodeSymbol(d) {
495
+ const size = this._nodeRadius(d) * this._nodeRadius(d) * Math.PI;
496
+ if (d.nodeType === 'host') return d3.symbol().type(d3.symbolCircle).size(size * 1.2)();
497
+ if (d.nodeType === 'process') return d3.symbol().type(d3.symbolDiamond).size(size)();
498
+ if (d.nodeType === 'alert') return d3.symbol().type(d3.symbolTriangle).size(size)();
499
+ if (d.nodeType === 'vulnerability') return d3.symbol().type(d3.symbolSquare).size(size)();
500
+ // IOC = hexagon-like using wye
501
+ return d3.symbol().type(d3.symbolWye).size(size)();
502
+ }
503
+
504
+ _nodeColor(d) {
505
+ if (d.nodeType === 'host') {
506
+ if (d.status === 'compromised') return '#ef4444';
507
+ if (d.status === 'isolated') return '#6b7280';
508
+ if (d.status === 'queried') return '#06b6d4';
509
+ return '#3b82f6';
510
+ }
511
+ if (d.nodeType === 'process') {
512
+ return d.killed ? '#6b7280' : '#f59e0b';
513
+ }
514
+ if (d.nodeType === 'ioc') {
515
+ if (d.blocked) return 'transparent';
516
+ if (d.enriched) return '#8b5cf6';
517
+ return '#ef4444';
518
+ }
519
+ if (d.nodeType === 'alert') {
520
+ const colors = { critical: '#ef4444', high: '#f97316', medium: '#f59e0b', low: '#10b981' };
521
+ return d.isPivot ? '#ef4444' : (colors[d.severity] || '#94a3b8');
522
+ }
523
+ if (d.nodeType === 'vulnerability') return 'transparent';
524
+ return '#94a3b8';
525
+ }
526
+
527
+ _nodeStroke(d) {
528
+ if (d.nodeType === 'ioc') {
529
+ if (d.blocked) return '#10b981';
530
+ if (d.enriched) return '#8b5cf6';
531
+ return '#ef4444';
532
+ }
533
+ if (d.nodeType === 'vulnerability') return '#10b981';
534
+ if (d.nodeType === 'host' && d.status === 'compromised') return '#fca5a5';
535
+ if (d.nodeType === 'host' && d.status === 'isolated') return '#f59e0b';
536
+ return 'transparent';
537
+ }
538
+
539
+ _nodeFilter(d) {
540
+ if (d.nodeType === 'host' && d.status === 'compromised') return 'url(#glow-red)';
541
+ if (d.nodeType === 'host' && d.status === 'queried') return 'url(#glow-cyan)';
542
+ if (d.nodeType === 'ioc' && d.enriched) return 'url(#glow-purple)';
543
+ if (d.nodeType === 'ioc' && d.blocked) return 'url(#glow-green)';
544
+ if (d.nodeType === 'alert' && d.isPivot) return 'url(#glow-red)';
545
+ if (d.nodeType === 'alert' && d.severity === 'critical') return 'url(#glow-amber)';
546
+ if (d.nodeType === 'process' && !d.killed) return 'url(#glow-amber)';
547
+ return null;
548
+ }
549
+
550
+ _showTooltip(event, d) {
551
+ const tt = this.tooltip;
552
+ if (!tt) return;
553
+ let html = `<div class="graph-tooltip-title">${d.label || d.id}</div>`;
554
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Type:</span><span class="graph-tooltip-value">${d.nodeType}</span></div>`;
555
+
556
+ if (d.nodeType === 'host') {
557
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Status:</span><span class="graph-tooltip-value">${d.status || 'online'}</span></div>`;
558
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Subnet:</span><span class="graph-tooltip-value">${d.subnet || '—'}</span></div>`;
559
+ } else if (d.nodeType === 'ioc') {
560
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">IOC Type:</span><span class="graph-tooltip-value">${d.iocType || '—'}</span></div>`;
561
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Blocked:</span><span class="graph-tooltip-value">${d.blocked ? '✅' : '❌'}</span></div>`;
562
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Enriched:</span><span class="graph-tooltip-value">${d.enriched ? '✅' : '❌'}</span></div>`;
563
+ if (d.threatActor) html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Actor:</span><span class="graph-tooltip-value">${d.threatActor}</span></div>`;
564
+ } else if (d.nodeType === 'process') {
565
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Host:</span><span class="graph-tooltip-value">${d.hostname || '—'}</span></div>`;
566
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Status:</span><span class="graph-tooltip-value">${d.killed ? '💀 Killed' : '⚠️ Running'}</span></div>`;
567
+ } else if (d.nodeType === 'alert') {
568
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Severity:</span><span class="graph-tooltip-value">${d.severity}</span></div>`;
569
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Host:</span><span class="graph-tooltip-value">${d.sourceHost || '—'}</span></div>`;
570
+ if (d.isPivot) html += `<div style="color:#ef4444;margin-top:4px;font-size:10px;">⚡ LATERAL PIVOT</div>`;
571
+ } else if (d.nodeType === 'vulnerability') {
572
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">CVSS:</span><span class="graph-tooltip-value">${d.cvssScore}</span></div>`;
573
+ html += `<div class="graph-tooltip-row"><span class="graph-tooltip-key">Exploitability:</span><span class="graph-tooltip-value">${d.exploitability}</span></div>`;
574
+ }
575
+
576
+ tt.classed('hidden', false)
577
+ .style('left', (event.offsetX + 12) + 'px')
578
+ .style('top', (event.offsetY - 10) + 'px')
579
+ .html(html);
580
+ }
581
+
582
+ _hideTooltip() {
583
+ if (this.tooltip) this.tooltip.classed('hidden', true);
584
+ }
585
+
586
+ _highlightNode(d) {
587
+ // Dim all, highlight connected nodes
588
+ const connectedIds = new Set([d.id]);
589
+ this._linkData.forEach(l => {
590
+ const src = l.source.id || l.source;
591
+ const tgt = l.target.id || l.target;
592
+ if (src === d.id || tgt === d.id) {
593
+ connectedIds.add(src); connectedIds.add(tgt);
594
+ }
595
+ });
596
+
597
+ this.nodeGroup.selectAll('.graph-node')
598
+ .attr('opacity', n => connectedIds.has(n.id) ? 1 : 0.2);
599
+ this.linkGroup.selectAll('.graph-link')
600
+ .attr('opacity', l => {
601
+ const src = l.source.id || l.source;
602
+ const tgt = l.target.id || l.target;
603
+ return (src === d.id || tgt === d.id) ? 1 : 0.1;
604
+ });
605
+
606
+ // Reset after 3s
607
+ setTimeout(() => {
608
+ this.nodeGroup.selectAll('.graph-node').attr('opacity', 1);
609
+ this.linkGroup.selectAll('.graph-link').attr('opacity', l =>
610
+ l.edgeType === 'pivoted_from' ? 0.9 : 0.6
611
+ );
612
+ }, 3000);
613
+ }
614
+
615
+ // Animate a pivot edge with traveling dot
616
+ animatePivot(sourceId, targetId) {
617
+ const pivotLink = this._linkData.find(l => {
618
+ const s = l.source.id || l.source;
619
+ const t = l.target.id || l.target;
620
+ return l.edgeType === 'pivoted_from' && ((s === sourceId && t === targetId) || (s === targetId && t === sourceId));
621
+ });
622
+ if (!pivotLink) return;
623
+
624
+ const dot = this.graphGroup.append('circle')
625
+ .attr('r', 5)
626
+ .attr('fill', '#ef4444')
627
+ .attr('filter', 'url(#glow-red)')
628
+ .attr('opacity', 0);
629
+
630
+ let elapsed = 0;
631
+ const duration = 1500;
632
+ const cycles = 5;
633
+ const total = duration * cycles;
634
+
635
+ const timer = d3.timer(t => {
636
+ elapsed = t;
637
+ const progress = (t % duration) / duration;
638
+ const src = pivotLink.source;
639
+ const tgt = pivotLink.target;
640
+ if (!src || !tgt) { timer.stop(); dot.remove(); return; }
641
+ const sx = src.x || 0, sy = src.y || 0;
642
+ const tx = tgt.x || 0, ty = tgt.y || 0;
643
+ const x = sx + (tx - sx) * progress;
644
+ const y = sy + (ty - sy) * progress;
645
+ dot.attr('cx', x).attr('cy', y).attr('opacity', Math.sin(progress * Math.PI) * 0.9 + 0.1);
646
+ if (t > total) { timer.stop(); dot.remove(); }
647
+ });
648
+
649
+ this._pivotTimers.push(timer);
650
+ }
651
+
652
+ // Flash a specific node
653
+ flashNode(nodeId, color = 'red') {
654
+ const filterMap = { red: 'glow-red', blue: 'glow-blue', green: 'glow-green' };
655
+ const filter = `url(#${filterMap[color] || 'glow-red'})`;
656
+ const node = this.nodeGroup.selectAll('.graph-node').filter(d => d.id === nodeId);
657
+ if (node.empty()) return;
658
+ node.transition().duration(200).attr('filter', filter)
659
+ .transition().duration(200).attr('filter', null)
660
+ .transition().duration(200).attr('filter', filter)
661
+ .transition().duration(200).attr('filter', null)
662
+ .transition().duration(200).attr('filter', filter)
663
+ .transition().duration(500).attr('filter', d => this._nodeFilter(d));
664
+ }
665
+ }
666
+
667
+
668
+ // ============================================================
669
+ // Chart.js — 10-Dimensional Score Radar
670
+ // ============================================================
671
+ class RadarChart {
672
+ constructor(canvasId) {
673
+ this.canvasId = canvasId;
674
+ this.chart = null;
675
+ this.scores = {
676
+ threat_containment: 0, ioc_blocking: 0, forensic_investigation: 0,
677
+ siem_correlation: 0, threat_intel_usage: 0, vuln_root_cause: 0,
678
+ business_impact: 0, step_efficiency: 0, plan_coverage: 0, plan_evidence_quality: 0,
679
+ };
680
+ }
681
+
682
+ init() {
683
+ const canvas = document.getElementById(this.canvasId);
684
+ if (!canvas || !window.Chart) return;
685
+ const ctx = canvas.getContext('2d');
686
+
687
+ this.chart = new Chart(ctx, {
688
+ type: 'radar',
689
+ data: {
690
+ labels: [
691
+ 'Threat\nContainment',
692
+ 'IOC\nBlocking',
693
+ 'Forensic\nInvest.',
694
+ 'SIEM\nCorrelation',
695
+ 'Threat\nIntel',
696
+ 'Vuln\nRoot Cause',
697
+ 'Business\nImpact',
698
+ 'Step\nEfficiency',
699
+ 'Plan\nCoverage',
700
+ 'Plan\nEvidence',
701
+ ],
702
+ datasets: [{
703
+ data: Object.values(this.scores),
704
+ backgroundColor: 'rgba(6,182,212,0.15)',
705
+ borderColor: '#06b6d4',
706
+ borderWidth: 2,
707
+ pointBackgroundColor: '#3b82f6',
708
+ pointBorderColor: '#06b6d4',
709
+ pointBorderWidth: 1,
710
+ pointRadius: 3,
711
+ pointHoverRadius: 5,
712
+ }],
713
+ },
714
+ options: {
715
+ responsive: true,
716
+ maintainAspectRatio: false,
717
+ animation: { duration: 600, easing: 'easeInOutQuart' },
718
+ scales: {
719
+ r: {
720
+ beginAtZero: true,
721
+ min: 0,
722
+ max: 1,
723
+ ticks: {
724
+ count: 5,
725
+ color: '#4b5563',
726
+ font: { family: 'JetBrains Mono', size: 9 },
727
+ backdropColor: 'transparent',
728
+ stepSize: 0.25,
729
+ },
730
+ grid: { color: 'rgba(42,48,64,0.8)', circular: false },
731
+ angleLines: { color: 'rgba(42,48,64,0.6)' },
732
+ pointLabels: {
733
+ color: '#94a3b8',
734
+ font: { family: 'Inter', size: 9, weight: '500' },
735
+ },
736
+ },
737
+ },
738
+ plugins: {
739
+ legend: { display: false },
740
+ tooltip: {
741
+ backgroundColor: '#1a1f2e',
742
+ borderColor: '#2a3040',
743
+ borderWidth: 1,
744
+ titleColor: '#06b6d4',
745
+ bodyColor: '#94a3b8',
746
+ titleFont: { family: 'JetBrains Mono', size: 11 },
747
+ bodyFont: { family: 'JetBrains Mono', size: 10 },
748
+ callbacks: {
749
+ label: (ctx) => ` ${ctx.raw.toFixed(3)}`,
750
+ },
751
+ },
752
+ },
753
+ },
754
+ });
755
+ }
756
+
757
+ update(breakdown) {
758
+ if (!this.chart || !breakdown) return;
759
+ const order = [
760
+ 'threat_containment', 'ioc_blocking', 'forensic_investigation',
761
+ 'siem_correlation', 'threat_intel_usage', 'vuln_root_cause',
762
+ 'business_impact', 'step_efficiency', 'plan_coverage', 'plan_evidence_quality',
763
+ ];
764
+ this.chart.data.datasets[0].data = order.map(k => breakdown[k] ?? 0);
765
+ this.chart.update('active');
766
+ }
767
+
768
+ // Incrementally update a single dimension (for live updates during episode)
769
+ updateDimension(key, value) {
770
+ const order = [
771
+ 'threat_containment', 'ioc_blocking', 'forensic_investigation',
772
+ 'siem_correlation', 'threat_intel_usage', 'vuln_root_cause',
773
+ 'business_impact', 'step_efficiency', 'plan_coverage', 'plan_evidence_quality',
774
+ ];
775
+ const idx = order.indexOf(key);
776
+ if (idx < 0 || !this.chart) return;
777
+ this.chart.data.datasets[0].data[idx] = value;
778
+ this.chart.update('none');
779
+ }
780
+ }
781
+
782
+
783
+ // ============================================================
784
+ // Chart.js — Cumulative Reward Timeline
785
+ // ============================================================
786
+ class RewardTimeline {
787
+ constructor(canvasId) {
788
+ this.canvasId = canvasId;
789
+ this.chart = null;
790
+ }
791
+
792
+ init() {
793
+ const canvas = document.getElementById(this.canvasId);
794
+ if (!canvas || !window.Chart) return;
795
+ const ctx = canvas.getContext('2d');
796
+
797
+ // Gradient fill
798
+ const gradient = ctx.createLinearGradient(0, 0, 0, 100);
799
+ gradient.addColorStop(0, 'rgba(16,185,129,0.3)');
800
+ gradient.addColorStop(1, 'rgba(16,185,129,0.02)');
801
+
802
+ this.chart = new Chart(ctx, {
803
+ type: 'line',
804
+ data: {
805
+ labels: [],
806
+ datasets: [{
807
+ data: [],
808
+ borderColor: '#10b981',
809
+ backgroundColor: gradient,
810
+ borderWidth: 2,
811
+ pointRadius: 3,
812
+ pointBackgroundColor: ctx => {
813
+ const v = ctx.raw;
814
+ return v < 0 ? '#ef4444' : '#10b981';
815
+ },
816
+ pointBorderColor: 'transparent',
817
+ tension: 0.3,
818
+ fill: true,
819
+ }],
820
+ },
821
+ options: {
822
+ responsive: true,
823
+ maintainAspectRatio: false,
824
+ animation: { duration: 300 },
825
+ scales: {
826
+ x: {
827
+ grid: { color: 'rgba(42,48,64,0.5)' },
828
+ ticks: { color: '#4b5563', font: { family: 'JetBrains Mono', size: 9 }, maxTicksLimit: 10 },
829
+ title: { display: true, text: 'Step', color: '#4b5563', font: { size: 9 } },
830
+ },
831
+ y: {
832
+ grid: { color: 'rgba(42,48,64,0.5)' },
833
+ ticks: { color: '#4b5563', font: { family: 'JetBrains Mono', size: 9 } },
834
+ title: { display: true, text: 'Reward', color: '#4b5563', font: { size: 9 } },
835
+ },
836
+ },
837
+ plugins: {
838
+ legend: { display: false },
839
+ tooltip: {
840
+ backgroundColor: '#1a1f2e',
841
+ borderColor: '#2a3040',
842
+ borderWidth: 1,
843
+ titleColor: '#06b6d4',
844
+ bodyColor: '#94a3b8',
845
+ titleFont: { family: 'JetBrains Mono', size: 10 },
846
+ bodyFont: { family: 'JetBrains Mono', size: 10 },
847
+ },
848
+ annotation: {
849
+ annotations: {
850
+ zeroLine: {
851
+ type: 'line',
852
+ yMin: 0, yMax: 0,
853
+ borderColor: 'rgba(148,163,184,0.3)',
854
+ borderWidth: 1,
855
+ borderDash: [4, 4],
856
+ },
857
+ },
858
+ },
859
+ },
860
+ },
861
+ });
862
+ }
863
+
864
+ addPoint(step, cumulativeReward, actionType) {
865
+ if (!this.chart) return;
866
+ this.chart.data.labels.push(`${step}`);
867
+ this.chart.data.datasets[0].data.push(parseFloat(cumulativeReward.toFixed(3)));
868
+ // Color negative points red
869
+ const colors = this.chart.data.datasets[0].data.map(v => v < 0 ? '#ef4444' : '#10b981');
870
+ this.chart.data.datasets[0].pointBackgroundColor = colors;
871
+ this.chart.update('none');
872
+ }
873
+
874
+ reset() {
875
+ if (!this.chart) return;
876
+ this.chart.data.labels = [];
877
+ this.chart.data.datasets[0].data = [];
878
+ this.chart.update('none');
879
+ }
880
+ }
dashboard_server.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ CyberSOC Dashboard Server
4
+ =========================
5
+ Wraps the existing FastAPI app with:
6
+ - CORS middleware (required when dashboard is served separately)
7
+ - Static file serving for the dashboard at /dashboard/
8
+
9
+ Usage:
10
+ python dashboard_server.py
11
+ python dashboard_server.py --port 8000
12
+
13
+ Then open: http://localhost:8000/dashboard/
14
+ """
15
+
16
+ import argparse
17
+ import os
18
+ import sys
19
+
20
+ # Ensure MetaRound2 root is on sys.path
21
+ ROOT = os.path.dirname(os.path.abspath(__file__))
22
+ if ROOT not in sys.path:
23
+ sys.path.insert(0, ROOT)
24
+
25
+ try:
26
+ from server.app import app
27
+ except ImportError as e:
28
+ print(f"[ERROR] Could not import CyberSOCEnv app: {e}")
29
+ print("Make sure you have the openenv package installed.")
30
+ sys.exit(1)
31
+
32
+ from fastapi.middleware.cors import CORSMiddleware
33
+
34
+ # ── CORS (allow all origins for local demo) ─────────────────────────────────
35
+ app.add_middleware(
36
+ CORSMiddleware,
37
+ allow_origins=["*"],
38
+ allow_credentials=True,
39
+ allow_methods=["*"],
40
+ allow_headers=["*"],
41
+ )
42
+
43
+ # ── Static dashboard files at /dashboard/ ───────────────────────────────────
44
+ dashboard_dir = os.path.join(ROOT, "dashboard")
45
+ if os.path.isdir(dashboard_dir):
46
+ try:
47
+ from fastapi.staticfiles import StaticFiles
48
+ app.mount("/dashboard", StaticFiles(directory=dashboard_dir, html=True), name="dashboard")
49
+ _STATIC_OK = True
50
+ except ImportError:
51
+ _STATIC_OK = False
52
+ print("[WARN] aiofiles not installed — static file serving disabled.")
53
+ print(" Install with: pip install aiofiles")
54
+ else:
55
+ _STATIC_OK = False
56
+ print(f"[WARN] Dashboard directory not found: {dashboard_dir}")
57
+
58
+
59
+ def main():
60
+ parser = argparse.ArgumentParser(description="CyberSOC Dashboard Server")
61
+ parser.add_argument("--host", default="0.0.0.0")
62
+ parser.add_argument("--port", type=int, default=8000)
63
+ parser.add_argument("--reload", action="store_true")
64
+ args = parser.parse_args()
65
+
66
+ try:
67
+ import uvicorn
68
+ except ImportError:
69
+ print("[ERROR] uvicorn not installed. Run: pip install uvicorn")
70
+ sys.exit(1)
71
+
72
+ print()
73
+ print("╔══════════════════════════════════════════════╗")
74
+ print("║ 🛡️ CyberSOC Command Center ║")
75
+ print("╠══════════════════════════════════════════════╣")
76
+ print(f"║ API Server : http://localhost:{args.port:<5} ║")
77
+ if _STATIC_OK:
78
+ print(f"║ Dashboard : http://localhost:{args.port}/dashboard/ ║")
79
+ else:
80
+ print("║ Dashboard : open dashboard/index.html ║")
81
+ print("╚══════════════════════════════════════════════╝")
82
+ print()
83
+
84
+ uvicorn.run(
85
+ app,
86
+ host=args.host,
87
+ port=args.port,
88
+ reload=args.reload,
89
+ )
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
inference.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ # All rights reserved.
4
+ #
5
+ # This source code is licensed under the BSD-style license found in the
6
+ # LICENSE file in the root directory of this source tree.
7
+
8
+ """
9
+ CyberSOCEnv Baseline Inference Script.
10
+
11
+ HACKATHON RULES:
12
+ - File must be named inference.py in the project root
13
+ - Must use OpenAI Client for all LLM calls
14
+ - Must emit structured stdout logs: [START], [STEP], [END]
15
+ - Runtime < 20 minutes
16
+ - Must work on vcpu=2, memory=8gb
17
+
18
+ Environment Variables:
19
+ API_BASE_URL - The API endpoint for the LLM
20
+ MODEL_NAME - The model identifier to use for inference
21
+ HF_TOKEN - Your Hugging Face / API key
22
+ """
23
+
24
+ import asyncio
25
+ import json
26
+ import os
27
+ import textwrap
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ from openai import OpenAI
31
+
32
+ from models import SOCActionWrapper, SOCObservation
33
+ from server.play_environment import CyberSOCEnvironment
34
+
35
+ # =============================================================================
36
+ # Configuration (from environment variables)
37
+ # =============================================================================
38
+
39
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
40
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
41
+ HF_TOKEN = os.getenv("HF_TOKEN")
42
+
43
+ BENCHMARK = "cybersocenv"
44
+ TASKS = ["easy", "medium", "hard"]
45
+ MAX_STEPS = {"easy": 15, "medium": 25, "hard": 30}
46
+ TEMPERATURE = 0.1
47
+ MAX_TOKENS = 1024
48
+
49
+ # Scoring: normalize rewards to [0, 1]
50
+ MAX_POSSIBLE_REWARD = 2.0 # Approximate max reward per episode
51
+ SUCCESS_SCORE_THRESHOLD = 0.3
52
+
53
+ # =============================================================================
54
+ # System Prompt
55
+ # =============================================================================
56
+
57
+ SYSTEM_PROMPT = textwrap.dedent("""
58
+ You are an expert Cybersecurity SOC (Security Operations Center) Analyst AI.
59
+ You are responding to security incidents on a 500-node enterprise network.
60
+
61
+ Your goal: Investigate alerts, contain all threats, and submit a containment plan — while minimizing business downtime.
62
+
63
+ Available Actions (respond with exactly ONE JSON object per turn):
64
+
65
+ 1. Query a host: {"type": "query_host", "hostname": "<HOST>"}
66
+ 2. Isolate a segment (causes downtime): {"type": "isolate_segment", "subnet": "<SUBNET>", "reason": "<WHY>"}
67
+ 3. Block an IOC: {"type": "block_ioc", "ioc_value": "<VALUE>", "ioc_type": "ip|domain|hash"}
68
+ 4. Run forensics: {"type": "run_forensics", "hostname": "<HOST>"}
69
+ 5. Kill a process: {"type": "kill_process", "hostname": "<HOST>", "process_name": "<PROC>"}
70
+ 6. Submit containment plan (ends episode): {"type": "submit_containment_plan", "plan": [{"threat_id": "<ID>", "actions_taken": [...], "root_cause": "<CAUSE>", "confidence": 0.0-1.0}], "executive_summary": "<SUMMARY>"}
71
+
72
+ Rules:
73
+ - Respond with ONLY a valid JSON object. No markdown, no explanation.
74
+ - Investigate before acting. Query hosts and run forensics to gather evidence.
75
+ - Block IOCs (IPs, domains, hashes) found in alerts and forensics.
76
+ - Kill malicious processes found via forensics.
77
+ - Avoid unnecessary subnet isolation — it increases business impact.
78
+ - Submit the containment plan once you've contained all threats.
79
+ - You have a limited number of steps. Be efficient.
80
+ """).strip()
81
+
82
+
83
+ # =============================================================================
84
+ # Logging Helpers (EXACT hackathon format — lowercase booleans, null errors)
85
+ # =============================================================================
86
+
87
+ def log_start(task: str, env: str, model: str) -> None:
88
+ print(f"[START] task={task} env={env} model={model}", flush=True)
89
+
90
+
91
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
92
+ error_val = error if error else "null"
93
+ done_val = str(done).lower()
94
+ print(
95
+ f"[STEP] step={step} action={action} reward={reward:.2f} done={done_val} error={error_val}",
96
+ flush=True,
97
+ )
98
+
99
+
100
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
101
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
102
+ print(
103
+ f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}",
104
+ flush=True,
105
+ )
106
+
107
+
108
+ # =============================================================================
109
+ # Observation Formatting for LLM
110
+ # =============================================================================
111
+
112
+ def format_observation(obs: SOCObservation) -> str:
113
+ """Format observation into readable text for the LLM."""
114
+ parts = []
115
+
116
+ # Alert queue
117
+ if obs.alert_queue:
118
+ parts.append(f"## Active Alerts ({len(obs.alert_queue)}):")
119
+ for a in obs.alert_queue:
120
+ parts.append(
121
+ f" - [{a.severity.value.upper()}] {a.alert_id} "
122
+ f"on {a.source_host} ({a.subnet}): {a.description}"
123
+ )
124
+ if a.ioc_indicators:
125
+ parts.append(f" IOCs: {', '.join(a.ioc_indicators)}")
126
+
127
+ # Network topology
128
+ topo = obs.network_topology
129
+ parts.append(f"\n## Network Status:")
130
+ parts.append(f" Compromised: {topo.compromised_count} | "
131
+ f"Isolated: {topo.isolated_count} | "
132
+ f"Online: {topo.online_count}")
133
+
134
+ # Forensics
135
+ if obs.host_forensics:
136
+ f = obs.host_forensics
137
+ parts.append(f"\n## Forensics Result ({f.hostname}):")
138
+ parts.append(f" Compromised: {f.is_compromised}")
139
+ parts.append(f" Malicious processes: {f.malicious_processes}")
140
+ parts.append(f" Suspicious files: {f.suspicious_files}")
141
+ parts.append(f" Network connections: {f.network_connections}")
142
+ parts.append(f" Memory artifacts: {f.memory_artifacts}")
143
+
144
+ # Active threats
145
+ parts.append(f"\n## Active Threats: {obs.active_threats if obs.active_threats else 'None (all contained!)'}")
146
+ parts.append(f"## Business Impact: {obs.business_impact_score:.2f}")
147
+ parts.append(f"## Step: {obs.step_count} / {obs.max_steps}")
148
+
149
+ # Timeline (last 5)
150
+ if obs.timeline:
151
+ parts.append(f"\n## Recent Actions:")
152
+ for t in obs.timeline[-5:]:
153
+ parts.append(f" Step {t.step}: {t.action_type} -> {t.target} (reward={t.reward:.2f})")
154
+
155
+ return "\n".join(parts)
156
+
157
+
158
+ def parse_llm_action(content: str) -> Dict[str, Any]:
159
+ """Parse the LLM's response into a valid action dict."""
160
+ content = content.strip()
161
+ if content.startswith("```"):
162
+ lines = content.split("\n")
163
+ lines = [l for l in lines if not l.strip().startswith("```")]
164
+ content = "\n".join(lines).strip()
165
+
166
+ try:
167
+ action = json.loads(content)
168
+ if isinstance(action, dict) and "type" in action:
169
+ return action
170
+ except json.JSONDecodeError:
171
+ pass
172
+
173
+ # Try to find JSON in the response
174
+ for start in range(len(content)):
175
+ if content[start] == "{":
176
+ for end in range(len(content), start, -1):
177
+ if content[end - 1] == "}":
178
+ try:
179
+ action = json.loads(content[start:end])
180
+ if isinstance(action, dict) and "type" in action:
181
+ return action
182
+ except json.JSONDecodeError:
183
+ continue
184
+
185
+ raise ValueError(f"Could not parse action from LLM response: {content[:200]}")
186
+
187
+
188
+ def get_model_action(
189
+ client: OpenAI,
190
+ step: int,
191
+ obs: SOCObservation,
192
+ task_id: str,
193
+ history: List[str],
194
+ ) -> str:
195
+ """Get the next action from the LLM."""
196
+ obs_text = format_observation(obs)
197
+
198
+ if step == 1:
199
+ user_content = (
200
+ f"## Incident Briefing (Task: {task_id.upper()})\n\n"
201
+ f"{obs_text}\n\n"
202
+ f"Analyze the alerts and begin your investigation. Respond with a single JSON action."
203
+ )
204
+ else:
205
+ user_content = (
206
+ f"## Observation after your action:\n\n"
207
+ f"{obs_text}\n\n"
208
+ f"Continue your investigation. Respond with a single JSON action."
209
+ )
210
+
211
+ try:
212
+ completion = client.chat.completions.create(
213
+ model=MODEL_NAME,
214
+ messages=[
215
+ {"role": "system", "content": SYSTEM_PROMPT},
216
+ {"role": "user", "content": user_content},
217
+ ],
218
+ temperature=TEMPERATURE,
219
+ max_tokens=MAX_TOKENS,
220
+ stream=False,
221
+ )
222
+ text = (completion.choices[0].message.content or "").strip()
223
+ return text if text else '{"type": "query_host", "hostname": "WS-001"}'
224
+ except Exception as exc:
225
+ if "429" in str(exc) or "RateLimit" in str(exc):
226
+ raise # Let the batch runner handle rate limits
227
+ print(f"[DEBUG] Model request failed: {exc}", flush=True)
228
+ return '{"type": "query_host", "hostname": "WS-001"}'
229
+
230
+
231
+ # =============================================================================
232
+ # Episode Runner
233
+ # =============================================================================
234
+
235
+ async def run_episode(client: OpenAI, task_id: str) -> tuple:
236
+ """Run a single episode. Returns (success, steps, score, rewards)."""
237
+ env = CyberSOCEnvironment()
238
+ history: List[str] = []
239
+ rewards: List[float] = []
240
+ steps_taken = 0
241
+ score = 0.0
242
+ success = False
243
+
244
+ log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME)
245
+
246
+ try:
247
+ # Reset environment
248
+ obs = env.reset(task_id=task_id)
249
+
250
+ max_steps = MAX_STEPS.get(task_id, 30)
251
+
252
+ for step in range(1, max_steps + 1):
253
+ if obs.done:
254
+ break
255
+
256
+ # Get action from LLM
257
+ llm_response = get_model_action(client, step, obs, task_id, history)
258
+
259
+ # Parse and execute
260
+ error = None
261
+ action_str = "unknown"
262
+ reward = 0.0
263
+
264
+ try:
265
+ action_dict = parse_llm_action(llm_response)
266
+ action_str = action_dict.get("type", "unknown")
267
+ action = SOCActionWrapper(**action_dict)
268
+ obs = env.step(action)
269
+ reward = obs.reward or 0.0
270
+ done = obs.done
271
+ except Exception as exc:
272
+ error = str(exc)[:200]
273
+ done = False
274
+ reward = 0.0
275
+
276
+ rewards.append(reward)
277
+ steps_taken = step
278
+
279
+ log_step(step=step, action=action_str, reward=reward, done=done, error=error)
280
+
281
+ history.append(f"Step {step}: {action_str} -> reward {reward:+.2f}")
282
+
283
+ if done:
284
+ break
285
+
286
+ # Calculate score from final_score if available, else normalize rewards
287
+ if obs.final_score is not None:
288
+ score = obs.final_score
289
+ else:
290
+ score = sum(rewards) / MAX_POSSIBLE_REWARD if MAX_POSSIBLE_REWARD > 0 else 0.0
291
+
292
+ score = min(max(score, 0.0), 1.0) # clamp to [0, 1]
293
+ success = score >= SUCCESS_SCORE_THRESHOLD
294
+
295
+ finally:
296
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
297
+
298
+ return success, steps_taken, score, rewards
299
+
300
+
301
+ # =============================================================================
302
+ # Main
303
+ # =============================================================================
304
+
305
+ async def main() -> None:
306
+ """Run baseline inference across all tasks."""
307
+ client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
308
+
309
+ total_scores = {}
310
+ for task_id in TASKS:
311
+ success, steps, score, rewards = await run_episode(client, task_id)
312
+ total_scores[task_id] = score
313
+
314
+ # Print summary
315
+ avg = sum(total_scores.values()) / len(total_scores) if total_scores else 0.0
316
+ print(f"\n# Summary: avg_score={avg:.3f}", flush=True)
317
+ for tid, s in total_scores.items():
318
+ print(f"# {tid}: {s:.3f}", flush=True)
319
+
320
+
321
+ if __name__ == "__main__":
322
+ asyncio.run(main())
models.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Data models for the CyberSOCEnv — Enterprise Cybersecurity Operations Center.
9
+
10
+ Defines strict Pydantic models for:
11
+ - Observation: What the agent sees (alerts, forensics, network state, business impact)
12
+ - Action: What the agent can do (discriminated union of 6 action types)
13
+ - Internal state: Deterministic network graph, attack chains, and task tracking
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from enum import Enum
19
+ from typing import Annotated, Any, Dict, List, Literal, Optional, Union
20
+
21
+ from openenv.core.env_server.types import Action, Observation, State
22
+ from pydantic import BaseModel, ConfigDict, Field
23
+
24
+
25
+ # =============================================================================
26
+ # Enums
27
+ # =============================================================================
28
+
29
+
30
+ class Severity(str, Enum):
31
+ """SIEM alert severity levels."""
32
+ LOW = "low"
33
+ MEDIUM = "medium"
34
+ HIGH = "high"
35
+ CRITICAL = "critical"
36
+
37
+
38
+ class ThreatType(str, Enum):
39
+ """Classification of threat types in the SOC environment."""
40
+ RANSOMWARE = "ransomware"
41
+ PHISHING = "phishing"
42
+ CREDENTIAL_THEFT = "credential_theft"
43
+ LATERAL_MOVEMENT = "lateral_movement"
44
+ C2_COMMUNICATION = "c2_communication"
45
+ DATA_EXFILTRATION = "data_exfiltration"
46
+ PRIVILEGE_ESCALATION = "privilege_escalation"
47
+ MALWARE = "malware"
48
+ CRYPTOMINING = "cryptomining"
49
+ SUPPLY_CHAIN = "supply_chain"
50
+ INSIDER_THREAT = "insider_threat"
51
+ WEBSHELL = "webshell"
52
+ BOTNET = "botnet"
53
+
54
+
55
+ class HostStatus(str, Enum):
56
+ """Host operational status."""
57
+ ONLINE = "online"
58
+ COMPROMISED = "compromised"
59
+ ISOLATED = "isolated"
60
+ OFFLINE = "offline"
61
+
62
+
63
+ class SubnetRole(str, Enum):
64
+ """Business function of a network subnet."""
65
+ CORPORATE = "corporate"
66
+ ENGINEERING = "engineering"
67
+ FINANCE = "finance"
68
+ DMZ = "dmz"
69
+ DATACENTER = "datacenter"
70
+ EXECUTIVE = "executive"
71
+
72
+
73
+ # =============================================================================
74
+ # Alert & Network Sub-Models (used in Observation)
75
+ # =============================================================================
76
+
77
+
78
+ class Alert(BaseModel):
79
+ """A single SIEM/EDR alert in the queue."""
80
+ model_config = ConfigDict(extra="forbid")
81
+
82
+ alert_id: str = Field(..., description="Unique alert identifier")
83
+ timestamp: str = Field(..., description="ISO-8601 timestamp of the alert")
84
+ source_host: str = Field(..., description="Hostname that generated the alert")
85
+ severity: Severity = Field(..., description="Alert severity level")
86
+ threat_type: ThreatType = Field(..., description="Classified threat type")
87
+ description: str = Field(..., description="Human-readable alert description")
88
+ ioc_indicators: List[str] = Field(
89
+ default_factory=list,
90
+ description="Indicators of compromise (IPs, hashes, domains)",
91
+ )
92
+ subnet: str = Field(..., description="Subnet where the alert originated")
93
+ is_acknowledged: bool = Field(default=False, description="Whether the SOC analyst has acknowledged this alert")
94
+
95
+
96
+ class HostInfo(BaseModel):
97
+ """Summary information about a single network host."""
98
+ model_config = ConfigDict(extra="forbid")
99
+
100
+ hostname: str = Field(..., description="Host FQDN")
101
+ ip_address: str = Field(..., description="IPv4 address")
102
+ subnet: str = Field(..., description="Subnet the host belongs to")
103
+ role: SubnetRole = Field(..., description="Business function")
104
+ status: HostStatus = Field(default=HostStatus.ONLINE, description="Current status")
105
+ running_processes: List[str] = Field(default_factory=list, description="Running process names")
106
+ open_ports: List[int] = Field(default_factory=list, description="Open TCP ports")
107
+ criticality: float = Field(
108
+ default=0.5, ge=0.0, le=1.0,
109
+ description="Business criticality score (0=low, 1=mission-critical)",
110
+ )
111
+
112
+
113
+ class NetworkTopology(BaseModel):
114
+ """Summarized view of the 500-node enterprise network."""
115
+ model_config = ConfigDict(extra="forbid")
116
+
117
+ total_hosts: int = Field(default=500, description="Total hosts in the network")
118
+ subnets: Dict[str, int] = Field(
119
+ default_factory=dict,
120
+ description="Map of subnet name -> host count",
121
+ )
122
+ compromised_count: int = Field(default=0, description="Number of compromised hosts")
123
+ isolated_count: int = Field(default=0, description="Number of isolated hosts")
124
+ online_count: int = Field(default=500, description="Number of online hosts")
125
+
126
+
127
+ class ForensicsResult(BaseModel):
128
+ """Results from running forensics on a host."""
129
+ model_config = ConfigDict(extra="forbid")
130
+
131
+ hostname: str = Field(..., description="Analyzed host")
132
+ malicious_processes: List[str] = Field(default_factory=list, description="Detected malicious processes")
133
+ suspicious_files: List[str] = Field(default_factory=list, description="Suspicious file paths found")
134
+ network_connections: List[str] = Field(
135
+ default_factory=list,
136
+ description="Suspicious outbound connections (ip:port)",
137
+ )
138
+ registry_modifications: List[str] = Field(default_factory=list, description="Modified registry keys")
139
+ memory_artifacts: List[str] = Field(default_factory=list, description="In-memory IOCs found")
140
+ is_compromised: bool = Field(default=False, description="Whether forensics confirm compromise")
141
+
142
+
143
+ class TimelineEntry(BaseModel):
144
+ """A single entry in the analyst action timeline."""
145
+ model_config = ConfigDict(extra="forbid")
146
+
147
+ step: int = Field(..., description="Step number when this action was taken")
148
+ action_type: str = Field(..., description="Type of action taken")
149
+ target: str = Field(..., description="Target of the action (host, subnet, IOC)")
150
+ result: str = Field(..., description="Outcome description")
151
+ reward: float = Field(default=0.0, description="Reward received for this action")
152
+
153
+
154
+ # =============================================================================
155
+ # Observation
156
+ # =============================================================================
157
+
158
+
159
+ class SOCObservation(Observation):
160
+ """What the SOC agent sees at each step.
161
+
162
+ Extends OpenEnv Observation (inherits: done, reward, metadata).
163
+ """
164
+
165
+ episode_id: str = Field(
166
+ default="",
167
+ description="Unique UUID for this episode — used by the RL training loop to prevent hash collisions in GRPO batched rollouts.",
168
+ )
169
+ alert_queue: List[Alert] = Field(
170
+ default_factory=list,
171
+ description="Current queue of unresolved SIEM/EDR alerts",
172
+ )
173
+ network_topology: NetworkTopology = Field(
174
+ default_factory=NetworkTopology,
175
+ description="Summary of the enterprise network state",
176
+ )
177
+ host_forensics: Optional[ForensicsResult] = Field(
178
+ default=None,
179
+ description="Forensics results if RunForensics was the last action, else None",
180
+ )
181
+ timeline: List[TimelineEntry] = Field(
182
+ default_factory=list,
183
+ description="Chronological log of all actions taken in this episode",
184
+ )
185
+ business_impact_score: float = Field(
186
+ default=0.0, ge=0.0, le=1.0,
187
+ description="Current business impact (0=no impact, 1=catastrophic outage)",
188
+ )
189
+ step_count: int = Field(default=0, ge=0, description="Current step number")
190
+ active_threats: List[str] = Field(
191
+ default_factory=list,
192
+ description="List of threat IDs that are still active/uncontained",
193
+ )
194
+ max_steps: int = Field(default=30, description="Maximum steps allowed in this episode")
195
+ task_id: str = Field(default="easy", description="Current task identifier")
196
+ total_reward: float = Field(default=0.0, description="Accumulated episode reward")
197
+ final_score: Optional[float] = Field(
198
+ default=None,
199
+ description="Post-episode grader score (0.0-1.0). Only set when done=True and plan submitted.",
200
+ )
201
+ grade_breakdown: Optional[Dict[str, Any]] = Field(
202
+ default=None,
203
+ description="Detailed grading breakdown. Only set when done=True and plan submitted.",
204
+ )
205
+ correlation_results: Optional[Dict[str, Any]] = Field(
206
+ default=None,
207
+ description="Results from the most recent correlate_alerts call.",
208
+ )
209
+ ioc_enrichment: Optional[Dict[str, Any]] = Field(
210
+ default=None,
211
+ description="Results from the most recent enrich_ioc call.",
212
+ )
213
+ vulnerability_results: Optional[List[Dict[str, Any]]] = Field(
214
+ default=None,
215
+ description="Results from the most recent scan_host_vulnerabilities call.",
216
+ )
217
+ playbook_result: Optional[Dict[str, Any]] = Field(
218
+ default=None,
219
+ description="Results from the most recent trigger_playbook call.",
220
+ )
221
+ threat_graph_summary: Optional[str] = Field(
222
+ default=None,
223
+ description="Compact textual summary of the current threat graph.",
224
+ )
225
+ available_playbooks: List[str] = Field(
226
+ default_factory=list,
227
+ description="Names of SOAR playbooks available to the agent.",
228
+ )
229
+ reward_dimensions: Optional[Dict[str, float]] = Field(
230
+ default=None,
231
+ description=(
232
+ "Running partial scores for each of the 10 grading dimensions, "
233
+ "updated every step for live GRPO credit-assignment signals. "
234
+ "Keys match grade_breakdown (threat_containment, ioc_blocking, etc.)."
235
+ ),
236
+ )
237
+
238
+
239
+ # =============================================================================
240
+ # Actions (Discriminated Union)
241
+ # =============================================================================
242
+
243
+
244
+ class QueryHost(Action):
245
+ """Query a specific host for status, processes, and connections."""
246
+ type: Literal["query_host"] = Field(default="query_host", description="Action discriminator")
247
+ hostname: str = Field(..., description="Target hostname to query")
248
+
249
+
250
+ class IsolateSegment(Action):
251
+ """Isolate an entire network segment, or a single host from the network."""
252
+ type: Literal["isolate_segment"] = Field(default="isolate_segment", description="Action discriminator")
253
+ subnet: str = Field(default="", description="Subnet name to isolate (mutually exclusive with target_host)")
254
+ target_host: Optional[str] = Field(
255
+ default=None,
256
+ description="If set, isolate only this single host instead of the whole subnet",
257
+ )
258
+ reason: str = Field(default="", description="Justification for isolation")
259
+
260
+
261
+ class BlockIOC(Action):
262
+ """Block an Indicator of Compromise at the perimeter firewall."""
263
+ type: Literal["block_ioc"] = Field(default="block_ioc", description="Action discriminator")
264
+ ioc_value: str = Field(..., description="The IOC to block (IP, domain, or file hash)")
265
+ ioc_type: Literal["ip", "domain", "hash"] = Field(..., description="Type of IOC")
266
+
267
+
268
+ class RunForensics(Action):
269
+ """Run deep forensic analysis on a specific host."""
270
+ type: Literal["run_forensics"] = Field(default="run_forensics", description="Action discriminator")
271
+ hostname: str = Field(..., description="Target hostname for forensics")
272
+
273
+
274
+ class KillProcess(Action):
275
+ """Terminate a specific process on a host."""
276
+ type: Literal["kill_process"] = Field(default="kill_process", description="Action discriminator")
277
+ hostname: str = Field(..., description="Host where the process is running")
278
+ process_name: str = Field(..., description="Name of the process to terminate")
279
+
280
+
281
+ class ContainmentEntry(BaseModel):
282
+ """A single entry in the containment plan."""
283
+ model_config = ConfigDict(extra="forbid")
284
+
285
+ threat_id: str = Field(..., description="Threat being addressed")
286
+ actions_taken: List[str] = Field(..., description="List of actions taken to contain this threat")
287
+ root_cause: str = Field(..., description="Identified root cause")
288
+ confidence: float = Field(
289
+ ..., ge=0.0, le=1.0,
290
+ description="Confidence in the containment (0-1)",
291
+ )
292
+
293
+
294
+ class SubmitContainmentPlan(Action):
295
+ """Submit the final containment plan to end the episode."""
296
+ type: Literal["submit_containment_plan"] = Field(
297
+ default="submit_containment_plan", description="Action discriminator"
298
+ )
299
+ plan: List[ContainmentEntry] = Field(
300
+ ..., description="The containment plan addressing all identified threats"
301
+ )
302
+ executive_summary: str = Field(
303
+ ..., description="Brief executive summary for CISO reporting"
304
+ )
305
+
306
+
307
+ class CorrelateAlerts(Action):
308
+ """Correlate two or more alerts to find shared entities/IOCs."""
309
+ type: Literal["correlate_alerts"] = Field(default="correlate_alerts")
310
+ alert_ids: List[str] = Field(..., min_length=2, description="At least 2 alert IDs to correlate")
311
+
312
+
313
+ class EnrichIOC(Action):
314
+ """Enrich an IOC with threat-intelligence data (actor, MITRE TTPs)."""
315
+ type: Literal["enrich_ioc"] = Field(default="enrich_ioc")
316
+ ioc_value: str = Field(..., description="The IOC value to enrich")
317
+ ioc_type: Literal["ip", "domain", "hash", "filename"] = Field(..., description="Type of IOC")
318
+
319
+
320
+ class ScanHostVulnerabilities(Action):
321
+ """Run a vulnerability scan on a host to discover CVEs."""
322
+ type: Literal["scan_host_vulnerabilities"] = Field(default="scan_host_vulnerabilities")
323
+ hostname: str = Field(..., description="Target hostname for vulnerability scan")
324
+
325
+
326
+ class TriggerPlaybook(Action):
327
+ """Trigger a SOAR playbook against a target host."""
328
+ type: Literal["trigger_playbook"] = Field(default="trigger_playbook")
329
+ playbook_name: Literal[
330
+ "ransomware_containment",
331
+ "c2_disruption",
332
+ "lateral_movement_lockdown",
333
+ "phishing_response",
334
+ "data_exfil_stop",
335
+ ] = Field(..., description="Name of the SOAR playbook to trigger")
336
+ target: str = Field(..., description="Target hostname for playbook execution")
337
+
338
+
339
+ # Discriminated union of all SOC actions
340
+ SOCAction = Annotated[
341
+ Union[
342
+ QueryHost,
343
+ IsolateSegment,
344
+ BlockIOC,
345
+ RunForensics,
346
+ KillProcess,
347
+ SubmitContainmentPlan,
348
+ CorrelateAlerts,
349
+ EnrichIOC,
350
+ ScanHostVulnerabilities,
351
+ TriggerPlaybook,
352
+ ],
353
+ Field(discriminator="type"),
354
+ ]
355
+
356
+ # Wrapper model so OpenEnv's create_app can accept it as a single Action class
357
+ class SOCActionWrapper(Action):
358
+ """Wrapper that deserializes the discriminated union action.
359
+
360
+ OpenEnv's create_app expects a single Action subclass. This wrapper
361
+ uses a discriminated union field so the HTTP/WS layer can parse
362
+ any of the 6 action types from a flat JSON payload.
363
+
364
+ Client sends: {"action": {"type": "query_host", "hostname": "WS-001"}}
365
+ The wrapper validates -> QueryHost(hostname="WS-001")
366
+ """
367
+ type: str = Field(..., description="Action type discriminator")
368
+
369
+ model_config = ConfigDict(extra="allow") # Allow action-specific fields
370
+
371
+ def to_typed_action(self):
372
+ """Convert the raw wrapper into the correctly typed action."""
373
+ data = self.model_dump(exclude={"metadata"})
374
+ action_map = {
375
+ "query_host": QueryHost,
376
+ "isolate_segment": IsolateSegment,
377
+ "block_ioc": BlockIOC,
378
+ "run_forensics": RunForensics,
379
+ "kill_process": KillProcess,
380
+ "submit_containment_plan": SubmitContainmentPlan,
381
+ "correlate_alerts": CorrelateAlerts,
382
+ "enrich_ioc": EnrichIOC,
383
+ "scan_host_vulnerabilities": ScanHostVulnerabilities,
384
+ "trigger_playbook": TriggerPlaybook,
385
+ }
386
+ cls = action_map.get(data["type"])
387
+ if cls is None:
388
+ raise ValueError(
389
+ f"Unknown action type: {data['type']}. "
390
+ f"Valid types: {list(action_map.keys())}"
391
+ )
392
+ return cls(**data)
393
+
394
+
395
+ # =============================================================================
396
+ # Internal State (not exposed to agent directly)
397
+ # =============================================================================
398
+
399
+
400
+ class SOCState(State):
401
+ """Internal environment state tracking the attack simulation.
402
+
403
+ Extends OpenEnv State (inherits: episode_id, step_count).
404
+ Uses extra='allow' from base State.
405
+ """
406
+
407
+ task_id: str = Field(default="easy", description="Current task: 'easy', 'medium', or 'hard'")
408
+ max_steps: int = Field(default=30, description="Maximum steps for this episode")
409
+ total_reward: float = Field(default=0.0, description="Accumulated reward")
410
+ business_impact: float = Field(default=0.0, ge=0.0, le=1.0, description="Current business impact score")
411
+ contained_threats: List[str] = Field(default_factory=list, description="Threat IDs that have been contained")
412
+ active_threats: List[str] = Field(default_factory=list, description="Currently active threat IDs")
413
+ blocked_iocs: List[str] = Field(default_factory=list, description="IOCs blocked at perimeter")
414
+ isolated_subnets: List[str] = Field(default_factory=list, description="Isolated network segments")
415
+ forensics_run: List[str] = Field(default_factory=list, description="Hosts that had forensics run")
416
+ killed_processes: List[Dict[str, str]] = Field(default_factory=list, description="Processes killed")
417
+ queried_hosts: List[str] = Field(default_factory=list, description="Hosts queried")
418
+ timeline: List[Dict[str, Any]] = Field(default_factory=list, description="Action timeline")
419
+ is_done: bool = Field(default=False, description="Whether episode has ended")
420
+ submitted_plan: bool = Field(default=False, description="Whether containment plan was submitted")
421
+ enriched_iocs: List[str] = Field(default_factory=list, description="IOCs that have been threat-intel enriched")
422
+ scanned_hosts: List[str] = Field(default_factory=list, description="Hosts that had vulnerability scans")
423
+ correlated_alert_pairs: List[Any] = Field(default_factory=list, description="Pairs/groups of alert IDs correlated together")
424
+ triggered_playbooks: List[str] = Field(default_factory=list, description="SOAR playbooks that were triggered")
425
+ live_requirements: Optional[Dict[str, Any]] = Field(
426
+ default=None,
427
+ description="Mutable copy of containment_requirements (for adaptive grading).",
428
+ )
openenv.yaml ADDED
The diff for this file is too large to render. See raw diff
 
pyproject.toml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-cybersocenv"
13
+ version = "0.1.0"
14
+ description = "CyberSOCEnv — Enterprise SOC Incident Response environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
+ "openenv-core[core]>=0.2.2",
19
+ # Inference dependencies
20
+ "openai>=1.0.0",
21
+ "websockets>=12.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0.0",
27
+ "pytest-cov>=4.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ server = "play.server.app:main"
32
+
33
+ [tool.setuptools]
34
+ include-package-data = true
35
+ packages = ["play", "play.server"]
36
+ package-dir = { "play" = ".", "play.server" = "server" }
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ pydantic
4
+ networkx
5
+ websockets
6
+ openai
7
+ tenacity
8
+ openenv-core
server/Dockerfile ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base
8
+ # This Dockerfile is flexible and works for both:
9
+ # - In-repo environments (with local OpenEnv sources)
10
+ # - Standalone environments (with openenv from PyPI/Git)
11
+ # The build script (openenv build) handles context detection and sets appropriate build args.
12
+
13
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
14
+ FROM ${BASE_IMAGE} AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Ensure git is available (required for installing dependencies from VCS)
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends git && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Build argument to control whether we're building standalone or in-repo
24
+ ARG BUILD_MODE=in-repo
25
+ ARG ENV_NAME=play
26
+
27
+ # Copy environment code (always at root of build context)
28
+ COPY . /app/env
29
+
30
+ # For in-repo builds, openenv is already vendored in the build context
31
+ # For standalone builds, openenv will be installed via pyproject.toml
32
+ WORKDIR /app/env
33
+
34
+ # Ensure uv is available (for local builds where base image lacks it)
35
+ RUN if ! command -v uv >/dev/null 2>&1; then \
36
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
37
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
38
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
39
+ fi
40
+
41
+ # Install dependencies using uv sync
42
+ # If uv.lock exists, use it; otherwise resolve on the fly
43
+ RUN --mount=type=cache,target=/root/.cache/uv \
44
+ if [ -f uv.lock ]; then \
45
+ uv sync --frozen --no-install-project --no-editable; \
46
+ else \
47
+ uv sync --no-install-project --no-editable; \
48
+ fi
49
+
50
+ RUN --mount=type=cache,target=/root/.cache/uv \
51
+ if [ -f uv.lock ]; then \
52
+ uv sync --frozen --no-editable; \
53
+ else \
54
+ uv sync --no-editable; \
55
+ fi
56
+
57
+ # Final runtime stage
58
+ FROM ${BASE_IMAGE}
59
+
60
+ WORKDIR /app
61
+
62
+ # Copy the virtual environment from builder
63
+ COPY --from=builder /app/env/.venv /app/.venv
64
+
65
+ # Copy the environment code
66
+ COPY --from=builder /app/env /app/env
67
+
68
+ # Set PATH to use the virtual environment
69
+ ENV PATH="/app/.venv/bin:$PATH"
70
+
71
+ # Set PYTHONPATH so imports work correctly
72
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+
74
+ # Health check
75
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
76
+ CMD curl -f http://localhost:8000/health || exit 1
77
+
78
+ # Run the FastAPI server
79
+ # The module path is constructed to work with the /app/env structure
80
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """CyberSOCEnv server components."""
8
+
9
+ from .play_environment import CyberSOCEnvironment
10
+
11
+ __all__ = ["CyberSOCEnvironment"]
server/action_validation.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """3-gate action validation middleware: phase whitelist + schema + graph groundedness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .threat_graph import ThreatGraph
9
+
10
+
11
+ PHASE_TOOL_WHITELIST = {
12
+ "triage": {"read_alerts", "read_topology", "correlate_alerts"},
13
+ "investigation": {"query_host", "run_forensics", "add_ioc",
14
+ "enrich_ioc", "scan_host_vulnerabilities"},
15
+ "remediation": {"block_ioc", "kill_process", "isolate_segment",
16
+ "trigger_playbook", "request_human_approval"},
17
+ "report": {"submit_containment_plan"},
18
+ }
19
+
20
+ PHASE_VIOLATION = "PHASE_VIOLATION"
21
+ INVALID_PARAMS = "INVALID_PARAMS"
22
+ UNGROUNDED_ACTION = "UNGROUNDED_ACTION"
23
+
24
+
25
+ _REQUIRED_ARGS = {
26
+ "block_ioc": ["ioc_value"],
27
+ "kill_process": ["hostname", "process_name"],
28
+ "isolate_segment": ["target"],
29
+ "correlate_alerts": ["alert_ids"],
30
+ "enrich_ioc": ["ioc_value", "ioc_type"],
31
+ "scan_host_vulnerabilities": ["hostname"],
32
+ "trigger_playbook": ["playbook_name", "target"],
33
+ }
34
+
35
+
36
+ class ActionValidationMiddleware:
37
+
38
+ def validate(
39
+ self,
40
+ phase: str,
41
+ tool_name: str,
42
+ arguments: dict,
43
+ graph: "ThreatGraph",
44
+ ) -> Optional[dict]:
45
+ # Gate 1 — Phase whitelist
46
+ allowed = PHASE_TOOL_WHITELIST.get(phase, set())
47
+ if tool_name not in allowed:
48
+ return {
49
+ "error": PHASE_VIOLATION,
50
+ "message": (
51
+ f"Tool '{tool_name}' is not allowed in phase '{phase}'. "
52
+ f"Allowed tools: {sorted(allowed)}"
53
+ ),
54
+ "retry": False,
55
+ }
56
+
57
+ # Gate 2 — Argument presence (basic schema check)
58
+ required = _REQUIRED_ARGS.get(tool_name, [])
59
+ for arg in required:
60
+ if arg not in arguments:
61
+ return {
62
+ "error": INVALID_PARAMS,
63
+ "message": f"Missing required argument '{arg}' for tool '{tool_name}'",
64
+ "retry": True,
65
+ }
66
+ if tool_name == "correlate_alerts":
67
+ ids = arguments.get("alert_ids", [])
68
+ if not isinstance(ids, (list, tuple)) or len(ids) < 2:
69
+ return {
70
+ "error": INVALID_PARAMS,
71
+ "message": "correlate_alerts requires at least 2 alert_ids",
72
+ "retry": True,
73
+ }
74
+
75
+ # Gate 3 — Graph groundedness
76
+ if tool_name == "block_ioc":
77
+ if arguments["ioc_value"] not in graph.iocs:
78
+ return {
79
+ "error": UNGROUNDED_ACTION,
80
+ "message": "IOC not in Threat Graph. Run investigation first.",
81
+ "retry": True,
82
+ }
83
+ elif tool_name == "kill_process":
84
+ key = f"{arguments['hostname']}:{arguments['process_name']}"
85
+ if key not in graph.processes:
86
+ return {
87
+ "error": UNGROUNDED_ACTION,
88
+ "message": "Process not in Threat Graph.",
89
+ "retry": True,
90
+ }
91
+ elif tool_name == "enrich_ioc":
92
+ if arguments["ioc_value"] not in graph.iocs:
93
+ return {
94
+ "error": UNGROUNDED_ACTION,
95
+ "message": "IOC not known. Discover it during investigation first.",
96
+ "retry": True,
97
+ }
98
+
99
+ return None
server/app.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ FastAPI application for the CyberSOCEnv Environment.
9
+
10
+ Endpoints:
11
+ - POST /reset: Reset the environment (pass task_id in body)
12
+ - POST /step: Execute an action
13
+ - GET /state: Get current environment state
14
+ - GET /schema: Get action/observation schemas
15
+ - WS /ws: WebSocket endpoint for persistent sessions
16
+
17
+ Usage:
18
+ # Development (with auto-reload):
19
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
20
+
21
+ # Production:
22
+ uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
23
+ """
24
+
25
+ try:
26
+ from openenv.core.env_server.http_server import create_app
27
+ except Exception as e: # pragma: no cover
28
+ raise ImportError(
29
+ "openenv is required. Install with: pip install 'openenv-core[core]'"
30
+ ) from e
31
+
32
+ try:
33
+ from ..models import SOCObservation, SOCActionWrapper
34
+ from .play_environment import CyberSOCEnvironment
35
+ except (ImportError, ModuleNotFoundError):
36
+ from models import SOCObservation, SOCActionWrapper
37
+ from server.play_environment import CyberSOCEnvironment
38
+
39
+
40
+ # Create the app with the CyberSOCEnv environment
41
+ app = create_app(
42
+ CyberSOCEnvironment,
43
+ SOCActionWrapper,
44
+ SOCObservation,
45
+ env_name="cybersocenv",
46
+ max_concurrent_envs=16,
47
+ )
48
+
49
+ # CORS middleware — allows the dashboard frontend to communicate with the server
50
+ from starlette.middleware.cors import CORSMiddleware
51
+
52
+ app.add_middleware(
53
+ CORSMiddleware,
54
+ allow_origins=["*"],
55
+ allow_credentials=True,
56
+ allow_methods=["*"],
57
+ allow_headers=["*"],
58
+ )
59
+
60
+
61
+ def main(host: str = "0.0.0.0", port: int = 8000):
62
+ """Entry point for direct execution.
63
+
64
+ Usage:
65
+ python -m play.server.app
66
+ python -m play.server.app --port 8001
67
+ """
68
+ import uvicorn
69
+ uvicorn.run(app, host=host, port=port)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
server/episode_sandbox.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Episode sandbox — wall-clock + step-limit guard with state-integrity rollback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from copy import deepcopy
9
+
10
+
11
+ def _snapshot_hash(value) -> str:
12
+ """Deterministic SHA-256 hash of an arbitrary value.
13
+
14
+ Uses json.dumps with sort_keys=True and default=str so that dict-type
15
+ protected fields (task_def, live_requirements) are compared by content
16
+ rather than by object identity, preventing false-positive rollbacks.
17
+ """
18
+ serialized = json.dumps(value, sort_keys=True, default=str)
19
+ return hashlib.sha256(serialized.encode()).hexdigest()
20
+
21
+
22
+ EPISODE_TIMEOUT_SECONDS = 300
23
+ MAX_STEPS_PER_EPISODE = 20
24
+ PROTECTED_STATE_FIELDS = [
25
+ "_task_def",
26
+ "_live_requirements",
27
+ "_threat_graph",
28
+ "_step_count",
29
+ "_network",
30
+ "_host_index",
31
+ ]
32
+
33
+
34
+ class EpisodeTimeout(Exception):
35
+ pass
36
+
37
+
38
+ class StateIntegrityViolation(Exception):
39
+ pass
40
+
41
+
42
+ class EpisodeSandbox:
43
+ def __init__(self, env):
44
+ self.env = env
45
+ self._start_time = None
46
+ self._protected_snapshot: dict = {} # field -> deepcopy for rollback
47
+ self._snapshot_hashes: dict = {} # field -> SHA-256 of snapshot
48
+ self._hacking_attempts: list[str] = []
49
+
50
+ def __enter__(self):
51
+ self._start_time = time.time()
52
+ self._protected_snapshot = {}
53
+ self._snapshot_hashes = {}
54
+ for field in PROTECTED_STATE_FIELDS:
55
+ if hasattr(self.env, field):
56
+ original = deepcopy(getattr(self.env, field))
57
+ self._protected_snapshot[field] = original
58
+ self._snapshot_hashes[field] = _snapshot_hash(original)
59
+ return self
60
+
61
+ def __exit__(self, exc_type, exc_val, exc_tb):
62
+ if exc_type is not None and exc_type is not EpisodeTimeout:
63
+ return False
64
+
65
+ elapsed = time.time() - self._start_time
66
+ if elapsed > EPISODE_TIMEOUT_SECONDS:
67
+ raise EpisodeTimeout(
68
+ f"Episode exceeded {EPISODE_TIMEOUT_SECONDS}s wall-clock limit"
69
+ )
70
+
71
+ # Compare by content hash to avoid false-positives from identity comparison
72
+ for field, original_value in self._protected_snapshot.items():
73
+ current_value = getattr(self.env, field, None)
74
+ current_hash = _snapshot_hash(current_value)
75
+ if current_hash != self._snapshot_hashes[field]:
76
+ setattr(self.env, field, original_value)
77
+ self._hacking_attempts.append(
78
+ f"Protected field '{field}' was mutated externally"
79
+ )
80
+ return False
81
+
82
+ def check_step_limit(self, step_count: int) -> None:
83
+ """Raise EpisodeTimeout if step_count >= MAX_STEPS_PER_EPISODE."""
84
+ if step_count >= MAX_STEPS_PER_EPISODE:
85
+ raise EpisodeTimeout(
86
+ f"Episode exceeded {MAX_STEPS_PER_EPISODE} step limit"
87
+ )
88
+
89
+ def elapsed_seconds(self) -> float:
90
+ if self._start_time is None:
91
+ return 0.0
92
+ return time.time() - self._start_time
93
+
94
+ def was_hacked(self) -> bool:
95
+ return len(self._hacking_attempts) > 0
96
+
97
+ def hacking_report(self) -> list[str]:
98
+ return self._hacking_attempts.copy()
server/graders.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ 10-dimensional deterministic graders for CyberSOCEnv.
9
+
10
+ grade_episode() returns a structured dict with per-dimension breakdown,
11
+ penalties, bonuses, and reward signals suitable for GRPO. Wrappers
12
+ grade_easy/medium/hard preserve their backward-compatible float return.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from .threat_graph import ThreatGraph
21
+
22
+
23
+ _DIMENSION_WEIGHTS = {
24
+ "threat_containment": 0.20,
25
+ "ioc_blocking": 0.12,
26
+ "forensic_investigation": 0.10,
27
+ "siem_correlation": 0.08,
28
+ "threat_intel_usage": 0.08,
29
+ "vuln_root_cause": 0.08,
30
+ "business_impact": 0.10,
31
+ "step_efficiency": 0.07,
32
+ "plan_coverage": 0.10,
33
+ "plan_evidence_quality": 0.07,
34
+ }
35
+
36
+ _PER_OCCURRENCE_CAP = 0.15
37
+
38
+
39
+ def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
40
+ return max(lo, min(hi, x))
41
+
42
+
43
+ def _capped(delta: float) -> float:
44
+ return max(-_PER_OCCURRENCE_CAP, min(_PER_OCCURRENCE_CAP, delta))
45
+
46
+
47
+ def grade_episode(
48
+ episode_actions: List[Dict[str, Any]],
49
+ final_plan: Optional[Dict[str, Any]],
50
+ graph: "ThreatGraph",
51
+ task_def: Dict[str, Any],
52
+ state,
53
+ ) -> Dict[str, Any]:
54
+ """Score the episode across 10 dimensions and return structured output."""
55
+
56
+ requirements = task_def.get("containment_requirements", {}) or {}
57
+ must_kill = requirements.get("must_kill", []) or []
58
+ must_block = requirements.get("must_block_iocs", []) or []
59
+ breakdown: Dict[str, float] = {}
60
+ penalties: List[Dict[str, Any]] = []
61
+ bonuses: List[Dict[str, Any]] = []
62
+
63
+ killed_processes = list(getattr(state, "killed_processes", []) or [])
64
+ blocked_iocs = list(getattr(state, "blocked_iocs", []) or [])
65
+ isolated_subnets = list(getattr(state, "isolated_subnets", []) or [])
66
+ scanned_hosts = list(getattr(state, "scanned_hosts", []) or [])
67
+ enriched_iocs = list(getattr(state, "enriched_iocs", []) or [])
68
+ correlated_pairs = list(getattr(state, "correlated_alert_pairs", []) or [])
69
+ triggered_playbooks = list(getattr(state, "triggered_playbooks", []) or [])
70
+
71
+ # ---- 1. threat_containment ----
72
+ if must_kill:
73
+ matched = 0
74
+ for req in must_kill:
75
+ req_host = req.get("hostname") if isinstance(req, dict) else None
76
+ req_proc = req.get("process") if isinstance(req, dict) else None
77
+ for k in killed_processes:
78
+ if k.get("hostname") == req_host and k.get("process") == req_proc:
79
+ matched += 1
80
+ break
81
+ breakdown["threat_containment"] = matched / len(must_kill)
82
+ else:
83
+ breakdown["threat_containment"] = 1.0
84
+
85
+ # ---- 2. ioc_blocking ----
86
+ if must_block:
87
+ matched_blocks = sum(1 for ioc in must_block if ioc in blocked_iocs)
88
+ breakdown["ioc_blocking"] = matched_blocks / len(must_block)
89
+ else:
90
+ breakdown["ioc_blocking"] = 1.0
91
+
92
+ # blind blocking penalty: blocked IOCs that were never enriched
93
+ blind_count = sum(1 for ioc in blocked_iocs if ioc not in enriched_iocs)
94
+ if blind_count > 0:
95
+ delta = _capped(-0.05 * blind_count)
96
+ penalties.append({
97
+ "type": "blind_blocking",
98
+ "delta": delta,
99
+ "detail": f"{blind_count} IOC(s) blocked without enrichment",
100
+ })
101
+ breakdown["ioc_blocking"] = _clamp(breakdown["ioc_blocking"] + delta)
102
+
103
+ # ---- 3. forensic_investigation ----
104
+ compromised_hosts = [
105
+ h for h, node in graph.hosts.items() if node.status == "compromised"
106
+ ]
107
+ if compromised_hosts:
108
+ examined = sum(1 for h in compromised_hosts if h in scanned_hosts)
109
+ breakdown["forensic_investigation"] = examined / len(compromised_hosts)
110
+ else:
111
+ breakdown["forensic_investigation"] = 1.0
112
+
113
+ # ---- 4. siem_correlation ----
114
+ if correlated_pairs:
115
+ # Build a set of alert_ids that belong to the same threat chain
116
+ alert_to_threat: Dict[str, str] = {}
117
+ for threat in task_def.get("attack_chain", []) or []:
118
+ tid = threat.get("threat_id", "")
119
+ for a in task_def.get("initial_alerts", []) or []:
120
+ src = a.get("source_host", "")
121
+ if src in (threat.get("compromised_hosts", []) or []):
122
+ alert_to_threat[a.get("alert_id", "")] = tid
123
+
124
+ correct_pairs = 0
125
+ incorrect_pairs = 0
126
+ for pair in correlated_pairs:
127
+ if isinstance(pair, (list, tuple)) and len(pair) == 2:
128
+ t1 = alert_to_threat.get(pair[0], "__UNK1__")
129
+ t2 = alert_to_threat.get(pair[1], "__UNK2__")
130
+ if t1 == t2 and t1 != "__UNK1__":
131
+ correct_pairs += 1
132
+ else:
133
+ incorrect_pairs += 1
134
+ else:
135
+ correct_pairs += 1 # legacy format: assume valid
136
+
137
+ expected_pairs = max(1, len(set(alert_to_threat.values())))
138
+ raw_corr = max(0.0, (correct_pairs - incorrect_pairs * 0.5)) / expected_pairs
139
+ breakdown["siem_correlation"] = _clamp(raw_corr)
140
+
141
+ # bonus if correlation happened before any remediation action
142
+ first_remediation_idx = next(
143
+ (i for i, a in enumerate(episode_actions)
144
+ if a.get("action_type") in {"block_ioc", "kill_process",
145
+ "isolate_segment", "trigger_playbook"}),
146
+ None,
147
+ )
148
+ first_correlation_idx = next(
149
+ (i for i, a in enumerate(episode_actions)
150
+ if a.get("action_type") == "correlate_alerts"),
151
+ None,
152
+ )
153
+ if first_correlation_idx is not None and (
154
+ first_remediation_idx is None or first_correlation_idx < first_remediation_idx
155
+ ):
156
+ delta = _capped(0.03)
157
+ bonuses.append({
158
+ "type": "early_correlation",
159
+ "delta": delta,
160
+ "detail": "correlation occurred before any remediation",
161
+ })
162
+ breakdown["siem_correlation"] = _clamp(breakdown["siem_correlation"] + delta)
163
+ else:
164
+ breakdown["siem_correlation"] = 0.0
165
+
166
+ # ---- 5. threat_intel_usage ----
167
+ total_iocs = len(graph.iocs)
168
+ if total_iocs == 0:
169
+ breakdown["threat_intel_usage"] = 0.5
170
+ else:
171
+ enriched_in_graph = sum(1 for ioc in graph.iocs.values() if ioc.enriched)
172
+ breakdown["threat_intel_usage"] = enriched_in_graph / total_iocs
173
+
174
+ # ---- 6. vuln_root_cause ----
175
+ cve_found = any(
176
+ v.exploited_by_threat is not None for v in graph.vulnerabilities.values()
177
+ )
178
+ breakdown["vuln_root_cause"] = 1.0 if cve_found else 0.0
179
+ if cve_found and final_plan:
180
+ cve_ids = {v.cve_id for v in graph.vulnerabilities.values()
181
+ if v.exploited_by_threat is not None}
182
+ plan_blob = str(final_plan)
183
+ if any(cid in plan_blob for cid in cve_ids):
184
+ delta = _capped(0.05)
185
+ bonuses.append({
186
+ "type": "cve_in_plan",
187
+ "delta": delta,
188
+ "detail": "CVE root-cause referenced in final plan",
189
+ })
190
+ breakdown["vuln_root_cause"] = _clamp(breakdown["vuln_root_cause"] + delta)
191
+
192
+ # ---- 7. business_impact ----
193
+ base = 1.0
194
+ must_not_isolate = requirements.get("must_not_isolate", []) or []
195
+ for s in [s for s in isolated_subnets if s in must_not_isolate]:
196
+ delta = _capped(-0.10)
197
+ penalties.append({
198
+ "type": "unnecessary_isolation",
199
+ "delta": delta,
200
+ "detail": f"subnet '{s}' isolated unnecessarily",
201
+ })
202
+ base += delta
203
+
204
+ # Per healthy-host isolation (graph view): every isolated host costs -0.10
205
+ healthy_isolated = sum(1 for h in graph.hosts.values() if h.status == "isolated")
206
+ for _ in range(healthy_isolated):
207
+ delta = _capped(-0.10)
208
+ penalties.append({
209
+ "type": "healthy_host_isolated",
210
+ "delta": delta,
211
+ "detail": "host was isolated (potential downtime)",
212
+ })
213
+ base += delta
214
+
215
+ total_hosts = len(graph.hosts)
216
+ if total_hosts > 0:
217
+ isolated_count = healthy_isolated
218
+ if isolated_count / total_hosts > 0.20:
219
+ # Spec specifies -0.30 here; bypass the per-occurrence cap so the
220
+ # over-isolation flag can dominate as intended.
221
+ delta = -0.30
222
+ penalties.append({
223
+ "type": "over_isolation",
224
+ "delta": delta,
225
+ "detail": ">20% of hosts isolated",
226
+ })
227
+ base += delta
228
+
229
+ breakdown["business_impact"] = max(0.0, base)
230
+
231
+ # ---- 8. step_efficiency ----
232
+ eff_base = 1.0 if triggered_playbooks else 0.5
233
+ playbook_bonus_total = 0.0
234
+ for _ in triggered_playbooks:
235
+ delta = _capped(0.10)
236
+ bonuses.append({
237
+ "type": "playbook_triggered",
238
+ "delta": delta,
239
+ "detail": "SOAR playbook used",
240
+ })
241
+ playbook_bonus_total += delta
242
+ playbook_bonus_total = min(playbook_bonus_total, 0.30)
243
+ eff_base += playbook_bonus_total
244
+
245
+ steps_used = len(episode_actions)
246
+ over = max(0, steps_used - 15)
247
+ if over > 0:
248
+ delta = _capped(-0.05 * over)
249
+ penalties.append({
250
+ "type": "step_overrun",
251
+ "delta": delta,
252
+ "detail": f"used {steps_used} steps, over budget by {over}",
253
+ })
254
+ eff_base += delta
255
+ breakdown["step_efficiency"] = _clamp(eff_base)
256
+
257
+ # ---- 9. plan_coverage ----
258
+ if final_plan is None:
259
+ breakdown["plan_coverage"] = 0.0
260
+ else:
261
+ # total known threats = unique threat IDs in containment_requirements
262
+ known_threats = set()
263
+ for k in ("must_kill", "must_block_iocs", "must_forensics"):
264
+ for entry in requirements.get(k, []) or []:
265
+ if isinstance(entry, dict) and "threat_id" in entry:
266
+ known_threats.add(entry["threat_id"])
267
+ # also include attack_chain threats
268
+ for t in task_def.get("attack_chain", []) or []:
269
+ if isinstance(t, dict) and "threat_id" in t:
270
+ known_threats.add(t["threat_id"])
271
+
272
+ if not known_threats:
273
+ breakdown["plan_coverage"] = 1.0
274
+ else:
275
+ plan_blob = str(final_plan)
276
+ covered = sum(1 for t in known_threats if t in plan_blob)
277
+ raw_coverage = covered / len(known_threats)
278
+
279
+ # Plan padding penalty: entries with no evidence are punished
280
+ plan_entries = final_plan.get("entries", []) if isinstance(final_plan, dict) else []
281
+ padded = sum(
282
+ 1 for e in plan_entries
283
+ if isinstance(e, dict) and (
284
+ (e.get("confidence", 1.0) < 0.2) or
285
+ (not e.get("root_cause"))
286
+ )
287
+ )
288
+ if padded > 0:
289
+ delta = _capped(-0.10 * padded)
290
+ penalties.append({
291
+ "type": "plan_padding",
292
+ "delta": delta,
293
+ "detail": f"{padded} plan entries lack evidence (confidence<0.2 or empty root_cause)",
294
+ })
295
+ raw_coverage = max(0.0, raw_coverage + delta)
296
+
297
+ breakdown["plan_coverage"] = _clamp(raw_coverage)
298
+
299
+ # ---- 10. plan_evidence_quality ----
300
+ if final_plan is None:
301
+ breakdown["plan_evidence_quality"] = 0.0
302
+ else:
303
+ primary = final_plan.get("primary_threat_id", "") if isinstance(final_plan, dict) else ""
304
+ rubric_items = (
305
+ len(must_kill)
306
+ + len(must_block)
307
+ + len(requirements.get("must_forensics", []) or [])
308
+ )
309
+ breakdown["plan_evidence_quality"] = _clamp(
310
+ graph.compute_evidence_confidence(primary, rubric_item_count=rubric_items)
311
+ )
312
+
313
+ # Final weighted score
314
+ raw_score = sum(_DIMENSION_WEIGHTS[k] * v for k, v in breakdown.items())
315
+ final_score = _clamp(raw_score)
316
+
317
+ reward_functions = {f"reward_{k}": v for k, v in breakdown.items()}
318
+
319
+ return {
320
+ "final_score": final_score,
321
+ "breakdown": breakdown,
322
+ "penalties": penalties,
323
+ "bonuses": bonuses,
324
+ "reward_functions": reward_functions,
325
+ }
326
+
327
+
328
+ def grade_easy(
329
+ episode_actions: List[Dict[str, Any]],
330
+ final_plan: Optional[Dict[str, Any]],
331
+ graph: "ThreatGraph",
332
+ task_def: Dict[str, Any],
333
+ state,
334
+ ) -> float:
335
+ """Backward-compatible: returns final_score float for the easy task."""
336
+ return grade_episode(episode_actions, final_plan, graph, task_def, state)["final_score"]
337
+
338
+
339
+ def grade_medium(
340
+ episode_actions: List[Dict[str, Any]],
341
+ final_plan: Optional[Dict[str, Any]],
342
+ graph: "ThreatGraph",
343
+ task_def: Dict[str, Any],
344
+ state,
345
+ ) -> float:
346
+ """Backward-compatible: returns final_score float for the medium task."""
347
+ return grade_episode(episode_actions, final_plan, graph, task_def, state)["final_score"]
348
+
349
+
350
+ def grade_hard(
351
+ episode_actions: List[Dict[str, Any]],
352
+ final_plan: Optional[Dict[str, Any]],
353
+ graph: "ThreatGraph",
354
+ task_def: Dict[str, Any],
355
+ state,
356
+ ) -> float:
357
+ """Backward-compatible: returns final_score float for the hard task."""
358
+ return grade_episode(episode_actions, final_plan, graph, task_def, state)["final_score"]
server/play_environment.py ADDED
@@ -0,0 +1,1315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ CyberSOCEnv — Enterprise Cybersecurity Operations Center Environment.
9
+
10
+ Implements the OpenEnv Environment interface for a deterministic SOC
11
+ incident response simulation on a 500-node enterprise network.
12
+
13
+ The agent receives SIEM/EDR alerts, queries hosts, runs forensics,
14
+ isolates segments, blocks IOCs, kills processes, and submits a
15
+ containment plan — all while minimizing business downtime.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import copy
21
+ import random
22
+ import uuid
23
+ from typing import Any, Dict, List, Optional
24
+ from uuid import uuid4
25
+
26
+ from openenv.core.env_server.interfaces import Environment
27
+ from openenv.core.env_server.types import State
28
+
29
+ try:
30
+ from ..models import (
31
+ SOCObservation,
32
+ SOCActionWrapper,
33
+ SOCState,
34
+ Alert,
35
+ NetworkTopology,
36
+ ForensicsResult,
37
+ TimelineEntry,
38
+ QueryHost,
39
+ IsolateSegment,
40
+ BlockIOC,
41
+ RunForensics,
42
+ KillProcess,
43
+ SubmitContainmentPlan,
44
+ CorrelateAlerts,
45
+ EnrichIOC,
46
+ ScanHostVulnerabilities,
47
+ TriggerPlaybook,
48
+ )
49
+ except ImportError:
50
+ from models import (
51
+ SOCObservation,
52
+ SOCActionWrapper,
53
+ SOCState,
54
+ Alert,
55
+ NetworkTopology,
56
+ ForensicsResult,
57
+ TimelineEntry,
58
+ QueryHost,
59
+ IsolateSegment,
60
+ BlockIOC,
61
+ RunForensics,
62
+ KillProcess,
63
+ SubmitContainmentPlan,
64
+ CorrelateAlerts,
65
+ EnrichIOC,
66
+ ScanHostVulnerabilities,
67
+ TriggerPlaybook,
68
+ )
69
+
70
+ from .tasks import get_task, build_network
71
+ from .graders import grade_episode
72
+ from .threat_graph import (
73
+ ThreatGraph,
74
+ HostNode,
75
+ ProcessNode,
76
+ IOCNode,
77
+ VulnerabilityNode,
78
+ AlertNode,
79
+ Edge,
80
+ )
81
+ from .soar_playbooks import PLAYBOOKS, check_prerequisites
82
+
83
+
84
+ class ActionMiddleware:
85
+ """Pre-flight validation for SOC actions.
86
+
87
+ Detects phase violations (action out of order) and graph-ungrounded actions
88
+ (action references an entity not yet discovered in the ThreatGraph).
89
+ Returns None if the action is valid, or an error dict otherwise.
90
+ """
91
+
92
+ def validate(
93
+ self,
94
+ current_phase: str,
95
+ action_type: str,
96
+ args: Dict[str, Any],
97
+ graph,
98
+ ) -> Optional[Dict[str, str]]:
99
+ # Phase violation: plan submission before any investigation
100
+ if action_type == "submit_containment_plan" and current_phase == "triage":
101
+ return {
102
+ "error_type": "PHASE_VIOLATION",
103
+ "message": "submit_containment_plan requires investigation phase first",
104
+ }
105
+
106
+ # Graph-groundedness: IOC must be discovered before enrichment
107
+ if action_type == "enrich_ioc":
108
+ ioc_val = args.get("ioc_value", "")
109
+ if ioc_val and graph is not None and ioc_val not in graph.iocs:
110
+ return {
111
+ "error_type": "GRAPH_FAILURE",
112
+ "message": f"IOC '{ioc_val}' not in threat graph; receive an alert or run forensics first",
113
+ }
114
+
115
+ # Graph-groundedness: host must be known before vulnerability scan
116
+ if action_type == "scan_host_vulnerabilities":
117
+ hostname = args.get("hostname", "")
118
+ if hostname and graph is not None and hostname not in graph.hosts:
119
+ return {
120
+ "error_type": "GRAPH_FAILURE",
121
+ "message": f"Host '{hostname}' not in threat graph; run query_host first",
122
+ }
123
+
124
+ return None
125
+
126
+
127
+ class CyberSOCEnvironment(Environment):
128
+ """
129
+ Deterministic SOC incident response environment.
130
+
131
+ Simulates a 500-node enterprise network under attack. The agent must
132
+ investigate alerts, contain threats, and submit a containment plan
133
+ while minimizing business downtime.
134
+
135
+ Supports concurrent WebSocket sessions (each gets own instance).
136
+
137
+ Example:
138
+ >>> env = CyberSOCEnvironment()
139
+ >>> obs = env.reset(task_id="easy")
140
+ >>> print(len(obs.alert_queue)) # Initial alerts
141
+ >>> obs = env.step(SOCActionWrapper(type="query_host", hostname="WS-042"))
142
+ """
143
+
144
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
145
+
146
+ def __init__(self, adaptive: bool = False):
147
+ """Initialize the environment (actual state set in reset)."""
148
+ super().__init__()
149
+ self._adaptive = adaptive
150
+ self._live_requirements: Dict[str, Any] = {}
151
+ self._threat_graph = None # will be initialized on reset()
152
+ self._state = SOCState(episode_id=str(uuid4()), step_count=0)
153
+ self._network: Dict[str, List[Dict[str, Any]]] = {}
154
+ self._task_def: Dict[str, Any] = {}
155
+ self._alert_queue: List[Dict[str, Any]] = []
156
+ self._host_index: Dict[str, Dict[str, Any]] = {} # hostname -> host dict
157
+ self._plan_entries: List[Dict[str, Any]] = []
158
+ self._last_forensics: Optional[ForensicsResult] = None
159
+ self._middleware = ActionMiddleware()
160
+ self._rng = random.Random(0) # overwritten in reset()
161
+
162
+ def _reset_rubric(self):
163
+ """Initialize live containment requirements for dynamic grading in adaptive mode."""
164
+ import copy
165
+ self._live_requirements = copy.deepcopy(
166
+ self._task_def.get("containment_requirements", {})
167
+ )
168
+
169
+ # ===========================================================================
170
+ # reset()
171
+ # ===========================================================================
172
+
173
+ def reset(
174
+ self,
175
+ seed: Optional[int] = None,
176
+ episode_id: Optional[str] = None,
177
+ **kwargs: Any,
178
+ ) -> SOCObservation:
179
+ """Reset the environment for a specific task.
180
+
181
+ Args:
182
+ seed: Ignored (environment is fully deterministic).
183
+ episode_id: Optional custom episode ID.
184
+ **kwargs: Must include task_id ('easy', 'medium', or 'hard').
185
+
186
+ Returns:
187
+ Initial SOCObservation with alert queue and network state.
188
+ """
189
+ task_id = kwargs.get("task_id", "easy")
190
+ self._rng = random.Random(hash(task_id))
191
+ self._task_def = get_task(task_id)
192
+ self._recent_actions = [] # reset stall detector
193
+
194
+ # Build deterministic network (cached per task for GRPO throughput)
195
+ if not hasattr(CyberSOCEnvironment, "_network_cache"):
196
+ CyberSOCEnvironment._network_cache = {}
197
+ cache_key = task_id
198
+ if cache_key in CyberSOCEnvironment._network_cache:
199
+ self._network = copy.deepcopy(CyberSOCEnvironment._network_cache[cache_key])
200
+ else:
201
+ self._network = build_network()
202
+ CyberSOCEnvironment._network_cache[cache_key] = copy.deepcopy(self._network)
203
+
204
+ # Build hostname index for O(1) lookups
205
+ self._host_index = {}
206
+ for subnet_name, hosts in self._network.items():
207
+ for host in hosts:
208
+ self._host_index[host["hostname"]] = host
209
+
210
+ # Inject attack chain: mark compromised hosts, add malicious processes
211
+ for threat in self._task_def["attack_chain"]:
212
+ for hostname in threat["compromised_hosts"]:
213
+ if hostname in self._host_index:
214
+ host = self._host_index[hostname]
215
+ host["status"] = "compromised"
216
+ for proc in threat["malicious_processes"]:
217
+ if proc not in host["running_processes"]:
218
+ host["running_processes"].append(proc)
219
+
220
+ # Initialize alert queue (deep copy so mutations don't affect task def)
221
+ self._alert_queue = copy.deepcopy(self._task_def["initial_alerts"])
222
+
223
+ # Reset state
224
+ eid = episode_id or str(uuid4())
225
+ self._state = SOCState(
226
+ episode_id=eid,
227
+ step_count=0,
228
+ task_id=task_id,
229
+ max_steps=self._task_def["max_steps"],
230
+ total_reward=0.0,
231
+ business_impact=self._task_def["initial_business_impact"],
232
+ contained_threats=[],
233
+ active_threats=[t["threat_id"] for t in self._task_def["attack_chain"]],
234
+ blocked_iocs=[],
235
+ isolated_subnets=[],
236
+ forensics_run=[],
237
+ killed_processes=[],
238
+ queried_hosts=[],
239
+ timeline=[],
240
+ is_done=False,
241
+ submitted_plan=False,
242
+ )
243
+
244
+ self._plan_entries = []
245
+ self._last_forensics = None
246
+ self._reset_rubric()
247
+ self._fired_step_rewards: set = set()
248
+ self._step_reward_total: float = 0.0
249
+
250
+ # Initialize threat graph from task definition
251
+ self._threat_graph = ThreatGraph()
252
+ self._populate_threat_graph()
253
+ self._last_obs_extras: Dict[str, Any] = {}
254
+
255
+ return self._build_observation(reward=0.0, done=False)
256
+
257
+ def _populate_threat_graph(self) -> None:
258
+ """Seed the threat graph with hosts, processes, IOCs, and alerts from task_def."""
259
+ graph = self._threat_graph
260
+
261
+ # Hosts: include compromised hosts from attack chain + every host they live on
262
+ compromised_set: set[str] = set()
263
+ for threat in self._task_def.get("attack_chain", []):
264
+ for hn in threat.get("compromised_hosts", []):
265
+ compromised_set.add(hn)
266
+
267
+ for hostname in compromised_set:
268
+ host_dict = self._host_index.get(hostname)
269
+ if host_dict is None:
270
+ continue
271
+ graph.add_host(HostNode(
272
+ hostname=hostname,
273
+ subnet=host_dict.get("subnet", "corporate"),
274
+ business_criticality="high" if host_dict.get("criticality", 0.5) >= 0.7 else "medium",
275
+ status="compromised",
276
+ ))
277
+
278
+ # Processes: malicious processes per compromised host
279
+ for threat in self._task_def.get("attack_chain", []):
280
+ tid = threat.get("threat_id", "T?")
281
+ for hostname in threat.get("compromised_hosts", []):
282
+ if hostname not in graph.hosts:
283
+ continue
284
+ for proc in threat.get("malicious_processes", []):
285
+ pid = f"{hostname}:{proc}"
286
+ if pid not in graph.processes:
287
+ graph.add_process(ProcessNode(
288
+ process_id=pid,
289
+ hostname=hostname,
290
+ process_name=proc,
291
+ ))
292
+ # Add part_of_chain edge
293
+ graph.add_edge(Edge(
294
+ edge_type="part_of_chain",
295
+ source_id=tid,
296
+ target_id=hostname,
297
+ ))
298
+
299
+ # IOCs from attack chain
300
+ for threat in self._task_def.get("attack_chain", []):
301
+ iocs = threat.get("iocs", {}) or {}
302
+ for ioc_value in iocs.get("hashes", []):
303
+ if ioc_value not in graph.iocs:
304
+ graph.add_ioc(IOCNode(ioc_value=ioc_value, ioc_type="hash", confidence=0.85))
305
+ for ioc_value in iocs.get("ips", []):
306
+ if ioc_value not in graph.iocs:
307
+ graph.add_ioc(IOCNode(ioc_value=ioc_value, ioc_type="ip", confidence=0.85))
308
+ for ioc_value in iocs.get("domains", []):
309
+ if ioc_value not in graph.iocs:
310
+ graph.add_ioc(IOCNode(ioc_value=ioc_value, ioc_type="domain", confidence=0.85))
311
+ for c2 in threat.get("c2_servers", []):
312
+ if c2 not in graph.iocs:
313
+ graph.add_ioc(IOCNode(ioc_value=c2, ioc_type="ip", confidence=0.95))
314
+
315
+ # Alerts
316
+ for a in self._task_def.get("initial_alerts", []):
317
+ aid = a.get("alert_id")
318
+ if aid and aid not in graph.alerts:
319
+ graph.add_alert(AlertNode(
320
+ alert_id=aid,
321
+ severity=a.get("severity", "medium"),
322
+ priority_score=1.0,
323
+ source_host=a.get("source_host", ""),
324
+ ))
325
+
326
+ # ===========================================================================
327
+ # step()
328
+ # ===========================================================================
329
+
330
+ def step(
331
+ self,
332
+ action: SOCActionWrapper, # type: ignore[override]
333
+ timeout_s: Optional[float] = None,
334
+ **kwargs: Any,
335
+ ) -> SOCObservation:
336
+ """Process one agent action.
337
+
338
+ Args:
339
+ action: SOCActionWrapper containing the typed action.
340
+ timeout_s: Ignored.
341
+
342
+ Returns:
343
+ SOCObservation with updated state, reward, and done flag.
344
+ """
345
+ if self._state.is_done:
346
+ return self._build_observation(reward=0.0, done=True)
347
+
348
+ # Convert wrapper to typed action (before consuming a step)
349
+ typed_action = action.to_typed_action()
350
+ args = typed_action.model_dump(exclude={"metadata", "type"})
351
+
352
+ # Pre-flight validation — invalid actions are penalised without consuming a step
353
+ current_phase = self._get_current_phase()
354
+ validation_error = self._middleware.validate(
355
+ current_phase, typed_action.type, args, self._threat_graph
356
+ )
357
+ if validation_error:
358
+ error_type = validation_error.get("error_type", "")
359
+ penalty = -0.10 if error_type == "PHASE_VIOLATION" else -0.05
360
+ self._state.total_reward += penalty
361
+ return self._build_observation(reward=penalty, done=False)
362
+
363
+ # Action is valid — now consume the step
364
+ self._state.step_count += 1
365
+
366
+ # Dispatch to handler
367
+ reward = 0.0
368
+ result_description = "unknown action"
369
+
370
+ # Reset per-step observation extras at the start of every step
371
+ self._last_obs_extras = {}
372
+
373
+ if isinstance(typed_action, QueryHost):
374
+ reward, result_description = self._handle_query_host(typed_action)
375
+ elif isinstance(typed_action, IsolateSegment):
376
+ reward, result_description = self._handle_isolate_segment(typed_action)
377
+ elif isinstance(typed_action, BlockIOC):
378
+ reward, result_description = self._handle_block_ioc(typed_action)
379
+ elif isinstance(typed_action, RunForensics):
380
+ reward, result_description = self._handle_run_forensics(typed_action)
381
+ elif isinstance(typed_action, KillProcess):
382
+ reward, result_description = self._handle_kill_process(typed_action)
383
+ elif isinstance(typed_action, SubmitContainmentPlan):
384
+ reward, result_description = self._handle_submit_plan(typed_action)
385
+ elif isinstance(typed_action, CorrelateAlerts):
386
+ result = self._handle_correlate_alerts(typed_action)
387
+ self._last_obs_extras.update(result)
388
+ reward = 0.05 if "error" not in result else -0.05
389
+ result_description = result.get("description", "correlate_alerts")
390
+ elif isinstance(typed_action, EnrichIOC):
391
+ result = self._handle_enrich_ioc(typed_action)
392
+ self._last_obs_extras.update(result)
393
+ reward = 0.05 if "error" not in result else -0.05
394
+ result_description = result.get("description", "enrich_ioc")
395
+ elif isinstance(typed_action, ScanHostVulnerabilities):
396
+ result = self._handle_scan_vulnerabilities(typed_action)
397
+ self._last_obs_extras.update(result)
398
+ reward = 0.05 if "error" not in result else -0.05
399
+ result_description = result.get("description", "scan_host_vulnerabilities")
400
+ elif isinstance(typed_action, TriggerPlaybook):
401
+ result = self._handle_trigger_playbook(typed_action)
402
+ self._last_obs_extras.update(result)
403
+ reward = 0.10 if "error" not in result else -0.05
404
+ result_description = result.get("description", "trigger_playbook")
405
+
406
+ # Step reward (idempotent per triple)
407
+ target = self._get_action_target(typed_action)
408
+ step_r = self._get_step_reward(phase="investigation", action_type=typed_action.type, target=target)
409
+ reward += step_r
410
+ self._step_reward_total += step_r
411
+
412
+ # Stall detection: penalise 3+ consecutive identical actions
413
+ stall_key = (typed_action.type, target)
414
+ if not hasattr(self, "_recent_actions"):
415
+ self._recent_actions = []
416
+ self._recent_actions.append(stall_key)
417
+ if len(self._recent_actions) >= 3:
418
+ last_three = self._recent_actions[-3:]
419
+ if last_three[0] == last_three[1] == last_three[2]:
420
+ reward -= 0.05 # stall penalty
421
+
422
+ # Adaptive adversary reaction
423
+ self._adversary_react(action_type=typed_action.type, target=target)
424
+
425
+ # Business impact grows each step (attacker progresses)
426
+ if not self._state.is_done:
427
+ impact_rate = self._task_def.get("impact_per_step", 0.02)
428
+ # Reduce impact growth if threats are being contained
429
+ active_ratio = len(self._state.active_threats) / max(1, len(self._task_def["attack_chain"]))
430
+ self._state.business_impact = min(
431
+ 1.0,
432
+ self._state.business_impact + impact_rate * active_ratio,
433
+ )
434
+
435
+ # Record timeline
436
+ self._state.timeline.append({
437
+ "step": self._state.step_count,
438
+ "action_type": typed_action.type,
439
+ "target": self._get_action_target(typed_action),
440
+ "result": result_description,
441
+ "reward": reward,
442
+ })
443
+
444
+ # Accumulate reward
445
+ self._state.total_reward += reward
446
+
447
+ # Check termination
448
+ done = False
449
+ if self._state.submitted_plan:
450
+ done = True
451
+ self._state.is_done = True
452
+ elif self._state.step_count >= self._state.max_steps:
453
+ done = True
454
+ self._state.is_done = True
455
+ reward -= 0.20 # Penalty for running out of time
456
+ self._state.total_reward += (-0.20)
457
+
458
+ return self._build_observation(reward=reward, done=done)
459
+
460
+ # ===========================================================================
461
+ # Action Handlers (return (reward, description))
462
+ # ===========================================================================
463
+
464
+ def _handle_query_host(self, action: QueryHost) -> tuple[float, str]:
465
+ """Query a host for status info."""
466
+ hostname = action.hostname
467
+ self._last_forensics = None # Clear forensics from previous step
468
+
469
+ if hostname not in self._host_index:
470
+ return -0.05, f"Host '{hostname}' not found in network"
471
+
472
+ host = self._host_index[hostname]
473
+
474
+ # Reward for querying compromised hosts (useful investigation)
475
+ reward = 0.0
476
+ if host["status"] == "compromised" and hostname not in self._state.queried_hosts:
477
+ reward = 0.05 # Good: investigating a compromised host
478
+ elif hostname in self._state.queried_hosts:
479
+ reward = -0.02 # Penalty: re-querying same host wastes time
480
+
481
+ self._state.queried_hosts.append(hostname)
482
+
483
+ # Enhanced observation extras: process_tree + network_connections from graph
484
+ process_tree = []
485
+ if self._threat_graph is not None:
486
+ for p in self._threat_graph.processes.values():
487
+ if p.hostname == hostname:
488
+ process_tree.append({
489
+ "process_id": p.process_id,
490
+ "process_name": p.process_name,
491
+ "killed": p.killed,
492
+ })
493
+ network_connections = []
494
+ if self._threat_graph is not None:
495
+ for e in self._threat_graph.edges:
496
+ if e.edge_type == "communicates_with" and (
497
+ e.source_id == hostname or e.target_id == hostname
498
+ ):
499
+ other = e.target_id if e.source_id == hostname else e.source_id
500
+ if other in self._threat_graph.iocs:
501
+ network_connections.append(other)
502
+ self._last_obs_extras["process_tree"] = process_tree
503
+ self._last_obs_extras["network_connections"] = network_connections
504
+
505
+ return reward, f"Queried {hostname}: status={host['status']}, procs={len(host['running_processes'])}"
506
+
507
+ def _handle_isolate_segment(self, action: IsolateSegment) -> tuple[float, str]:
508
+ """Isolate a network segment, or a single host if target_host is set."""
509
+ self._last_forensics = None
510
+
511
+ # Single-host isolation path
512
+ target_host = getattr(action, "target_host", None)
513
+ if target_host:
514
+ if target_host not in self._host_index:
515
+ return -0.05, f"Host '{target_host}' not found"
516
+ self._host_index[target_host]["status"] = "isolated"
517
+ if self._threat_graph is not None and target_host in self._threat_graph.hosts:
518
+ self._threat_graph.hosts[target_host].status = "isolated"
519
+ return 0.10, f"Isolated single host '{target_host}'"
520
+
521
+ subnet = action.subnet
522
+
523
+ if subnet not in self._network:
524
+ return -0.05, f"Subnet '{subnet}' does not exist"
525
+
526
+ if subnet in self._state.isolated_subnets:
527
+ return -0.02, f"Subnet '{subnet}' is already isolated"
528
+
529
+ # Isolate all hosts in the subnet
530
+ for host in self._network[subnet]:
531
+ host["status"] = "isolated"
532
+ if self._threat_graph is not None and host["hostname"] in self._threat_graph.hosts:
533
+ self._threat_graph.hosts[host["hostname"]].status = "isolated"
534
+
535
+ self._state.isolated_subnets.append(subnet)
536
+
537
+ # Check if this contains any active threats
538
+ reward = 0.0
539
+ threats_contained = []
540
+ for threat in self._task_def["attack_chain"]:
541
+ if threat["threat_id"] in self._state.active_threats:
542
+ # Check if any compromised hosts are in this subnet
543
+ for ch in threat["compromised_hosts"]:
544
+ if ch in self._host_index and self._host_index[ch]["subnet"] == subnet:
545
+ threats_contained.append(threat["threat_id"])
546
+ break
547
+
548
+ if threats_contained:
549
+ reward = 0.15 * len(threats_contained) # Good: containing lateral movement
550
+ for tid in threats_contained:
551
+ if tid not in self._state.contained_threats:
552
+ self._state.contained_threats.append(tid)
553
+ if tid in self._state.active_threats:
554
+ self._state.active_threats.remove(tid)
555
+
556
+ # Check if this is an unnecessary isolation (business downtime)
557
+ must_not_isolate = self._task_def["containment_requirements"].get("must_not_isolate", [])
558
+ if subnet in must_not_isolate:
559
+ reward -= 0.10 # Penalty: unnecessary downtime
560
+ self._state.business_impact = min(1.0, self._state.business_impact + 0.08)
561
+
562
+ return reward, f"Isolated subnet '{subnet}'. Threats contained: {threats_contained}"
563
+
564
+ def _handle_block_ioc(self, action: BlockIOC) -> tuple[float, str]:
565
+ """Block an IOC at the perimeter."""
566
+ ioc = action.ioc_value
567
+ self._last_forensics = None
568
+
569
+ if ioc in self._state.blocked_iocs:
570
+ return -0.02, f"IOC '{ioc}' is already blocked"
571
+
572
+ self._state.blocked_iocs.append(ioc)
573
+
574
+ # Check if this IOC is relevant to any active threat
575
+ reward = 0.0
576
+ relevant = False
577
+ for threat in self._task_def["attack_chain"]:
578
+ all_iocs = (
579
+ threat["iocs"].get("hashes", [])
580
+ + threat["iocs"].get("ips", [])
581
+ + threat["iocs"].get("domains", [])
582
+ )
583
+ if ioc in all_iocs:
584
+ relevant = True
585
+ # Extra reward for blocking C2 server IPs
586
+ if ioc in threat.get("c2_servers", []):
587
+ reward += 0.15 # High value: cutting C2
588
+ else:
589
+ reward += 0.10 # Good: blocking relevant IOC
590
+ break
591
+
592
+ if not relevant:
593
+ reward = -0.03 # Noise: blocking irrelevant IOC
594
+
595
+ return reward, f"Blocked IOC '{ioc}' (type={action.ioc_type}). Relevant: {relevant}"
596
+
597
+ def _handle_run_forensics(self, action: RunForensics) -> tuple[float, str]:
598
+ """Run forensic analysis on a host."""
599
+ hostname = action.hostname
600
+
601
+ if hostname not in self._host_index:
602
+ self._last_forensics = None
603
+ return -0.05, f"Host '{hostname}' not found"
604
+
605
+ host = self._host_index[hostname]
606
+
607
+ # Build forensics result based on actual host state
608
+ is_compromised = host["status"] == "compromised"
609
+ malicious_procs = []
610
+ suspicious_files = []
611
+ network_conns = []
612
+ registry_mods = []
613
+ memory_artifacts = []
614
+
615
+ if is_compromised:
616
+ # Find which threat(s) affect this host
617
+ for threat in self._task_def["attack_chain"]:
618
+ if hostname in threat["compromised_hosts"]:
619
+ malicious_procs.extend(threat["malicious_processes"])
620
+ # Generate deterministic forensic artifacts
621
+ for proc in threat["malicious_processes"]:
622
+ suspicious_files.append(f"C:\\Windows\\Temp\\{proc}.dat")
623
+ registry_mods.append(f"HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\{proc}")
624
+ for c2 in threat.get("c2_servers", []):
625
+ network_conns.append(f"{c2}:443")
626
+ for ioc_hash in threat["iocs"].get("hashes", []):
627
+ memory_artifacts.append(f"memory_inject_{ioc_hash[:8]}")
628
+
629
+ self._last_forensics = ForensicsResult(
630
+ hostname=hostname,
631
+ malicious_processes=malicious_procs,
632
+ suspicious_files=suspicious_files,
633
+ network_connections=network_conns,
634
+ registry_modifications=registry_mods,
635
+ memory_artifacts=memory_artifacts,
636
+ is_compromised=is_compromised,
637
+ )
638
+
639
+ # Reward
640
+ reward = 0.0
641
+ if hostname not in self._state.forensics_run:
642
+ if is_compromised:
643
+ reward = 0.10 # Good: found evidence
644
+ else:
645
+ reward = 0.02 # Cleared a host (some value)
646
+ self._state.forensics_run.append(hostname)
647
+ else:
648
+ reward = -0.02 # Re-running forensics wastes time
649
+
650
+ # Enhanced: behavioral_chain and network_flows from graph
651
+ behavioral_chain = []
652
+ network_flows = []
653
+ if self._threat_graph is not None:
654
+ for e in self._threat_graph.edges:
655
+ if e.source_id == hostname or e.target_id == hostname:
656
+ behavioral_chain.append({
657
+ "edge_type": e.edge_type,
658
+ "source_id": e.source_id,
659
+ "target_id": e.target_id,
660
+ })
661
+ for e in self._threat_graph.edges:
662
+ if e.edge_type == "communicates_with":
663
+ if e.source_id == hostname or e.target_id == hostname:
664
+ other = e.target_id if e.source_id == hostname else e.source_id
665
+ if other in self._threat_graph.iocs:
666
+ network_flows.append(other)
667
+ self._last_obs_extras["behavioral_chain"] = behavioral_chain
668
+ self._last_obs_extras["network_flows"] = network_flows
669
+
670
+ return reward, f"Forensics on {hostname}: compromised={is_compromised}, procs={malicious_procs}"
671
+
672
+ def _handle_kill_process(self, action: KillProcess) -> tuple[float, str]:
673
+ """Kill a process on a host."""
674
+ hostname = action.hostname
675
+ process = action.process_name
676
+ self._last_forensics = None
677
+
678
+ if hostname not in self._host_index:
679
+ return -0.05, f"Host '{hostname}' not found"
680
+
681
+ host = self._host_index[hostname]
682
+
683
+ if host["status"] == "isolated":
684
+ return -0.02, f"Host '{hostname}' is isolated — cannot interact"
685
+
686
+ if process not in host["running_processes"]:
687
+ return -0.03, f"Process '{process}' not running on {hostname}"
688
+
689
+ # Kill the process
690
+ host["running_processes"].remove(process)
691
+ self._state.killed_processes.append({"hostname": hostname, "process": process})
692
+
693
+ # Check if this was a malicious process
694
+ reward = 0.0
695
+ was_malicious = False
696
+ for threat in self._task_def["attack_chain"]:
697
+ if hostname in threat["compromised_hosts"] and process in threat["malicious_processes"]:
698
+ was_malicious = True
699
+ reward = 0.15 # Major reward: stopping malicious activity
700
+
701
+ # Check if all processes for this threat are killed
702
+ all_killed = True
703
+ for th_host in threat["compromised_hosts"]:
704
+ for th_proc in threat["malicious_processes"]:
705
+ still_running = (
706
+ th_host in self._host_index
707
+ and th_proc in self._host_index[th_host]["running_processes"]
708
+ )
709
+ if still_running:
710
+ all_killed = False
711
+ break
712
+
713
+ if all_killed and threat["threat_id"] in self._state.active_threats:
714
+ self._state.active_threats.remove(threat["threat_id"])
715
+ if threat["threat_id"] not in self._state.contained_threats:
716
+ self._state.contained_threats.append(threat["threat_id"])
717
+ reward += 0.10 # Bonus: fully contained a threat
718
+ break
719
+
720
+ if not was_malicious:
721
+ reward = -0.08 # Penalty: killing legitimate process = downtime
722
+ self._state.business_impact = min(1.0, self._state.business_impact + 0.03)
723
+
724
+ if was_malicious:
725
+ self._maybe_reinfect(hostname, process)
726
+
727
+ return reward, f"Killed '{process}' on {hostname}. Malicious: {was_malicious}"
728
+
729
+ def _handle_submit_plan(self, action: SubmitContainmentPlan) -> tuple[float, str]:
730
+ """Submit the final containment plan."""
731
+ self._last_forensics = None
732
+ self._state.submitted_plan = True
733
+ self._plan_entries = [entry.model_dump() for entry in action.plan]
734
+
735
+ # Grade the episode using new 10-dim grader
736
+ final_plan_dict = {
737
+ "entries": self._plan_entries,
738
+ "primary_threat_id": (self._plan_entries[0]["threat_id"]
739
+ if self._plan_entries else ""),
740
+ }
741
+ grade_result = grade_episode(
742
+ episode_actions=list(self._state.timeline),
743
+ final_plan=final_plan_dict,
744
+ graph=self._threat_graph,
745
+ task_def=self._task_def,
746
+ state=self._state,
747
+ )
748
+ final_score = grade_result["final_score"]
749
+
750
+ # Reward proportional to final grade
751
+ reward = final_score * 1.0 # Scale: perfect score = 1.0 reward
752
+ description = (
753
+ f"Containment plan submitted. "
754
+ f"Grade: {final_score:.3f}. "
755
+ f"Threats contained: {len(self._state.contained_threats)}/{len(self._task_def['attack_chain'])}. "
756
+ f"Business impact: {self._state.business_impact:.2f}"
757
+ )
758
+
759
+ return reward, description
760
+
761
+ # ===========================================================================
762
+ # New Action Handlers (return observation-update dict)
763
+ # ===========================================================================
764
+
765
+ def _handle_correlate_alerts(self, action: CorrelateAlerts) -> dict:
766
+ """Correlate alerts to find shared hosts/IOCs."""
767
+ if len(action.alert_ids) < 2:
768
+ return {"error": "correlate_alerts requires at least 2 alert IDs",
769
+ "description": "correlate_alerts error"}
770
+
771
+ graph = self._threat_graph
772
+ known_alerts = {aid: graph.alerts[aid] for aid in action.alert_ids if aid in graph.alerts}
773
+ if len(known_alerts) < 2:
774
+ return {"error": "fewer than 2 alert IDs found in graph",
775
+ "description": "correlate_alerts error"}
776
+
777
+ # Find shared source hosts
778
+ source_hosts: dict[str, list[str]] = {}
779
+ for aid, alert in known_alerts.items():
780
+ source_hosts.setdefault(alert.source_host, []).append(aid)
781
+ shared_hosts = [h for h, aids in source_hosts.items() if len(aids) >= 2]
782
+
783
+ # Find shared IOCs via "involves" edges
784
+ shared_iocs: set[str] = set()
785
+ for e in graph.edges:
786
+ if e.edge_type == "involves" and e.source_id in known_alerts:
787
+ if any(
788
+ e2.edge_type == "involves" and e2.target_id == e.target_id
789
+ and e2.source_id in known_alerts and e2.source_id != e.source_id
790
+ for e2 in graph.edges
791
+ ):
792
+ shared_iocs.add(e.target_id)
793
+
794
+ # Update correlated_with on each alert
795
+ all_ids = list(known_alerts.keys())
796
+ for aid, alert in known_alerts.items():
797
+ for other_id in all_ids:
798
+ if other_id != aid and other_id not in alert.correlated_with:
799
+ alert.correlated_with.append(other_id)
800
+
801
+ self._state.correlated_alert_pairs.append(tuple(all_ids))
802
+
803
+ shared_count = len(shared_hosts) + len(shared_iocs)
804
+ correlation_score = min(1.0, shared_count / len(all_ids))
805
+
806
+ result = {
807
+ "correlation_results": {
808
+ "shared_hosts": shared_hosts,
809
+ "shared_iocs": list(shared_iocs),
810
+ "correlation_score": correlation_score,
811
+ },
812
+ "description": f"Correlated {len(all_ids)} alerts: {len(shared_hosts)} shared hosts",
813
+ }
814
+ return result
815
+
816
+ def _handle_enrich_ioc(self, action: EnrichIOC) -> dict:
817
+ """Enrich an IOC with threat-intel data."""
818
+ graph = self._threat_graph
819
+
820
+ if action.ioc_value not in graph.iocs:
821
+ return {"error": "IOC not yet discovered",
822
+ "description": "enrich_ioc error"}
823
+
824
+ intel = self._task_def.get("threat_intel_data", {}) or {}
825
+ data = intel.get(action.ioc_value, {
826
+ "reputation": 0.5,
827
+ "threat_actor": "unknown",
828
+ "mitre_ttps": [],
829
+ })
830
+
831
+ # Update IOC node in graph
832
+ ioc_node = graph.iocs[action.ioc_value]
833
+ ioc_node.enriched = True
834
+ ioc_node.threat_actor = data.get("threat_actor")
835
+ ioc_node.mitre_ttps = data.get("mitre_ttps", [])
836
+
837
+ if action.ioc_value not in self._state.enriched_iocs:
838
+ self._state.enriched_iocs.append(action.ioc_value)
839
+
840
+ return {
841
+ "ioc_enrichment": data,
842
+ "description": f"Enriched IOC {action.ioc_value}: actor={data.get('threat_actor')}",
843
+ }
844
+
845
+ def _handle_scan_vulnerabilities(self, action: ScanHostVulnerabilities) -> dict:
846
+ """Scan a host for CVE vulnerabilities."""
847
+ graph = self._threat_graph
848
+ hostname = action.hostname
849
+
850
+ if hostname not in graph.hosts:
851
+ return {"error": f"Host '{hostname}' not in Threat Graph",
852
+ "description": "scan_host_vulnerabilities error"}
853
+
854
+ vuln_chain = self._task_def.get("vulnerability_chain", []) or []
855
+ vuln_results: list[dict] = []
856
+ for entry in vuln_chain:
857
+ if not isinstance(entry, dict):
858
+ continue
859
+ if entry.get("hostname") == hostname or entry.get("affected_hosts") and hostname in entry["affected_hosts"]:
860
+ cve_id = entry.get("cve_id", "CVE-UNKNOWN")
861
+ vuln_node = VulnerabilityNode(
862
+ cve_id=cve_id,
863
+ hostname=hostname,
864
+ cvss_score=entry.get("cvss_score", 5.0),
865
+ exploitability=entry.get("exploitability", "theoretical"),
866
+ patch_available=entry.get("patch_available", False),
867
+ exploited_by_threat=entry.get("threat_id"),
868
+ )
869
+ graph.add_vulnerability(vuln_node)
870
+ graph.add_edge(Edge(
871
+ edge_type="exploits",
872
+ source_id=cve_id,
873
+ target_id=hostname,
874
+ ))
875
+ vuln_results.append(entry)
876
+
877
+ # Mark host as scanned
878
+ graph.hosts[hostname].scanned = True
879
+ if hostname not in self._state.scanned_hosts:
880
+ self._state.scanned_hosts.append(hostname)
881
+
882
+ return {
883
+ "vulnerability_results": vuln_results,
884
+ "description": f"Scanned {hostname}: found {len(vuln_results)} CVEs",
885
+ }
886
+
887
+ def _handle_trigger_playbook(self, action: TriggerPlaybook) -> dict:
888
+ """Trigger a SOAR playbook against a target host."""
889
+ ok, reason = check_prerequisites(
890
+ action.playbook_name, action.target, self._state, self._threat_graph
891
+ )
892
+ if not ok:
893
+ return {"error": reason,
894
+ "description": f"trigger_playbook failed: {reason}"}
895
+
896
+ sub_actions = PLAYBOOKS[action.playbook_name]["sub_actions"]
897
+ if action.playbook_name not in self._state.triggered_playbooks:
898
+ self._state.triggered_playbooks.append(action.playbook_name)
899
+
900
+ return {
901
+ "playbook_result": {
902
+ "playbook": action.playbook_name,
903
+ "sub_actions": sub_actions,
904
+ "status": "executed",
905
+ },
906
+ "description": f"Executed playbook '{action.playbook_name}' on {action.target}",
907
+ }
908
+
909
+ # ===========================================================================
910
+ # Helpers
911
+ # ===========================================================================
912
+
913
+ def _compute_reward_dimensions(self) -> Dict[str, float]:
914
+ """Per-step heuristic partial scores for all 10 grading dimensions.
915
+
916
+ Updated every step so GRPO can assign credit without waiting for the
917
+ terminal grade. Scores are in [0, 1]; the terminal grade_breakdown
918
+ (from grade_episode) supersedes these once the plan is submitted.
919
+ """
920
+ state = self._state
921
+ task_chain = self._task_def.get("attack_chain", [])
922
+ total_threats = max(1, len(task_chain))
923
+
924
+ total_compromised = max(1, sum(len(t.get("compromised_hosts", [])) for t in task_chain))
925
+ total_iocs = max(1, sum(
926
+ len(t.get("iocs", {}).get("hashes", []))
927
+ + len(t.get("iocs", {}).get("ips", []))
928
+ + len(t.get("iocs", {}).get("domains", []))
929
+ for t in task_chain
930
+ ))
931
+
932
+ # 1. threat_containment — fraction of threats neutralised
933
+ threat_containment = min(1.0, len(state.contained_threats) / total_threats)
934
+
935
+ # 2. ioc_blocking — fraction of known IOCs blocked
936
+ ioc_blocking = min(1.0, len(state.blocked_iocs) / total_iocs)
937
+
938
+ # 3. forensic_investigation — fraction of compromised hosts investigated
939
+ forensic_investigation = min(1.0, len(state.forensics_run) / total_compromised)
940
+
941
+ # 4. siem_correlation — binary: did the agent correlate alerts?
942
+ siem_correlation = 1.0 if state.correlated_alert_pairs else 0.0
943
+
944
+ # 5. threat_intel_usage — fraction of IOCs enriched with threat intel
945
+ threat_intel_usage = min(1.0, len(state.enriched_iocs) / total_iocs)
946
+
947
+ # 6. vuln_root_cause — fraction of threats with a scanned host
948
+ vuln_root_cause = min(1.0, len(state.scanned_hosts) / total_threats)
949
+
950
+ # 7. business_impact — lower impact is better
951
+ business_impact = max(0.0, 1.0 - state.business_impact)
952
+
953
+ # 8. step_efficiency — reward early resolution
954
+ ratio = state.step_count / max(1, state.max_steps)
955
+ step_efficiency = max(0.0, 1.0 - max(0.0, ratio - 0.5) * 1.5)
956
+
957
+ # 9. plan_coverage — partial credit scales with threats addressed
958
+ if state.submitted_plan:
959
+ plan_coverage = min(1.0, len(self._plan_entries) / total_threats)
960
+ else:
961
+ plan_coverage = min(0.5, len(state.contained_threats) / total_threats * 0.5)
962
+
963
+ # 10. plan_evidence_quality — confidence of submitted plan; else proxy from investigation depth
964
+ if state.submitted_plan and self._plan_entries:
965
+ avg_conf = sum(e.get("confidence", 0.0) for e in self._plan_entries) / len(self._plan_entries)
966
+ plan_evidence_quality = float(avg_conf)
967
+ else:
968
+ evidence_count = len(state.forensics_run) + len(state.enriched_iocs) + len(state.scanned_hosts)
969
+ plan_evidence_quality = min(0.5, evidence_count / (total_compromised * 3) * 0.5)
970
+
971
+ return {
972
+ "threat_containment": round(threat_containment, 4),
973
+ "ioc_blocking": round(ioc_blocking, 4),
974
+ "forensic_investigation":round(forensic_investigation, 4),
975
+ "siem_correlation": round(siem_correlation, 4),
976
+ "threat_intel_usage": round(threat_intel_usage, 4),
977
+ "vuln_root_cause": round(vuln_root_cause, 4),
978
+ "business_impact": round(business_impact, 4),
979
+ "step_efficiency": round(step_efficiency, 4),
980
+ "plan_coverage": round(plan_coverage, 4),
981
+ "plan_evidence_quality": round(plan_evidence_quality, 4),
982
+ }
983
+
984
+ def _get_current_phase(self) -> str:
985
+ """Derive episode phase from the action history in the timeline."""
986
+ action_types = {t["action_type"] for t in self._state.timeline}
987
+ if any(t in action_types for t in ["kill_process", "block_ioc", "isolate_segment", "trigger_playbook"]):
988
+ return "remediation"
989
+ if any(t in action_types for t in ["run_forensics", "enrich_ioc", "scan_host_vulnerabilities", "query_host"]):
990
+ return "investigation"
991
+ return "triage"
992
+
993
+ def _build_observation(self, reward: float, done: bool) -> SOCObservation:
994
+ """Build the observation from current state."""
995
+ # Compute network topology summary
996
+ subnet_counts = {name: len(hosts) for name, hosts in self._network.items()}
997
+ compromised = sum(
998
+ 1 for hosts in self._network.values()
999
+ for h in hosts if h["status"] == "compromised"
1000
+ )
1001
+ isolated = sum(
1002
+ 1 for hosts in self._network.values()
1003
+ for h in hosts if h["status"] == "isolated"
1004
+ )
1005
+ total = sum(len(hosts) for hosts in self._network.values())
1006
+
1007
+ topology = NetworkTopology(
1008
+ total_hosts=total,
1009
+ subnets=subnet_counts,
1010
+ compromised_count=compromised,
1011
+ isolated_count=isolated,
1012
+ online_count=total - compromised - isolated,
1013
+ )
1014
+
1015
+ # Build alert list
1016
+ alerts = [Alert(**a) for a in self._alert_queue]
1017
+
1018
+ # Build timeline
1019
+ timeline = [
1020
+ TimelineEntry(
1021
+ step=t["step"],
1022
+ action_type=t["action_type"],
1023
+ target=t["target"],
1024
+ result=t["result"],
1025
+ reward=t["reward"],
1026
+ )
1027
+ for t in self._state.timeline
1028
+ ]
1029
+
1030
+ # Compute final grade if done
1031
+ final_score_val = None
1032
+ grade_breakdown_val = None
1033
+
1034
+ if done and self._state.submitted_plan:
1035
+ final_plan_dict = {
1036
+ "entries": self._plan_entries,
1037
+ "primary_threat_id": (self._plan_entries[0]["threat_id"]
1038
+ if self._plan_entries else ""),
1039
+ }
1040
+ computed = grade_episode(
1041
+ episode_actions=list(self._state.timeline),
1042
+ final_plan=final_plan_dict,
1043
+ graph=self._threat_graph,
1044
+ task_def=self._task_def,
1045
+ state=self._state,
1046
+ )
1047
+ final_score_val = round(computed["final_score"], 4)
1048
+ grade_breakdown_val = computed["breakdown"]
1049
+
1050
+ # Merge per-step observation extras (process_tree, correlation_results, etc.)
1051
+ extras = getattr(self, "_last_obs_extras", {}) or {}
1052
+ threat_graph_summary = None
1053
+ if self._threat_graph is not None:
1054
+ threat_graph_summary = self._threat_graph.get_context_summary()
1055
+
1056
+ # Per-step partial reward dimensions for GRPO credit assignment
1057
+ reward_dimensions = self._compute_reward_dimensions()
1058
+
1059
+ return SOCObservation(
1060
+ episode_id=self._state.episode_id or "",
1061
+ alert_queue=alerts,
1062
+ network_topology=topology,
1063
+ host_forensics=self._last_forensics,
1064
+ timeline=timeline,
1065
+ business_impact_score=round(self._state.business_impact, 4),
1066
+ step_count=self._state.step_count,
1067
+ active_threats=list(self._state.active_threats),
1068
+ max_steps=self._state.max_steps,
1069
+ task_id=self._state.task_id,
1070
+ total_reward=round(self._state.total_reward, 4),
1071
+ final_score=final_score_val,
1072
+ grade_breakdown=grade_breakdown_val,
1073
+ done=done,
1074
+ reward=round(reward, 4),
1075
+ correlation_results=extras.get("correlation_results"),
1076
+ ioc_enrichment=extras.get("ioc_enrichment"),
1077
+ vulnerability_results=extras.get("vulnerability_results"),
1078
+ playbook_result=extras.get("playbook_result"),
1079
+ threat_graph_summary=threat_graph_summary,
1080
+ available_playbooks=list(PLAYBOOKS.keys()),
1081
+ reward_dimensions=reward_dimensions,
1082
+ )
1083
+
1084
+ def _get_action_target(self, action: Any) -> str:
1085
+ """Extract the target string from a typed action for timeline logging."""
1086
+ if isinstance(action, QueryHost):
1087
+ return action.hostname
1088
+ elif isinstance(action, IsolateSegment):
1089
+ return getattr(action, "target_host", None) or action.subnet
1090
+ elif isinstance(action, BlockIOC):
1091
+ return f"{action.ioc_type}:{action.ioc_value}"
1092
+ elif isinstance(action, RunForensics):
1093
+ return action.hostname
1094
+ elif isinstance(action, KillProcess):
1095
+ return f"{action.hostname}/{action.process_name}"
1096
+ elif isinstance(action, SubmitContainmentPlan):
1097
+ return f"{len(action.plan)} entries"
1098
+ elif isinstance(action, CorrelateAlerts):
1099
+ return ",".join(action.alert_ids)
1100
+ elif isinstance(action, EnrichIOC):
1101
+ return action.ioc_value
1102
+ elif isinstance(action, ScanHostVulnerabilities):
1103
+ return action.hostname
1104
+ elif isinstance(action, TriggerPlaybook):
1105
+ return f"{action.playbook_name}@{action.target}"
1106
+ return "unknown"
1107
+
1108
+ # ===========================================================================
1109
+ # Adaptive Red Team + Step Rewards (Task 10)
1110
+ # ===========================================================================
1111
+
1112
+ STEP_REWARDS: Dict[Any, float] = {
1113
+ ("investigation", "run_forensics"): +0.10,
1114
+ ("investigation", "enrich_ioc"): +0.05,
1115
+ ("investigation", "scan_host_vulnerabilities"): +0.05,
1116
+ ("triage", "correlate_alerts"): +0.05,
1117
+ "phase_violation_attempt": -0.20,
1118
+ "ungrounded_action_attempt": -0.10,
1119
+ }
1120
+
1121
+ def _get_step_reward(self, phase: str, action_type: str, target: str) -> float:
1122
+ """Idempotent step reward — fires only once per (phase, action_type, target) triple.
1123
+
1124
+ Hard cap: total step rewards per episode never exceed 0.40.
1125
+ """
1126
+ if not hasattr(self, "_fired_step_rewards"):
1127
+ self._fired_step_rewards = set()
1128
+ # Hard cap: once we've reached 0.40 in step rewards, return 0 for all subsequent
1129
+ if getattr(self, "_step_reward_total", 0.0) >= 0.40:
1130
+ return 0.0
1131
+ key = (phase, action_type, target)
1132
+ if key in self._fired_step_rewards:
1133
+ return 0.0
1134
+ reward = self.STEP_REWARDS.get((phase, action_type), 0.0)
1135
+ if reward != 0.0:
1136
+ self._fired_step_rewards.add(key)
1137
+ return reward
1138
+
1139
+ def _maybe_reinfect(self, hostname: str, process_name: str) -> None:
1140
+ """30 % chance to reinfect with a _v2 variant when unblocked IOCs exist in the threat chain."""
1141
+ if not self._adaptive:
1142
+ return
1143
+ graph = self._threat_graph
1144
+ if graph is None:
1145
+ return
1146
+
1147
+ # Check whether any IOC in the host's threat chain is still unblocked
1148
+ unblocked_chain_iocs = False
1149
+ for ioc_node in graph.iocs.values():
1150
+ if not ioc_node.blocked:
1151
+ # Is this IOC linked (via any edge) to the same host's chain?
1152
+ for e in graph.edges:
1153
+ if e.target_id == hostname or e.source_id == hostname:
1154
+ unblocked_chain_iocs = True
1155
+ break
1156
+ if unblocked_chain_iocs:
1157
+ break
1158
+
1159
+ if not unblocked_chain_iocs:
1160
+ return
1161
+
1162
+ if self._rng.random() >= 0.3:
1163
+ return
1164
+
1165
+ # Reinfect: spawn a _v2 variant process on the host
1166
+ variant_name = f"{process_name}_v2"
1167
+ if hostname in self._host_index:
1168
+ host = self._host_index[hostname]
1169
+ if variant_name not in host["running_processes"]:
1170
+ host["running_processes"].append(variant_name)
1171
+ host["status"] = "compromised"
1172
+
1173
+ # Add the variant to the threat graph
1174
+ pid = f"{hostname}:{variant_name}"
1175
+ if pid not in graph.processes:
1176
+ graph.add_process(ProcessNode(
1177
+ process_id=pid,
1178
+ hostname=hostname,
1179
+ process_name=variant_name,
1180
+ killed=False,
1181
+ ))
1182
+
1183
+ # Emit a CRITICAL alert to signal the reinfection
1184
+ alert_id = f"REINFECT-{uuid.uuid4().hex[:6].upper()}"
1185
+ graph.add_alert(AlertNode(
1186
+ alert_id=alert_id,
1187
+ severity="critical",
1188
+ priority_score=18.0,
1189
+ source_host=hostname,
1190
+ ))
1191
+ self._alert_queue.append({
1192
+ "alert_id": alert_id,
1193
+ "timestamp": "2024-01-01T00:00:00Z",
1194
+ "source_host": hostname,
1195
+ "severity": "critical",
1196
+ "threat_type": "malware",
1197
+ "description": f"Reinfection detected: {variant_name} spawned on {hostname} (IOC-assisted persistence)",
1198
+ "ioc_indicators": [],
1199
+ "subnet": self._host_index.get(hostname, {}).get("subnet", "unknown"),
1200
+ "is_acknowledged": False,
1201
+ })
1202
+
1203
+ def _adversary_react(self, action_type: str, target: str) -> None:
1204
+ """Adaptive red team response — fires after each step when adaptive=True."""
1205
+ if not self._adaptive:
1206
+ return
1207
+
1208
+ difficulty = self._task_def.get("difficulty") or getattr(self._state, "task_id", "easy")
1209
+ # Reduced medium base probability for better GRPO credit assignment
1210
+ pivot_probability = {"easy": 0.0, "medium": 0.3, "hard": 1.0}.get(difficulty, 0.0)
1211
+
1212
+ # Time-pressure escalation: attacker moves faster when uncontained and late in episode
1213
+ if self._state.step_count > 10 and len(self._state.contained_threats) == 0:
1214
+ pivot_probability += 0.2
1215
+
1216
+ # Trigger on isolate_segment OR kill_process (extended pivot trigger)
1217
+ if action_type in ("isolate_segment", "kill_process") and pivot_probability > 0:
1218
+ if self._rng.random() < pivot_probability:
1219
+ self._execute_lateral_pivot(source_host=target)
1220
+
1221
+ def _execute_lateral_pivot(self, source_host: str) -> None:
1222
+ """Copy-not-move lateral pivot: spread to an adjacent healthy host.
1223
+
1224
+ Rubric is capped at MAX_RUBRIC_ITEMS to prevent competent agents from
1225
+ being penalised by an impossible-to-complete rubric.
1226
+ """
1227
+ MAX_RUBRIC_ITEMS = 12
1228
+ graph = self._threat_graph
1229
+ if graph is None:
1230
+ return
1231
+
1232
+ # Rubric cap: stop pivoting once live_requirements is full
1233
+ if self._live_requirements:
1234
+ current_items = (
1235
+ len(self._live_requirements.get("must_kill", []))
1236
+ + len(self._live_requirements.get("must_isolate", []))
1237
+ )
1238
+ if current_items >= MAX_RUBRIC_ITEMS:
1239
+ return
1240
+
1241
+ adjacent_hosts = [
1242
+ e.target_id for e in graph.edges
1243
+ if e.source_id == source_host and e.target_id in graph.hosts
1244
+ and graph.hosts[e.target_id].status == "healthy"
1245
+ ]
1246
+ if not adjacent_hosts:
1247
+ # Try graph hosts first, then fall back to full host_index
1248
+ healthy_hosts = [
1249
+ h for h, node in graph.hosts.items()
1250
+ if node.status == "healthy" and h != source_host
1251
+ ]
1252
+ if not healthy_hosts:
1253
+ # Expand search to the full network
1254
+ healthy_hosts = [
1255
+ h for h, hd in self._host_index.items()
1256
+ if hd.get("status", "online") not in ("compromised", "isolated")
1257
+ and h != source_host
1258
+ and h not in graph.hosts
1259
+ ]
1260
+ if not healthy_hosts:
1261
+ return
1262
+ adjacent_hosts = healthy_hosts
1263
+
1264
+ dest_host = self._rng.choice(adjacent_hosts)
1265
+
1266
+ # Ensure destination host is in graph
1267
+ if dest_host not in graph.hosts:
1268
+ hd = self._host_index.get(dest_host, {})
1269
+ graph.add_host(HostNode(
1270
+ hostname=dest_host,
1271
+ subnet=hd.get("subnet", "corporate"),
1272
+ business_criticality="medium",
1273
+ status="healthy",
1274
+ ))
1275
+
1276
+ source_processes = [p for p in graph.processes.values() if p.hostname == source_host]
1277
+ if not source_processes:
1278
+ return
1279
+ original = source_processes[0]
1280
+
1281
+ new_pid = str(uuid.uuid4())[:8] # uuid imported at module level
1282
+ new_process = ProcessNode(
1283
+ process_id=f"{dest_host}:{new_pid}",
1284
+ hostname=dest_host,
1285
+ process_name=original.process_name,
1286
+ killed=False,
1287
+ )
1288
+ graph.add_process(new_process)
1289
+
1290
+ graph.add_edge(Edge(
1291
+ edge_type="pivoted_from",
1292
+ source_id=dest_host,
1293
+ target_id=source_host,
1294
+ evidence={"trigger_action": "isolate_segment", "step": self._state.step_count},
1295
+ ))
1296
+
1297
+ if self._live_requirements is None:
1298
+ self._live_requirements = {}
1299
+ self._live_requirements.setdefault("must_kill", []).append(
1300
+ f"{dest_host}:{original.process_name}"
1301
+ )
1302
+ self._live_requirements.setdefault("must_isolate", []).append(dest_host)
1303
+
1304
+ new_alert = AlertNode(
1305
+ alert_id=f"PIVOT-{new_pid}",
1306
+ severity="critical",
1307
+ priority_score=15.0,
1308
+ source_host=dest_host,
1309
+ )
1310
+ graph.add_alert(new_alert)
1311
+
1312
+ @property
1313
+ def state(self) -> SOCState:
1314
+ """Get the current internal environment state."""
1315
+ return self._state
server/requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ openenv-core[core]>=0.2.2
2
+ openai>=1.0.0
3
+ websockets>=12.0
server/soar_playbooks.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SOAR Playbook Library — 5 deterministic playbooks + prerequisite checker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .threat_graph import ThreatGraph
9
+
10
+
11
+ PLAYBOOKS = {
12
+ "ransomware_containment": {
13
+ "name": "ransomware_containment",
14
+ "description": "Kill encryption processes and block malware hashes",
15
+ "prerequisites": ["forensics_run_on_target", "process_identified"],
16
+ "sub_actions": ["kill_process", "block_ioc"],
17
+ "target_attack_types": ["ransomware", "encryption"],
18
+ },
19
+ "c2_disruption": {
20
+ "name": "c2_disruption",
21
+ "description": "Block C2 IPs and disrupt command and control channel",
22
+ "prerequisites": ["ioc_enriched", "c2_ip_identified"],
23
+ "sub_actions": ["block_ioc", "isolate_segment"],
24
+ "target_attack_types": ["c2", "backdoor", "remote_access"],
25
+ },
26
+ "lateral_movement_lockdown": {
27
+ "name": "lateral_movement_lockdown",
28
+ "description": "Block east-west traffic and kill lateral movement backdoors",
29
+ "prerequisites": ["forensics_run_on_target", "lateral_movement_detected"],
30
+ "sub_actions": ["kill_process", "isolate_segment"],
31
+ "target_attack_types": ["lateral_movement", "pivot"],
32
+ },
33
+ "phishing_response": {
34
+ "name": "phishing_response",
35
+ "description": "Enrich phishing IOCs and block phishing domains",
36
+ "prerequisites": ["phishing_vector_confirmed"],
37
+ "sub_actions": ["enrich_ioc", "block_ioc"],
38
+ "target_attack_types": ["phishing", "spearphishing"],
39
+ },
40
+ "data_exfil_stop": {
41
+ "name": "data_exfil_stop",
42
+ "description": "Block exfil destinations and kill exfil processes",
43
+ "prerequisites": ["forensics_run_on_target", "exfil_destination_identified"],
44
+ "sub_actions": ["block_ioc", "kill_process"],
45
+ "target_attack_types": ["exfiltration", "data_theft"],
46
+ },
47
+ }
48
+
49
+
50
+ def _check_single_prerequisite(
51
+ prereq: str,
52
+ target_hostname: str,
53
+ state,
54
+ graph: "ThreatGraph",
55
+ ) -> tuple[bool, str]:
56
+ if prereq == "forensics_run_on_target":
57
+ scanned = getattr(state, "scanned_hosts", []) or []
58
+ if target_hostname in scanned:
59
+ return True, ""
60
+ # Also accept if a forensic-type edge (exploits / runs_on) exists for this host
61
+ for e in graph.edges:
62
+ if e.edge_type in ("exploits", "runs_on") and (
63
+ e.target_id == target_hostname or e.source_id == target_hostname
64
+ ):
65
+ return True, ""
66
+ return False, f"forensics not run on {target_hostname}"
67
+
68
+ if prereq == "process_identified":
69
+ for p in graph.processes.values():
70
+ if p.hostname == target_hostname:
71
+ return True, ""
72
+ return False, f"no process identified on {target_hostname}"
73
+
74
+ if prereq == "ioc_enriched":
75
+ for ioc in graph.iocs.values():
76
+ if ioc.enriched:
77
+ return True, ""
78
+ return False, "no IOC has been enriched yet"
79
+
80
+ if prereq == "c2_ip_identified":
81
+ for ioc in graph.iocs.values():
82
+ if ioc.ioc_type == "ip" and ioc.confidence > 0.7:
83
+ return True, ""
84
+ return False, "no high-confidence C2 IP identified"
85
+
86
+ if prereq == "lateral_movement_detected":
87
+ for e in graph.edges:
88
+ if e.edge_type == "pivoted_from":
89
+ return True, ""
90
+ return False, "no lateral movement detected in graph"
91
+
92
+ if prereq == "phishing_vector_confirmed":
93
+ for a in graph.alerts.values():
94
+ if a.source_host == target_hostname:
95
+ return True, ""
96
+ return False, f"no alert confirms phishing vector on {target_hostname}"
97
+
98
+ if prereq == "exfil_destination_identified":
99
+ for ioc in graph.iocs.values():
100
+ if (
101
+ ioc.ioc_type in ("ip", "domain")
102
+ and ioc.confidence > 0.6
103
+ and not ioc.blocked
104
+ ):
105
+ return True, ""
106
+ return False, "no unblocked exfil destination identified"
107
+
108
+ return False, f"unknown prerequisite: {prereq}"
109
+
110
+
111
+ def check_prerequisites(
112
+ playbook_name: str,
113
+ target_hostname: str,
114
+ state,
115
+ graph: "ThreatGraph",
116
+ ) -> tuple[bool, str]:
117
+ """Validate all prerequisites for a playbook against state + graph."""
118
+ if playbook_name not in PLAYBOOKS:
119
+ raise KeyError(f"Unknown playbook: {playbook_name}")
120
+
121
+ for prereq in PLAYBOOKS[playbook_name]["prerequisites"]:
122
+ ok, reason = _check_single_prerequisite(prereq, target_hostname, state, graph)
123
+ if not ok:
124
+ return False, reason
125
+ return True, ""
server/task_generator.py ADDED
@@ -0,0 +1,674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Procedural Task Generator for CyberSOCEnv.
9
+
10
+ Generates 1000+ unique, deterministic attack scenarios from a task_id seed.
11
+ Each task_id (e.g. 'gen_0001') always produces the exact same scenario.
12
+
13
+ Design:
14
+ - hash(task_id) -> deterministic seed -> random.Random instance
15
+ - Seed drives ALL choices: attack type, hosts, processes, IOCs, alerts
16
+ - 12 attack categories, 50+ malware names, 40+ C2 domains
17
+ - 3 difficulty tiers based on task number
18
+
19
+ No actual randomness — reproducible across runs and platforms.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import random
26
+ import itertools
27
+ from typing import Any, Dict, List, Tuple
28
+
29
+ # =============================================================================
30
+ # Validation & Exclusions
31
+ # =============================================================================
32
+
33
+ INCOMPATIBLE_THREATS = {
34
+ ("ransomware", "cryptomining"), # Ransomware kills host, no mining possible
35
+ ("data_exfiltration", "ransomware"), # Exfil needs host alive
36
+ }
37
+
38
+ def validate_task_def(task_def: dict) -> List[str]:
39
+ errors = []
40
+ compromised_hosts = set()
41
+ for threat in task_def.get("attack_chain", []):
42
+ compromised_hosts.update(threat.get("compromised_hosts", []))
43
+
44
+ reqs = task_def.get("containment_requirements", {})
45
+ for req in reqs.get("must_kill", []):
46
+ hostname = req.get("hostname") if isinstance(req, dict) else req.split(":")[0]
47
+ if hostname not in compromised_hosts:
48
+ errors.append(f"must_kill references non-compromised host: {hostname}")
49
+
50
+ for host in reqs.get("must_isolate", []):
51
+ if host not in compromised_hosts:
52
+ errors.append(f"must_isolate references non-compromised host: {host}")
53
+
54
+ return errors
55
+
56
+
57
+ # =============================================================================
58
+ # Template Pools (the "vocabulary" of the generator)
59
+ # =============================================================================
60
+
61
+ # --- Malware process names by category ---
62
+ MALWARE_PROCESSES = {
63
+ "ransomware": [
64
+ "cryptolocker.exe", "wannacry.exe", "blackcat_ransom.exe",
65
+ "lockbit3.exe", "revil_encrypt.exe", "hive_locker.exe",
66
+ "conti_crypt.exe", "ryuk_payload.exe", "maze_encrypt.exe",
67
+ "darkside_enc.exe", "babuk_lock.exe", "avaddon_crypt.exe",
68
+ ],
69
+ "phishing": [
70
+ "outlook_macro.exe", "word_dropper.exe", "macro_loader.exe",
71
+ "vba_agent.exe", "pdf_exploit.exe", "html_smuggler.exe",
72
+ "iso_mounter.exe", "lnk_runner.exe",
73
+ ],
74
+ "credential_theft": [
75
+ "mimikatz.exe", "lazagne.exe", "hashdump.exe",
76
+ "procdump_lsass.exe", "rubeus.exe", "kerbrute.exe",
77
+ "sharphound.exe", "bloodhound_collect.exe",
78
+ ],
79
+ "lateral_movement": [
80
+ "svchost_backdoor.exe", "psexec_svc.exe", "wmic_lateral.exe",
81
+ "rdp_hijack.exe", "ssh_brute.exe", "evil_winrm.exe",
82
+ "dcom_exec.exe", "smb_relay.exe",
83
+ ],
84
+ "c2_communication": [
85
+ "svchost_c2.exe", "cobalt_beacon.exe", "sliver_implant.exe",
86
+ "meterpreter.exe", "covenant_grunt.exe", "mythic_agent.exe",
87
+ "dns_tunnel.exe", "icmp_beacon.exe",
88
+ ],
89
+ "privilege_escalation": [
90
+ "exploit_kernel.exe", "potato_exploit.exe", "uac_bypass.exe",
91
+ "printspoofer.exe", "juicy_potato.exe", "named_pipe_exploit.exe",
92
+ "token_impersonate.exe", "dll_hijack.exe",
93
+ ],
94
+ "data_exfiltration": [
95
+ "data_pump.exe", "rclone_sync.exe", "mega_upload.exe",
96
+ "ftp_exfil.exe", "dns_exfil.exe", "cloud_sync_mal.exe",
97
+ "archive_send.exe", "stealer_agent.exe",
98
+ ],
99
+ "cryptomining": [
100
+ "xmrig_miner.exe", "ethminer.exe", "cpuminer.exe",
101
+ "nicehash_mal.exe", "coinhive_svc.exe", "monero_mine.exe",
102
+ ],
103
+ "supply_chain": [
104
+ "update_agent_mal.exe", "npm_backdoor.exe", "pip_trojan.exe",
105
+ "vscode_ext_mal.exe", "docker_implant.exe", "nuget_poison.exe",
106
+ ],
107
+ "insider_threat": [
108
+ "usb_copy.exe", "screen_capture.exe", "keylogger_svc.exe",
109
+ "email_forward.exe", "cloud_upload.exe", "print_spooler_mal.exe",
110
+ ],
111
+ "webshell": [
112
+ "cmd_webshell.php", "asp_backdoor.exe", "jsp_shell.exe",
113
+ "python_rshell.exe", "nodejs_shell.exe", "perl_cgi_shell.exe",
114
+ ],
115
+ "botnet": [
116
+ "mirai_bot.exe", "emotet_loader.exe", "trickbot_svc.exe",
117
+ "qbot_agent.exe", "dridex_dll.exe", "zloader_inject.exe",
118
+ ],
119
+ }
120
+
121
+ # --- C2 domains ---
122
+ C2_DOMAINS = [
123
+ "cdn-update.malware-c2.net", "api.darkc2.io", "telemetry-svc.ru",
124
+ "secure-update.evil.net", "cdn.payload-delivery.com", "api.shadownet.io",
125
+ "sync.cloud-c2.xyz", "update.legit-looking.com", "beacon.covert-ops.net",
126
+ "dns.tunnel-relay.org", "img.cdn-malware.com", "static.evil-cdn.net",
127
+ "api.stealthc2.io", "ws.encrypted-relay.net", "feed.darkweb-proxy.com",
128
+ "auth.phish-server.net", "login.fake-portal.com", "mail.spoof-relay.org",
129
+ "git.supply-chain.dev", "npm.compromised-pkg.io", "pypi.trojan-lib.org",
130
+ "dl.ransomware-pay.onion", "tor.exit-node-c2.net", "i2p.covert-chan.net",
131
+ "iot.botnet-c2.xyz", "cam.mirai-variant.net", "mqtt.iot-exploit.io",
132
+ "ftp.exfil-server.ru", "sftp.data-steal.com", "mega.cloud-drop.io",
133
+ "gist.code-exfil.dev", "paste.data-dump.xyz", "bin.steganography.net",
134
+ "vpn.tunnel-c2.com", "proxy.relay-beacon.org", "socks.covert-proxy.io",
135
+ "wpad.evil-config.net", "ntp.time-beacon.com", "ldap.ad-exploit.org",
136
+ "kerberos.ticket-steal.net",
137
+ ]
138
+
139
+ # --- C2 IPs (RFC 5737 documentation ranges + realistic-looking) ---
140
+ C2_IPS = [
141
+ "198.51.100.10", "198.51.100.22", "198.51.100.33", "198.51.100.44",
142
+ "198.51.100.55", "198.51.100.66", "198.51.100.77", "198.51.100.88",
143
+ "198.51.100.99", "198.51.100.110", "198.51.100.121", "198.51.100.132",
144
+ "203.0.113.10", "203.0.113.21", "203.0.113.32", "203.0.113.43",
145
+ "203.0.113.54", "203.0.113.65", "203.0.113.76", "203.0.113.87",
146
+ "203.0.113.98", "203.0.113.109", "203.0.113.120", "203.0.113.131",
147
+ "192.0.2.10", "192.0.2.21", "192.0.2.32", "192.0.2.43",
148
+ "192.0.2.54", "192.0.2.65", "192.0.2.76", "192.0.2.87",
149
+ "100.64.0.10", "100.64.0.22", "100.64.0.33", "100.64.0.44",
150
+ ]
151
+
152
+ # --- Subnet definitions (must match build_network() in tasks.py) ---
153
+ SUBNETS = {
154
+ "corporate": {"prefix": "WS", "count": 90, "criticality": 0.3},
155
+ "engineering": {"prefix": "DEV", "count": 36, "criticality": 0.5},
156
+ "finance": {"prefix": "FIN", "count": 14, "criticality": 0.8},
157
+ "dmz": {"prefix": "DMZ", "count": 3, "criticality": 0.6},
158
+ "datacenter": {"prefix": "SRV", "count": 20, "criticality": 0.9},
159
+ "executive": {"prefix": "EXEC", "count": 5, "criticality": 1.0},
160
+ }
161
+
162
+ # --- Attack phases in kill-chain order ---
163
+ ATTACK_PHASES = [
164
+ "initial_access", "execution", "persistence", "privilege_escalation",
165
+ "credential_access", "lateral_movement", "command_and_control",
166
+ "exfiltration", "impact",
167
+ ]
168
+
169
+ # --- Alert description templates ---
170
+ ALERT_TEMPLATES = {
171
+ "ransomware": [
172
+ "EDR detected file encryption activity on {host}. Process '{proc}' is encrypting files in user directories.",
173
+ "Anomalous file system activity: {count} files renamed with .{ext} extension in {secs} seconds on {host}.",
174
+ "Ransomware signature detected in process '{proc}' on {host}. Volume shadow copies being deleted.",
175
+ ],
176
+ "phishing": [
177
+ "User on {host} clicked suspicious link in email. {proc} execution detected downloading payload from {domain}.",
178
+ "Macro-enabled document opened on {host}. Outbound connection to {domain} detected.",
179
+ "Suspicious email attachment executed on {host}. Process '{proc}' spawned child processes.",
180
+ ],
181
+ "credential_theft": [
182
+ "LSASS memory access detected on {host} — possible credential dumping via {proc}.",
183
+ "Kerberos ticket request anomaly on {host}. Process '{proc}' attempting ticket manipulation.",
184
+ "SAM database access detected on {host}. Credential harvesting tool '{proc}' identified.",
185
+ ],
186
+ "lateral_movement": [
187
+ "Suspicious RDP login to {host} from compromised source using admin credentials. Process '{proc}' spawned.",
188
+ "SMB lateral movement detected: '{proc}' deployed on {host} via remote service creation.",
189
+ "WMI remote execution detected on {host}. Process '{proc}' launched from external host.",
190
+ ],
191
+ "c2_communication": [
192
+ "Periodic beaconing detected from {host} to {ip} every {interval} seconds. Encrypted payload exchange observed.",
193
+ "DNS tunneling activity from {host}. Suspicious queries to {domain} with encoded payloads.",
194
+ "Cobalt Strike beacon profile detected on {host}. Process '{proc}' communicating with {ip}.",
195
+ ],
196
+ "privilege_escalation": [
197
+ "Kernel exploit attempt on {host}. Process '{proc}' gained SYSTEM privileges.",
198
+ "UAC bypass detected on {host}. Process '{proc}' elevated to admin without user consent.",
199
+ "Token impersonation attack on {host}. Process '{proc}' obtained domain admin token.",
200
+ ],
201
+ "data_exfiltration": [
202
+ "Large data transfer ({size} GB) to external IP {ip} from {host}. Possible exfiltration of {data_type}.",
203
+ "Staging activity detected on {host}. Process '{proc}' archiving sensitive directories for extraction.",
204
+ "Cloud storage upload from {host} to unauthorized account. Process '{proc}' transferring {data_type}.",
205
+ ],
206
+ "cryptomining": [
207
+ "High CPU usage (98%) on {host}. Process '{proc}' identified as cryptocurrency miner.",
208
+ "Mining pool connection from {host} to {ip}:{port}. Process '{proc}' consuming all available cores.",
209
+ "Stratum protocol detected on {host}. Unauthorized mining process '{proc}' active.",
210
+ ],
211
+ "supply_chain": [
212
+ "Compromised package detected in CI/CD pipeline on {host}. Process '{proc}' executing post-install scripts.",
213
+ "Backdoored update agent on {host}. Process '{proc}' downloading payloads from {domain}.",
214
+ "Malicious dependency loaded on {host}. Process '{proc}' establishing covert communication channels.",
215
+ ],
216
+ "insider_threat": [
217
+ "Unusual data access pattern on {host}. Process '{proc}' accessing files outside user's normal scope.",
218
+ "USB mass storage device connected on {host}. Process '{proc}' copying sensitive files to removable media.",
219
+ "After-hours bulk file download on {host}. Process '{proc}' archiving {data_type} documents.",
220
+ ],
221
+ "webshell": [
222
+ "Web shell detected on {host}. Process '{proc}' executing system commands via HTTP POST requests.",
223
+ "Suspicious file upload on {host}. Process '{proc}' created in web-accessible directory with bash capabilities.",
224
+ "Remote code execution on {host}. Process '{proc}' spawned from web server with SYSTEM context.",
225
+ ],
226
+ "botnet": [
227
+ "Bot agent detected on {host}. Process '{proc}' joining command pool at {ip}.",
228
+ "DDoS toolkit loaded on {host}. Process '{proc}' ready to receive attack instructions from {domain}.",
229
+ "Worm propagation from {host}. Process '{proc}' scanning network for vulnerable hosts.",
230
+ ],
231
+ }
232
+
233
+ # --- Severity levels with weights ---
234
+ SEVERITIES = ["low", "medium", "high", "critical"]
235
+ SEVERITY_WEIGHTS = {"easy": [0.1, 0.4, 0.4, 0.1], "medium": [0.0, 0.2, 0.5, 0.3], "hard": [0.0, 0.1, 0.3, 0.6]}
236
+
237
+ # --- Data types for exfil descriptions ---
238
+ DATA_TYPES = [
239
+ "customer PII", "financial records", "employee credentials",
240
+ "source code", "trade secrets", "medical records",
241
+ "encryption keys", "database backups", "API tokens",
242
+ "board meeting minutes", "M&A documents", "patent filings",
243
+ ]
244
+
245
+ # --- File extensions for ransomware ---
246
+ RANSOM_EXTENSIONS = [
247
+ "locked", "encrypted", "crypted", "crypt", "enc", "pay",
248
+ "ransom", "darkside", "blackcat", "hive", "lockbit", "ryuk",
249
+ ]
250
+
251
+
252
+ # =============================================================================
253
+ # Deterministic Seed Helper
254
+ # =============================================================================
255
+
256
+ def _seed_from_task_id(task_id: str) -> int:
257
+ """Create a deterministic integer seed from a task_id string."""
258
+ h = hashlib.sha256(task_id.encode("utf-8")).hexdigest()
259
+ return int(h[:16], 16)
260
+
261
+
262
+ def _make_hash(rng: random.Random) -> str:
263
+ """Generate a fake MD5-like hash deterministically."""
264
+ return "".join(rng.choice("0123456789abcdef") for _ in range(32))
265
+
266
+
267
+ # =============================================================================
268
+ # Difficulty Classification
269
+ # =============================================================================
270
+
271
+ def _get_difficulty(task_id: str, rng: random.Random) -> str:
272
+ """Determine difficulty from task_id pattern or seed."""
273
+ # If task_id has an explicit difficulty prefix, use it
274
+ if task_id.startswith("easy_") or task_id.startswith("gen_easy_"):
275
+ return "easy"
276
+ if task_id.startswith("medium_") or task_id.startswith("gen_medium_"):
277
+ return "medium"
278
+ if task_id.startswith("hard_") or task_id.startswith("gen_hard_"):
279
+ return "hard"
280
+
281
+ # For gen_NNNN pattern, use number ranges
282
+ if task_id.startswith("gen_"):
283
+ try:
284
+ num = int(task_id.split("_")[1])
285
+ if num <= 333:
286
+ return "easy"
287
+ elif num <= 666:
288
+ return "medium"
289
+ else:
290
+ return "hard"
291
+ except (ValueError, IndexError):
292
+ pass
293
+
294
+ # Fallback: use seed-based distribution
295
+ return rng.choice(["easy", "medium", "hard"])
296
+
297
+
298
+ # =============================================================================
299
+ # Core Generator
300
+ # =============================================================================
301
+
302
+ def _pick_hosts(rng: random.Random, subnet: str, count: int) -> List[str]:
303
+ """Pick `count` unique host names from a subnet."""
304
+ info = SUBNETS[subnet]
305
+ prefix = info["prefix"]
306
+ max_idx = info["count"]
307
+ indices = rng.sample(range(1, max_idx + 1), min(count, max_idx))
308
+ return [f"{prefix}-{idx:03d}" for idx in indices]
309
+
310
+
311
+ def _pick_subnets(rng: random.Random, count: int) -> List[str]:
312
+ """Pick `count` unique subnet names."""
313
+ all_subnets = list(SUBNETS.keys())
314
+ return rng.sample(all_subnets, min(count, len(all_subnets)))
315
+
316
+
317
+ def _generate_threat(
318
+ rng: random.Random,
319
+ threat_id: str,
320
+ attack_type: str,
321
+ phase: str,
322
+ available_subnets: List[str],
323
+ used_hosts: set,
324
+ ) -> Tuple[Dict[str, Any], List[str]]:
325
+ """Generate a single threat in the attack chain.
326
+
327
+ Returns:
328
+ (threat_dict, list_of_compromised_hosts)
329
+ """
330
+ # Pick target subnet and hosts
331
+ subnet = rng.choice(available_subnets)
332
+ num_hosts = rng.randint(1, 3) if attack_type != "ransomware" else rng.randint(1, 2)
333
+
334
+ hosts = _pick_hosts(rng, subnet, num_hosts + 3) # Pick extra to avoid collisions
335
+ hosts = [h for h in hosts if h not in used_hosts][:num_hosts]
336
+ if not hosts:
337
+ # Fallback: pick from any subnet
338
+ fallback_subnet = rng.choice(list(SUBNETS.keys()))
339
+ hosts = _pick_hosts(rng, fallback_subnet, num_hosts + 5)
340
+ hosts = [h for h in hosts if h not in used_hosts][:max(1, num_hosts)]
341
+
342
+ # Pick malware process
343
+ procs = MALWARE_PROCESSES.get(attack_type, MALWARE_PROCESSES["lateral_movement"])
344
+ proc = rng.choice(procs)
345
+
346
+ # Generate IOCs
347
+ num_hashes = rng.randint(1, 2)
348
+ hashes = [_make_hash(rng) for _ in range(num_hashes)]
349
+
350
+ num_ips = rng.randint(0, 2) if attack_type in ("c2_communication", "data_exfiltration", "cryptomining", "botnet") else rng.randint(0, 1)
351
+ ips = rng.sample(C2_IPS, min(num_ips, len(C2_IPS))) if num_ips > 0 else []
352
+
353
+ num_domains = rng.randint(0, 2) if attack_type in ("c2_communication", "phishing", "supply_chain", "botnet") else rng.randint(0, 1)
354
+ domains = rng.sample(C2_DOMAINS, min(num_domains, len(C2_DOMAINS))) if num_domains > 0 else []
355
+
356
+ # C2 servers (subset of IPs for c2/exfil types)
357
+ c2_servers = ips[:1] if attack_type in ("c2_communication", "data_exfiltration", "botnet") else []
358
+
359
+ # Lateral targets (for movement-type threats)
360
+ lateral_targets: List[str] = []
361
+ if attack_type in ("lateral_movement", "credential_theft", "c2_communication"):
362
+ lat_subnet = rng.choice(list(SUBNETS.keys()))
363
+ lat_hosts = _pick_hosts(rng, lat_subnet, 2)
364
+ lateral_targets = [h for h in lat_hosts if h not in used_hosts and h not in hosts][:rng.randint(0, 2)]
365
+
366
+ # Exfil targets
367
+ exfil_targets: List[str] = []
368
+ if attack_type == "data_exfiltration":
369
+ exfil_targets = list(hosts)
370
+
371
+ threat = {
372
+ "threat_id": threat_id,
373
+ "threat_type": attack_type,
374
+ "phase": phase,
375
+ "compromised_hosts": hosts,
376
+ "malicious_processes": [proc],
377
+ "c2_servers": c2_servers,
378
+ "iocs": {
379
+ "hashes": hashes,
380
+ "ips": ips,
381
+ "domains": domains,
382
+ },
383
+ "lateral_targets": lateral_targets,
384
+ "exfil_targets": exfil_targets,
385
+ }
386
+
387
+ return threat, hosts
388
+
389
+
390
+ def _generate_alert(
391
+ rng: random.Random,
392
+ alert_idx: int,
393
+ task_prefix: str,
394
+ threat: Dict[str, Any],
395
+ timestamp_base: int,
396
+ ) -> Dict[str, Any]:
397
+ """Generate a single SIEM alert for a threat."""
398
+ attack_type = threat["threat_type"]
399
+ host = rng.choice(threat["compromised_hosts"])
400
+ proc = threat["malicious_processes"][0]
401
+
402
+ # Pick template
403
+ templates = ALERT_TEMPLATES.get(attack_type, ALERT_TEMPLATES["lateral_movement"])
404
+ template = rng.choice(templates)
405
+
406
+ # Fill template
407
+ description = template.format(
408
+ host=host,
409
+ proc=proc,
410
+ domain=rng.choice(threat["iocs"]["domains"]) if threat["iocs"]["domains"] else "unknown.example.com",
411
+ ip=rng.choice(threat["iocs"]["ips"]) if threat["iocs"]["ips"] else "0.0.0.0",
412
+ count=rng.randint(50, 500),
413
+ ext=rng.choice(RANSOM_EXTENSIONS),
414
+ secs=rng.randint(10, 120),
415
+ interval=rng.choice([30, 60, 90, 120, 300]),
416
+ size=round(rng.uniform(0.5, 15.0), 1),
417
+ data_type=rng.choice(DATA_TYPES),
418
+ port=rng.choice([3333, 4444, 5555, 8080, 8443, 9090]),
419
+ )
420
+
421
+ # Collect IOC indicators for the alert
422
+ ioc_indicators = []
423
+ if threat["iocs"]["hashes"]:
424
+ ioc_indicators.append(rng.choice(threat["iocs"]["hashes"]))
425
+ if threat["iocs"]["ips"]:
426
+ ioc_indicators.append(rng.choice(threat["iocs"]["ips"]))
427
+ if threat["iocs"]["domains"]:
428
+ ioc_indicators.append(rng.choice(threat["iocs"]["domains"]))
429
+
430
+ # Determine subnet from host prefix
431
+ subnet = "corporate"
432
+ for sn, info in SUBNETS.items():
433
+ if host.startswith(info["prefix"]):
434
+ subnet = sn
435
+ break
436
+
437
+ # Severity
438
+ severity_weights = SEVERITY_WEIGHTS.get(
439
+ "hard" if attack_type in ("data_exfiltration", "ransomware", "privilege_escalation") else "medium",
440
+ SEVERITY_WEIGHTS["medium"]
441
+ )
442
+ severity = rng.choices(SEVERITIES, weights=severity_weights, k=1)[0]
443
+
444
+ # Timestamp (spread across a few hours)
445
+ minutes_offset = timestamp_base + alert_idx * rng.randint(5, 45)
446
+ hour = 6 + (minutes_offset // 60)
447
+ minute = minutes_offset % 60
448
+ timestamp = f"2025-01-15T{hour:02d}:{minute:02d}:00Z"
449
+
450
+ return {
451
+ "alert_id": f"ALERT-{task_prefix}{alert_idx + 1:03d}",
452
+ "timestamp": timestamp,
453
+ "source_host": host,
454
+ "severity": severity,
455
+ "threat_type": attack_type,
456
+ "description": description,
457
+ "ioc_indicators": ioc_indicators,
458
+ "subnet": subnet,
459
+ "is_acknowledged": False,
460
+ }
461
+
462
+
463
+ # =============================================================================
464
+ # Main Generator Function
465
+ # =============================================================================
466
+
467
+ def generate_task(task_id: str, eval_mode: bool = False) -> Dict[str, Any]:
468
+ """Generate a complete, deterministic task definition from a task_id.
469
+
470
+ The task_id is hashed to create a seed, ensuring the same task_id
471
+ always produces the exact same scenario.
472
+
473
+ Args:
474
+ task_id: Any string (e.g. 'gen_0001', 'gen_0500', 'phishing_test')
475
+
476
+ Returns:
477
+ A task_def dict compatible with CyberSOCEnvironment.reset()
478
+ """
479
+ seed_offset = 0
480
+ # Add offset if eval_mode to ensure different data for same ID format
481
+ if eval_mode:
482
+ seed_offset += 10000
483
+
484
+ while True:
485
+ seed = _seed_from_task_id(task_id) + seed_offset
486
+ rng = random.Random(seed)
487
+
488
+ # Determine difficulty
489
+ difficulty = _get_difficulty(task_id, rng)
490
+
491
+ # Configure parameters based on difficulty
492
+ if difficulty == "easy":
493
+ num_threats = 1
494
+ max_steps = rng.randint(12, 18)
495
+ initial_impact = round(rng.uniform(0.02, 0.08), 2)
496
+ impact_per_step = round(rng.uniform(0.01, 0.03), 3)
497
+ num_subnets = rng.randint(1, 2)
498
+ elif difficulty == "medium":
499
+ num_threats = rng.randint(2, 3)
500
+ max_steps = rng.randint(20, 28)
501
+ initial_impact = round(rng.uniform(0.08, 0.15), 2)
502
+ impact_per_step = round(rng.uniform(0.02, 0.04), 3)
503
+ num_subnets = rng.randint(2, 4)
504
+ else: # hard
505
+ num_threats = rng.randint(3, 6)
506
+ max_steps = rng.randint(25, 35)
507
+ initial_impact = round(rng.uniform(0.15, 0.25), 2)
508
+ impact_per_step = round(rng.uniform(0.03, 0.05), 3)
509
+ num_subnets = rng.randint(3, 6)
510
+
511
+ # Pick attack types for this scenario
512
+ all_attack_types = list(MALWARE_PROCESSES.keys())
513
+ if difficulty == "easy":
514
+ # Easy: single focused attack
515
+ attack_types = [rng.choice(all_attack_types)]
516
+ elif difficulty == "medium":
517
+ # Medium: multi-stage, pick a plausible chain
518
+ chains = [
519
+ ["phishing", "credential_theft", "lateral_movement"],
520
+ ["phishing", "c2_communication", "data_exfiltration"],
521
+ ["webshell", "privilege_escalation", "lateral_movement"],
522
+ ["supply_chain", "c2_communication", "credential_theft"],
523
+ ["botnet", "cryptomining", "lateral_movement"],
524
+ ["insider_threat", "data_exfiltration"],
525
+ ]
526
+ chain = rng.choice(chains)
527
+ attack_types = chain[:num_threats]
528
+ else:
529
+ # Hard: complex multi-phase APT
530
+ chains = [
531
+ ["phishing", "c2_communication", "privilege_escalation", "data_exfiltration", "ransomware"],
532
+ ["supply_chain", "c2_communication", "lateral_movement", "credential_theft", "data_exfiltration", "ransomware"],
533
+ ["webshell", "privilege_escalation", "c2_communication", "lateral_movement", "data_exfiltration"],
534
+ ["phishing", "credential_theft", "lateral_movement", "cryptomining", "botnet"],
535
+ ["insider_threat", "privilege_escalation", "data_exfiltration", "c2_communication"],
536
+ ["botnet", "lateral_movement", "privilege_escalation", "ransomware", "data_exfiltration"],
537
+ ]
538
+ chain = rng.choice(chains)
539
+ attack_types = chain[:num_threats]
540
+
541
+ # Re-roll if incompatible threats are chosen
542
+ if any((t1, t2) in INCOMPATIBLE_THREATS or (t2, t1) in INCOMPATIBLE_THREATS
543
+ for t1, t2 in itertools.combinations(attack_types, 2)):
544
+ seed_offset += 1
545
+ continue
546
+
547
+ # Pick subnets involved
548
+ involved_subnets = _pick_subnets(rng, num_subnets)
549
+
550
+ # Generate attack chain
551
+ attack_chain: List[Dict[str, Any]] = []
552
+ used_hosts: set = set()
553
+ task_prefix = task_id.replace("gen_", "G").upper()[:6]
554
+
555
+ for i, attack_type in enumerate(attack_types):
556
+ phase_idx = min(i, len(ATTACK_PHASES) - 1)
557
+ # Use realistic phase based on attack type
558
+ phase_map = {
559
+ "phishing": "initial_access",
560
+ "webshell": "initial_access",
561
+ "supply_chain": "initial_access",
562
+ "credential_theft": "credential_access",
563
+ "privilege_escalation": "privilege_escalation",
564
+ "lateral_movement": "lateral_movement",
565
+ "c2_communication": "command_and_control",
566
+ "data_exfiltration": "exfiltration",
567
+ "ransomware": "impact",
568
+ "cryptomining": "impact",
569
+ "insider_threat": "exfiltration",
570
+ "botnet": "command_and_control",
571
+ }
572
+ phase = phase_map.get(attack_type, ATTACK_PHASES[phase_idx])
573
+
574
+ threat_id = f"T-{task_prefix}-{i + 1:03d}"
575
+ threat, new_hosts = _generate_threat(
576
+ rng, threat_id, attack_type, phase, involved_subnets, used_hosts
577
+ )
578
+ attack_chain.append(threat)
579
+ used_hosts.update(new_hosts)
580
+
581
+ # Generate alerts (1-2 per threat)
582
+ initial_alerts: List[Dict[str, Any]] = []
583
+ timestamp_base = rng.randint(0, 60)
584
+ for i, threat in enumerate(attack_chain):
585
+ num_alerts = rng.randint(1, 2)
586
+ for j in range(num_alerts):
587
+ alert = _generate_alert(
588
+ rng, len(initial_alerts), task_prefix, threat, timestamp_base
589
+ )
590
+ initial_alerts.append(alert)
591
+
592
+ # Generate containment requirements
593
+ must_kill = []
594
+ must_block_iocs = []
595
+ must_forensics = []
596
+ must_not_isolate = []
597
+
598
+ for threat in attack_chain:
599
+ for host in threat["compromised_hosts"]:
600
+ for proc in threat["malicious_processes"]:
601
+ must_kill.append({"hostname": host, "process": proc})
602
+ if host not in must_forensics:
603
+ must_forensics.append(host)
604
+
605
+ # Collect all IOCs as required blocks
606
+ for h in threat["iocs"]["hashes"]:
607
+ if h not in must_block_iocs:
608
+ must_block_iocs.append(h)
609
+ for ip in threat["iocs"]["ips"]:
610
+ if ip not in must_block_iocs:
611
+ must_block_iocs.append(ip)
612
+ for d in threat["iocs"]["domains"]:
613
+ if d not in must_block_iocs:
614
+ must_block_iocs.append(d)
615
+
616
+ # Subnets that should NOT be isolated (business-critical ones not in the attack)
617
+ non_involved = [s for s in SUBNETS if s not in involved_subnets]
618
+ if difficulty == "easy":
619
+ must_not_isolate = non_involved
620
+ elif difficulty == "medium":
621
+ must_not_isolate = [s for s in non_involved if SUBNETS[s]["criticality"] >= 0.8]
622
+
623
+ # Build description
624
+ type_names = list(set(t["threat_type"] for t in attack_chain))
625
+ host_count = len(used_hosts)
626
+ desc = (
627
+ f"[{difficulty.upper()}] {', '.join(type_names).replace('_', ' ').title()} "
628
+ f"across {host_count} host(s) in {', '.join(involved_subnets)}."
629
+ )
630
+
631
+ task_def = {
632
+ "description": desc,
633
+ "max_steps": max_steps,
634
+ "initial_business_impact": initial_impact,
635
+ "impact_per_step": impact_per_step,
636
+ "attack_chain": attack_chain,
637
+ "initial_alerts": initial_alerts,
638
+ "optimal_actions": [
639
+ "run_forensics", "kill_process", "block_ioc", "submit_containment_plan"
640
+ ],
641
+ "containment_requirements": {
642
+ "must_kill": must_kill,
643
+ "must_block_iocs": must_block_iocs,
644
+ "must_forensics": must_forensics,
645
+ "must_not_isolate": must_not_isolate,
646
+ },
647
+ }
648
+
649
+ errors = validate_task_def(task_def)
650
+ if not errors:
651
+ return task_def
652
+
653
+ # If validation fails, try again with a different seed
654
+ seed_offset += 1
655
+
656
+
657
+ # =============================================================================
658
+ # Batch Generation (for openenv.yaml and validation)
659
+ # =============================================================================
660
+
661
+ def list_generated_task_ids(count: int = 1000) -> List[str]:
662
+ """Return the list of generated task IDs."""
663
+ return [f"gen_{i:04d}" for i in range(1, count + 1)]
664
+
665
+
666
+ def get_task_summary(task_id: str) -> Dict[str, str]:
667
+ """Get a short summary of a generated task (for openenv.yaml)."""
668
+ task_def = generate_task(task_id)
669
+ difficulty = _get_difficulty(task_id, random.Random(_seed_from_task_id(task_id)))
670
+ return {
671
+ "description": task_def["description"],
672
+ "max_steps": task_def["max_steps"],
673
+ "difficulty": difficulty,
674
+ }
server/tasks.py ADDED
@@ -0,0 +1,530 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Deterministic task definitions for CyberSOCEnv.
9
+
10
+ Each task defines a fixed attack chain, network layout, and expected
11
+ containment actions. No randomness — every run of the same task_id
12
+ produces identical initial state.
13
+
14
+ Tasks:
15
+ - easy: Single ransomware endpoint on the corporate subnet.
16
+ - medium: Multi-stage lateral movement (phishing -> cred theft -> 3 subnets).
17
+ - hard: APT + ransomware with C2, exfiltration, and executive pressure.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Dict, List
23
+
24
+
25
+ # =============================================================================
26
+ # Network Topology Builder (deterministic, 500-node)
27
+ # =============================================================================
28
+
29
+ def _build_subnet(
30
+ name: str,
31
+ role: str,
32
+ prefix: str,
33
+ ip_base: str,
34
+ count: int,
35
+ start_idx: int,
36
+ criticality: float,
37
+ default_ports: List[int],
38
+ default_procs: List[str],
39
+ ) -> List[Dict[str, Any]]:
40
+ """Build a list of host dicts for a subnet."""
41
+ hosts = []
42
+ for i in range(count):
43
+ idx = start_idx + i
44
+ hosts.append({
45
+ "hostname": f"{prefix}-{idx:03d}",
46
+ "ip_address": f"{ip_base}.{idx}",
47
+ "subnet": name,
48
+ "role": role,
49
+ "status": "online",
50
+ "running_processes": list(default_procs),
51
+ "open_ports": list(default_ports),
52
+ "criticality": criticality,
53
+ })
54
+ return hosts
55
+
56
+
57
+ def build_network() -> Dict[str, List[Dict[str, Any]]]:
58
+ """Build the deterministic enterprise network (~50 active hosts).
59
+
60
+ Reduced from 400 to ~50 nodes for GRPO training throughput (target ≥8 eps/min).
61
+ Host indices are chosen to cover all hand-crafted task references (WS-042, WS-088,
62
+ DEV-033, FIN-008, FIN-012, SRV-002..SRV-015, EXEC-003) plus a small buffer for
63
+ procedural generation and lateral pivot targets. The README still describes a
64
+ "500-node enterprise" — the simulation covers the same topology, but only
65
+ materializes the operationally relevant hosts.
66
+
67
+ Returns:
68
+ Dict mapping subnet name -> list of host dicts.
69
+ """
70
+ network: Dict[str, List[Dict[str, Any]]] = {}
71
+
72
+ # Corporate: WS-001..WS-005 (buffer) + WS-015..WS-020 (covers WS-017)
73
+ # + WS-040..WS-045 (covers WS-042) + WS-085..WS-090 (covers WS-088)
74
+ corporate: List[Dict[str, Any]] = []
75
+ for start, count in [(1, 5), (15, 6), (40, 6), (85, 6)]:
76
+ corporate.extend(_build_subnet(
77
+ name="corporate", role="corporate", prefix="WS",
78
+ ip_base="10.1.1", count=count, start_idx=start,
79
+ criticality=0.3,
80
+ default_ports=[135, 445, 3389],
81
+ default_procs=["outlook.exe", "chrome.exe", "explorer.exe"],
82
+ ))
83
+ network["corporate"] = corporate # 23 hosts
84
+
85
+ # Engineering: DEV-001..DEV-005 + DEV-030..DEV-036 (covers DEV-033)
86
+ engineering: List[Dict[str, Any]] = []
87
+ for start, count in [(1, 5), (30, 7)]:
88
+ engineering.extend(_build_subnet(
89
+ name="engineering", role="engineering", prefix="DEV",
90
+ ip_base="10.2.1", count=count, start_idx=start,
91
+ criticality=0.5,
92
+ default_ports=[22, 443, 8080, 3389],
93
+ default_procs=["vscode.exe", "python.exe", "docker.exe", "git.exe"],
94
+ ))
95
+ network["engineering"] = engineering # 12 hosts
96
+
97
+ # Finance: FIN-001..FIN-005 + FIN-008..FIN-014 (covers FIN-008, FIN-012)
98
+ finance: List[Dict[str, Any]] = []
99
+ for start, count in [(1, 5), (8, 7)]:
100
+ finance.extend(_build_subnet(
101
+ name="finance", role="finance", prefix="FIN",
102
+ ip_base="10.3.1", count=count, start_idx=start,
103
+ criticality=0.8,
104
+ default_ports=[443, 1433, 3389],
105
+ default_procs=["excel.exe", "sap.exe", "sqlcmd.exe"],
106
+ ))
107
+ network["finance"] = finance # 12 hosts
108
+
109
+ # DMZ: DMZ-001..DMZ-003 (no tasks reference DMZ hosts)
110
+ network["dmz"] = _build_subnet(
111
+ name="dmz", role="dmz", prefix="DMZ",
112
+ ip_base="10.4.1", count=3, start_idx=1,
113
+ criticality=0.6,
114
+ default_ports=[80, 443, 8443],
115
+ default_procs=["nginx", "node", "java"],
116
+ ) # 3 hosts
117
+
118
+ # Datacenter: SRV-001..SRV-020 (covers SRV-002, SRV-005, SRV-010, SRV-015)
119
+ network["datacenter"] = _build_subnet(
120
+ name="datacenter", role="datacenter", prefix="SRV",
121
+ ip_base="10.5.1", count=20, start_idx=1,
122
+ criticality=0.9,
123
+ default_ports=[22, 443, 5432, 6379, 9200],
124
+ default_procs=["postgres", "redis-server", "elasticsearch", "kubelet"],
125
+ ) # 20 hosts
126
+
127
+ # Executive: EXEC-001..EXEC-005 (covers EXEC-003)
128
+ network["executive"] = _build_subnet(
129
+ name="executive", role="executive", prefix="EXEC",
130
+ ip_base="10.6.1", count=5, start_idx=1,
131
+ criticality=1.0,
132
+ default_ports=[443, 3389],
133
+ default_procs=["outlook.exe", "teams.exe", "chrome.exe"],
134
+ ) # 5 hosts
135
+
136
+ return network # Total: ~75 hosts
137
+
138
+
139
+ # =============================================================================
140
+ # Attack Chain Definitions
141
+ # =============================================================================
142
+
143
+ TASKS: Dict[str, Dict[str, Any]] = {
144
+ # ----- EASY: Single ransomware endpoint -----
145
+ "easy": {
146
+ "description": "Ransomware detected on a single corporate workstation. Isolate and contain.",
147
+ "max_steps": 15,
148
+ "initial_business_impact": 0.05,
149
+ "impact_per_step": 0.02, # Impact grows slowly per step
150
+ "attack_chain": [
151
+ {
152
+ "threat_id": "T-EASY-001",
153
+ "threat_type": "ransomware",
154
+ "phase": "execution",
155
+ "compromised_hosts": ["WS-042"],
156
+ "malicious_processes": ["cryptolocker.exe"],
157
+ "c2_servers": [],
158
+ "iocs": {
159
+ "hashes": ["e99a18c428cb38d5f260853678922e03"],
160
+ "ips": [],
161
+ "domains": [],
162
+ },
163
+ "lateral_targets": [],
164
+ "exfil_targets": [],
165
+ },
166
+ ],
167
+ "initial_alerts": [
168
+ {
169
+ "alert_id": "ALERT-E001",
170
+ "timestamp": "2025-01-15T09:23:17Z",
171
+ "source_host": "WS-042",
172
+ "severity": "critical",
173
+ "threat_type": "ransomware",
174
+ "description": "EDR detected file encryption activity on WS-042. Process 'cryptolocker.exe' is encrypting files in C:\\Users\\jsmith\\Documents.",
175
+ "ioc_indicators": ["e99a18c428cb38d5f260853678922e03"],
176
+ "subnet": "corporate",
177
+ "is_acknowledged": False,
178
+ },
179
+ {
180
+ "alert_id": "ALERT-E002",
181
+ "timestamp": "2025-01-15T09:23:45Z",
182
+ "source_host": "WS-042",
183
+ "severity": "high",
184
+ "threat_type": "ransomware",
185
+ "description": "Anomalous file system activity: 147 files renamed with .locked extension in 28 seconds.",
186
+ "ioc_indicators": [],
187
+ "subnet": "corporate",
188
+ "is_acknowledged": False,
189
+ },
190
+ ],
191
+ # Optimal containment: kill process, run forensics, block hash, submit plan
192
+ "optimal_actions": ["kill_process", "run_forensics", "block_ioc", "submit_containment_plan"],
193
+ "containment_requirements": {
194
+ "must_kill": [{"hostname": "WS-042", "process": "cryptolocker.exe"}],
195
+ "must_block_iocs": ["e99a18c428cb38d5f260853678922e03"],
196
+ "must_forensics": ["WS-042"],
197
+ "must_not_isolate": ["finance", "engineering", "datacenter"], # Unnecessary isolation = downtime
198
+ },
199
+ },
200
+
201
+ # ----- MEDIUM: Multi-stage lateral movement -----
202
+ "medium": {
203
+ "description": "Phishing attack led to credential theft and lateral movement across 3 subnets.",
204
+ "max_steps": 25,
205
+ "initial_business_impact": 0.10,
206
+ "impact_per_step": 0.03,
207
+ "attack_chain": [
208
+ {
209
+ "threat_id": "T-MED-001",
210
+ "threat_type": "phishing",
211
+ "phase": "initial_access",
212
+ "compromised_hosts": ["WS-017"],
213
+ "malicious_processes": ["powershell.exe"],
214
+ "c2_servers": [],
215
+ "iocs": {
216
+ "hashes": ["d41d8cd98f00b204e9800998ecf8427e"],
217
+ "ips": [],
218
+ "domains": ["evil-login.example.com"],
219
+ },
220
+ "lateral_targets": [],
221
+ "exfil_targets": [],
222
+ },
223
+ {
224
+ "threat_id": "T-MED-002",
225
+ "threat_type": "credential_theft",
226
+ "phase": "credential_access",
227
+ "compromised_hosts": ["WS-017"],
228
+ "malicious_processes": ["mimikatz.exe"],
229
+ "c2_servers": [],
230
+ "iocs": {
231
+ "hashes": ["aabbccdd11223344eeff5566778899aa"],
232
+ "ips": [],
233
+ "domains": [],
234
+ },
235
+ "lateral_targets": ["DEV-033", "FIN-012"],
236
+ "exfil_targets": [],
237
+ },
238
+ {
239
+ "threat_id": "T-MED-003",
240
+ "threat_type": "lateral_movement",
241
+ "phase": "lateral_movement",
242
+ "compromised_hosts": ["DEV-033", "FIN-012"],
243
+ "malicious_processes": ["svchost_backdoor.exe"],
244
+ "c2_servers": [],
245
+ "iocs": {
246
+ "hashes": ["112233445566778899aabbccddeeff00"],
247
+ "ips": ["203.0.113.50"],
248
+ "domains": [],
249
+ },
250
+ "lateral_targets": ["SRV-005"],
251
+ "exfil_targets": [],
252
+ },
253
+ ],
254
+ "initial_alerts": [
255
+ {
256
+ "alert_id": "ALERT-M001",
257
+ "timestamp": "2025-01-15T08:15:00Z",
258
+ "source_host": "WS-017",
259
+ "severity": "medium",
260
+ "threat_type": "phishing",
261
+ "description": "User clicked suspicious link in email. PowerShell execution detected downloading payload from evil-login.example.com.",
262
+ "ioc_indicators": ["evil-login.example.com"],
263
+ "subnet": "corporate",
264
+ "is_acknowledged": False,
265
+ },
266
+ {
267
+ "alert_id": "ALERT-M002",
268
+ "timestamp": "2025-01-15T08:32:00Z",
269
+ "source_host": "WS-017",
270
+ "severity": "high",
271
+ "threat_type": "credential_theft",
272
+ "description": "LSASS memory access detected — possible credential dumping via Mimikatz.",
273
+ "ioc_indicators": ["aabbccdd11223344eeff5566778899aa"],
274
+ "subnet": "corporate",
275
+ "is_acknowledged": False,
276
+ },
277
+ {
278
+ "alert_id": "ALERT-M003",
279
+ "timestamp": "2025-01-15T09:05:00Z",
280
+ "source_host": "DEV-033",
281
+ "severity": "high",
282
+ "threat_type": "lateral_movement",
283
+ "description": "Suspicious RDP login from WS-017 using admin credentials. New process svchost_backdoor.exe spawned.",
284
+ "ioc_indicators": ["203.0.113.50", "112233445566778899aabbccddeeff00"],
285
+ "subnet": "engineering",
286
+ "is_acknowledged": False,
287
+ },
288
+ {
289
+ "alert_id": "ALERT-M004",
290
+ "timestamp": "2025-01-15T09:12:00Z",
291
+ "source_host": "FIN-012",
292
+ "severity": "critical",
293
+ "threat_type": "lateral_movement",
294
+ "description": "Unauthorized access to FIN-012 from compromised credentials. Backdoor process active.",
295
+ "ioc_indicators": ["112233445566778899aabbccddeeff00"],
296
+ "subnet": "finance",
297
+ "is_acknowledged": False,
298
+ },
299
+ ],
300
+ "optimal_actions": [
301
+ "query_host", "run_forensics", "kill_process", "block_ioc",
302
+ "isolate_segment", "run_forensics", "submit_containment_plan",
303
+ ],
304
+ "containment_requirements": {
305
+ "must_kill": [
306
+ {"hostname": "WS-017", "process": "powershell.exe"},
307
+ {"hostname": "WS-017", "process": "mimikatz.exe"},
308
+ {"hostname": "DEV-033", "process": "svchost_backdoor.exe"},
309
+ {"hostname": "FIN-012", "process": "svchost_backdoor.exe"},
310
+ ],
311
+ "must_block_iocs": [
312
+ "evil-login.example.com",
313
+ "203.0.113.50",
314
+ "d41d8cd98f00b204e9800998ecf8427e",
315
+ "aabbccdd11223344eeff5566778899aa",
316
+ "112233445566778899aabbccddeeff00",
317
+ ],
318
+ "must_forensics": ["WS-017", "DEV-033", "FIN-012"],
319
+ "must_not_isolate": ["executive", "datacenter"],
320
+ },
321
+ },
322
+
323
+ # ----- HARD: APT + Ransomware, C2, exfiltration, executive pressure -----
324
+ "hard": {
325
+ "description": "Advanced Persistent Threat with active C2 comms, data exfiltration in progress, and ransomware deployment imminent. Board is watching — minimize downtime.",
326
+ "max_steps": 30,
327
+ "initial_business_impact": 0.20,
328
+ "impact_per_step": 0.04,
329
+ "attack_chain": [
330
+ {
331
+ "threat_id": "T-HARD-001",
332
+ "threat_type": "phishing",
333
+ "phase": "initial_access",
334
+ "compromised_hosts": ["EXEC-003"],
335
+ "malicious_processes": ["outlook_macro.exe"],
336
+ "c2_servers": ["198.51.100.77"],
337
+ "iocs": {
338
+ "hashes": ["deadbeef0123456789abcdef01234567"],
339
+ "ips": ["198.51.100.77"],
340
+ "domains": ["cdn-update.malware-c2.net"],
341
+ },
342
+ "lateral_targets": ["WS-088"],
343
+ "exfil_targets": [],
344
+ },
345
+ {
346
+ "threat_id": "T-HARD-002",
347
+ "threat_type": "c2_communication",
348
+ "phase": "command_and_control",
349
+ "compromised_hosts": ["EXEC-003", "WS-088"],
350
+ "malicious_processes": ["svchost_c2.exe"],
351
+ "c2_servers": ["198.51.100.77"],
352
+ "iocs": {
353
+ "hashes": ["cafebabe9876543210fedcba98765432"],
354
+ "ips": ["198.51.100.77"],
355
+ "domains": ["cdn-update.malware-c2.net", "exfil.malware-c2.net"],
356
+ },
357
+ "lateral_targets": ["SRV-002", "FIN-008"],
358
+ "exfil_targets": [],
359
+ },
360
+ {
361
+ "threat_id": "T-HARD-003",
362
+ "threat_type": "privilege_escalation",
363
+ "phase": "privilege_escalation",
364
+ "compromised_hosts": ["SRV-002"],
365
+ "malicious_processes": ["exploit_kernel.exe"],
366
+ "c2_servers": ["198.51.100.77"],
367
+ "iocs": {
368
+ "hashes": ["1122334455667788aabbccddeeff0011"],
369
+ "ips": ["198.51.100.77"],
370
+ "domains": [],
371
+ },
372
+ "lateral_targets": ["SRV-010", "SRV-015"],
373
+ "exfil_targets": ["SRV-002"],
374
+ },
375
+ {
376
+ "threat_id": "T-HARD-004",
377
+ "threat_type": "data_exfiltration",
378
+ "phase": "exfiltration",
379
+ "compromised_hosts": ["SRV-002", "FIN-008"],
380
+ "malicious_processes": ["data_pump.exe"],
381
+ "c2_servers": ["198.51.100.77"],
382
+ "iocs": {
383
+ "hashes": ["ffeeddccbbaa99887766554433221100"],
384
+ "ips": ["198.51.100.77", "203.0.113.99"],
385
+ "domains": ["exfil.malware-c2.net"],
386
+ },
387
+ "lateral_targets": [],
388
+ "exfil_targets": ["SRV-002", "FIN-008"],
389
+ },
390
+ {
391
+ "threat_id": "T-HARD-005",
392
+ "threat_type": "ransomware",
393
+ "phase": "impact",
394
+ "compromised_hosts": ["SRV-010", "SRV-015"],
395
+ "malicious_processes": ["blackcat_ransom.exe"],
396
+ "c2_servers": [],
397
+ "iocs": {
398
+ "hashes": ["aabb0011ccdd2233eeff4455667788"],
399
+ "ips": [],
400
+ "domains": [],
401
+ },
402
+ "lateral_targets": [],
403
+ "exfil_targets": [],
404
+ },
405
+ ],
406
+ "initial_alerts": [
407
+ {
408
+ "alert_id": "ALERT-H001",
409
+ "timestamp": "2025-01-15T06:00:00Z",
410
+ "source_host": "EXEC-003",
411
+ "severity": "medium",
412
+ "threat_type": "phishing",
413
+ "description": "Executive VP opened macro-enabled document. Outbound connection to cdn-update.malware-c2.net detected.",
414
+ "ioc_indicators": ["cdn-update.malware-c2.net", "198.51.100.77"],
415
+ "subnet": "executive",
416
+ "is_acknowledged": False,
417
+ },
418
+ {
419
+ "alert_id": "ALERT-H002",
420
+ "timestamp": "2025-01-15T06:45:00Z",
421
+ "source_host": "WS-088",
422
+ "severity": "high",
423
+ "threat_type": "c2_communication",
424
+ "description": "Periodic beaconing detected to 198.51.100.77 every 60 seconds. Encrypted payload exchange observed.",
425
+ "ioc_indicators": ["198.51.100.77", "cafebabe9876543210fedcba98765432"],
426
+ "subnet": "corporate",
427
+ "is_acknowledged": False,
428
+ },
429
+ {
430
+ "alert_id": "ALERT-H003",
431
+ "timestamp": "2025-01-15T07:30:00Z",
432
+ "source_host": "SRV-002",
433
+ "severity": "critical",
434
+ "threat_type": "privilege_escalation",
435
+ "description": "Kernel exploit attempt on SRV-002 (database server). Process exploit_kernel.exe gained SYSTEM privileges.",
436
+ "ioc_indicators": ["1122334455667788aabbccddeeff0011"],
437
+ "subnet": "datacenter",
438
+ "is_acknowledged": False,
439
+ },
440
+ {
441
+ "alert_id": "ALERT-H004",
442
+ "timestamp": "2025-01-15T08:00:00Z",
443
+ "source_host": "SRV-002",
444
+ "severity": "critical",
445
+ "threat_type": "data_exfiltration",
446
+ "description": "Large data transfer (2.3 GB) to external IP 203.0.113.99 from database server SRV-002. Possible exfiltration of customer PII.",
447
+ "ioc_indicators": ["203.0.113.99", "exfil.malware-c2.net"],
448
+ "subnet": "datacenter",
449
+ "is_acknowledged": False,
450
+ },
451
+ {
452
+ "alert_id": "ALERT-H005",
453
+ "timestamp": "2025-01-15T08:10:00Z",
454
+ "source_host": "FIN-008",
455
+ "severity": "critical",
456
+ "threat_type": "data_exfiltration",
457
+ "description": "Financial records being staged for exfiltration on FIN-008. Process data_pump.exe accessing sensitive directories.",
458
+ "ioc_indicators": ["ffeeddccbbaa99887766554433221100"],
459
+ "subnet": "finance",
460
+ "is_acknowledged": False,
461
+ },
462
+ {
463
+ "alert_id": "ALERT-H006",
464
+ "timestamp": "2025-01-15T08:30:00Z",
465
+ "source_host": "SRV-010",
466
+ "severity": "critical",
467
+ "threat_type": "ransomware",
468
+ "description": "BlackCat ransomware deployment detected on SRV-010! File encryption starting on production storage.",
469
+ "ioc_indicators": ["aabb0011ccdd2233eeff4455667788"],
470
+ "subnet": "datacenter",
471
+ "is_acknowledged": False,
472
+ },
473
+ ],
474
+ "optimal_actions": [
475
+ "block_ioc", "kill_process", "run_forensics", "isolate_segment",
476
+ "kill_process", "block_ioc", "run_forensics", "kill_process",
477
+ "submit_containment_plan",
478
+ ],
479
+ "containment_requirements": {
480
+ "must_kill": [
481
+ {"hostname": "EXEC-003", "process": "outlook_macro.exe"},
482
+ {"hostname": "EXEC-003", "process": "svchost_c2.exe"},
483
+ {"hostname": "WS-088", "process": "svchost_c2.exe"},
484
+ {"hostname": "SRV-002", "process": "exploit_kernel.exe"},
485
+ {"hostname": "SRV-002", "process": "data_pump.exe"},
486
+ {"hostname": "FIN-008", "process": "data_pump.exe"},
487
+ {"hostname": "SRV-010", "process": "blackcat_ransom.exe"},
488
+ {"hostname": "SRV-015", "process": "blackcat_ransom.exe"},
489
+ ],
490
+ "must_block_iocs": [
491
+ "198.51.100.77",
492
+ "203.0.113.99",
493
+ "cdn-update.malware-c2.net",
494
+ "exfil.malware-c2.net",
495
+ "deadbeef0123456789abcdef01234567",
496
+ "cafebabe9876543210fedcba98765432",
497
+ ],
498
+ "must_forensics": ["EXEC-003", "WS-088", "SRV-002", "FIN-008", "SRV-010"],
499
+ "must_not_isolate": [], # In APT scenario, any isolation decision is valid
500
+ },
501
+ },
502
+ }
503
+
504
+
505
+ def get_task(task_id: str) -> Dict[str, Any]:
506
+ """Retrieve a task definition by ID.
507
+
508
+ Supports:
509
+ - 'easy', 'medium', 'hard': Hand-crafted curated benchmarks
510
+ - 'gen_0001' through 'gen_1000': Procedurally generated scenarios
511
+ - Any other string: Generated on-the-fly via seeded procedural generation
512
+
513
+ Args:
514
+ task_id: Task identifier string.
515
+
516
+ Returns:
517
+ Task definition dict.
518
+ """
519
+ # Check hand-crafted tasks first
520
+ if task_id in TASKS:
521
+ return TASKS[task_id]
522
+
523
+ # Fall back to procedural generation
524
+ try:
525
+ from .task_generator import generate_task
526
+ except ImportError:
527
+ from server.task_generator import generate_task
528
+
529
+ return generate_task(task_id)
530
+
server/threat_graph.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ThreatGraph — typed knowledge graph of SOC entities, edges, and evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Literal, Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class HostNode(BaseModel):
12
+ hostname: str
13
+ subnet: str
14
+ business_criticality: Literal["low", "medium", "high", "critical"]
15
+ status: Literal["healthy", "suspicious", "compromised", "isolated", "contained"]
16
+ first_seen_suspicious: Optional[datetime] = None
17
+ scanned: bool = False
18
+
19
+
20
+ class ProcessNode(BaseModel):
21
+ process_id: str # format: "hostname:pid"
22
+ hostname: str
23
+ process_name: str
24
+ killed: bool = False
25
+
26
+
27
+ class IOCNode(BaseModel):
28
+ ioc_value: str
29
+ ioc_type: Literal["ip", "domain", "hash", "filename"]
30
+ confidence: float
31
+ blocked: bool = False
32
+ enriched: bool = False
33
+ threat_actor: Optional[str] = None
34
+ mitre_ttps: list[str] = Field(default_factory=list)
35
+
36
+
37
+ class VulnerabilityNode(BaseModel):
38
+ cve_id: str
39
+ hostname: str
40
+ cvss_score: float
41
+ exploitability: Literal["active", "theoretical", "patched"]
42
+ patch_available: bool
43
+ exploited_by_threat: Optional[str] = None
44
+
45
+
46
+ class AlertNode(BaseModel):
47
+ alert_id: str
48
+ severity: Literal["low", "medium", "high", "critical"]
49
+ priority_score: float
50
+ source_host: str
51
+ correlated_with: list[str] = Field(default_factory=list)
52
+
53
+
54
+ class Edge(BaseModel):
55
+ edge_type: Literal[
56
+ "runs_on", "involves", "communicates_with",
57
+ "pivoted_from", "part_of_chain", "exploits",
58
+ ]
59
+ source_id: str
60
+ target_id: str
61
+ evidence: dict = Field(default_factory=dict)
62
+
63
+
64
+ MAX_GRAPH_NODES = 200
65
+
66
+
67
+ class ThreatGraph:
68
+ def __init__(self):
69
+ self.hosts: dict[str, HostNode] = {}
70
+ self.processes: dict[str, ProcessNode] = {}
71
+ self.iocs: dict[str, IOCNode] = {}
72
+ self.vulnerabilities: dict[str, VulnerabilityNode] = {}
73
+ self.alerts: dict[str, AlertNode] = {}
74
+ self.edges: list[Edge] = []
75
+ self.version: int = 0
76
+ # changelog entries: (version_after_add, entity_type, entity_id)
77
+ self._changelog: list[tuple[int, str, str]] = []
78
+ # insertion-order tracking for IOC pruning (oldest first)
79
+ self._ioc_insertion_order: list[str] = []
80
+
81
+ def _total_nodes(self) -> int:
82
+ return (
83
+ len(self.hosts) + len(self.processes) + len(self.iocs)
84
+ + len(self.vulnerabilities) + len(self.alerts)
85
+ )
86
+
87
+ def _prune_oldest_iocs(self, needed: int = 1) -> None:
88
+ """Remove the oldest `needed` IOCNodes to stay under MAX_GRAPH_NODES."""
89
+ pruned = 0
90
+ while pruned < needed and self._ioc_insertion_order:
91
+ oldest = self._ioc_insertion_order.pop(0)
92
+ if oldest in self.iocs:
93
+ del self.iocs[oldest]
94
+ pruned += 1
95
+
96
+ def add_host(self, node: HostNode) -> None:
97
+ self.hosts[node.hostname] = node
98
+ self.version += 1
99
+ self._changelog.append((self.version, "host", node.hostname))
100
+
101
+ def add_process(self, node: ProcessNode) -> None:
102
+ self.processes[node.process_id] = node
103
+ self.version += 1
104
+ self._changelog.append((self.version, "process", node.process_id))
105
+
106
+ def add_ioc(self, node: IOCNode) -> None:
107
+ if node.ioc_value in self.iocs:
108
+ return # already present — no cap action needed
109
+ if self._total_nodes() >= MAX_GRAPH_NODES:
110
+ self._prune_oldest_iocs(needed=1)
111
+ self.iocs[node.ioc_value] = node
112
+ self._ioc_insertion_order.append(node.ioc_value)
113
+ self.version += 1
114
+ self._changelog.append((self.version, "ioc", node.ioc_value))
115
+
116
+ def add_vulnerability(self, node: VulnerabilityNode) -> None:
117
+ key = f"{node.hostname}:{node.cve_id}"
118
+ self.vulnerabilities[key] = node
119
+ self.version += 1
120
+ self._changelog.append((self.version, "vulnerability", key))
121
+
122
+ def add_alert(self, node: AlertNode) -> None:
123
+ self.alerts[node.alert_id] = node
124
+ self.version += 1
125
+ self._changelog.append((self.version, "alert", node.alert_id))
126
+
127
+ def add_edge(self, edge: Edge) -> None:
128
+ self.edges.append(edge)
129
+ self.version += 1
130
+ edge_id = f"{edge.edge_type}:{edge.source_id}->{edge.target_id}"
131
+ self._changelog.append((self.version, "edge", edge_id))
132
+
133
+ def delta_since(self, version: int) -> dict:
134
+ """Return compact summary of nodes/edges added since `version`."""
135
+ if version <= 0:
136
+ entries = list(self._changelog)
137
+ else:
138
+ entries = [e for e in self._changelog if e[0] > version]
139
+
140
+ counts: dict[str, int] = {}
141
+ ids_by_type: dict[str, list[str]] = {}
142
+ for _, etype, eid in entries:
143
+ counts[etype] = counts.get(etype, 0) + 1
144
+ ids_by_type.setdefault(etype, []).append(eid)
145
+
146
+ # Truncate each id list to keep summary compact
147
+ compact_ids = {k: v[:5] for k, v in ids_by_type.items()}
148
+
149
+ summary_parts = [f"{t}={counts[t]}" for t in sorted(counts.keys())]
150
+ summary_text = f"Δ since v{version}: " + ", ".join(summary_parts) if summary_parts else f"Δ since v{version}: (no changes)"
151
+
152
+ return {
153
+ "from_version": version,
154
+ "to_version": self.version,
155
+ "counts": counts,
156
+ "ids": compact_ids,
157
+ "summary": summary_text,
158
+ }
159
+
160
+ def compute_evidence_confidence(
161
+ self, threat_id: str, rubric_item_count: int = 3
162
+ ) -> float:
163
+ """Confidence that a threat is well-evidenced.
164
+
165
+ Denominator is normalized to task complexity: max(3, rubric_item_count * 1.5).
166
+ This prevents reward hacking via forensics spam — a spammer who generates
167
+ 10 graph nodes only scores against the rubric-sized baseline, not 10.
168
+ """
169
+ linked_ids: set[str] = set()
170
+ for edge in self.edges:
171
+ if edge.source_id == threat_id:
172
+ linked_ids.add(edge.target_id)
173
+ elif edge.target_id == threat_id:
174
+ linked_ids.add(edge.source_id)
175
+
176
+ if not linked_ids:
177
+ return 0.0
178
+
179
+ non_alert_count = 0
180
+ for nid in linked_ids:
181
+ if nid in self.alerts:
182
+ continue
183
+ if (
184
+ nid in self.hosts
185
+ or nid in self.processes
186
+ or nid in self.iocs
187
+ or nid in self.vulnerabilities
188
+ ):
189
+ non_alert_count += 1
190
+
191
+ denominator = max(3.0, rubric_item_count * 1.5)
192
+ confidence = non_alert_count / denominator
193
+ return max(0.0, min(1.0, confidence))
194
+
195
+ def get_context_summary(self) -> str:
196
+ """Compact LLM-injectable summary of current graph state."""
197
+ compromised = sum(1 for h in self.hosts.values() if h.status == "compromised")
198
+ critical_alerts = sum(1 for a in self.alerts.values() if a.severity == "critical")
199
+ blocked = sum(1 for i in self.iocs.values() if i.blocked)
200
+ enriched = sum(1 for i in self.iocs.values() if i.enriched)
201
+ return (
202
+ f"Hosts: {len(self.hosts)} ({compromised} compromised) "
203
+ f"Alerts: {len(self.alerts)} ({critical_alerts} critical) "
204
+ f"IOCs: {len(self.iocs)} ({blocked} blocked, {enriched} enriched) "
205
+ f"Vulns: {len(self.vulnerabilities)} "
206
+ f"Edges: {len(self.edges)}"
207
+ )
server/tool_router.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic Tool Router (phase machine) + Triage Solver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .threat_graph import ThreatGraph, AlertNode, HostNode
9
+
10
+
11
+ class ToolRouter:
12
+ PHASE_ORDER = ["triage", "investigation", "remediation", "report"]
13
+ MAX_INVESTIGATION_LOOPS = 4
14
+ MAX_REMEDIATION_LOOPS = 3
15
+
16
+ def __init__(self):
17
+ self._investigation_loop_count = 0
18
+ self._remediation_loop_count = 0
19
+
20
+ def next_phase(
21
+ self,
22
+ current_phase: str,
23
+ graph: "ThreatGraph",
24
+ steps_remaining: int,
25
+ ) -> str:
26
+ if current_phase == "triage":
27
+ if len(graph.alerts) > 0:
28
+ return "investigation"
29
+ return "report"
30
+
31
+ if current_phase == "investigation":
32
+ if (
33
+ self._has_sufficient_evidence(graph)
34
+ or steps_remaining < 4
35
+ or self._investigation_loop_count >= self.MAX_INVESTIGATION_LOOPS
36
+ ):
37
+ return "remediation"
38
+ self._investigation_loop_count += 1
39
+ return "investigation"
40
+
41
+ if current_phase == "remediation":
42
+ if (
43
+ self._all_threats_contained(graph)
44
+ or steps_remaining < 2
45
+ or self._remediation_loop_count >= self.MAX_REMEDIATION_LOOPS
46
+ ):
47
+ return "report"
48
+ if (
49
+ self._remediation_loop_count < self.MAX_REMEDIATION_LOOPS
50
+ and not self._all_threats_contained(graph)
51
+ and steps_remaining >= 4
52
+ ):
53
+ self._remediation_loop_count += 1
54
+ return "investigation"
55
+ return "report"
56
+
57
+ if current_phase == "report":
58
+ return "done"
59
+
60
+ return "done"
61
+
62
+ def _has_sufficient_evidence(self, graph: "ThreatGraph") -> bool:
63
+ has_unhealthy_host = any(h.status != "healthy" for h in graph.hosts.values())
64
+ has_ioc = len(graph.iocs) > 0
65
+ has_process = len(graph.processes) > 0
66
+ return has_unhealthy_host and has_ioc and has_process
67
+
68
+ def _all_threats_contained(self, graph: "ThreatGraph") -> bool:
69
+ suspicious_or_compromised = [
70
+ h for h in graph.hosts.values()
71
+ if h.status in ("suspicious", "compromised")
72
+ ]
73
+ if not suspicious_or_compromised:
74
+ return True
75
+ return all(
76
+ h.status in ("isolated", "contained") for h in graph.hosts.values()
77
+ )
78
+
79
+ def reset(self):
80
+ self._investigation_loop_count = 0
81
+ self._remediation_loop_count = 0
82
+
83
+ def honor_pushback(
84
+ self,
85
+ proposed_next_phase: str,
86
+ justification_graph_refs: list[str],
87
+ graph: "ThreatGraph",
88
+ ) -> tuple[bool, str]:
89
+ if proposed_next_phase not in self.PHASE_ORDER and proposed_next_phase != "done":
90
+ return False, f"invalid phase '{proposed_next_phase}'"
91
+ if not justification_graph_refs:
92
+ return False, "no justification graph references provided"
93
+
94
+ all_node_ids = (
95
+ set(graph.alerts.keys())
96
+ | set(graph.hosts.keys())
97
+ | set(graph.processes.keys())
98
+ | set(graph.iocs.keys())
99
+ | set(graph.vulnerabilities.keys())
100
+ )
101
+ for ref in justification_graph_refs:
102
+ if ref not in all_node_ids:
103
+ return False, f"reference '{ref}' not present in graph"
104
+
105
+ has_critical_alert = any(
106
+ ref in graph.alerts and graph.alerts[ref].severity in ("high", "critical")
107
+ for ref in justification_graph_refs
108
+ )
109
+ if not has_critical_alert:
110
+ return False, "at least one referenced alert must be high/critical severity"
111
+
112
+ return True, ""
113
+
114
+
115
+ # ===========================================================================
116
+ # Triage Solver
117
+ # ===========================================================================
118
+
119
+ SEVERITY_W = {"low": 1, "medium": 3, "high": 7, "critical": 15}
120
+ CRITICALITY_W = {"low": 1, "medium": 2, "high": 4, "critical": 8}
121
+ REACHABILITY_SCALE = 10
122
+
123
+
124
+ def compute_triage_priority(
125
+ alert: "AlertNode",
126
+ host: "HostNode",
127
+ graph: "ThreatGraph",
128
+ ) -> float:
129
+ blast_radius = sum(1 for e in graph.edges if e.source_id == host.hostname)
130
+ return (
131
+ SEVERITY_W[alert.severity]
132
+ * CRITICALITY_W[host.business_criticality]
133
+ * (1 + blast_radius / REACHABILITY_SCALE)
134
+ )
135
+
136
+
137
+ def solve_triage_order(graph: "ThreatGraph") -> list[str]:
138
+ scored: list[tuple[float, str]] = []
139
+ for alert in graph.alerts.values():
140
+ host = graph.hosts.get(alert.source_host)
141
+ if host is None:
142
+ continue
143
+ score = compute_triage_priority(alert, host, graph)
144
+ scored.append((score, alert.alert_id))
145
+ scored.sort(key=lambda t: t[0], reverse=True)
146
+ return [aid for _, aid in scored]
server/visualize_graph.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Red Line Demo Visualizer — renders ThreatGraph as a PNG.
2
+
3
+ Usage:
4
+ from server.visualize_graph import visualize_graph
5
+ visualize_graph(env._threat_graph, "snapshot.png")
6
+
7
+ Pivot edges (edge_type == 'pivoted_from') are drawn as thick bright-red lines
8
+ so the adaptive red-team lateral movement is visually undeniable in demos.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from .threat_graph import ThreatGraph
17
+
18
+
19
+ _NODE_COLORS = {
20
+ "host": "#4A90D9",
21
+ "process": "#F5A623",
22
+ "ioc": "#D0021B",
23
+ "alert": "#9B9B9B",
24
+ "vulnerability": "#7ED321",
25
+ }
26
+
27
+ _EDGE_STYLES: dict[str, dict] = {
28
+ "pivoted_from": {"color": "red", "width": 3.0, "style": "solid"},
29
+ "part_of_chain": {"color": "#555555", "width": 1.5, "style": "dashed"},
30
+ "exploits": {"color": "#D0021B", "width": 1.5, "style": "solid"},
31
+ "runs_on": {"color": "#4A90D9", "width": 1.0, "style": "solid"},
32
+ "involves": {"color": "#9B9B9B", "width": 1.0, "style": "dotted"},
33
+ "communicates_with": {"color": "#F5A623", "width": 1.0, "style": "dashed"},
34
+ }
35
+
36
+
37
+ def visualize_graph(threat_graph: "ThreatGraph", output_path: str = "threat_graph.png") -> None:
38
+ """Render the ThreatGraph to a PNG file.
39
+
40
+ Args:
41
+ threat_graph: A populated ThreatGraph instance.
42
+ output_path: Destination file path for the PNG.
43
+ """
44
+ try:
45
+ import networkx as nx
46
+ import matplotlib
47
+ matplotlib.use("Agg") # headless backend — safe in CI / server contexts
48
+ import matplotlib.pyplot as plt
49
+ import matplotlib.patches as mpatches
50
+ except ImportError as exc:
51
+ raise ImportError(
52
+ "visualize_graph requires networkx and matplotlib. "
53
+ "Install them with: pip install networkx matplotlib"
54
+ ) from exc
55
+
56
+ G = nx.DiGraph()
57
+
58
+ # --- Add nodes ---
59
+ node_color_map: dict[str, str] = {}
60
+
61
+ for hostname in threat_graph.hosts:
62
+ G.add_node(hostname)
63
+ node_color_map[hostname] = _NODE_COLORS["host"]
64
+
65
+ for proc_id, proc in threat_graph.processes.items():
66
+ G.add_node(proc_id)
67
+ node_color_map[proc_id] = _NODE_COLORS["process"]
68
+
69
+ for ioc_value in threat_graph.iocs:
70
+ G.add_node(ioc_value)
71
+ node_color_map[ioc_value] = _NODE_COLORS["ioc"]
72
+
73
+ for alert_id in threat_graph.alerts:
74
+ G.add_node(alert_id)
75
+ node_color_map[alert_id] = _NODE_COLORS["alert"]
76
+
77
+ for vuln_key in threat_graph.vulnerabilities:
78
+ G.add_node(vuln_key)
79
+ node_color_map[vuln_key] = _NODE_COLORS["vulnerability"]
80
+
81
+ # --- Add edges, split by type for drawing ---
82
+ pivot_edges: list[tuple[str, str]] = []
83
+ other_edges: list[tuple[str, str]] = []
84
+ other_edge_styles: list[dict] = []
85
+
86
+ for edge in threat_graph.edges:
87
+ src, tgt = edge.source_id, edge.target_id
88
+ # Ensure both endpoints exist as nodes (may be threat-IDs not in node sets)
89
+ if src not in G:
90
+ G.add_node(src)
91
+ node_color_map[src] = "#CCCCCC"
92
+ if tgt not in G:
93
+ G.add_node(tgt)
94
+ node_color_map[tgt] = "#CCCCCC"
95
+ G.add_edge(src, tgt, edge_type=edge.edge_type)
96
+
97
+ if edge.edge_type == "pivoted_from":
98
+ pivot_edges.append((src, tgt))
99
+ else:
100
+ style = _EDGE_STYLES.get(edge.edge_type, {"color": "#AAAAAA", "width": 1.0, "style": "solid"})
101
+ other_edges.append((src, tgt))
102
+ other_edge_styles.append(style)
103
+
104
+ # --- Layout ---
105
+ fig, ax = plt.subplots(figsize=(14, 10))
106
+ ax.set_title("CyberSOC Threat Graph", fontsize=14, fontweight="bold")
107
+ ax.axis("off")
108
+
109
+ pos = nx.spring_layout(G, seed=42, k=1.5)
110
+
111
+ node_list = list(G.nodes())
112
+ colors = [node_color_map.get(n, "#CCCCCC") for n in node_list]
113
+
114
+ nx.draw_networkx_nodes(G, pos, nodelist=node_list, node_color=colors,
115
+ node_size=600, ax=ax, alpha=0.9)
116
+ nx.draw_networkx_labels(G, pos, font_size=6, ax=ax)
117
+
118
+ # Draw non-pivot edges grouped by style
119
+ _style_map: dict[str, list[tuple[str, str]]] = {}
120
+ for (src, tgt), style in zip(other_edges, other_edge_styles):
121
+ key = f"{style['color']}|{style['width']}|{style['style']}"
122
+ _style_map.setdefault(key, []).append((src, tgt))
123
+
124
+ for key, elist in _style_map.items():
125
+ color, width_s, linestyle = key.split("|")
126
+ nx.draw_networkx_edges(
127
+ G, pos, edgelist=elist,
128
+ edge_color=color, width=float(width_s), style=linestyle,
129
+ arrows=True, arrowsize=12, ax=ax, alpha=0.7,
130
+ )
131
+
132
+ # CRITICAL: pivot edges — thick bright red, drawn last so they sit on top
133
+ if pivot_edges:
134
+ nx.draw_networkx_edges(
135
+ G, pos, edgelist=pivot_edges,
136
+ edge_color="red", width=3.0, style="solid",
137
+ arrows=True, arrowsize=18, ax=ax, alpha=1.0,
138
+ )
139
+
140
+ # --- Legend ---
141
+ legend_handles = [
142
+ mpatches.Patch(color=c, label=label)
143
+ for label, c in [
144
+ ("Host", _NODE_COLORS["host"]),
145
+ ("Process", _NODE_COLORS["process"]),
146
+ ("IOC", _NODE_COLORS["ioc"]),
147
+ ("Alert", _NODE_COLORS["alert"]),
148
+ ("Vulnerability", _NODE_COLORS["vulnerability"]),
149
+ ]
150
+ ]
151
+ legend_handles.append(
152
+ mpatches.Patch(color="red", label="Lateral Pivot (pivoted_from)")
153
+ )
154
+ ax.legend(handles=legend_handles, loc="lower left", fontsize=8, framealpha=0.8)
155
+
156
+ plt.tight_layout()
157
+ plt.savefig(output_path, dpi=150, bbox_inches="tight")
158
+ plt.close(fig)
tests/__init__.py ADDED
File without changes
tests/test_integration.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests — Task 10."""
2
+
3
+ import os
4
+ import sys
5
+ import subprocess
6
+
7
+ import pytest
8
+
9
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
10
+ if _PROJECT_ROOT not in sys.path:
11
+ sys.path.insert(0, _PROJECT_ROOT)
12
+
13
+ from server.play_environment import CyberSOCEnvironment
14
+ from server.episode_sandbox import EpisodeTimeout
15
+ from server.graders import grade_episode
16
+ from models import SOCActionWrapper
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Helpers
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def _first_host(env):
24
+ return next(iter(env._threat_graph.hosts), "WS-042")
25
+
26
+
27
+ def _first_alert(env):
28
+ return next(iter(env._threat_graph.alerts), None)
29
+
30
+
31
+ def _valid_action(action_type, **kwargs):
32
+ return SOCActionWrapper(type=action_type, **kwargs)
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Tests
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def test_easy_episode_completes():
40
+ env = CyberSOCEnvironment()
41
+ obs = env.reset(task_id="easy")
42
+ hostname = _first_host(env)
43
+ for _ in range(5):
44
+ obs = env.step(_valid_action("query_host", hostname=hostname))
45
+ assert obs is not None
46
+
47
+
48
+ def test_medium_episode_with_all_10_actions():
49
+ env = CyberSOCEnvironment()
50
+ obs = env.reset(task_id="medium")
51
+ hostname = _first_host(env)
52
+ ioc_value = next(iter(env._threat_graph.iocs), None)
53
+
54
+ # Triage phase
55
+ alerts = list(env._threat_graph.alerts.keys())
56
+ if len(alerts) >= 2:
57
+ obs = env.step(_valid_action("correlate_alerts", alert_ids=alerts[:2]))
58
+
59
+ # Investigation phase
60
+ obs = env.step(_valid_action("query_host", hostname=hostname))
61
+ obs = env.step(_valid_action("run_forensics", hostname=hostname))
62
+
63
+ if ioc_value:
64
+ obs = env.step(_valid_action("enrich_ioc", ioc_value=ioc_value, ioc_type="ip"))
65
+ obs = env.step(_valid_action("scan_host_vulnerabilities", hostname=hostname))
66
+
67
+ # Remediation phase
68
+ if ioc_value:
69
+ obs = env.step(_valid_action("block_ioc", ioc_value=ioc_value, ioc_type="ip"))
70
+ obs = env.step(_valid_action("kill_process", hostname=hostname, process_name="nonexistent.exe"))
71
+ obs = env.step(_valid_action("isolate_segment", subnet="corporate", reason="test"))
72
+
73
+ # Trigger playbook (prereqs may or may not be met — just must not crash)
74
+ try:
75
+ obs = env.step(_valid_action("trigger_playbook", playbook_name="c2_disruption", target=hostname))
76
+ except Exception:
77
+ pass
78
+
79
+ # Submit plan
80
+ obs = env.step(_valid_action(
81
+ "submit_containment_plan",
82
+ plan=[{"threat_id": "T1", "actions_taken": ["kill"], "root_cause": "malware", "confidence": 0.8}],
83
+ executive_summary="Contained ransomware.",
84
+ ))
85
+ assert obs is not None
86
+
87
+
88
+ def test_phase_violation_returns_error():
89
+ """kill_process during triage should record negative reward (not crash)."""
90
+ env = CyberSOCEnvironment()
91
+ env.reset(task_id="easy")
92
+ hostname = _first_host(env)
93
+ # This action is dispatched — may return low reward but must not raise
94
+ obs = env.step(_valid_action("kill_process", hostname=hostname, process_name="fake.exe"))
95
+ assert obs is not None
96
+
97
+
98
+ def test_adaptive_pivot_fires_on_hard():
99
+ env = CyberSOCEnvironment(adaptive=True)
100
+ env.reset(task_id="hard")
101
+
102
+ # Force pivot probability to 1.0 (hard task)
103
+ # We need to isolate_segment where the host is the source_host for an edge
104
+ # OR just call _execute_lateral_pivot directly for test certainty
105
+ hostname = _first_host(env)
106
+ env._execute_lateral_pivot(source_host=hostname)
107
+
108
+ pivot_edges = [e for e in env._threat_graph.edges if e.edge_type == "pivoted_from"]
109
+ assert len(pivot_edges) >= 1
110
+
111
+
112
+ def test_step_reward_accumulates():
113
+ env = CyberSOCEnvironment()
114
+ env.reset(task_id="easy")
115
+ hostname = _first_host(env)
116
+
117
+ before = env._step_reward_total
118
+ # run_forensics in investigation phase earns +0.10
119
+ for h in list(env._threat_graph.hosts.keys())[:3]:
120
+ if h in env._host_index:
121
+ env.step(_valid_action("run_forensics", hostname=h))
122
+
123
+ assert env._step_reward_total > before
124
+
125
+
126
+ def test_step_reward_idempotent():
127
+ env = CyberSOCEnvironment()
128
+ env.reset(task_id="easy")
129
+ hostname = _first_host(env)
130
+
131
+ env.step(_valid_action("run_forensics", hostname=hostname))
132
+ after_first = env._step_reward_total
133
+ env.step(_valid_action("run_forensics", hostname=hostname))
134
+ after_second = env._step_reward_total
135
+
136
+ # Second call on same host earns 0 extra step reward (though may earn -0.02 from handler)
137
+ step_reward_delta = after_second - after_first
138
+ # The idempotent part: _get_step_reward returns 0 on second call for same triple
139
+ key = ("investigation", "run_forensics", hostname)
140
+ assert key in env._fired_step_rewards
141
+
142
+
143
+ def test_grader_returns_10_dim():
144
+ env = CyberSOCEnvironment()
145
+ env.reset(task_id="easy")
146
+ hostname = _first_host(env)
147
+ env.step(_valid_action("query_host", hostname=hostname))
148
+
149
+ result = grade_episode(
150
+ episode_actions=list(env._state.timeline),
151
+ final_plan=None,
152
+ graph=env._threat_graph,
153
+ task_def=env._task_def,
154
+ state=env._state,
155
+ )
156
+ assert len(result["breakdown"]) == 10
157
+
158
+
159
+ def test_sandbox_step_limit():
160
+ from server.episode_sandbox import EpisodeSandbox, MAX_STEPS_PER_EPISODE
161
+
162
+ env = CyberSOCEnvironment()
163
+ env.reset(task_id="easy")
164
+ sb = EpisodeSandbox(env)
165
+
166
+ with pytest.raises(EpisodeTimeout):
167
+ sb.check_step_limit(MAX_STEPS_PER_EPISODE)
168
+
169
+
170
+ def test_openenv_validate_compatible():
171
+ """Run openenv validate and assert it exits 0 (env is valid with adaptive=False)."""
172
+ python = os.path.join(_PROJECT_ROOT, "..", "venv", "Scripts", "python.exe")
173
+ python = os.path.abspath(python)
174
+ if not os.path.exists(python):
175
+ pytest.skip("venv python not found")
176
+
177
+ result = subprocess.run(
178
+ [python, "-c",
179
+ "from server.play_environment import CyberSOCEnvironment; "
180
+ "env = CyberSOCEnvironment(adaptive=False); "
181
+ "obs = env.reset(task_id='easy'); "
182
+ "print('OK', len(obs.alert_queue))"],
183
+ cwd=_PROJECT_ROOT,
184
+ capture_output=True,
185
+ text=True,
186
+ timeout=60,
187
+ )
188
+ assert result.returncode == 0, f"stderr: {result.stderr}"
189
+ assert "OK" in result.stdout
tests/test_task1.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 1 — Fix Crash Bug + Project Scaffold."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ # Ensure project root (MetaRound2) is on sys.path before importing server.*
7
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
8
+ if _PROJECT_ROOT not in sys.path:
9
+ sys.path.insert(0, _PROJECT_ROOT)
10
+
11
+ from server.play_environment import CyberSOCEnvironment
12
+
13
+
14
+ def test_reset_does_not_crash():
15
+ env = CyberSOCEnvironment()
16
+ obs = env.reset(task_id="easy")
17
+ assert obs is not None
18
+
19
+
20
+ def test_live_requirements_populated():
21
+ env = CyberSOCEnvironment()
22
+ env.reset(task_id="easy")
23
+ assert env._live_requirements is not None
24
+ assert isinstance(env._live_requirements, dict)
25
+
26
+
27
+ def test_adaptive_flag_default():
28
+ env = CyberSOCEnvironment()
29
+ assert env._adaptive is False
30
+
31
+
32
+ def test_adaptive_flag_set():
33
+ env = CyberSOCEnvironment(adaptive=True)
34
+ assert env._adaptive is True
tests/test_task2.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 2 — Threat Graph Core."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
7
+ if _PROJECT_ROOT not in sys.path:
8
+ sys.path.insert(0, _PROJECT_ROOT)
9
+
10
+ from server.threat_graph import (
11
+ ThreatGraph,
12
+ HostNode,
13
+ ProcessNode,
14
+ IOCNode,
15
+ VulnerabilityNode,
16
+ AlertNode,
17
+ Edge,
18
+ )
19
+
20
+
21
+ def _make_host(name="WS-001"):
22
+ return HostNode(
23
+ hostname=name,
24
+ subnet="corporate",
25
+ business_criticality="medium",
26
+ status="compromised",
27
+ )
28
+
29
+
30
+ def _make_ioc(value="1.2.3.4", **kw):
31
+ return IOCNode(ioc_value=value, ioc_type="ip", confidence=0.9, **kw)
32
+
33
+
34
+ def test_add_and_retrieve_host():
35
+ g = ThreatGraph()
36
+ g.add_host(_make_host("WS-001"))
37
+ assert "WS-001" in g.hosts
38
+
39
+
40
+ def test_add_ioc_with_enrichment():
41
+ g = ThreatGraph()
42
+ g.add_ioc(_make_ioc("8.8.8.8", enriched=True))
43
+ assert g.iocs["8.8.8.8"].enriched is True
44
+
45
+
46
+ def test_version_increments():
47
+ g = ThreatGraph()
48
+ assert g.version == 0
49
+ g.add_host(_make_host("WS-001"))
50
+ assert g.version == 1
51
+ g.add_ioc(_make_ioc("1.1.1.1"))
52
+ assert g.version == 2
53
+
54
+
55
+ def test_delta_since_zero_returns_all():
56
+ g = ThreatGraph()
57
+ g.add_host(_make_host("WS-001"))
58
+ g.add_host(_make_host("WS-002"))
59
+ g.add_ioc(_make_ioc("1.1.1.1"))
60
+ delta = g.delta_since(0)
61
+ counts = delta["counts"]
62
+ assert counts.get("host", 0) == 2
63
+ assert counts.get("ioc", 0) == 1
64
+
65
+
66
+ def test_delta_since_version_filters():
67
+ g = ThreatGraph()
68
+ g.add_host(_make_host("WS-001")) # version becomes 1
69
+ g.add_ioc(_make_ioc("1.1.1.1")) # version becomes 2
70
+ delta = g.delta_since(1)
71
+ counts = delta["counts"]
72
+ assert counts.get("host", 0) == 0
73
+ assert counts.get("ioc", 0) == 1
74
+
75
+
76
+ def test_evidence_confidence_zero_when_no_edges():
77
+ g = ThreatGraph()
78
+ assert g.compute_evidence_confidence("THREAT-XYZ") == 0.0
79
+
80
+
81
+ def test_evidence_confidence_partial():
82
+ g = ThreatGraph()
83
+ g.add_host(_make_host("WS-001"))
84
+ g.add_edge(Edge(edge_type="part_of_chain", source_id="THREAT-1", target_id="WS-001"))
85
+ conf = g.compute_evidence_confidence("THREAT-1")
86
+ assert 0.0 < conf < 1.0
87
+
88
+
89
+ def test_context_summary_under_100_words():
90
+ g = ThreatGraph()
91
+ g.add_host(_make_host("WS-001"))
92
+ g.add_ioc(_make_ioc("1.1.1.1"))
93
+ summary = g.get_context_summary()
94
+ assert len(summary.split()) <= 100
95
+
96
+
97
+ def test_add_vulnerability():
98
+ g = ThreatGraph()
99
+ vuln = VulnerabilityNode(
100
+ cve_id="CVE-2024-0001",
101
+ hostname="WS-001",
102
+ cvss_score=9.8,
103
+ exploitability="active",
104
+ patch_available=True,
105
+ )
106
+ g.add_vulnerability(vuln)
107
+ assert "WS-001:CVE-2024-0001" in g.vulnerabilities
tests/test_task3.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 3 — Action Models (10 actions)."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
10
+ if _PROJECT_ROOT not in sys.path:
11
+ sys.path.insert(0, _PROJECT_ROOT)
12
+
13
+ from models import (
14
+ CorrelateAlerts,
15
+ EnrichIOC,
16
+ ScanHostVulnerabilities,
17
+ TriggerPlaybook,
18
+ SOCActionWrapper,
19
+ SOCObservation,
20
+ SOCState,
21
+ )
22
+
23
+
24
+ def test_correlate_alerts_model():
25
+ a = CorrelateAlerts(alert_ids=["A1", "A2"])
26
+ assert a.type == "correlate_alerts"
27
+
28
+
29
+ def test_enrich_ioc_model():
30
+ a = EnrichIOC(ioc_value="1.2.3.4", ioc_type="ip")
31
+ assert a.type == "enrich_ioc"
32
+
33
+
34
+ def test_scan_host_vulnerabilities_model():
35
+ a = ScanHostVulnerabilities(hostname="WS-001")
36
+ assert a.type == "scan_host_vulnerabilities"
37
+
38
+
39
+ def test_trigger_playbook_valid():
40
+ a = TriggerPlaybook(playbook_name="ransomware_containment", target="WS-001")
41
+ assert a.type == "trigger_playbook"
42
+
43
+
44
+ def test_trigger_playbook_invalid_name():
45
+ with pytest.raises(ValidationError):
46
+ TriggerPlaybook(playbook_name="fake_playbook", target="WS-001")
47
+
48
+
49
+ def test_wrapper_routes_correlate_alerts():
50
+ w = SOCActionWrapper(type="correlate_alerts", alert_ids=["A", "B"])
51
+ assert isinstance(w.to_typed_action(), CorrelateAlerts)
52
+
53
+
54
+ def test_wrapper_routes_enrich_ioc():
55
+ w = SOCActionWrapper(type="enrich_ioc", ioc_value="x", ioc_type="ip")
56
+ assert isinstance(w.to_typed_action(), EnrichIOC)
57
+
58
+
59
+ def test_observation_has_new_fields():
60
+ obs = SOCObservation()
61
+ for attr in [
62
+ "correlation_results",
63
+ "ioc_enrichment",
64
+ "vulnerability_results",
65
+ "playbook_result",
66
+ "threat_graph_summary",
67
+ "available_playbooks",
68
+ ]:
69
+ assert hasattr(obs, attr), f"missing {attr}"
70
+
71
+
72
+ def test_state_has_new_fields():
73
+ st = SOCState(episode_id="e", step_count=0)
74
+ for attr in [
75
+ "enriched_iocs",
76
+ "scanned_hosts",
77
+ "correlated_alert_pairs",
78
+ "triggered_playbooks",
79
+ "live_requirements",
80
+ ]:
81
+ assert hasattr(st, attr), f"missing {attr}"
tests/test_task4.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 4 — SOAR Playbook Library."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
9
+ if _PROJECT_ROOT not in sys.path:
10
+ sys.path.insert(0, _PROJECT_ROOT)
11
+
12
+ from server.soar_playbooks import PLAYBOOKS, check_prerequisites
13
+ from server.threat_graph import ThreatGraph, HostNode, ProcessNode, IOCNode
14
+ from models import SOCState
15
+
16
+
17
+ def _fresh_state():
18
+ return SOCState(episode_id="e", step_count=0)
19
+
20
+
21
+ def _empty_graph():
22
+ return ThreatGraph()
23
+
24
+
25
+ def test_all_five_playbooks_defined():
26
+ for k in [
27
+ "ransomware_containment",
28
+ "c2_disruption",
29
+ "lateral_movement_lockdown",
30
+ "phishing_response",
31
+ "data_exfil_stop",
32
+ ]:
33
+ assert k in PLAYBOOKS
34
+
35
+
36
+ def test_playbook_structure():
37
+ for name, p in PLAYBOOKS.items():
38
+ for key in ["name", "description", "prerequisites", "sub_actions", "target_attack_types"]:
39
+ assert key in p, f"{name} missing {key}"
40
+
41
+
42
+ def test_ransomware_containment_sub_actions():
43
+ assert PLAYBOOKS["ransomware_containment"]["sub_actions"] == ["kill_process", "block_ioc"]
44
+
45
+
46
+ def test_check_prerequisites_fails_no_forensics():
47
+ ok, reason = check_prerequisites("ransomware_containment", "WS-001", _fresh_state(), _empty_graph())
48
+ assert ok is False
49
+ assert isinstance(reason, str) and len(reason) > 0
50
+
51
+
52
+ def test_check_prerequisites_passes_when_met():
53
+ state = _fresh_state()
54
+ state.scanned_hosts = ["WS-001"]
55
+ g = ThreatGraph()
56
+ g.add_process(ProcessNode(process_id="WS-001:1234", hostname="WS-001", process_name="evil.exe"))
57
+ ok, reason = check_prerequisites("ransomware_containment", "WS-001", state, g)
58
+ assert ok is True
59
+ assert reason == ""
60
+
61
+
62
+ def test_unknown_playbook_raises():
63
+ with pytest.raises((KeyError, ValueError)):
64
+ check_prerequisites("nonexistent_playbook", "WS-001", _fresh_state(), _empty_graph())
65
+
66
+
67
+ def test_c2_disruption_needs_enriched_ioc():
68
+ g = ThreatGraph()
69
+ # add IP IOC but not enriched
70
+ g.add_ioc(IOCNode(ioc_value="1.2.3.4", ioc_type="ip", confidence=0.9, enriched=False))
71
+ ok, reason = check_prerequisites("c2_disruption", "WS-001", _fresh_state(), g)
72
+ assert ok is False
tests/test_task5.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 5 — Episode Sandbox."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
9
+ if _PROJECT_ROOT not in sys.path:
10
+ sys.path.insert(0, _PROJECT_ROOT)
11
+
12
+ from server.episode_sandbox import (
13
+ EpisodeSandbox,
14
+ EpisodeTimeout,
15
+ MAX_STEPS_PER_EPISODE,
16
+ )
17
+
18
+
19
+ class _MockEnv:
20
+ def __init__(self):
21
+ self._task_def = {"difficulty": "easy", "attack_chain": []}
22
+ self._live_requirements = {"must_kill": []}
23
+ self._threat_graph = None
24
+ self._step_count = 0
25
+
26
+
27
+ def test_sandbox_enters_and_exits_cleanly():
28
+ env = _MockEnv()
29
+ with EpisodeSandbox(env):
30
+ pass
31
+
32
+
33
+ def test_elapsed_seconds_positive():
34
+ env = _MockEnv()
35
+ with EpisodeSandbox(env) as sb:
36
+ # Even fast: elapsed should be >= 0 immediately, > 0 after a tick.
37
+ # Force a small wait-free tick by reading time twice.
38
+ e1 = sb.elapsed_seconds()
39
+ # Busy-loop a small amount to guarantee elapsed > 0
40
+ end = e1 + 1e-3
41
+ while sb.elapsed_seconds() <= end:
42
+ pass
43
+ assert sb.elapsed_seconds() > 0.0
44
+
45
+
46
+ def test_step_limit_raises_at_max():
47
+ env = _MockEnv()
48
+ sb = EpisodeSandbox(env)
49
+ with pytest.raises(EpisodeTimeout):
50
+ sb.check_step_limit(MAX_STEPS_PER_EPISODE)
51
+
52
+
53
+ def test_step_limit_ok_below_max():
54
+ env = _MockEnv()
55
+ sb = EpisodeSandbox(env)
56
+ sb.check_step_limit(MAX_STEPS_PER_EPISODE - 1)
57
+
58
+
59
+ def test_state_integrity_violation_detected():
60
+ env = _MockEnv()
61
+ with EpisodeSandbox(env) as sb:
62
+ env._step_count = 9999 # mutate protected field
63
+ assert sb.was_hacked() is True
64
+
65
+
66
+ def test_state_rollback_on_violation():
67
+ env = _MockEnv()
68
+ original = dict(env._task_def)
69
+ with EpisodeSandbox(env):
70
+ env._task_def["difficulty"] = "hacked"
71
+ assert env._task_def == original
72
+
73
+
74
+ def test_no_false_hacking_on_clean_run():
75
+ env = _MockEnv()
76
+ with EpisodeSandbox(env) as sb:
77
+ pass
78
+ assert sb.was_hacked() is False
79
+
80
+
81
+ def test_hacking_report_lists_violated_fields():
82
+ env = _MockEnv()
83
+ with EpisodeSandbox(env) as sb:
84
+ env._step_count = 42
85
+ report = sb.hacking_report()
86
+ assert any("_step_count" in r for r in report)
tests/test_task6.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 6 — Action Validation Middleware."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
7
+ if _PROJECT_ROOT not in sys.path:
8
+ sys.path.insert(0, _PROJECT_ROOT)
9
+
10
+ from server.action_validation import (
11
+ ActionValidationMiddleware,
12
+ PHASE_VIOLATION,
13
+ INVALID_PARAMS,
14
+ UNGROUNDED_ACTION,
15
+ )
16
+ from server.threat_graph import ThreatGraph, IOCNode, ProcessNode
17
+
18
+
19
+ def _empty():
20
+ return ThreatGraph()
21
+
22
+
23
+ def _graph_with_ioc(value="1.2.3.4"):
24
+ g = ThreatGraph()
25
+ g.add_ioc(IOCNode(ioc_value=value, ioc_type="ip", confidence=0.9))
26
+ return g
27
+
28
+
29
+ def _graph_with_process(host="H", proc_name="evil.exe"):
30
+ g = ThreatGraph()
31
+ g.add_process(ProcessNode(process_id=f"{host}:{proc_name}", hostname=host, process_name=proc_name))
32
+ return g
33
+
34
+
35
+ def test_gate1_rejects_wrong_phase():
36
+ m = ActionValidationMiddleware()
37
+ err = m.validate("triage", "kill_process", {}, _empty())
38
+ assert err is not None
39
+ assert err["error"] == PHASE_VIOLATION
40
+
41
+
42
+ def test_gate1_passes_correct_phase():
43
+ m = ActionValidationMiddleware()
44
+ err = m.validate("remediation", "kill_process", {"hostname": "H", "process_name": "evil.exe"}, _graph_with_process())
45
+ assert err is None
46
+
47
+
48
+ def test_gate1_error_lists_allowed_tools():
49
+ m = ActionValidationMiddleware()
50
+ err = m.validate("triage", "kill_process", {}, _empty())
51
+ assert err is not None
52
+ msg = err["message"].lower()
53
+ assert any(t in msg for t in ["read_alerts", "read_topology", "correlate_alerts"])
54
+
55
+
56
+ def test_gate2_rejects_missing_ioc_value():
57
+ m = ActionValidationMiddleware()
58
+ err = m.validate("remediation", "block_ioc", {}, _empty())
59
+ assert err is not None and err["error"] == INVALID_PARAMS
60
+
61
+
62
+ def test_gate2_rejects_correlate_with_one_alert():
63
+ m = ActionValidationMiddleware()
64
+ err = m.validate("triage", "correlate_alerts", {"alert_ids": ["A1"]}, _empty())
65
+ assert err is not None and err["error"] == INVALID_PARAMS
66
+
67
+
68
+ def test_gate3_rejects_ungrounded_block():
69
+ m = ActionValidationMiddleware()
70
+ err = m.validate("remediation", "block_ioc", {"ioc_value": "1.2.3.4"}, _empty())
71
+ assert err is not None and err["error"] == UNGROUNDED_ACTION
72
+
73
+
74
+ def test_gate3_passes_grounded_block():
75
+ m = ActionValidationMiddleware()
76
+ err = m.validate("remediation", "block_ioc", {"ioc_value": "1.2.3.4"}, _graph_with_ioc())
77
+ assert err is None
78
+
79
+
80
+ def test_gate3_rejects_ungrounded_kill():
81
+ m = ActionValidationMiddleware()
82
+ err = m.validate("remediation", "kill_process", {"hostname": "H", "process_name": "unknown.exe"}, _empty())
83
+ assert err is not None and err["error"] == UNGROUNDED_ACTION
84
+
85
+
86
+ def test_all_gates_pass_returns_none():
87
+ m = ActionValidationMiddleware()
88
+ err = m.validate("remediation", "block_ioc", {"ioc_value": "1.2.3.4"}, _graph_with_ioc())
89
+ assert err is None
90
+
91
+
92
+ def test_retry_flag_false_for_phase_violation():
93
+ m = ActionValidationMiddleware()
94
+ err = m.validate("triage", "kill_process", {}, _empty())
95
+ assert err["retry"] is False
96
+
97
+
98
+ def test_retry_flag_true_for_invalid_params():
99
+ m = ActionValidationMiddleware()
100
+ err = m.validate("remediation", "block_ioc", {}, _empty())
101
+ assert err["retry"] is True
tests/test_task7.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 7 — Tool Router + Triage Solver."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
7
+ if _PROJECT_ROOT not in sys.path:
8
+ sys.path.insert(0, _PROJECT_ROOT)
9
+
10
+ from server.tool_router import (
11
+ ToolRouter,
12
+ compute_triage_priority,
13
+ solve_triage_order,
14
+ )
15
+ from server.threat_graph import (
16
+ ThreatGraph,
17
+ AlertNode,
18
+ HostNode,
19
+ IOCNode,
20
+ ProcessNode,
21
+ )
22
+
23
+
24
+ def _alert(aid="A1", severity="high", source="WS-001"):
25
+ return AlertNode(alert_id=aid, severity=severity, priority_score=1.0, source_host=source)
26
+
27
+
28
+ def _host(name="WS-001", crit="medium", status="compromised"):
29
+ return HostNode(hostname=name, subnet="corporate", business_criticality=crit, status=status)
30
+
31
+
32
+ def _full_evidence_graph():
33
+ g = ThreatGraph()
34
+ g.add_host(_host("WS-001"))
35
+ g.add_ioc(IOCNode(ioc_value="1.1.1.1", ioc_type="ip", confidence=0.9))
36
+ g.add_process(ProcessNode(process_id="WS-001:1", hostname="WS-001", process_name="x"))
37
+ return g
38
+
39
+
40
+ def test_triage_to_investigation_with_alerts():
41
+ g = ThreatGraph()
42
+ g.add_alert(_alert())
43
+ r = ToolRouter()
44
+ assert r.next_phase("triage", g, 10) == "investigation"
45
+
46
+
47
+ def test_triage_to_report_no_alerts():
48
+ g = ThreatGraph()
49
+ r = ToolRouter()
50
+ assert r.next_phase("triage", g, 10) == "report"
51
+
52
+
53
+ def test_investigation_loops_then_exits():
54
+ g = ThreatGraph() # evidence-free
55
+ r = ToolRouter()
56
+ out = "investigation"
57
+ for _ in range(r.MAX_INVESTIGATION_LOOPS + 1):
58
+ out = r.next_phase("investigation", g, 10)
59
+ assert out == "remediation"
60
+
61
+
62
+ def test_investigation_exits_on_sufficient_evidence():
63
+ r = ToolRouter()
64
+ assert r.next_phase("investigation", _full_evidence_graph(), 10) == "remediation"
65
+
66
+
67
+ def test_remediation_exits_when_contained():
68
+ r = ToolRouter()
69
+ g = ThreatGraph()
70
+ g.add_host(_host("WS-001", status="isolated"))
71
+ assert r.next_phase("remediation", g, 10) == "report"
72
+
73
+
74
+ def test_report_returns_done():
75
+ r = ToolRouter()
76
+ assert r.next_phase("report", ThreatGraph(), 10) == "done"
77
+
78
+
79
+ def test_honor_pushback_rejects_no_graph_refs():
80
+ r = ToolRouter()
81
+ ok, _ = r.honor_pushback("investigation", [], ThreatGraph())
82
+ assert ok is False
83
+
84
+
85
+ def test_honor_pushback_accepts_valid_critical_alert():
86
+ g = ThreatGraph()
87
+ g.add_alert(_alert("A1", severity="critical"))
88
+ r = ToolRouter()
89
+ ok, _ = r.honor_pushback("investigation", ["A1"], g)
90
+ assert ok is True
91
+
92
+
93
+ def test_triage_priority_higher_for_critical():
94
+ g = ThreatGraph()
95
+ a_crit = _alert("A1", severity="critical")
96
+ a_low = _alert("A2", severity="low", source="WS-002")
97
+ h_crit = _host("WS-001", crit="critical")
98
+ h_low = _host("WS-002", crit="low")
99
+ s_crit = compute_triage_priority(a_crit, h_crit, g)
100
+ s_low = compute_triage_priority(a_low, h_low, g)
101
+ assert s_crit > s_low
102
+
103
+
104
+ def test_solve_triage_order_descending():
105
+ g = ThreatGraph()
106
+ g.add_host(_host("WS-001", crit="critical"))
107
+ g.add_host(_host("WS-002", crit="medium"))
108
+ g.add_host(_host("WS-003", crit="low"))
109
+ g.add_alert(_alert("A1", severity="critical", source="WS-001"))
110
+ g.add_alert(_alert("A2", severity="medium", source="WS-002"))
111
+ g.add_alert(_alert("A3", severity="low", source="WS-003"))
112
+ order = solve_triage_order(g)
113
+ assert order == ["A1", "A2", "A3"]
tests/test_task8.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 8 — 10-dimensional Grader."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
7
+ if _PROJECT_ROOT not in sys.path:
8
+ sys.path.insert(0, _PROJECT_ROOT)
9
+
10
+ from server.graders import grade_episode, grade_easy
11
+ from server.threat_graph import (
12
+ ThreatGraph,
13
+ HostNode,
14
+ ProcessNode,
15
+ IOCNode,
16
+ VulnerabilityNode,
17
+ AlertNode,
18
+ )
19
+ from models import SOCState
20
+
21
+
22
+ def _state(**overrides):
23
+ s = SOCState(episode_id="e", step_count=0)
24
+ for k, v in overrides.items():
25
+ setattr(s, k, v)
26
+ return s
27
+
28
+
29
+ def _task_def_simple():
30
+ return {
31
+ "containment_requirements": {
32
+ "must_kill": [{"hostname": "WS-001", "process": "evil.exe", "threat_id": "T1"}],
33
+ "must_block_iocs": ["1.2.3.4"],
34
+ "must_forensics": ["WS-001"],
35
+ "must_not_isolate": [],
36
+ },
37
+ "attack_chain": [{"threat_id": "T1"}],
38
+ }
39
+
40
+
41
+ def _empty_graph():
42
+ return ThreatGraph()
43
+
44
+
45
+ def test_returns_correct_keys():
46
+ res = grade_episode([], None, _empty_graph(), _task_def_simple(), _state())
47
+ for k in ("final_score", "breakdown", "penalties", "bonuses", "reward_functions"):
48
+ assert k in res
49
+
50
+
51
+ def test_breakdown_has_10_keys():
52
+ res = grade_episode([], None, _empty_graph(), _task_def_simple(), _state())
53
+ assert len(res["breakdown"]) == 10
54
+
55
+
56
+ def test_reward_functions_has_10_keys():
57
+ res = grade_episode([], None, _empty_graph(), _task_def_simple(), _state())
58
+ assert len(res["reward_functions"]) == 10
59
+
60
+
61
+ def test_all_rubric_met_scores_high():
62
+ g = ThreatGraph()
63
+ g.add_host(HostNode(hostname="WS-001", subnet="corporate",
64
+ business_criticality="medium", status="contained"))
65
+ g.add_ioc(IOCNode(ioc_value="1.2.3.4", ioc_type="ip", confidence=0.9, enriched=True, blocked=True))
66
+ g.add_process(ProcessNode(process_id="WS-001:1", hostname="WS-001",
67
+ process_name="evil.exe", killed=True))
68
+ g.add_vulnerability(VulnerabilityNode(
69
+ cve_id="CVE-1", hostname="WS-001", cvss_score=9.0,
70
+ exploitability="active", patch_available=True,
71
+ exploited_by_threat="T1",
72
+ ))
73
+ state = _state(
74
+ killed_processes=[{"hostname": "WS-001", "process": "evil.exe"}],
75
+ blocked_iocs=["1.2.3.4"],
76
+ scanned_hosts=["WS-001"],
77
+ enriched_iocs=["1.2.3.4"],
78
+ correlated_alert_pairs=[("A1", "A2")],
79
+ triggered_playbooks=["ransomware_containment"],
80
+ )
81
+ actions = [
82
+ {"action_type": "correlate_alerts", "target": "A"},
83
+ {"action_type": "kill_process", "target": "WS-001"},
84
+ ]
85
+ plan = {"entries": [{"threat_id": "T1", "actions_taken": ["kill"], "root_cause": "CVE-1", "confidence": 0.9}],
86
+ "primary_threat_id": "T1"}
87
+ res = grade_episode(actions, plan, g, _task_def_simple(), state)
88
+ assert res["final_score"] >= 0.7
89
+
90
+
91
+ def test_no_actions_scores_low():
92
+ res = grade_episode([], None, _empty_graph(), _task_def_simple(), _state())
93
+ assert res["final_score"] <= 0.3
94
+
95
+
96
+ def test_blind_blocking_penalty():
97
+ state = _state(blocked_iocs=["1.2.3.4"], enriched_iocs=[])
98
+ res = grade_episode([], None, _empty_graph(), _task_def_simple(), state)
99
+ pen_types = [p["type"] for p in res["penalties"]]
100
+ assert "blind_blocking" in pen_types
101
+
102
+
103
+ def test_business_impact_penalises_over_isolation():
104
+ g = ThreatGraph()
105
+ # 10 hosts, 3 isolated -> 30%
106
+ for i in range(10):
107
+ status = "isolated" if i < 3 else "healthy"
108
+ g.add_host(HostNode(hostname=f"H{i}", subnet="corporate",
109
+ business_criticality="medium", status=status))
110
+ res = grade_episode([], None, g, _task_def_simple(), _state())
111
+ assert res["breakdown"]["business_impact"] < 0.5
112
+
113
+
114
+ def test_step_efficiency_bonus_for_playbook():
115
+ state = _state(triggered_playbooks=["ransomware_containment"])
116
+ res = grade_episode([], None, _empty_graph(), _task_def_simple(), state)
117
+ assert res["breakdown"]["step_efficiency"] > 0.5
118
+
119
+
120
+ def test_plan_coverage_zero_without_plan():
121
+ res = grade_episode([], None, _empty_graph(), _task_def_simple(), _state())
122
+ assert res["breakdown"]["plan_coverage"] == 0.0
123
+
124
+
125
+ def test_final_score_clamped_0_to_1():
126
+ res = grade_episode([], None, _empty_graph(), _task_def_simple(), _state())
127
+ assert 0.0 <= res["final_score"] <= 1.0
128
+
129
+
130
+ def test_wrappers_still_return_float():
131
+ val = grade_easy([], None, _empty_graph(), _task_def_simple(), _state())
132
+ assert isinstance(val, float)
tests/test_task9.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Task 9 — 4 New Action Handlers + Enhanced Existing Handlers."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
7
+ if _PROJECT_ROOT not in sys.path:
8
+ sys.path.insert(0, _PROJECT_ROOT)
9
+
10
+ from server.play_environment import CyberSOCEnvironment
11
+ from server.threat_graph import HostNode, ProcessNode, IOCNode, AlertNode, Edge
12
+ from models import CorrelateAlerts, EnrichIOC, ScanHostVulnerabilities, TriggerPlaybook
13
+
14
+
15
+ def _env_with_graph():
16
+ """Return a reset env with a seeded threat graph."""
17
+ env = CyberSOCEnvironment()
18
+ env.reset(task_id="easy")
19
+ return env
20
+
21
+
22
+ def _add_alerts(env, alert_ids):
23
+ """Add AlertNodes to the threat graph."""
24
+ for aid in alert_ids:
25
+ if aid not in env._threat_graph.alerts:
26
+ env._threat_graph.add_alert(AlertNode(
27
+ alert_id=aid, severity="high", priority_score=5.0, source_host="WS-001"
28
+ ))
29
+
30
+
31
+ def _add_ioc(env, value="1.2.3.4"):
32
+ if value not in env._threat_graph.iocs:
33
+ env._threat_graph.add_ioc(IOCNode(ioc_value=value, ioc_type="ip", confidence=0.9))
34
+
35
+
36
+ def _add_host(env, hostname="WS-FAKE"):
37
+ if hostname not in env._threat_graph.hosts:
38
+ env._threat_graph.add_host(HostNode(
39
+ hostname=hostname, subnet="corporate",
40
+ business_criticality="medium", status="compromised"
41
+ ))
42
+
43
+
44
+ def test_correlate_alerts_returns_correlation_results():
45
+ env = _env_with_graph()
46
+ _add_alerts(env, ["A1", "A2"])
47
+ result = env._handle_correlate_alerts(CorrelateAlerts(alert_ids=["A1", "A2"]))
48
+ assert "correlation_results" in result
49
+ assert "correlation_score" in result["correlation_results"]
50
+
51
+
52
+ def test_correlate_alerts_error_on_single_id():
53
+ env = _env_with_graph()
54
+ _add_alerts(env, ["A1"])
55
+ result = env._handle_correlate_alerts(CorrelateAlerts(alert_ids=["A1", "MISSING"]))
56
+ # fewer than 2 alerts found in graph → error
57
+ assert "error" in result
58
+
59
+
60
+ def test_enrich_ioc_updates_graph():
61
+ env = _env_with_graph()
62
+ # Grab any IOC already in graph (seeded from task def)
63
+ ioc_value = next(iter(env._threat_graph.iocs), None)
64
+ if ioc_value is None:
65
+ _add_ioc(env)
66
+ ioc_value = "1.2.3.4"
67
+ env._handle_enrich_ioc(EnrichIOC(ioc_value=ioc_value, ioc_type="ip"))
68
+ assert env._threat_graph.iocs[ioc_value].enriched is True
69
+
70
+
71
+ def test_enrich_ioc_error_when_not_in_graph():
72
+ env = _env_with_graph()
73
+ result = env._handle_enrich_ioc(EnrichIOC(ioc_value="not.in.graph", ioc_type="ip"))
74
+ assert "error" in result
75
+
76
+
77
+ def test_scan_vulnerabilities_adds_vuln_nodes():
78
+ env = _env_with_graph()
79
+ # Use a host that exists in the graph
80
+ hostname = next(iter(env._threat_graph.hosts), None)
81
+ if hostname is None:
82
+ _add_host(env)
83
+ hostname = "WS-FAKE"
84
+ # Seed a vulnerability_chain entry for this host
85
+ env._task_def["vulnerability_chain"] = [{
86
+ "hostname": hostname,
87
+ "cve_id": "CVE-2024-9999",
88
+ "cvss_score": 9.8,
89
+ "exploitability": "active",
90
+ "patch_available": True,
91
+ "threat_id": "T1",
92
+ }]
93
+ env._handle_scan_vulnerabilities(ScanHostVulnerabilities(hostname=hostname))
94
+ assert len(env._threat_graph.vulnerabilities) > 0
95
+
96
+
97
+ def test_scan_vulnerabilities_marks_host_scanned():
98
+ env = _env_with_graph()
99
+ hostname = next(iter(env._threat_graph.hosts), None)
100
+ if hostname is None:
101
+ _add_host(env)
102
+ hostname = "WS-FAKE"
103
+ env._task_def["vulnerability_chain"] = []
104
+ env._handle_scan_vulnerabilities(ScanHostVulnerabilities(hostname=hostname))
105
+ assert env._threat_graph.hosts[hostname].scanned is True
106
+
107
+
108
+ def test_trigger_playbook_fails_without_prerequisites():
109
+ env = _env_with_graph()
110
+ hostname = next(iter(env._threat_graph.hosts), "WS-001")
111
+ result = env._handle_trigger_playbook(
112
+ TriggerPlaybook(playbook_name="ransomware_containment", target=hostname)
113
+ )
114
+ assert "error" in result
115
+
116
+
117
+ def test_trigger_playbook_adds_to_triggered_list():
118
+ env = _env_with_graph()
119
+ hostname = next(iter(env._threat_graph.hosts), None)
120
+ if hostname is None:
121
+ _add_host(env)
122
+ hostname = "WS-FAKE"
123
+
124
+ # Satisfy prerequisites: forensics_run_on_target + process_identified
125
+ env._state.scanned_hosts.append(hostname)
126
+ if not any(p.hostname == hostname for p in env._threat_graph.processes.values()):
127
+ env._threat_graph.add_process(ProcessNode(
128
+ process_id=f"{hostname}:1", hostname=hostname, process_name="evil.exe"
129
+ ))
130
+
131
+ result = env._handle_trigger_playbook(
132
+ TriggerPlaybook(playbook_name="ransomware_containment", target=hostname)
133
+ )
134
+ assert "error" not in result
135
+ assert "ransomware_containment" in env._state.triggered_playbooks
136
+
137
+
138
+ def test_query_host_returns_process_tree():
139
+ env = _env_with_graph()
140
+ hostname = next(iter(env._threat_graph.hosts), None)
141
+ if hostname is None:
142
+ _add_host(env)
143
+ hostname = "WS-FAKE"
144
+ # also add to host_index
145
+ env._host_index[hostname] = {
146
+ "hostname": hostname, "subnet": "corporate",
147
+ "status": "compromised", "running_processes": [], "criticality": 0.5
148
+ }
149
+ env._last_obs_extras = {}
150
+ env._handle_query_host(type("QH", (), {"hostname": hostname})())
151
+ assert "process_tree" in env._last_obs_extras
152
+
153
+
154
+ def test_isolate_single_host_sets_isolated():
155
+ env = _env_with_graph()
156
+ hostname = next(iter(env._threat_graph.hosts), None)
157
+ if hostname is None:
158
+ _add_host(env)
159
+ hostname = "WS-FAKE"
160
+ env._host_index[hostname] = {
161
+ "hostname": hostname, "subnet": "corporate",
162
+ "status": "compromised", "running_processes": [], "criticality": 0.5
163
+ }
164
+
165
+ from models import IsolateSegment
166
+ action = IsolateSegment(target_host=hostname, reason="test")
167
+ env._handle_isolate_segment(action)
168
+ assert env._host_index[hostname]["status"] == "isolated"
169
+ # Only this host isolated, not all of its subnet
170
+ subnet = env._host_index[hostname].get("subnet", "corporate")
171
+ subnet_hosts = env._network.get(subnet, [])
172
+ still_up = sum(1 for h in subnet_hosts if h["status"] != "isolated")
173
+ assert still_up > 0
training/__init__.py ADDED
File without changes
training/collect_sft_data.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Collect SFT data by running a scripted 'perfect' agent through Easy tasks.
3
+ This satisfies Daniel's Law of RL: ensuring the base model can emit valid JSON
4
+ actions before starting GRPO.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+ # Ensure server module can be imported
12
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from server.play_environment import CyberSOCEnvironment
15
+ from server.tasks import get_task
16
+ from models import SOCActionWrapper
17
+
18
+ def _format_as_chat(history: list) -> dict:
19
+ """Format a sequence of observations and actions into a chat trace."""
20
+ messages = [
21
+ {
22
+ "role": "system",
23
+ "content": "You are an autonomous CyberSOC Agent. Analyze the environment and output JSON tool calls to contain the threat."
24
+ }
25
+ ]
26
+ for step in history:
27
+ if step["type"] == "observation":
28
+ messages.append({"role": "user", "content": json.dumps(step["data"], indent=2)})
29
+ elif step["type"] == "action":
30
+ messages.append({"role": "assistant", "content": json.dumps(step["data"])})
31
+ return {"messages": messages}
32
+
33
+ def collect_winning_traces(num_traces=100, output_file="training/sft_data.jsonl"):
34
+ print(f"Collecting {num_traces} SFT traces...")
35
+ os.makedirs(os.path.dirname(output_file), exist_ok=True)
36
+
37
+ env = CyberSOCEnvironment()
38
+ traces = []
39
+
40
+ for i in range(1, num_traces + 1):
41
+ task_id = f"gen_{i:04d}"
42
+ task_def = get_task(task_id)
43
+
44
+ history = []
45
+ obs = env.reset(task_id=task_id)
46
+ history.append({"type": "observation", "data": obs.model_dump()})
47
+
48
+ # Scripted "Perfect" Agent
49
+ reqs = task_def.get("containment_requirements", {})
50
+
51
+ # 1. Run forensics on compromised hosts
52
+ for host in reqs.get("must_forensics", []):
53
+ action = {"type": "run_forensics", "hostname": host}
54
+ history.append({"type": "action", "data": action})
55
+ obs = env.step(SOCActionWrapper(**action))
56
+ history.append({"type": "observation", "data": obs.model_dump()})
57
+
58
+ # 2. Kill malicious processes
59
+ for proc in reqs.get("must_kill", []):
60
+ action = {"type": "kill_process", "hostname": proc["hostname"], "process_name": proc["process"]}
61
+ history.append({"type": "action", "data": action})
62
+ obs = env.step(SOCActionWrapper(**action))
63
+ history.append({"type": "observation", "data": obs.model_dump()})
64
+
65
+ # 3. Block IOCs
66
+ for ioc in reqs.get("must_block_iocs", []):
67
+ action = {"type": "block_ioc", "ioc_type": "hash" if len(ioc) > 30 else "ip", "ioc_value": ioc}
68
+ history.append({"type": "action", "data": action})
69
+ obs = env.step(SOCActionWrapper(**action))
70
+ history.append({"type": "observation", "data": obs.model_dump()})
71
+
72
+ # 4. Submit plan
73
+ plan_entries = []
74
+ for threat in task_def.get("attack_chain", []):
75
+ plan_entries.append({
76
+ "threat_id": threat.get("threat_id"),
77
+ "actions_taken": ["run_forensics", "kill_process", "block_ioc"],
78
+ "root_cause": "Initial access via " + threat.get("threat_type"),
79
+ "confidence": 0.95,
80
+ })
81
+
82
+ action = {
83
+ "type": "submit_containment_plan",
84
+ "plan": plan_entries,
85
+ "executive_summary": "All threats contained. Malicious processes killed, IOCs blocked, forensics completed.",
86
+ }
87
+ history.append({"type": "action", "data": action})
88
+ obs = env.step(SOCActionWrapper(**action))
89
+ history.append({"type": "observation", "data": obs.model_dump()})
90
+
91
+ if obs.final_score > 0.3:
92
+ traces.append(_format_as_chat(history))
93
+ print(f"Task {task_id}: Score = {obs.final_score:.2f} (Added)")
94
+ else:
95
+ print(f"Task {task_id}: Score = {obs.final_score:.2f} (Skipped - Score too low)")
96
+
97
+ with open(output_file, "w") as f:
98
+ for trace in traces:
99
+ f.write(json.dumps(trace) + "\n")
100
+
101
+ print(f"Saved {len(traces)} traces to {output_file}")
102
+
103
+ if __name__ == "__main__":
104
+ collect_winning_traces()
training/reward_funcs.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TRL-compatible reward functions for GRPO training on CyberSOCEnv.
2
+
3
+ Usage:
4
+ from training.reward_funcs import make_soc_reward_funcs
5
+
6
+ reward_fns = make_soc_reward_funcs("http://localhost:8000")
7
+ # reward_fns is a list of 10 callables — one per grading dimension.
8
+ # Pass them directly to trl.GRPOTrainer(reward_funcs=reward_fns).
9
+
10
+ Each reward function matches the TRL GRPO signature::
11
+
12
+ def reward_fn(completions: List[str], **kwargs) -> List[float]: ...
13
+
14
+ Completions should be JSON strings encoding a list of SOC action dicts, e.g.::
15
+
16
+ '[{"type": "query_host", "hostname": "WS-042"},
17
+ {"type": "run_forensics", "hostname": "WS-042"},
18
+ {"type": "submit_containment_plan", "plan": [...], "executive_summary": "..."}]'
19
+
20
+ The function resets the environment, replays all actions, and returns the
21
+ requested dimension's score from the terminal grade_breakdown. Non-parseable
22
+ completions return 0.0.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from typing import Callable, List, Optional
29
+
30
+ try:
31
+ import requests as _requests
32
+ except ImportError: # pragma: no cover
33
+ _requests = None # type: ignore[assignment]
34
+
35
+
36
+ DIMENSION_NAMES: List[str] = [
37
+ "threat_containment",
38
+ "ioc_blocking",
39
+ "forensic_investigation",
40
+ "siem_correlation",
41
+ "threat_intel_usage",
42
+ "vuln_root_cause",
43
+ "business_impact",
44
+ "step_efficiency",
45
+ "plan_coverage",
46
+ "plan_evidence_quality",
47
+ ]
48
+
49
+
50
+ def _execute_completion(
51
+ env_url: str,
52
+ completion: str,
53
+ task_id: str = "hard",
54
+ timeout: int = 30,
55
+ ) -> Optional[dict]:
56
+ """Parse a completion and replay it against the live env server.
57
+
58
+ Returns the final observation dict on success, or None on any failure
59
+ (parse error, network error, server error).
60
+ """
61
+ if _requests is None:
62
+ raise RuntimeError("requests library is required: pip install requests")
63
+
64
+ # Parse the completion as a JSON list of action dicts
65
+ try:
66
+ actions = json.loads(completion)
67
+ if not isinstance(actions, list):
68
+ return None
69
+ except (json.JSONDecodeError, ValueError):
70
+ return None
71
+
72
+ try:
73
+ # Reset the environment for a fresh episode
74
+ resp = _requests.post(
75
+ f"{env_url}/reset",
76
+ json={"task_id": task_id},
77
+ timeout=timeout,
78
+ )
79
+ resp.raise_for_status()
80
+ obs = resp.json()
81
+
82
+ # Replay each action
83
+ for action in actions:
84
+ if not isinstance(action, dict) or "type" not in action:
85
+ continue
86
+ resp = _requests.post(f"{env_url}/step", json=action, timeout=timeout)
87
+ if not resp.ok:
88
+ break
89
+ obs = resp.json()
90
+ obs_data = obs.get("observation", obs)
91
+ if obs_data.get("done", False):
92
+ break
93
+
94
+ return obs.get("observation", obs)
95
+
96
+ except Exception:
97
+ return None
98
+
99
+
100
+ def _make_dimension_reward_fn(env_url: str, dimension: str) -> Callable:
101
+ """Return a single TRL GRPO reward function for one grading dimension."""
102
+
103
+ def reward_fn(completions: List[str], **kwargs) -> List[float]:
104
+ """TRL GRPO reward function for the ``{dim}`` dimension.
105
+
106
+ Args:
107
+ completions: Batch of model completions (JSON action-list strings).
108
+ **kwargs: Extra keyword arguments passed by TRL (ignored).
109
+
110
+ Returns:
111
+ List of floats in [0, 1], one score per completion.
112
+ """
113
+ scores: List[float] = []
114
+ for completion in completions:
115
+ obs = _execute_completion(env_url, completion)
116
+ if obs is None:
117
+ scores.append(0.0)
118
+ continue
119
+ # Prefer terminal grade_breakdown; fall back to per-step reward_dimensions
120
+ breakdown: dict = obs.get("grade_breakdown") or obs.get("reward_dimensions") or {}
121
+ scores.append(float(breakdown.get(dimension, 0.0)))
122
+ return scores
123
+
124
+ reward_fn.__name__ = f"soc_{dimension}"
125
+ reward_fn.__doc__ = reward_fn.__doc__.replace("{dim}", dimension) # type: ignore[union-attr]
126
+ return reward_fn
127
+
128
+
129
+ def make_soc_reward_funcs(env_url: str) -> List[Callable]:
130
+ """Return 10 TRL GRPO reward functions, one per grading dimension.
131
+
132
+ The functions are ordered to match ``DIMENSION_NAMES``:
133
+ 0 threat_containment (weight 0.20)
134
+ 1 ioc_blocking (weight 0.12)
135
+ 2 forensic_investigation (weight 0.10)
136
+ 3 siem_correlation (weight 0.08)
137
+ 4 threat_intel_usage (weight 0.08)
138
+ 5 vuln_root_cause (weight 0.08)
139
+ 6 business_impact (weight 0.10)
140
+ 7 step_efficiency (weight 0.07)
141
+ 8 plan_coverage (weight 0.10)
142
+ 9 plan_evidence_quality (weight 0.07)
143
+
144
+ Args:
145
+ env_url: Base URL of the running CyberSOCEnv FastAPI server,
146
+ e.g. ``"http://localhost:8000"``.
147
+
148
+ Returns:
149
+ List of 10 callables matching the TRL GRPO signature
150
+ ``reward_fn(completions, **kwargs) -> List[float]``.
151
+
152
+ Example::
153
+
154
+ from trl import GRPOTrainer, GRPOConfig
155
+ from training.reward_funcs import make_soc_reward_funcs
156
+
157
+ reward_fns = make_soc_reward_funcs("http://localhost:8000")
158
+ trainer = GRPOTrainer(
159
+ model=model,
160
+ reward_funcs=reward_fns,
161
+ args=GRPOConfig(...),
162
+ )
163
+ """
164
+ return [_make_dimension_reward_fn(env_url, dim) for dim in DIMENSION_NAMES]
training/sft_data.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
validate_submission.sh ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -uo pipefail
3
+
4
+ DOCKER_BUILD_TIMEOUT=600
5
+ if [ -t 1 ]; then
6
+ RED='\033[0;31m'
7
+ GREEN='\033[0;32m'
8
+ YELLOW='\033[1;33m'
9
+ BOLD='\033[1m'
10
+ NC='\033[0m'
11
+ else
12
+ RED='' GREEN='' YELLOW='' BOLD='' NC=''
13
+ fi
14
+
15
+ run_with_timeout() {
16
+ local secs="$1"; shift
17
+ if command -v timeout &>/dev/null; then
18
+ timeout "$secs" "$@"
19
+ elif command -v gtimeout &>/dev/null; then
20
+ gtimeout "$secs" "$@"
21
+ else
22
+ "$@" &
23
+ local pid=$!
24
+ ( sleep "$secs" && kill "$pid" 2>/dev/null ) &
25
+ local watcher=$!
26
+ wait "$pid" 2>/dev/null
27
+ local rc=$?
28
+ kill "$watcher" 2>/dev/null
29
+ wait "$watcher" 2>/dev/null
30
+ return $rc
31
+ fi
32
+ }
33
+
34
+ portable_mktemp() {
35
+ local prefix="${1:-validate}"
36
+ mktemp "${TMPDIR:-/tmp}/${prefix}-XXXXXX" 2>/dev/null || mktemp
37
+ }
38
+
39
+ CLEANUP_FILES=()
40
+ cleanup() { rm -f "${CLEANUP_FILES[@]+"${CLEANUP_FILES[@]}"}"; }
41
+ trap cleanup EXIT
42
+
43
+ PING_URL="${1:-}"
44
+ REPO_DIR="${2:-.}"
45
+
46
+ if [ -z "$PING_URL" ]; then
47
+ printf "Usage: %s <ping_url> [repo_dir]\n" "$0"
48
+ printf "\n"
49
+ printf " ping_url Your HuggingFace Space URL (e.g. https://your-space.hf.space)\n"
50
+ printf " repo_dir Path to your repo (default: current directory)\n"
51
+ exit 1
52
+ fi
53
+
54
+ if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
55
+ printf "Error: directory '%s' not found\n" "${2:-.}"
56
+ exit 1
57
+ fi
58
+ PING_URL="${PING_URL%/}"
59
+ export PING_URL
60
+ PASS=0
61
+
62
+ log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
63
+ pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
64
+ fail() { log "${RED}FAILED${NC} -- $1"; }
65
+ hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
66
+ stop_at() {
67
+ printf "\n"
68
+ printf "${RED}${BOLD}Validation stopped at %s.${NC} Fix the above before continuing.\n" "$1"
69
+ exit 1
70
+ }
71
+
72
+ printf "\n"
73
+ printf "${BOLD}========================================${NC}\n"
74
+ printf "${BOLD} OpenEnv Submission Validator${NC}\n"
75
+ printf "${BOLD}========================================${NC}\n"
76
+ log "Repo: $REPO_DIR"
77
+ log "Ping URL: $PING_URL"
78
+ printf "\n"
79
+
80
+ log "${BOLD}Step 1/3: Pinging HF Space${NC} ($PING_URL/reset) ..."
81
+
82
+ CURL_OUTPUT=$(portable_mktemp "validate-curl")
83
+ CLEANUP_FILES+=("$CURL_OUTPUT")
84
+ HTTP_CODE=$(curl -s -o "$CURL_OUTPUT" -w "%{http_code}" -X POST \
85
+ -H "Content-Type: application/json" -d '{}' \
86
+ "$PING_URL/reset" --max-time 30 2>"$CURL_OUTPUT" || printf "000")
87
+
88
+ if [ "$HTTP_CODE" = "200" ]; then
89
+ pass "HF Space is live and responds to /reset"
90
+ elif [ "$HTTP_CODE" = "000" ]; then
91
+ fail "HF Space not reachable (connection failed or timed out)"
92
+ hint "Check your network connection and that the Space is running."
93
+ hint "Try: curl -s -o /dev/null -w '%%{http_code}' -X POST $PING_URL/reset"
94
+ stop_at "Step 1"
95
+ else
96
+ fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
97
+ hint "Make sure your Space is running and the URL is correct."
98
+ hint "Try opening $PING_URL in your browser first."
99
+ stop_at "Step 1"
100
+ fi
101
+
102
+ log "${BOLD}Step 2/3: Running docker build${NC} ..."
103
+
104
+ if ! command -v docker &>/dev/null; then
105
+ fail "docker command not found"
106
+ hint "Install Docker: https://docs.docker.com/get-docker/"
107
+ stop_at "Step 2"
108
+ fi
109
+
110
+ if [ -f "$REPO_DIR/Dockerfile" ]; then
111
+ DOCKER_CONTEXT="$REPO_DIR"
112
+ elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
113
+ DOCKER_CONTEXT="$REPO_DIR/server"
114
+ else
115
+ fail "No Dockerfile found in repo root or server/ directory"
116
+ stop_at "Step 2"
117
+ fi
118
+
119
+ log " Found Dockerfile in $DOCKER_CONTEXT"
120
+
121
+ BUILD_OK=false
122
+ BUILD_OUTPUT=$(run_with_timeout "$DOCKER_BUILD_TIMEOUT" docker build "$DOCKER_CONTEXT" 2>&1) && BUILD_OK=true
123
+
124
+ if [ "$BUILD_OK" = true ]; then
125
+ pass "Docker build succeeded"
126
+ else
127
+ fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
128
+ printf "%s\n" "$BUILD_OUTPUT" | tail -20
129
+ stop_at "Step 2"
130
+ fi
131
+
132
+ log "${BOLD}Step 3/3: Running openenv validate${NC} ..."
133
+
134
+ if ! command -v openenv &>/dev/null; then
135
+ fail "openenv command not found"
136
+ hint "Install it: pip install openenv-core"
137
+ stop_at "Step 3"
138
+ fi
139
+
140
+ VALIDATE_OK=false
141
+ VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
142
+
143
+ if [ "$VALIDATE_OK" = true ]; then
144
+ pass "openenv validate passed"
145
+ [ -n "$VALIDATE_OUTPUT" ] && log " $VALIDATE_OUTPUT"
146
+ else
147
+ fail "openenv validate failed"
148
+ printf "%s\n" "$VALIDATE_OUTPUT"
149
+ stop_at "Step 3"
150
+ fi
151
+
152
+ printf "\n"
153
+ printf "${BOLD}========================================${NC}\n"
154
+ printf "${GREEN}${BOLD} All 3/3 checks passed!${NC}\n"
155
+ printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
156
+ printf "${BOLD}========================================${NC}\n"
157
+ printf "\n"
158
+
159
+ exit 0