Ajayyy00 commited on
Commit ·
57e71f8
0
Parent(s):
Initial commit of CyberSOC upgraded RLVR environment
Browse files- Dockerfile +19 -0
- README.md +531 -0
- __init__.py +33 -0
- client.py +103 -0
- dashboard/css/styles.css +527 -0
- dashboard/index.html +261 -0
- dashboard/js/animations.js +232 -0
- dashboard/js/api.js +123 -0
- dashboard/js/app.js +1083 -0
- dashboard/js/graphs.js +880 -0
- dashboard_server.py +93 -0
- inference.py +322 -0
- models.py +428 -0
- openenv.yaml +0 -0
- pyproject.toml +36 -0
- requirements.txt +8 -0
- server/Dockerfile +80 -0
- server/__init__.py +11 -0
- server/action_validation.py +99 -0
- server/app.py +73 -0
- server/episode_sandbox.py +98 -0
- server/graders.py +358 -0
- server/play_environment.py +1315 -0
- server/requirements.txt +3 -0
- server/soar_playbooks.py +125 -0
- server/task_generator.py +674 -0
- server/tasks.py +530 -0
- server/threat_graph.py +207 -0
- server/tool_router.py +146 -0
- server/visualize_graph.py +158 -0
- tests/__init__.py +0 -0
- tests/test_integration.py +189 -0
- tests/test_task1.py +34 -0
- tests/test_task2.py +107 -0
- tests/test_task3.py +81 -0
- tests/test_task4.py +72 -0
- tests/test_task5.py +86 -0
- tests/test_task6.py +101 -0
- tests/test_task7.py +113 -0
- tests/test_task8.py +132 -0
- tests/test_task9.py +173 -0
- training/__init__.py +0 -0
- training/collect_sft_data.py +104 -0
- training/reward_funcs.py +164 -0
- training/sft_data.jsonl +0 -0
- uv.lock +0 -0
- validate_submission.sh +159 -0
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
|