Spaces:
Sleeping
Sleeping
Add src to deployment
Browse files- Dockerfile +34 -0
- README.md +254 -9
- __init__.py +33 -0
- baseline.py +260 -0
- client.py +93 -0
- environment.py +393 -0
- graders.py +181 -0
- inference.py +200 -0
- models.py +112 -0
- openenv.yaml +68 -0
- pyproject.toml +36 -0
- requirements.txt +8 -0
- server/app.py +25 -0
- server/environment.py +393 -0
- tasks.py +247 -0
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for xsecure β Incident Response RL Environment
|
| 2 |
+
# Compatible with HF Spaces (port 7860) and local Docker
|
| 3 |
+
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
curl \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY models.py .
|
| 16 |
+
COPY client.py .
|
| 17 |
+
COPY graders.py .
|
| 18 |
+
COPY __init__.py .
|
| 19 |
+
COPY openenv.yaml .
|
| 20 |
+
COPY server/ server/
|
| 21 |
+
|
| 22 |
+
ENV TASK_ID=1
|
| 23 |
+
ENV HOST=0.0.0.0
|
| 24 |
+
ENV PORT=7860
|
| 25 |
+
ENV WORKERS=4
|
| 26 |
+
ENV MAX_CONCURRENT_ENVS=100
|
| 27 |
+
|
| 28 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
| 29 |
+
CMD curl -f http://localhost:${PORT}/health || exit 1
|
| 30 |
+
|
| 31 |
+
CMD uvicorn server.app:app \
|
| 32 |
+
--host $HOST \
|
| 33 |
+
--port $PORT \
|
| 34 |
+
--workers $WORKERS
|
README.md
CHANGED
|
@@ -1,12 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π‘οΈ Autonomous Incident Response & Threat Mitigation Environment
|
| 2 |
+
|
| 3 |
+
> *An RL environment where agents investigate evolving cyber threats and take sequential actions to detect and mitigate attacks before system compromise.*
|
| 4 |
+
|
| 5 |
+
[](https://github.com/meta-pytorch/OpenEnv)
|
| 6 |
+
[](https://www.python.org/)
|
| 7 |
+
[](https://opensource.org/licenses/Apache-2.0)
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## π§ Overview
|
| 12 |
+
|
| 13 |
+
This environment simulates a company's IT infrastructure under active cyber attack.
|
| 14 |
+
The agent acts as an autonomous SOC (Security Operations Center) analyst: it receives partial system logs and alerts, must investigate the incident through targeted actions, and mitigate the attack before the attacker achieves their objective.
|
| 15 |
+
|
| 16 |
+
Unlike log-classification tasks, this is a **multi-step sequential decision problem** where:
|
| 17 |
+
- The environment state evolves (attack progresses if ignored)
|
| 18 |
+
- The agent must *discover* hidden information through investigation before acting
|
| 19 |
+
- Wrong mitigation actions have real costs (legitimate users disrupted)
|
| 20 |
+
- Speed matters β faster resolution earns bonus reward
|
| 21 |
+
|
| 22 |
---
|
| 23 |
+
|
| 24 |
+
## π RL Loop
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
Observation (partial) β Action β Reward + New Observation β ... β Done
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### Observation Space
|
| 31 |
+
|
| 32 |
+
| Field | Type | Description |
|
| 33 |
+
|-------|------|-------------|
|
| 34 |
+
| `logs` | `List[LogEntry]` | System log entries visible so far |
|
| 35 |
+
| `alerts` | `List[AlertEntry]` | Security alerts (severity: low/medium/high) |
|
| 36 |
+
| `services` | `List[ServiceStatus]` | Running services and their health |
|
| 37 |
+
| `active_users` | `List[str]` | Currently active user accounts |
|
| 38 |
+
| `step_count` | `int` | Current step number |
|
| 39 |
+
| `last_action_result` | `str` | Human-readable result of last action |
|
| 40 |
+
|
| 41 |
+
### Action Space
|
| 42 |
+
|
| 43 |
+
| Action | Target | Description |
|
| 44 |
+
|--------|--------|-------------|
|
| 45 |
+
| `analyze_log` | `log_id` (e.g. `"L001"`) | Deep-dive into a log entry; may reveal new logs |
|
| 46 |
+
| `trace_user` | `user_id` (e.g. `"carol"`) | Investigate user activity; may reveal anomalies |
|
| 47 |
+
| `block_ip` | `ip_address` | Block a suspicious IP |
|
| 48 |
+
| `disable_account` | `user_id` | Disable a compromised account |
|
| 49 |
+
| `restart_service` | `service_name` | Restart a compromised or degraded service |
|
| 50 |
+
| `ignore` | `""` | No action β attack escalates, penalty applied |
|
| 51 |
+
|
| 52 |
+
### Reward Function
|
| 53 |
+
|
| 54 |
+
| Event | Reward |
|
| 55 |
+
|-------|--------|
|
| 56 |
+
| Revealing new evidence (analyze_log / trace_user) | +0.20 |
|
| 57 |
+
| Correctly identifying the threat actor | +0.30 |
|
| 58 |
+
| Correct mitigation (block_ip / disable_account / restart_service) | +0.50 |
|
| 59 |
+
| Fast resolution (< 40% of max steps) | +0.20 bonus |
|
| 60 |
+
| Wrong action (wrong target) | β0.30 |
|
| 61 |
+
| Ignoring the attack (each step) | β0.20 |
|
| 62 |
+
| Full system compromise | β0.60 |
|
| 63 |
+
|
| 64 |
+
Final episode score is normalised to **0.0 β 1.0** and reported in `info["final_score"]`.
|
| 65 |
+
|
| 66 |
---
|
| 67 |
|
| 68 |
+
## π§© Tasks
|
| 69 |
+
|
| 70 |
+
### Task 1 β Brute Force Detection (Easy)
|
| 71 |
+
A brute-force attack is underway against the authentication service.
|
| 72 |
+
Multiple failed login attempts are visible immediately.
|
| 73 |
+
**Expected flow:** analyze logs β confirm repeated failures from one IP β block that IP.
|
| 74 |
+
|
| 75 |
+
- Max steps: 12
|
| 76 |
+
- Required actions: `block_ip("192.168.1.47")`
|
| 77 |
+
- Oracle score: ~0.97
|
| 78 |
+
|
| 79 |
+
### Task 2 β Suspicious Login / Account Takeover (Medium)
|
| 80 |
+
A legitimate-looking login occurred from a geographically impossible location.
|
| 81 |
+
The agent must distinguish a compromised account from legitimate users and act before data is exfiltrated.
|
| 82 |
+
|
| 83 |
+
- Max steps: 14
|
| 84 |
+
- Required actions: `trace_user("carol")` β `disable_account("carol")`
|
| 85 |
+
- Oracle score: ~0.97
|
| 86 |
+
|
| 87 |
+
### Task 3 β Multi-Stage APT Kill Chain (Hard)
|
| 88 |
+
A sophisticated attack chain: phishing β privilege escalation β lateral movement β data exfiltration.
|
| 89 |
+
The agent must connect events across multiple logs, identify the compromised account, block the C2 IP, disable the account, and recover affected services.
|
| 90 |
+
|
| 91 |
+
- Max steps: 18
|
| 92 |
+
- Required actions: `block_ip("198.51.100.23")` + `disable_account("eve")` + `restart_service("database")` + `restart_service("prod-01")`
|
| 93 |
+
- Oracle score: ~0.94
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## π Setup & Usage
|
| 98 |
+
|
| 99 |
+
### Option A β Local development with Uvicorn
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
git clone https://huggingface.co/spaces/your-username/incident-response-env
|
| 103 |
+
cd incident-response-env
|
| 104 |
+
|
| 105 |
+
pip install -r requirements.txt
|
| 106 |
+
|
| 107 |
+
# Run Task 1
|
| 108 |
+
TASK_ID=1 uvicorn server.app:app --host 0.0.0.0 --port 8000 --reload
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
### Option B β Docker
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
# Build
|
| 115 |
+
docker build -t incident-response-env:latest .
|
| 116 |
+
|
| 117 |
+
# Run Task 1
|
| 118 |
+
docker run -d -p 8000:8000 -e TASK_ID=1 incident-response-env:latest
|
| 119 |
+
|
| 120 |
+
# Run Task 3 with custom scaling
|
| 121 |
+
docker run -d -p 8000:8000 \
|
| 122 |
+
-e TASK_ID=3 \
|
| 123 |
+
-e WORKERS=4 \
|
| 124 |
+
-e MAX_CONCURRENT_ENVS=100 \
|
| 125 |
+
incident-response-env:latest
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
### Option C β HF Spaces
|
| 129 |
+
|
| 130 |
+
```bash
|
| 131 |
+
# Already deployed β connect directly
|
| 132 |
+
pip install git+https://huggingface.co/spaces/your-username/incident-response-env
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
## π Python Usage
|
| 138 |
+
|
| 139 |
+
### Async (recommended)
|
| 140 |
+
|
| 141 |
+
```python
|
| 142 |
+
import asyncio
|
| 143 |
+
from client import IncidentResponseEnv
|
| 144 |
+
from models import IncidentAction
|
| 145 |
+
|
| 146 |
+
async def main():
|
| 147 |
+
async with IncidentResponseEnv(base_url="http://localhost:8000") as env:
|
| 148 |
+
# Task 1 β Brute Force
|
| 149 |
+
obs = await env.reset(task_id=1)
|
| 150 |
+
print(f"Logs: {[l.log_id for l in obs.logs]}")
|
| 151 |
+
print(f"Alerts: {[a.message for a in obs.alerts]}")
|
| 152 |
+
|
| 153 |
+
# Investigate
|
| 154 |
+
result = await env.step(IncidentAction(action_type="analyze_log", target="L001"))
|
| 155 |
+
print(f"reward={result.reward} new_logs={[l.log_id for l in result.observation.logs]}")
|
| 156 |
+
|
| 157 |
+
# Mitigate
|
| 158 |
+
result = await env.step(IncidentAction(action_type="block_ip", target="192.168.1.47"))
|
| 159 |
+
print(f"done={result.done} score={result.info.get('final_score')}")
|
| 160 |
+
|
| 161 |
+
asyncio.run(main())
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### Sync wrapper
|
| 165 |
+
|
| 166 |
+
```python
|
| 167 |
+
from client import IncidentResponseEnv
|
| 168 |
+
from models import IncidentAction
|
| 169 |
+
|
| 170 |
+
with IncidentResponseEnv(base_url="http://localhost:8000").sync() as env:
|
| 171 |
+
obs = env.reset(task_id=2)
|
| 172 |
+
result = env.step(IncidentAction(action_type="trace_user", target="carol"))
|
| 173 |
+
result = env.step(IncidentAction(action_type="disable_account", target="carol"))
|
| 174 |
+
print(f"Score: {result.info['final_score']}")
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## π Baseline
|
| 180 |
+
|
| 181 |
+
Run the LLM baseline against all tasks:
|
| 182 |
+
|
| 183 |
+
```bash
|
| 184 |
+
export OPENAI_API_KEY="sk-..."
|
| 185 |
+
export ENV_URL="http://localhost:8000"
|
| 186 |
+
|
| 187 |
+
python baseline.py
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
Expected output (gpt-4o-mini):
|
| 191 |
+
|
| 192 |
+
```
|
| 193 |
+
Task 1: mean_score=0.8700 success_rate=1.00
|
| 194 |
+
Task 2: mean_score=0.7600 success_rate=0.67
|
| 195 |
+
Task 3: mean_score=0.5200 success_rate=0.33
|
| 196 |
+
Overall mean score: 0.7167
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
Results are written to `baseline_results.json`.
|
| 200 |
+
|
| 201 |
+
---
|
| 202 |
+
|
| 203 |
+
## π Graders
|
| 204 |
+
|
| 205 |
+
Run the oracle graders to verify environment correctness:
|
| 206 |
+
|
| 207 |
+
```bash
|
| 208 |
+
python graders.py --url http://localhost:8000
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
## π API Endpoints
|
| 214 |
+
|
| 215 |
+
| Endpoint | Method | Description |
|
| 216 |
+
|----------|--------|-------------|
|
| 217 |
+
| `/ws` | WebSocket | Persistent session (recommended) |
|
| 218 |
+
| `/health` | GET | Health check |
|
| 219 |
+
| `/reset` | POST | Reset environment (stateless HTTP) |
|
| 220 |
+
| `/step` | POST | Execute action (stateless HTTP) |
|
| 221 |
+
| `/state` | GET | Full internal state |
|
| 222 |
+
| `/web` | GET | Interactive browser UI |
|
| 223 |
+
| `/docs` | GET | OpenAPI documentation |
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
## π Project Structure
|
| 228 |
+
|
| 229 |
+
```
|
| 230 |
+
incident_response_env/
|
| 231 |
+
βββ models.py # Typed Pydantic models (Action, Observation, State)
|
| 232 |
+
βββ tasks.py # 3 task scenario definitions
|
| 233 |
+
βββ client.py # Async + sync Python client
|
| 234 |
+
βββ graders.py # Programmatic graders (0.0β1.0)
|
| 235 |
+
βββ baseline.py # LLM baseline inference script
|
| 236 |
+
βββ server/
|
| 237 |
+
β βββ app.py # FastAPI server (WebSocket + HTTP)
|
| 238 |
+
β βββ environment.py # Core environment logic (reset/step/state)
|
| 239 |
+
βββ Dockerfile
|
| 240 |
+
βββ openenv.yaml
|
| 241 |
+
βββ pyproject.toml
|
| 242 |
+
βββ requirements.txt
|
| 243 |
+
βββ README.md
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
---
|
| 247 |
+
|
| 248 |
+
## Why This Environment?
|
| 249 |
+
|
| 250 |
+
Most RL environments for LLMs test single-step classification. This environment tests:
|
| 251 |
+
|
| 252 |
+
- **Multi-step reasoning** β you can't block the right IP without first discovering it through logs
|
| 253 |
+
- **Evidence-based decision making** β wrong mitigation actions are penalised
|
| 254 |
+
- **Dynamic adversary** β the attack progresses if you're slow
|
| 255 |
+
- **Trajectory-level reward** β the entire sequence of decisions matters
|
| 256 |
+
|
| 257 |
+
These properties make it suitable for training and evaluating agents on realistic SOC workflows.
|
__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
xsecure β Autonomous Incident Response & Threat Mitigation Environment.
|
| 3 |
+
OpenEnv-compatible package.
|
| 4 |
+
|
| 5 |
+
Quick start
|
| 6 |
+
-----------
|
| 7 |
+
from xsecure import IncidentResponseEnv, IncidentAction
|
| 8 |
+
|
| 9 |
+
async with IncidentResponseEnv(base_url="http://localhost:7860") as env:
|
| 10 |
+
obs = await env.reset(task_id=1)
|
| 11 |
+
result = await env.step(IncidentAction(action_type="analyze_log", target="L001"))
|
| 12 |
+
|
| 13 |
+
# Or let openenv-core manage Docker for you:
|
| 14 |
+
env = await IncidentResponseEnv.from_docker_image("xsecure:latest")
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from client import IncidentResponseEnv, StepResult # noqa: F401
|
| 18 |
+
from models import ( # noqa: F401
|
| 19 |
+
IncidentAction,
|
| 20 |
+
IncidentObservation,
|
| 21 |
+
IncidentState,
|
| 22 |
+
ActionType,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
__version__ = "1.0.0"
|
| 26 |
+
__all__ = [
|
| 27 |
+
"IncidentResponseEnv",
|
| 28 |
+
"StepResult",
|
| 29 |
+
"IncidentAction",
|
| 30 |
+
"IncidentObservation",
|
| 31 |
+
"IncidentState",
|
| 32 |
+
"ActionType",
|
| 33 |
+
]
|
baseline.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Baseline inference script β uses OpenAI API client to run an LLM agent
|
| 3 |
+
against all 3 tasks and produces reproducible scores.
|
| 4 |
+
|
| 5 |
+
Usage
|
| 6 |
+
-----
|
| 7 |
+
export OPENAI_API_KEY="sk-..."
|
| 8 |
+
export ENV_URL="http://localhost:8000" # optional, defaults to localhost
|
| 9 |
+
python baseline.py
|
| 10 |
+
|
| 11 |
+
The script prints per-task and aggregate scores, then writes results to
|
| 12 |
+
baseline_results.json for reproducibility.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import sys
|
| 22 |
+
from datetime import datetime
|
| 23 |
+
from typing import Dict, List, Optional
|
| 24 |
+
|
| 25 |
+
from openai import AsyncOpenAI
|
| 26 |
+
|
| 27 |
+
from client import IncidentResponseEnv, StepResult
|
| 28 |
+
from graders import GradeResult, grade
|
| 29 |
+
from models import IncidentAction, IncidentObservation
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Configuration
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
| 36 |
+
ENV_URL = os.getenv("ENV_URL", "http://localhost:8000")
|
| 37 |
+
MODEL_NAME = os.getenv("BASELINE_MODEL", "gpt-4o-mini")
|
| 38 |
+
NUM_EPISODES = int(os.getenv("NUM_EPISODES", "3"))
|
| 39 |
+
|
| 40 |
+
if not OPENAI_API_KEY:
|
| 41 |
+
print("ERROR: OPENAI_API_KEY environment variable is not set.", file=sys.stderr)
|
| 42 |
+
sys.exit(1)
|
| 43 |
+
|
| 44 |
+
client = AsyncOpenAI(api_key=OPENAI_API_KEY)
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# System prompt
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
SYSTEM_PROMPT = """\
|
| 51 |
+
You are an expert cybersecurity incident responder AI agent.
|
| 52 |
+
You will be given the current state of a simulated company under cyber attack.
|
| 53 |
+
Your goal is to investigate logs and alerts, identify the threat, and mitigate it before the system is compromised.
|
| 54 |
+
|
| 55 |
+
## Available Actions (one per step):
|
| 56 |
+
- analyze_log(log_id) β Examine a specific log entry for more detail
|
| 57 |
+
- trace_user(user_id) β Investigate a user's activity history
|
| 58 |
+
- block_ip(ip_address) β Block a suspicious IP address
|
| 59 |
+
- disable_account(user_id) β Disable a compromised user account
|
| 60 |
+
- restart_service(service) β Restart a compromised or degraded service
|
| 61 |
+
- ignore β Take no action (penalised β attack progresses!)
|
| 62 |
+
|
| 63 |
+
## Response Format (STRICT β machine-parsed):
|
| 64 |
+
You MUST respond ONLY with a JSON object like:
|
| 65 |
+
{"action_type": "analyze_log", "target": "L001"}
|
| 66 |
+
|
| 67 |
+
Valid action_type values: analyze_log, trace_user, block_ip, disable_account, restart_service, ignore
|
| 68 |
+
|
| 69 |
+
## Strategy:
|
| 70 |
+
1. First investigate (analyze_log, trace_user) to gather evidence.
|
| 71 |
+
2. Then act decisively on confirmed threats (block_ip, disable_account, restart_service).
|
| 72 |
+
3. Never block/disable unless you have strong evidence β wrong actions cost points.
|
| 73 |
+
4. Speed matters β faster resolution earns a bonus.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
# LLM-driven agent
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
def _format_observation(obs: IncidentObservation) -> str:
|
| 81 |
+
logs_txt = "\n".join(f" [{l.log_id}] {l.timestamp} β {l.message}" for l in obs.logs)
|
| 82 |
+
alerts_txt = "\n".join(f" [{a.severity.upper()}] {a.message}" for a in obs.alerts)
|
| 83 |
+
services_txt = "\n".join(f" {s.name}: {s.status}" for s in obs.services)
|
| 84 |
+
users_txt = ", ".join(obs.active_users)
|
| 85 |
+
|
| 86 |
+
return f"""\
|
| 87 |
+
=== Incident Response Dashboard (Step {obs.step_count}) ===
|
| 88 |
+
|
| 89 |
+
LOGS:
|
| 90 |
+
{logs_txt}
|
| 91 |
+
|
| 92 |
+
ALERTS:
|
| 93 |
+
{alerts_txt}
|
| 94 |
+
|
| 95 |
+
SERVICES:
|
| 96 |
+
{services_txt}
|
| 97 |
+
|
| 98 |
+
ACTIVE USERS: {users_txt}
|
| 99 |
+
|
| 100 |
+
Last action result: {obs.last_action_result}
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _parse_llm_response(text: str) -> IncidentAction:
|
| 105 |
+
"""Extract JSON action from LLM output. Falls back to ignore on parse failure."""
|
| 106 |
+
# Try direct JSON parse
|
| 107 |
+
stripped = text.strip()
|
| 108 |
+
try:
|
| 109 |
+
data = json.loads(stripped)
|
| 110 |
+
return IncidentAction(**data)
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
|
| 114 |
+
# Try extracting JSON from markdown code block
|
| 115 |
+
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", stripped, re.DOTALL)
|
| 116 |
+
if match:
|
| 117 |
+
try:
|
| 118 |
+
data = json.loads(match.group(1))
|
| 119 |
+
return IncidentAction(**data)
|
| 120 |
+
except Exception:
|
| 121 |
+
pass
|
| 122 |
+
|
| 123 |
+
# Try finding raw JSON object in text
|
| 124 |
+
match = re.search(r"\{[^{}]+\}", stripped)
|
| 125 |
+
if match:
|
| 126 |
+
try:
|
| 127 |
+
data = json.loads(match.group(0))
|
| 128 |
+
return IncidentAction(**data)
|
| 129 |
+
except Exception:
|
| 130 |
+
pass
|
| 131 |
+
|
| 132 |
+
# Fallback
|
| 133 |
+
print(f" [WARN] Could not parse LLM output: {text[:120]!r} β defaulting to ignore")
|
| 134 |
+
return IncidentAction(action_type="ignore", target="")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
async def _llm_agent_fn(
|
| 138 |
+
obs: IncidentObservation,
|
| 139 |
+
history: List[StepResult],
|
| 140 |
+
conversation: List[Dict],
|
| 141 |
+
) -> IncidentAction:
|
| 142 |
+
"""Call OpenAI API and return the next action."""
|
| 143 |
+
user_msg = _format_observation(obs)
|
| 144 |
+
|
| 145 |
+
conversation.append({"role": "user", "content": user_msg})
|
| 146 |
+
|
| 147 |
+
response = await client.chat.completions.create(
|
| 148 |
+
model=MODEL_NAME,
|
| 149 |
+
messages=[{"role": "system", "content": SYSTEM_PROMPT}] + conversation,
|
| 150 |
+
max_tokens=256,
|
| 151 |
+
temperature=0.0, # deterministic for reproducibility
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
assistant_text = response.choices[0].message.content or ""
|
| 155 |
+
conversation.append({"role": "assistant", "content": assistant_text})
|
| 156 |
+
|
| 157 |
+
return _parse_llm_response(assistant_text)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
# Episode runner
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
async def _run_llm_episode(task_id: int) -> GradeResult:
|
| 165 |
+
conversation: List[Dict] = []
|
| 166 |
+
last_result: Optional[StepResult] = None
|
| 167 |
+
|
| 168 |
+
async with IncidentResponseEnv(base_url=ENV_URL) as env:
|
| 169 |
+
obs = await env.reset(task_id=task_id)
|
| 170 |
+
history: List[StepResult] = []
|
| 171 |
+
|
| 172 |
+
for step in range(25): # safety cap
|
| 173 |
+
action = await _llm_agent_fn(obs, history, conversation)
|
| 174 |
+
print(f" step {step + 1:02d}: {action.action_type}({action.target!r})", end="")
|
| 175 |
+
|
| 176 |
+
result = await env.step(action)
|
| 177 |
+
print(f" β reward={result.reward:+.2f}")
|
| 178 |
+
|
| 179 |
+
history.append(result)
|
| 180 |
+
last_result = result
|
| 181 |
+
obs = result.observation
|
| 182 |
+
|
| 183 |
+
if result.done:
|
| 184 |
+
break
|
| 185 |
+
|
| 186 |
+
info = last_result.info if last_result else {}
|
| 187 |
+
return GradeResult(
|
| 188 |
+
task_id=task_id,
|
| 189 |
+
score=float(info.get("final_score", 0.0)),
|
| 190 |
+
success=bool(info.get("success", False)),
|
| 191 |
+
compromise=bool(info.get("compromise", False)),
|
| 192 |
+
steps_taken=obs.step_count,
|
| 193 |
+
wrong_actions=int(info.get("wrong_actions", 0)),
|
| 194 |
+
notes=obs.last_action_result,
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ---------------------------------------------------------------------------
|
| 199 |
+
# Main
|
| 200 |
+
# ---------------------------------------------------------------------------
|
| 201 |
+
|
| 202 |
+
async def main():
|
| 203 |
+
print("=" * 65)
|
| 204 |
+
print(f"Incident Response Env β Baseline ({MODEL_NAME})")
|
| 205 |
+
print(f"Environment: {ENV_URL}")
|
| 206 |
+
print(f"Episodes per task: {NUM_EPISODES}")
|
| 207 |
+
print("=" * 65)
|
| 208 |
+
|
| 209 |
+
all_scores: List[float] = []
|
| 210 |
+
output: Dict = {
|
| 211 |
+
"model": MODEL_NAME,
|
| 212 |
+
"env_url": ENV_URL,
|
| 213 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 214 |
+
"tasks": {},
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
for task_id in [1, 2, 3]:
|
| 218 |
+
task_names = {
|
| 219 |
+
1: "Brute Force (Easy)",
|
| 220 |
+
2: "Suspicious Login (Medium)",
|
| 221 |
+
3: "Multi-Stage APT (Hard)",
|
| 222 |
+
}
|
| 223 |
+
print(f"\n--- Task {task_id}: {task_names[task_id]} ---")
|
| 224 |
+
|
| 225 |
+
episode_results = []
|
| 226 |
+
for ep in range(NUM_EPISODES):
|
| 227 |
+
print(f" Episode {ep + 1}/{NUM_EPISODES}:")
|
| 228 |
+
result = await _run_llm_episode(task_id)
|
| 229 |
+
episode_results.append(result)
|
| 230 |
+
print(f" β {result}")
|
| 231 |
+
|
| 232 |
+
scores = [r.score for r in episode_results]
|
| 233 |
+
mean = sum(scores) / len(scores)
|
| 234 |
+
all_scores.extend(scores)
|
| 235 |
+
|
| 236 |
+
output["tasks"][str(task_id)] = {
|
| 237 |
+
"mean_score": round(mean, 4),
|
| 238 |
+
"success_rate": round(sum(1 for r in episode_results if r.success) / NUM_EPISODES, 4),
|
| 239 |
+
"episodes": [
|
| 240 |
+
{"score": r.score, "success": r.success, "steps": r.steps_taken}
|
| 241 |
+
for r in episode_results
|
| 242 |
+
],
|
| 243 |
+
}
|
| 244 |
+
print(f" Task {task_id} mean score: {mean:.4f}")
|
| 245 |
+
|
| 246 |
+
overall = sum(all_scores) / len(all_scores)
|
| 247 |
+
output["overall_mean_score"] = round(overall, 4)
|
| 248 |
+
|
| 249 |
+
print(f"\n{'=' * 65}")
|
| 250 |
+
print(f"Overall mean score: {overall:.4f}")
|
| 251 |
+
print("=" * 65)
|
| 252 |
+
|
| 253 |
+
out_path = "baseline_results.json"
|
| 254 |
+
with open(out_path, "w") as f:
|
| 255 |
+
json.dump(output, f, indent=2)
|
| 256 |
+
print(f"\nResults saved to {out_path}")
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
if __name__ == "__main__":
|
| 260 |
+
asyncio.run(main())
|
client.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
client.py β xsecure environment client.
|
| 3 |
+
Extends openenv-core's HTTPEnvClient β WebSocket, sync wrapper,
|
| 4 |
+
from_hub() and from_docker_image() all come for free.
|
| 5 |
+
|
| 6 |
+
Usage (async):
|
| 7 |
+
async with IncidentResponseEnv(base_url="http://localhost:7860") as env:
|
| 8 |
+
obs = await env.reset(task_id=1)
|
| 9 |
+
result = await env.step(IncidentAction(action_type="analyze_log", target="L001"))
|
| 10 |
+
|
| 11 |
+
Usage (sync):
|
| 12 |
+
with IncidentResponseEnv(base_url="http://localhost:7860").sync() as env:
|
| 13 |
+
obs = env.reset(task_id=1)
|
| 14 |
+
result = env.step(IncidentAction(action_type="analyze_log", target="L001"))
|
| 15 |
+
|
| 16 |
+
Usage (Docker β auto-pulls and runs):
|
| 17 |
+
env = await IncidentResponseEnv.from_docker_image("xsecure:latest")
|
| 18 |
+
async with env:
|
| 19 |
+
obs = await env.reset(task_id=1)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from dataclasses import asdict
|
| 25 |
+
from typing import Any, Dict
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
from openenv_core.http_env_client import HTTPEnvClient
|
| 29 |
+
from openenv_core.types import StepResult
|
| 30 |
+
except ImportError:
|
| 31 |
+
from core.http_env_client import HTTPEnvClient
|
| 32 |
+
from core.types import StepResult
|
| 33 |
+
|
| 34 |
+
from models import IncidentAction, IncidentObservation, IncidentState
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class IncidentResponseEnv(HTTPEnvClient[IncidentAction, IncidentObservation]):
|
| 38 |
+
"""
|
| 39 |
+
Client for the xsecure Incident Response environment.
|
| 40 |
+
Inherits reset(), step(), state(), sync(), from_hub(), from_docker_image()
|
| 41 |
+
from openenv-core's HTTPEnvClient.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def _step_payload(self, action: IncidentAction) -> Dict[str, Any]:
|
| 45 |
+
"""Serialize action to JSON dict for the HTTP /step endpoint."""
|
| 46 |
+
return {
|
| 47 |
+
"action_type": action.action_type,
|
| 48 |
+
"target": action.target,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
def _parse_result(self, payload: Dict[str, Any]) -> StepResult:
|
| 52 |
+
"""Deserialize HTTP response into a typed StepResult."""
|
| 53 |
+
obs_data = payload.get("observation", {})
|
| 54 |
+
|
| 55 |
+
# Re-hydrate nested dataclass lists
|
| 56 |
+
from models import LogEntry, AlertEntry, ServiceStatus
|
| 57 |
+
logs = [LogEntry(**l) for l in obs_data.get("logs", [])]
|
| 58 |
+
alerts = [AlertEntry(**a) for a in obs_data.get("alerts", [])]
|
| 59 |
+
services = [ServiceStatus(**s) for s in obs_data.get("services", [])]
|
| 60 |
+
|
| 61 |
+
obs = IncidentObservation(
|
| 62 |
+
logs=logs,
|
| 63 |
+
alerts=alerts,
|
| 64 |
+
services=services,
|
| 65 |
+
active_users=obs_data.get("active_users", []),
|
| 66 |
+
step_count=obs_data.get("step_count", 0),
|
| 67 |
+
reward=obs_data.get("reward", 0.0),
|
| 68 |
+
done=obs_data.get("done", False),
|
| 69 |
+
info=obs_data.get("info", {}),
|
| 70 |
+
last_action_result=obs_data.get("last_action_result", ""),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
return StepResult(
|
| 74 |
+
observation=obs,
|
| 75 |
+
reward=payload.get("reward", 0.0),
|
| 76 |
+
done=payload.get("done", False),
|
| 77 |
+
info=payload.get("info", {}),
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
def _parse_state(self, payload: Dict[str, Any]) -> IncidentState:
|
| 81 |
+
"""Deserialize /state response into IncidentState."""
|
| 82 |
+
data = payload.get("state", payload)
|
| 83 |
+
return IncidentState(**{
|
| 84 |
+
k: v for k, v in data.items()
|
| 85 |
+
if k in IncidentState.__dataclass_fields__
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
# Backward-compatible StepResult re-export
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
__all__ = ["IncidentResponseEnv", "StepResult"]
|
environment.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
server/environment.py β xsecure incident response simulation.
|
| 3 |
+
Extends openenv-core Environment base class correctly.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from openenv_core.env_server import Environment
|
| 13 |
+
except ImportError:
|
| 14 |
+
from core.env_server import Environment
|
| 15 |
+
|
| 16 |
+
from models import (
|
| 17 |
+
ActionType,
|
| 18 |
+
IncidentAction,
|
| 19 |
+
IncidentObservation,
|
| 20 |
+
IncidentState,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Task definitions
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
TASKS: Dict[int, dict] = {
|
| 28 |
+
1: {
|
| 29 |
+
"attack_type": "brute_force",
|
| 30 |
+
"attacker_ip": "192.168.1.47",
|
| 31 |
+
"target_user": "admin",
|
| 32 |
+
"target_service": "auth-service",
|
| 33 |
+
"max_steps": 12,
|
| 34 |
+
"logs": [
|
| 35 |
+
{"log_id": "L001", "message": "Multiple failed login attempts from 192.168.1.47 (47 attempts in 60s)", "timestamp": "2024-01-15T08:01:00Z"},
|
| 36 |
+
{"log_id": "L002", "message": "Account lockout triggered for user admin after failed logins from 192.168.1.47", "timestamp": "2024-01-15T08:01:45Z"},
|
| 37 |
+
{"log_id": "L003", "message": "Successful login from 192.168.1.47 after lockout bypass", "timestamp": "2024-01-15T08:03:10Z"},
|
| 38 |
+
],
|
| 39 |
+
"alerts": [
|
| 40 |
+
{"alert_id": "A001", "message": "Brute force pattern detected β 47 failed logins in 60 seconds", "severity": "high"},
|
| 41 |
+
{"alert_id": "A002", "message": "Account lockout bypass attempt detected", "severity": "high"},
|
| 42 |
+
],
|
| 43 |
+
"services": [
|
| 44 |
+
{"name": "auth-service", "status": "degraded"},
|
| 45 |
+
{"name": "web-app", "status": "running"},
|
| 46 |
+
{"name": "database", "status": "running"},
|
| 47 |
+
],
|
| 48 |
+
"active_users": ["admin", "alice", "bob"],
|
| 49 |
+
"correct_sequence": {
|
| 50 |
+
"analyze_log:L001": 0.15,
|
| 51 |
+
"analyze_log:L002": 0.15,
|
| 52 |
+
"block_ip:192.168.1.47": 0.50,
|
| 53 |
+
},
|
| 54 |
+
"wrong_penalty": -0.15,
|
| 55 |
+
"delay_penalty": 0.05,
|
| 56 |
+
"speed_bonus_threshold": 6,
|
| 57 |
+
"speed_bonus": 0.20,
|
| 58 |
+
"compromise_steps": 8,
|
| 59 |
+
},
|
| 60 |
+
2: {
|
| 61 |
+
"attack_type": "suspicious_login",
|
| 62 |
+
"attacker_ip": "203.0.113.55",
|
| 63 |
+
"target_user": "carol",
|
| 64 |
+
"target_service": "hr-portal",
|
| 65 |
+
"max_steps": 14,
|
| 66 |
+
"logs": [
|
| 67 |
+
{"log_id": "L001", "message": "Login for carol from unusual geo-location (203.0.113.55 β Eastern Europe)", "timestamp": "2024-01-15T14:22:00Z"},
|
| 68 |
+
{"log_id": "L002", "message": "carol accessed sensitive HR records 3 minutes after login", "timestamp": "2024-01-15T14:25:10Z"},
|
| 69 |
+
{"log_id": "L003", "message": "carol attempted to export 1,200 employee records", "timestamp": "2024-01-15T14:27:33Z"},
|
| 70 |
+
],
|
| 71 |
+
"alerts": [
|
| 72 |
+
{"alert_id": "A001", "message": "Login from unusual location for carol", "severity": "medium"},
|
| 73 |
+
{"alert_id": "A002", "message": "Unusual data access pattern β bulk HR record access", "severity": "high"},
|
| 74 |
+
],
|
| 75 |
+
"services": [
|
| 76 |
+
{"name": "hr-portal", "status": "running"},
|
| 77 |
+
{"name": "auth-service", "status": "running"},
|
| 78 |
+
{"name": "database", "status": "running"},
|
| 79 |
+
],
|
| 80 |
+
"active_users": ["carol", "dave", "alice"],
|
| 81 |
+
"correct_sequence": {
|
| 82 |
+
"analyze_log:L001": 0.10,
|
| 83 |
+
"analyze_log:L002": 0.10,
|
| 84 |
+
"trace_user:carol": 0.20,
|
| 85 |
+
"disable_account:carol": 0.45,
|
| 86 |
+
},
|
| 87 |
+
"wrong_penalty": -0.15,
|
| 88 |
+
"delay_penalty": 0.05,
|
| 89 |
+
"speed_bonus_threshold": 7,
|
| 90 |
+
"speed_bonus": 0.15,
|
| 91 |
+
"compromise_steps": 10,
|
| 92 |
+
},
|
| 93 |
+
3: {
|
| 94 |
+
"attack_type": "multi_stage",
|
| 95 |
+
"attacker_ip": "198.51.100.23",
|
| 96 |
+
"target_user": "eve",
|
| 97 |
+
"target_service": "database",
|
| 98 |
+
"max_steps": 18,
|
| 99 |
+
"logs": [
|
| 100 |
+
{"log_id": "L001", "message": "Phishing email link clicked by eve β redirect to 198.51.100.23", "timestamp": "2024-01-15T09:00:00Z"},
|
| 101 |
+
{"log_id": "L002", "message": "Credential theft tool executed on eve's workstation", "timestamp": "2024-01-15T09:15:22Z"},
|
| 102 |
+
{"log_id": "L003", "message": "eve authenticated to database server outside normal hours", "timestamp": "2024-01-15T09:22:45Z"},
|
| 103 |
+
{"log_id": "L004", "message": "Lateral movement: eve's credentials used on prod-01 and database", "timestamp": "2024-01-15T09:30:11Z"},
|
| 104 |
+
{"log_id": "L005", "message": "Ransomware staging detected on database β encryption not yet started", "timestamp": "2024-01-15T09:45:00Z"},
|
| 105 |
+
],
|
| 106 |
+
"alerts": [
|
| 107 |
+
{"alert_id": "A001", "message": "Phishing link accessed β possible credential compromise for eve", "severity": "medium"},
|
| 108 |
+
{"alert_id": "A002", "message": "Credential harvesting tool detected", "severity": "high"},
|
| 109 |
+
{"alert_id": "A003", "message": "Lateral movement across prod-01 and database", "severity": "high"},
|
| 110 |
+
],
|
| 111 |
+
"services": [
|
| 112 |
+
{"name": "database", "status": "degraded"},
|
| 113 |
+
{"name": "prod-01", "status": "degraded"},
|
| 114 |
+
{"name": "web-app", "status": "running"},
|
| 115 |
+
],
|
| 116 |
+
"active_users": ["eve", "frank", "grace"],
|
| 117 |
+
"correct_sequence": {
|
| 118 |
+
"analyze_log:L001": 0.08,
|
| 119 |
+
"analyze_log:L002": 0.08,
|
| 120 |
+
"analyze_log:L004": 0.08,
|
| 121 |
+
"trace_user:eve": 0.12,
|
| 122 |
+
"block_ip:198.51.100.23": 0.20,
|
| 123 |
+
"disable_account:eve": 0.20,
|
| 124 |
+
"restart_service:database": 0.12,
|
| 125 |
+
"restart_service:prod-01": 0.12,
|
| 126 |
+
},
|
| 127 |
+
"wrong_penalty": -0.10,
|
| 128 |
+
"delay_penalty": 0.04,
|
| 129 |
+
"speed_bonus_threshold": 10,
|
| 130 |
+
"speed_bonus": 0.10,
|
| 131 |
+
"compromise_steps": 12,
|
| 132 |
+
},
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
# Environment
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
|
| 140 |
+
class IncidentEnvironment(Environment):
|
| 141 |
+
|
| 142 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 143 |
+
|
| 144 |
+
def __init__(self):
|
| 145 |
+
super().__init__()
|
| 146 |
+
self._state: Optional[IncidentState] = None
|
| 147 |
+
self._task_def: Optional[dict] = None
|
| 148 |
+
|
| 149 |
+
# ---- openenv-core interface -----------------------------------------
|
| 150 |
+
|
| 151 |
+
def reset(
|
| 152 |
+
self,
|
| 153 |
+
seed: Optional[int] = None,
|
| 154 |
+
episode_id: Optional[str] = None,
|
| 155 |
+
**kwargs: Any,
|
| 156 |
+
) -> IncidentObservation:
|
| 157 |
+
task_id = int(kwargs.get("task_id", 1))
|
| 158 |
+
if task_id not in TASKS:
|
| 159 |
+
task_id = 1
|
| 160 |
+
|
| 161 |
+
td = TASKS[task_id]
|
| 162 |
+
self._task_def = td
|
| 163 |
+
self._state = IncidentState(
|
| 164 |
+
episode_id=episode_id or str(uuid.uuid4()),
|
| 165 |
+
task_id=task_id,
|
| 166 |
+
step_count=0,
|
| 167 |
+
max_steps=td["max_steps"],
|
| 168 |
+
attack_type=td["attack_type"],
|
| 169 |
+
attacker_ip=td["attacker_ip"],
|
| 170 |
+
target_user=td["target_user"],
|
| 171 |
+
target_service=td["target_service"],
|
| 172 |
+
)
|
| 173 |
+
return self._build_observation("Episode started. Investigate the alerts and logs.")
|
| 174 |
+
|
| 175 |
+
def step(
|
| 176 |
+
self,
|
| 177 |
+
action: IncidentAction,
|
| 178 |
+
timeout_s: Optional[float] = None,
|
| 179 |
+
**kwargs: Any,
|
| 180 |
+
) -> IncidentObservation:
|
| 181 |
+
if self._state is None:
|
| 182 |
+
raise RuntimeError("Call reset() before step().")
|
| 183 |
+
|
| 184 |
+
state = self._state
|
| 185 |
+
td = self._task_def
|
| 186 |
+
|
| 187 |
+
if state.done:
|
| 188 |
+
return self._build_observation("Episode already finished.", reward=0.0)
|
| 189 |
+
|
| 190 |
+
state.step_count += 1
|
| 191 |
+
action_key = f"{action.action_type}:{action.target}"
|
| 192 |
+
reward = 0.0
|
| 193 |
+
result_msg = ""
|
| 194 |
+
|
| 195 |
+
# Delay penalty every step
|
| 196 |
+
reward -= td["delay_penalty"]
|
| 197 |
+
|
| 198 |
+
atype = action.action_type
|
| 199 |
+
|
| 200 |
+
if atype == ActionType.ANALYZE_LOG:
|
| 201 |
+
r, result_msg = self._handle_analyze_log(action.target, action_key)
|
| 202 |
+
reward += r
|
| 203 |
+
elif atype == ActionType.TRACE_USER:
|
| 204 |
+
r, result_msg = self._handle_trace_user(action.target, action_key)
|
| 205 |
+
reward += r
|
| 206 |
+
elif atype == ActionType.BLOCK_IP:
|
| 207 |
+
r, result_msg = self._handle_block_ip(action.target, action_key)
|
| 208 |
+
reward += r
|
| 209 |
+
elif atype == ActionType.DISABLE_ACCOUNT:
|
| 210 |
+
r, result_msg = self._handle_disable_account(action.target, action_key)
|
| 211 |
+
reward += r
|
| 212 |
+
elif atype == ActionType.RESTART_SERVICE:
|
| 213 |
+
r, result_msg = self._handle_restart_service(action.target, action_key)
|
| 214 |
+
reward += r
|
| 215 |
+
elif atype == ActionType.IGNORE:
|
| 216 |
+
state.progress_level = min(state.progress_level + 1, state.max_progress)
|
| 217 |
+
reward -= 0.10
|
| 218 |
+
result_msg = "No action taken. Attack progresses!"
|
| 219 |
+
else:
|
| 220 |
+
state.wrong_actions += 1
|
| 221 |
+
reward += td["wrong_penalty"]
|
| 222 |
+
result_msg = f"Unknown action: {atype}"
|
| 223 |
+
|
| 224 |
+
reward = round(reward, 4)
|
| 225 |
+
state.total_reward += reward
|
| 226 |
+
|
| 227 |
+
done, info = self._check_termination()
|
| 228 |
+
if done:
|
| 229 |
+
state.done = True
|
| 230 |
+
|
| 231 |
+
return self._build_observation(result_msg, reward=reward, done=done, info=info)
|
| 232 |
+
|
| 233 |
+
@property
|
| 234 |
+
def state(self) -> IncidentState:
|
| 235 |
+
if self._state is None:
|
| 236 |
+
raise RuntimeError("Call reset() first.")
|
| 237 |
+
return self._state
|
| 238 |
+
|
| 239 |
+
# ---- Action handlers -----------------------------------------------
|
| 240 |
+
|
| 241 |
+
def _handle_analyze_log(self, target: str, key: str):
|
| 242 |
+
state = self._state
|
| 243 |
+
td = self._task_def
|
| 244 |
+
log_ids = [l["log_id"] for l in td["logs"]]
|
| 245 |
+
|
| 246 |
+
if target not in log_ids:
|
| 247 |
+
state.wrong_actions += 1
|
| 248 |
+
return td["wrong_penalty"], f"Log {target} does not exist."
|
| 249 |
+
if target in state.revealed_logs:
|
| 250 |
+
return -0.05, f"Log {target} already analyzed."
|
| 251 |
+
|
| 252 |
+
state.revealed_logs.append(target)
|
| 253 |
+
if key in td["correct_sequence"]:
|
| 254 |
+
state.correct_detections += 1
|
| 255 |
+
msg = next(l["message"] for l in td["logs"] if l["log_id"] == target)
|
| 256 |
+
return td["correct_sequence"][key], f"[ANALYSIS] {target}: {msg}"
|
| 257 |
+
return 0.05, f"Log {target} analyzed β no significant findings."
|
| 258 |
+
|
| 259 |
+
def _handle_trace_user(self, target: str, key: str):
|
| 260 |
+
state = self._state
|
| 261 |
+
td = self._task_def
|
| 262 |
+
|
| 263 |
+
if target not in td["active_users"]:
|
| 264 |
+
state.wrong_actions += 1
|
| 265 |
+
return td["wrong_penalty"], f"User {target!r} not found."
|
| 266 |
+
if target in state.revealed_users:
|
| 267 |
+
return -0.05, f"User {target} already traced."
|
| 268 |
+
|
| 269 |
+
state.revealed_users.append(target)
|
| 270 |
+
if key in td["correct_sequence"]:
|
| 271 |
+
state.correct_detections += 1
|
| 272 |
+
return td["correct_sequence"][key], f"[TRACE] {target}: Confirmed suspicious activity."
|
| 273 |
+
return 0.05, f"User {target} traced β activity appears normal."
|
| 274 |
+
|
| 275 |
+
def _handle_block_ip(self, target: str, key: str):
|
| 276 |
+
state = self._state
|
| 277 |
+
td = self._task_def
|
| 278 |
+
|
| 279 |
+
if target in state.blocked_ips:
|
| 280 |
+
return -0.05, f"IP {target} already blocked."
|
| 281 |
+
state.blocked_ips.append(target)
|
| 282 |
+
|
| 283 |
+
if key in td["correct_sequence"]:
|
| 284 |
+
state.correct_detections += 1
|
| 285 |
+
multiplier = 1.0 if state.correct_detections > 1 else 0.6
|
| 286 |
+
return td["correct_sequence"][key] * multiplier, f"[BLOCKED] IP {target} blocked."
|
| 287 |
+
state.wrong_actions += 1
|
| 288 |
+
return td["wrong_penalty"], f"Blocking {target} was incorrect."
|
| 289 |
+
|
| 290 |
+
def _handle_disable_account(self, target: str, key: str):
|
| 291 |
+
state = self._state
|
| 292 |
+
td = self._task_def
|
| 293 |
+
|
| 294 |
+
if target in state.disabled_accounts:
|
| 295 |
+
return -0.05, f"Account {target} already disabled."
|
| 296 |
+
state.disabled_accounts.append(target)
|
| 297 |
+
|
| 298 |
+
if key in td["correct_sequence"]:
|
| 299 |
+
state.correct_detections += 1
|
| 300 |
+
multiplier = 1.0 if state.correct_detections > 1 else 0.6
|
| 301 |
+
return td["correct_sequence"][key] * multiplier, f"[DISABLED] Account {target} disabled."
|
| 302 |
+
state.wrong_actions += 1
|
| 303 |
+
return td["wrong_penalty"], f"Disabling {target} was incorrect."
|
| 304 |
+
|
| 305 |
+
def _handle_restart_service(self, target: str, key: str):
|
| 306 |
+
state = self._state
|
| 307 |
+
td = self._task_def
|
| 308 |
+
|
| 309 |
+
valid = [s["name"] for s in td["services"]]
|
| 310 |
+
if target not in valid:
|
| 311 |
+
state.wrong_actions += 1
|
| 312 |
+
return td["wrong_penalty"], f"Service {target!r} not found."
|
| 313 |
+
if target in state.restarted_services:
|
| 314 |
+
return -0.05, f"Service {target} already restarted."
|
| 315 |
+
|
| 316 |
+
state.restarted_services.append(target)
|
| 317 |
+
if key in td["correct_sequence"]:
|
| 318 |
+
state.correct_detections += 1
|
| 319 |
+
return td["correct_sequence"][key], f"[RESTARTED] Service {target} restored."
|
| 320 |
+
state.wrong_actions += 1
|
| 321 |
+
return td["wrong_penalty"], f"Restarting {target} was not necessary."
|
| 322 |
+
|
| 323 |
+
# ---- Termination ---------------------------------------------------
|
| 324 |
+
|
| 325 |
+
def _check_termination(self):
|
| 326 |
+
state = self._state
|
| 327 |
+
td = self._task_def
|
| 328 |
+
seq = td["correct_sequence"]
|
| 329 |
+
|
| 330 |
+
all_done = all(self._action_completed(k) for k in seq)
|
| 331 |
+
|
| 332 |
+
speed_bonus = 0.0
|
| 333 |
+
if all_done and state.step_count <= td["speed_bonus_threshold"]:
|
| 334 |
+
speed_bonus = td["speed_bonus"]
|
| 335 |
+
state.total_reward += speed_bonus
|
| 336 |
+
|
| 337 |
+
compromise = (
|
| 338 |
+
state.progress_level >= state.max_progress
|
| 339 |
+
or state.step_count >= td["compromise_steps"]
|
| 340 |
+
) and not all_done
|
| 341 |
+
|
| 342 |
+
timeout = state.step_count >= state.max_steps
|
| 343 |
+
done = all_done or compromise or timeout
|
| 344 |
+
|
| 345 |
+
if done:
|
| 346 |
+
state.success = all_done and not compromise
|
| 347 |
+
state.compromise = compromise
|
| 348 |
+
|
| 349 |
+
max_possible = sum(seq.values()) + td["speed_bonus"]
|
| 350 |
+
final_score = round(min(max(state.total_reward, 0.0) / max_possible, 1.0), 4) if max_possible > 0 else 0.0
|
| 351 |
+
|
| 352 |
+
info = {
|
| 353 |
+
"final_score": final_score,
|
| 354 |
+
"success": state.success,
|
| 355 |
+
"compromise": state.compromise,
|
| 356 |
+
"wrong_actions": state.wrong_actions,
|
| 357 |
+
"speed_bonus": speed_bonus,
|
| 358 |
+
} if done else {}
|
| 359 |
+
|
| 360 |
+
return done, info
|
| 361 |
+
|
| 362 |
+
def _action_completed(self, key: str) -> bool:
|
| 363 |
+
action_type, target = key.split(":", 1)
|
| 364 |
+
s = self._state
|
| 365 |
+
return {
|
| 366 |
+
"analyze_log": target in s.revealed_logs,
|
| 367 |
+
"trace_user": target in s.revealed_users,
|
| 368 |
+
"block_ip": target in s.blocked_ips,
|
| 369 |
+
"disable_account": target in s.disabled_accounts,
|
| 370 |
+
"restart_service": target in s.restarted_services,
|
| 371 |
+
}.get(action_type, False)
|
| 372 |
+
|
| 373 |
+
# ---- Observation builder -------------------------------------------
|
| 374 |
+
|
| 375 |
+
def _build_observation(
|
| 376 |
+
self,
|
| 377 |
+
result_msg: str,
|
| 378 |
+
reward: float = 0.0,
|
| 379 |
+
done: bool = False,
|
| 380 |
+
info: dict = None,
|
| 381 |
+
) -> IncidentObservation:
|
| 382 |
+
td = self._task_def
|
| 383 |
+
return IncidentObservation(
|
| 384 |
+
logs=td["logs"],
|
| 385 |
+
alerts=td["alerts"],
|
| 386 |
+
services=td["services"],
|
| 387 |
+
active_users=td["active_users"],
|
| 388 |
+
step_count=self._state.step_count,
|
| 389 |
+
reward=reward,
|
| 390 |
+
done=done,
|
| 391 |
+
info=info or {},
|
| 392 |
+
last_action_result=result_msg,
|
| 393 |
+
)
|
graders.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
graders.py β Agent graders for the xsecure Incident Response environment.
|
| 3 |
+
Each grader runs a full episode and returns a normalised score 0.0β1.0.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
from typing import Callable, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
from client import IncidentResponseEnv, StepResult
|
| 12 |
+
from models import IncidentAction, IncidentObservation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Grader result
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
class GradeResult:
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
task_id: int,
|
| 23 |
+
score: float,
|
| 24 |
+
success: bool,
|
| 25 |
+
compromise: bool,
|
| 26 |
+
steps_taken: int,
|
| 27 |
+
wrong_actions: int,
|
| 28 |
+
notes: str = "",
|
| 29 |
+
):
|
| 30 |
+
self.task_id = task_id
|
| 31 |
+
self.score = score
|
| 32 |
+
self.success = success
|
| 33 |
+
self.compromise = compromise
|
| 34 |
+
self.steps_taken = steps_taken
|
| 35 |
+
self.wrong_actions = wrong_actions
|
| 36 |
+
self.notes = notes
|
| 37 |
+
|
| 38 |
+
def __repr__(self):
|
| 39 |
+
status = "β
SUCCESS" if self.success else ("β COMPROMISED" if self.compromise else "β± TIMEOUT")
|
| 40 |
+
return (
|
| 41 |
+
f"GradeResult(task={self.task_id}, score={self.score:.3f}, "
|
| 42 |
+
f"status={status}, steps={self.steps_taken}, wrong={self.wrong_actions})"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
# Episode runner
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
async def _run_episode(
|
| 51 |
+
base_url: str,
|
| 52 |
+
task_id: int,
|
| 53 |
+
agent_fn: Callable[[IncidentObservation, List[StepResult]], IncidentAction],
|
| 54 |
+
max_steps: int = 20,
|
| 55 |
+
seed: Optional[int] = None,
|
| 56 |
+
) -> GradeResult:
|
| 57 |
+
"""Run one full episode driven by agent_fn."""
|
| 58 |
+
history: List[StepResult] = []
|
| 59 |
+
last_result: Optional[StepResult] = None
|
| 60 |
+
|
| 61 |
+
async with IncidentResponseEnv(base_url=base_url) as env:
|
| 62 |
+
obs = await env.reset(task_id=task_id)
|
| 63 |
+
|
| 64 |
+
for _ in range(max_steps):
|
| 65 |
+
action = agent_fn(obs, history)
|
| 66 |
+
result = await env.step(action)
|
| 67 |
+
history.append(result)
|
| 68 |
+
last_result = result
|
| 69 |
+
obs = result.observation
|
| 70 |
+
|
| 71 |
+
if result.done:
|
| 72 |
+
break
|
| 73 |
+
|
| 74 |
+
info = last_result.info if last_result else {}
|
| 75 |
+
return GradeResult(
|
| 76 |
+
task_id=task_id,
|
| 77 |
+
score=float(info.get("final_score", 0.0)),
|
| 78 |
+
success=bool(info.get("success", False)),
|
| 79 |
+
compromise=bool(info.get("compromise", False)),
|
| 80 |
+
steps_taken=obs.step_count,
|
| 81 |
+
wrong_actions=int(info.get("wrong_actions", 0)),
|
| 82 |
+
notes=obs.last_action_result,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
# Public grade function
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
async def grade(
|
| 91 |
+
agent_fn: Callable[[IncidentObservation, List[StepResult]], IncidentAction],
|
| 92 |
+
task_id: int,
|
| 93 |
+
base_url: str = "http://localhost:7860",
|
| 94 |
+
num_episodes: int = 3,
|
| 95 |
+
) -> Dict:
|
| 96 |
+
"""
|
| 97 |
+
Run num_episodes of task_id and return aggregate stats.
|
| 98 |
+
|
| 99 |
+
Returns
|
| 100 |
+
-------
|
| 101 |
+
dict with keys: task_id, mean_score, min_score, max_score, success_rate, episodes
|
| 102 |
+
"""
|
| 103 |
+
results = []
|
| 104 |
+
for ep in range(num_episodes):
|
| 105 |
+
seed = ep * 1000 + task_id if num_episodes > 1 else None
|
| 106 |
+
r = await _run_episode(base_url, task_id, agent_fn, seed=seed)
|
| 107 |
+
results.append(r)
|
| 108 |
+
|
| 109 |
+
scores = [r.score for r in results]
|
| 110 |
+
return {
|
| 111 |
+
"task_id": task_id,
|
| 112 |
+
"mean_score": round(sum(scores) / len(scores), 4),
|
| 113 |
+
"min_score": round(min(scores), 4),
|
| 114 |
+
"max_score": round(max(scores), 4),
|
| 115 |
+
"success_rate": round(sum(1 for r in results if r.success) / len(results), 4),
|
| 116 |
+
"episodes": [repr(r) for r in results],
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
# Oracle agents (deterministic, for reproducible baseline scores)
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
|
| 124 |
+
def _oracle_task1(obs: IncidentObservation, history: List[StepResult]) -> IncidentAction:
|
| 125 |
+
sequence = [
|
| 126 |
+
IncidentAction(action_type="analyze_log", target="L001"),
|
| 127 |
+
IncidentAction(action_type="analyze_log", target="L002"),
|
| 128 |
+
IncidentAction(action_type="block_ip", target="192.168.1.47"),
|
| 129 |
+
]
|
| 130 |
+
return sequence[obs.step_count] if obs.step_count < len(sequence) else IncidentAction(action_type="ignore")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _oracle_task2(obs: IncidentObservation, history: List[StepResult]) -> IncidentAction:
|
| 134 |
+
sequence = [
|
| 135 |
+
IncidentAction(action_type="analyze_log", target="L001"),
|
| 136 |
+
IncidentAction(action_type="analyze_log", target="L002"),
|
| 137 |
+
IncidentAction(action_type="trace_user", target="carol"),
|
| 138 |
+
IncidentAction(action_type="disable_account", target="carol"),
|
| 139 |
+
]
|
| 140 |
+
return sequence[obs.step_count] if obs.step_count < len(sequence) else IncidentAction(action_type="ignore")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _oracle_task3(obs: IncidentObservation, history: List[StepResult]) -> IncidentAction:
|
| 144 |
+
sequence = [
|
| 145 |
+
IncidentAction(action_type="analyze_log", target="L001"),
|
| 146 |
+
IncidentAction(action_type="analyze_log", target="L002"),
|
| 147 |
+
IncidentAction(action_type="analyze_log", target="L004"),
|
| 148 |
+
IncidentAction(action_type="trace_user", target="eve"),
|
| 149 |
+
IncidentAction(action_type="block_ip", target="198.51.100.23"),
|
| 150 |
+
IncidentAction(action_type="disable_account", target="eve"),
|
| 151 |
+
IncidentAction(action_type="restart_service", target="database"),
|
| 152 |
+
IncidentAction(action_type="restart_service", target="prod-01"),
|
| 153 |
+
]
|
| 154 |
+
return sequence[obs.step_count] if obs.step_count < len(sequence) else IncidentAction(action_type="ignore")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
ORACLE_AGENTS = {1: _oracle_task1, 2: _oracle_task2, 3: _oracle_task3}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
# CLI β grade all oracle agents
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
async def _grade_all(base_url: str):
|
| 165 |
+
print("=" * 60)
|
| 166 |
+
print("xsecure β Grading Oracle Agents")
|
| 167 |
+
print("=" * 60)
|
| 168 |
+
for task_id in [1, 2, 3]:
|
| 169 |
+
result = await grade(ORACLE_AGENTS[task_id], task_id, base_url, num_episodes=3)
|
| 170 |
+
print(f"\nTask {task_id}: mean_score={result['mean_score']:.4f} "
|
| 171 |
+
f"success_rate={result['success_rate']:.2%}")
|
| 172 |
+
for ep in result["episodes"]:
|
| 173 |
+
print(f" {ep}")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
import argparse
|
| 178 |
+
parser = argparse.ArgumentParser()
|
| 179 |
+
parser.add_argument("--url", default="http://localhost:7860")
|
| 180 |
+
args = parser.parse_args()
|
| 181 |
+
asyncio.run(_grade_all(args.url))
|
inference.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
inference.py β OpenEnv-compliant inference script for xsecure.
|
| 3 |
+
|
| 4 |
+
Required env vars (set in .env for local dev, HF Secrets for production):
|
| 5 |
+
HF_TOKEN Hugging Face / API key
|
| 6 |
+
API_BASE_URL LLM endpoint
|
| 7 |
+
MODEL_NAME Model identifier
|
| 8 |
+
|
| 9 |
+
STDOUT FORMAT (machine-parsed by evaluator):
|
| 10 |
+
[START] task=<name> env=xsecure model=<model>
|
| 11 |
+
[STEP] step=<n> action=<str> reward=<0.00> done=<true|false> error=<msg|null>
|
| 12 |
+
[END] success=<true|false> steps=<n> score=<0.000> rewards=<r1,r2,...>
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import sys
|
| 22 |
+
from typing import Dict, List, Optional
|
| 23 |
+
|
| 24 |
+
from dotenv import load_dotenv
|
| 25 |
+
from openai import OpenAI
|
| 26 |
+
|
| 27 |
+
from client import IncidentResponseEnv, StepResult
|
| 28 |
+
from models import IncidentAction, IncidentObservation
|
| 29 |
+
|
| 30 |
+
# Load .env for local dev (no-op when running on HF Spaces with Secrets)
|
| 31 |
+
load_dotenv()
|
| 32 |
+
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
# Configuration β all from environment variables
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
|
| 37 |
+
API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "")
|
| 38 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
|
| 39 |
+
MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
|
| 40 |
+
ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")
|
| 41 |
+
BENCHMARK = "xsecure"
|
| 42 |
+
MAX_STEPS = 20
|
| 43 |
+
|
| 44 |
+
if not API_KEY:
|
| 45 |
+
print("ERROR: HF_TOKEN is not set.", file=sys.stderr)
|
| 46 |
+
sys.exit(1)
|
| 47 |
+
|
| 48 |
+
llm = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Mandatory stdout loggers
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
def log_start(task: str, env: str, model: str) -> None:
|
| 55 |
+
print(f"[START] task={task} env={env} model={model}", flush=True)
|
| 56 |
+
|
| 57 |
+
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 58 |
+
print(
|
| 59 |
+
f"[STEP] step={step} action={action} reward={reward:.2f} "
|
| 60 |
+
f"done={str(done).lower()} error={error or 'null'}",
|
| 61 |
+
flush=True,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 65 |
+
print(
|
| 66 |
+
f"[END] success={str(success).lower()} steps={steps} score={score:.3f} "
|
| 67 |
+
f"rewards={','.join(f'{r:.2f}' for r in rewards)}",
|
| 68 |
+
flush=True,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# System prompt
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
SYSTEM_PROMPT = """\
|
| 76 |
+
You are an expert cybersecurity incident responder AI agent.
|
| 77 |
+
You will be given the current state of a simulated company under cyber attack.
|
| 78 |
+
Your goal is to investigate logs and alerts, identify the threat, and mitigate it.
|
| 79 |
+
|
| 80 |
+
## Available Actions (one per step):
|
| 81 |
+
- analyze_log(log_id) β Examine a specific log entry
|
| 82 |
+
- trace_user(user_id) β Investigate a user's activity history
|
| 83 |
+
- block_ip(ip_address) β Block a suspicious IP address
|
| 84 |
+
- disable_account(user_id) β Disable a compromised user account
|
| 85 |
+
- restart_service(service) β Restart a compromised service
|
| 86 |
+
- ignore β Take no action (penalised!)
|
| 87 |
+
|
| 88 |
+
## Response Format (STRICT β machine-parsed):
|
| 89 |
+
{"action_type": "analyze_log", "target": "L001"}
|
| 90 |
+
|
| 91 |
+
## Strategy:
|
| 92 |
+
1. Investigate first (analyze_log, trace_user) to gather evidence.
|
| 93 |
+
2. Act decisively once you have evidence (block_ip, disable_account, restart_service).
|
| 94 |
+
3. Never block/disable without evidence β wrong actions cost points.
|
| 95 |
+
4. Speed matters β faster resolution earns a bonus.
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
# Helpers
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
|
| 102 |
+
def _format_observation(obs: IncidentObservation) -> str:
|
| 103 |
+
logs_txt = "\n".join(f" [{l.log_id}] {l.timestamp} β {l.message}" for l in obs.logs)
|
| 104 |
+
alerts_txt = "\n".join(f" [{a.severity.upper()}] {a.message}" for a in obs.alerts)
|
| 105 |
+
services_txt = "\n".join(f" {s.name}: {s.status}" for s in obs.services)
|
| 106 |
+
return (
|
| 107 |
+
f"=== Incident Dashboard (Step {obs.step_count}) ===\n\n"
|
| 108 |
+
f"LOGS:\n{logs_txt}\n\n"
|
| 109 |
+
f"ALERTS:\n{alerts_txt}\n\n"
|
| 110 |
+
f"SERVICES:\n{services_txt}\n\n"
|
| 111 |
+
f"ACTIVE USERS: {', '.join(obs.active_users)}\n\n"
|
| 112 |
+
f"Last action result: {obs.last_action_result}"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _parse_action(text: str) -> IncidentAction:
|
| 117 |
+
for pattern in [
|
| 118 |
+
lambda t: json.loads(t.strip()),
|
| 119 |
+
lambda t: json.loads(re.search(r"```(?:json)?\s*(\{.*?\})\s*```", t, re.DOTALL).group(1)),
|
| 120 |
+
lambda t: json.loads(re.search(r"\{[^{}]+\}", t).group(0)),
|
| 121 |
+
]:
|
| 122 |
+
try:
|
| 123 |
+
return IncidentAction(**pattern(text))
|
| 124 |
+
except Exception:
|
| 125 |
+
pass
|
| 126 |
+
return IncidentAction(action_type="ignore", target="")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _get_action(conversation: List[Dict], obs: IncidentObservation) -> IncidentAction:
|
| 130 |
+
conversation.append({"role": "user", "content": _format_observation(obs)})
|
| 131 |
+
response = llm.chat.completions.create(
|
| 132 |
+
model=MODEL_NAME,
|
| 133 |
+
messages=[{"role": "system", "content": SYSTEM_PROMPT}] + conversation,
|
| 134 |
+
max_tokens=256,
|
| 135 |
+
temperature=0.0,
|
| 136 |
+
)
|
| 137 |
+
text = response.choices[0].message.content or ""
|
| 138 |
+
conversation.append({"role": "assistant", "content": text})
|
| 139 |
+
return _parse_action(text)
|
| 140 |
+
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
# Episode runner
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
|
| 145 |
+
TASK_NAMES = {
|
| 146 |
+
1: "brute-force-easy",
|
| 147 |
+
2: "suspicious-login-medium",
|
| 148 |
+
3: "multi-stage-apt-hard",
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
async def run_episode(task_id: int) -> None:
|
| 152 |
+
task_name = TASK_NAMES[task_id]
|
| 153 |
+
rewards: List[float] = []
|
| 154 |
+
steps_taken = 0
|
| 155 |
+
success = False
|
| 156 |
+
score = 0.0
|
| 157 |
+
conversation: List[Dict] = []
|
| 158 |
+
|
| 159 |
+
log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
async with IncidentResponseEnv(base_url=ENV_URL) as env:
|
| 163 |
+
obs = await env.reset(task_id=task_id)
|
| 164 |
+
|
| 165 |
+
for step in range(1, MAX_STEPS + 1):
|
| 166 |
+
action = _get_action(conversation, obs)
|
| 167 |
+
result = await env.step(action)
|
| 168 |
+
|
| 169 |
+
rewards.append(result.reward)
|
| 170 |
+
steps_taken = step
|
| 171 |
+
obs = result.observation
|
| 172 |
+
|
| 173 |
+
log_step(
|
| 174 |
+
step=step,
|
| 175 |
+
action=f"{action.action_type}({action.target!r})",
|
| 176 |
+
reward=result.reward,
|
| 177 |
+
done=result.done,
|
| 178 |
+
error=None,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
if result.done:
|
| 182 |
+
info = result.info
|
| 183 |
+
score = min(max(float(info.get("final_score", 0.0)), 0.0), 1.0)
|
| 184 |
+
success = bool(info.get("success", False))
|
| 185 |
+
break
|
| 186 |
+
|
| 187 |
+
finally:
|
| 188 |
+
log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Main
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
async def main():
|
| 195 |
+
task_ids = [int(t) for t in os.getenv("TASK_IDS", "1,2,3").split(",")]
|
| 196 |
+
for task_id in task_ids:
|
| 197 |
+
await run_episode(task_id)
|
| 198 |
+
|
| 199 |
+
if __name__ == "__main__":
|
| 200 |
+
asyncio.run(main())
|
models.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
models.py β xsecure typed models.
|
| 3 |
+
All classes extend openenv-core Pydantic base classes correctly.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
from pydantic import Field
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from openenv_core.env_server import Action, Observation, State
|
| 15 |
+
except ImportError:
|
| 16 |
+
from core.env_server import Action, Observation, State
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Action space
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
class ActionType(str, Enum):
|
| 24 |
+
ANALYZE_LOG = "analyze_log"
|
| 25 |
+
TRACE_USER = "trace_user"
|
| 26 |
+
BLOCK_IP = "block_ip"
|
| 27 |
+
DISABLE_ACCOUNT = "disable_account"
|
| 28 |
+
RESTART_SERVICE = "restart_service"
|
| 29 |
+
IGNORE = "ignore"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class IncidentAction(Action):
|
| 33 |
+
"""
|
| 34 |
+
One action the agent can take per step.
|
| 35 |
+
Inherits from openenv-core Action (Pydantic BaseModel, extra='forbid').
|
| 36 |
+
Note: 'metadata' field is inherited from Action base class.
|
| 37 |
+
"""
|
| 38 |
+
action_type: str = Field(default="ignore", description="Action type to perform")
|
| 39 |
+
target: str = Field(default="", description="Target of the action")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Observation sub-models β use State base (extra='allow') for flexibility
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
class LogEntry(State):
|
| 47 |
+
log_id: str = ""
|
| 48 |
+
message: str = ""
|
| 49 |
+
timestamp: str = ""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class AlertEntry(State):
|
| 53 |
+
alert_id: str = ""
|
| 54 |
+
message: str = ""
|
| 55 |
+
severity: str = "low"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ServiceStatus(State):
|
| 59 |
+
name: str = ""
|
| 60 |
+
status: str = "running"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
# Observation
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
class IncidentObservation(Observation):
|
| 68 |
+
"""
|
| 69 |
+
Everything the agent sees at each step.
|
| 70 |
+
Inherits from openenv-core Observation (Pydantic BaseModel, extra='forbid').
|
| 71 |
+
Note: 'done', 'reward', 'metadata' are inherited from Observation.
|
| 72 |
+
"""
|
| 73 |
+
logs: List[Dict[str, Any]] = Field(default_factory=list)
|
| 74 |
+
alerts: List[Dict[str, Any]] = Field(default_factory=list)
|
| 75 |
+
services: List[Dict[str, Any]] = Field(default_factory=list)
|
| 76 |
+
active_users: List[str] = Field(default_factory=list)
|
| 77 |
+
step_count: int = 0
|
| 78 |
+
info: Dict[str, Any] = Field(default_factory=dict)
|
| 79 |
+
last_action_result: str = ""
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ---------------------------------------------------------------------------
|
| 83 |
+
# State β internal server state, never sent to agent directly
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
|
| 86 |
+
class IncidentState(State):
|
| 87 |
+
"""
|
| 88 |
+
Full internal episode state.
|
| 89 |
+
Inherits from openenv-core State (Pydantic BaseModel, extra='allow').
|
| 90 |
+
All fields have defaults so IncidentState() works with no arguments.
|
| 91 |
+
"""
|
| 92 |
+
task_id: int = 1
|
| 93 |
+
max_steps: int = 15
|
| 94 |
+
attack_type: str = "brute_force"
|
| 95 |
+
attacker_ip: str = ""
|
| 96 |
+
target_user: str = ""
|
| 97 |
+
target_service: str = "auth-service"
|
| 98 |
+
progress_level: int = 0
|
| 99 |
+
max_progress: int = 4
|
| 100 |
+
revealed_logs: List[str] = Field(default_factory=list)
|
| 101 |
+
revealed_users: List[str] = Field(default_factory=list)
|
| 102 |
+
blocked_ips: List[str] = Field(default_factory=list)
|
| 103 |
+
disabled_accounts: List[str] = Field(default_factory=list)
|
| 104 |
+
restarted_services: List[str] = Field(default_factory=list)
|
| 105 |
+
correct_detections: int = 0
|
| 106 |
+
wrong_actions: int = 0
|
| 107 |
+
total_reward: float = 0.0
|
| 108 |
+
success: bool = False
|
| 109 |
+
compromise: bool = False
|
| 110 |
+
stage: int = 1
|
| 111 |
+
phishing_done: bool = False
|
| 112 |
+
escalation_done: bool = False
|
openenv.yaml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: xsecure
|
| 2 |
+
version: "1.0.0"
|
| 3 |
+
description: >
|
| 4 |
+
Autonomous Incident Response & Threat Mitigation RL environment.
|
| 5 |
+
Agents investigate evolving cyber threats and take sequential actions
|
| 6 |
+
to detect and mitigate attacks before system compromise.
|
| 7 |
+
Supports 3 tasks of increasing difficulty.
|
| 8 |
+
author: your-hf-username
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
+
- cybersecurity
|
| 12 |
+
- incident-response
|
| 13 |
+
- multi-step
|
| 14 |
+
- agentic
|
| 15 |
+
|
| 16 |
+
server:
|
| 17 |
+
host: 0.0.0.0
|
| 18 |
+
port: 7860
|
| 19 |
+
workers: 4
|
| 20 |
+
|
| 21 |
+
env:
|
| 22 |
+
TASK_ID: "1"
|
| 23 |
+
MAX_CONCURRENT_ENVS: "100"
|
| 24 |
+
|
| 25 |
+
actions:
|
| 26 |
+
- name: analyze_log
|
| 27 |
+
description: "Analyze a specific log entry by ID (e.g. L001)"
|
| 28 |
+
params:
|
| 29 |
+
target: string
|
| 30 |
+
- name: trace_user
|
| 31 |
+
description: "Investigate a user's activity"
|
| 32 |
+
params:
|
| 33 |
+
target: string
|
| 34 |
+
- name: block_ip
|
| 35 |
+
description: "Block a suspicious IP address"
|
| 36 |
+
params:
|
| 37 |
+
target: string
|
| 38 |
+
- name: disable_account
|
| 39 |
+
description: "Disable a compromised user account"
|
| 40 |
+
params:
|
| 41 |
+
target: string
|
| 42 |
+
- name: restart_service
|
| 43 |
+
description: "Restart a degraded or compromised service"
|
| 44 |
+
params:
|
| 45 |
+
target: string
|
| 46 |
+
- name: ignore
|
| 47 |
+
description: "Take no action (penalised β attack progresses)"
|
| 48 |
+
params:
|
| 49 |
+
target: ""
|
| 50 |
+
|
| 51 |
+
tasks:
|
| 52 |
+
- id: 1
|
| 53 |
+
name: "Brute Force Detection"
|
| 54 |
+
difficulty: easy
|
| 55 |
+
max_steps: 12
|
| 56 |
+
score_range: [0.0, 1.0]
|
| 57 |
+
|
| 58 |
+
- id: 2
|
| 59 |
+
name: "Suspicious Login β Account Takeover"
|
| 60 |
+
difficulty: medium
|
| 61 |
+
max_steps: 14
|
| 62 |
+
score_range: [0.0, 1.0]
|
| 63 |
+
|
| 64 |
+
- id: 3
|
| 65 |
+
name: "Multi-Stage APT Kill Chain"
|
| 66 |
+
difficulty: hard
|
| 67 |
+
max_steps: 18
|
| 68 |
+
score_range: [0.0, 1.0]
|
pyproject.toml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "xsecure"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "Autonomous Incident Response & Threat Mitigation β OpenEnv RL environment."
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.10"
|
| 11 |
+
license = { text = "Apache-2.0" }
|
| 12 |
+
keywords = ["reinforcement-learning", "cybersecurity", "openenv", "incident-response"]
|
| 13 |
+
|
| 14 |
+
dependencies = [
|
| 15 |
+
"fastapi>=0.111.0",
|
| 16 |
+
"uvicorn[standard]>=0.30.0",
|
| 17 |
+
"websockets>=12.0",
|
| 18 |
+
"pydantic>=2.0.0",
|
| 19 |
+
"httpx>=0.27.0",
|
| 20 |
+
"openai>=1.30.0",
|
| 21 |
+
"python-dotenv>=1.0.0",
|
| 22 |
+
"openenv-core>=0.2.0",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[project.scripts]
|
| 26 |
+
server = "server.app:main"
|
| 27 |
+
|
| 28 |
+
[tool.hatch.build.targets.wheel]
|
| 29 |
+
packages = ["."]
|
| 30 |
+
|
| 31 |
+
[tool.uv]
|
| 32 |
+
dev-dependencies = [
|
| 33 |
+
"pytest>=8.0",
|
| 34 |
+
"pytest-asyncio>=0.23",
|
| 35 |
+
"httpx>=0.27",
|
| 36 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111.0
|
| 2 |
+
uvicorn[standard]>=0.30.0
|
| 3 |
+
websockets>=12.0
|
| 4 |
+
pydantic>=2.0.0
|
| 5 |
+
httpx>=0.27.0
|
| 6 |
+
openai>=1.30.0
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
openenv-core>=0.2.0
|
server/app.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
try:
|
| 5 |
+
from openenv_core.env_server import create_fastapi_app
|
| 6 |
+
except ImportError:
|
| 7 |
+
from core.env_server import create_fastapi_app
|
| 8 |
+
|
| 9 |
+
from .environment import IncidentEnvironment
|
| 10 |
+
from models import IncidentAction, IncidentObservation
|
| 11 |
+
|
| 12 |
+
# Pass the CLASS, not IncidentEnvironment()
|
| 13 |
+
app = create_fastapi_app(IncidentEnvironment, IncidentAction, IncidentObservation)
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
import uvicorn
|
| 17 |
+
uvicorn.run(
|
| 18 |
+
"server.app:app",
|
| 19 |
+
host=os.getenv("HOST", "0.0.0.0"),
|
| 20 |
+
port=int(os.getenv("PORT", "7860")),
|
| 21 |
+
workers=int(os.getenv("WORKERS", "4")),
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
main()
|
server/environment.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
server/environment.py β xsecure incident response simulation.
|
| 3 |
+
Extends openenv-core Environment base class correctly.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from openenv_core.env_server import Environment
|
| 13 |
+
except ImportError:
|
| 14 |
+
from core.env_server import Environment
|
| 15 |
+
|
| 16 |
+
from models import (
|
| 17 |
+
ActionType,
|
| 18 |
+
IncidentAction,
|
| 19 |
+
IncidentObservation,
|
| 20 |
+
IncidentState,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Task definitions
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
TASKS: Dict[int, dict] = {
|
| 28 |
+
1: {
|
| 29 |
+
"attack_type": "brute_force",
|
| 30 |
+
"attacker_ip": "192.168.1.47",
|
| 31 |
+
"target_user": "admin",
|
| 32 |
+
"target_service": "auth-service",
|
| 33 |
+
"max_steps": 12,
|
| 34 |
+
"logs": [
|
| 35 |
+
{"log_id": "L001", "message": "Multiple failed login attempts from 192.168.1.47 (47 attempts in 60s)", "timestamp": "2024-01-15T08:01:00Z"},
|
| 36 |
+
{"log_id": "L002", "message": "Account lockout triggered for user admin after failed logins from 192.168.1.47", "timestamp": "2024-01-15T08:01:45Z"},
|
| 37 |
+
{"log_id": "L003", "message": "Successful login from 192.168.1.47 after lockout bypass", "timestamp": "2024-01-15T08:03:10Z"},
|
| 38 |
+
],
|
| 39 |
+
"alerts": [
|
| 40 |
+
{"alert_id": "A001", "message": "Brute force pattern detected β 47 failed logins in 60 seconds", "severity": "high"},
|
| 41 |
+
{"alert_id": "A002", "message": "Account lockout bypass attempt detected", "severity": "high"},
|
| 42 |
+
],
|
| 43 |
+
"services": [
|
| 44 |
+
{"name": "auth-service", "status": "degraded"},
|
| 45 |
+
{"name": "web-app", "status": "running"},
|
| 46 |
+
{"name": "database", "status": "running"},
|
| 47 |
+
],
|
| 48 |
+
"active_users": ["admin", "alice", "bob"],
|
| 49 |
+
"correct_sequence": {
|
| 50 |
+
"analyze_log:L001": 0.15,
|
| 51 |
+
"analyze_log:L002": 0.15,
|
| 52 |
+
"block_ip:192.168.1.47": 0.50,
|
| 53 |
+
},
|
| 54 |
+
"wrong_penalty": -0.15,
|
| 55 |
+
"delay_penalty": 0.05,
|
| 56 |
+
"speed_bonus_threshold": 6,
|
| 57 |
+
"speed_bonus": 0.20,
|
| 58 |
+
"compromise_steps": 8,
|
| 59 |
+
},
|
| 60 |
+
2: {
|
| 61 |
+
"attack_type": "suspicious_login",
|
| 62 |
+
"attacker_ip": "203.0.113.55",
|
| 63 |
+
"target_user": "carol",
|
| 64 |
+
"target_service": "hr-portal",
|
| 65 |
+
"max_steps": 14,
|
| 66 |
+
"logs": [
|
| 67 |
+
{"log_id": "L001", "message": "Login for carol from unusual geo-location (203.0.113.55 β Eastern Europe)", "timestamp": "2024-01-15T14:22:00Z"},
|
| 68 |
+
{"log_id": "L002", "message": "carol accessed sensitive HR records 3 minutes after login", "timestamp": "2024-01-15T14:25:10Z"},
|
| 69 |
+
{"log_id": "L003", "message": "carol attempted to export 1,200 employee records", "timestamp": "2024-01-15T14:27:33Z"},
|
| 70 |
+
],
|
| 71 |
+
"alerts": [
|
| 72 |
+
{"alert_id": "A001", "message": "Login from unusual location for carol", "severity": "medium"},
|
| 73 |
+
{"alert_id": "A002", "message": "Unusual data access pattern β bulk HR record access", "severity": "high"},
|
| 74 |
+
],
|
| 75 |
+
"services": [
|
| 76 |
+
{"name": "hr-portal", "status": "running"},
|
| 77 |
+
{"name": "auth-service", "status": "running"},
|
| 78 |
+
{"name": "database", "status": "running"},
|
| 79 |
+
],
|
| 80 |
+
"active_users": ["carol", "dave", "alice"],
|
| 81 |
+
"correct_sequence": {
|
| 82 |
+
"analyze_log:L001": 0.10,
|
| 83 |
+
"analyze_log:L002": 0.10,
|
| 84 |
+
"trace_user:carol": 0.20,
|
| 85 |
+
"disable_account:carol": 0.45,
|
| 86 |
+
},
|
| 87 |
+
"wrong_penalty": -0.15,
|
| 88 |
+
"delay_penalty": 0.05,
|
| 89 |
+
"speed_bonus_threshold": 7,
|
| 90 |
+
"speed_bonus": 0.15,
|
| 91 |
+
"compromise_steps": 10,
|
| 92 |
+
},
|
| 93 |
+
3: {
|
| 94 |
+
"attack_type": "multi_stage",
|
| 95 |
+
"attacker_ip": "198.51.100.23",
|
| 96 |
+
"target_user": "eve",
|
| 97 |
+
"target_service": "database",
|
| 98 |
+
"max_steps": 18,
|
| 99 |
+
"logs": [
|
| 100 |
+
{"log_id": "L001", "message": "Phishing email link clicked by eve β redirect to 198.51.100.23", "timestamp": "2024-01-15T09:00:00Z"},
|
| 101 |
+
{"log_id": "L002", "message": "Credential theft tool executed on eve's workstation", "timestamp": "2024-01-15T09:15:22Z"},
|
| 102 |
+
{"log_id": "L003", "message": "eve authenticated to database server outside normal hours", "timestamp": "2024-01-15T09:22:45Z"},
|
| 103 |
+
{"log_id": "L004", "message": "Lateral movement: eve's credentials used on prod-01 and database", "timestamp": "2024-01-15T09:30:11Z"},
|
| 104 |
+
{"log_id": "L005", "message": "Ransomware staging detected on database β encryption not yet started", "timestamp": "2024-01-15T09:45:00Z"},
|
| 105 |
+
],
|
| 106 |
+
"alerts": [
|
| 107 |
+
{"alert_id": "A001", "message": "Phishing link accessed β possible credential compromise for eve", "severity": "medium"},
|
| 108 |
+
{"alert_id": "A002", "message": "Credential harvesting tool detected", "severity": "high"},
|
| 109 |
+
{"alert_id": "A003", "message": "Lateral movement across prod-01 and database", "severity": "high"},
|
| 110 |
+
],
|
| 111 |
+
"services": [
|
| 112 |
+
{"name": "database", "status": "degraded"},
|
| 113 |
+
{"name": "prod-01", "status": "degraded"},
|
| 114 |
+
{"name": "web-app", "status": "running"},
|
| 115 |
+
],
|
| 116 |
+
"active_users": ["eve", "frank", "grace"],
|
| 117 |
+
"correct_sequence": {
|
| 118 |
+
"analyze_log:L001": 0.08,
|
| 119 |
+
"analyze_log:L002": 0.08,
|
| 120 |
+
"analyze_log:L004": 0.08,
|
| 121 |
+
"trace_user:eve": 0.12,
|
| 122 |
+
"block_ip:198.51.100.23": 0.20,
|
| 123 |
+
"disable_account:eve": 0.20,
|
| 124 |
+
"restart_service:database": 0.12,
|
| 125 |
+
"restart_service:prod-01": 0.12,
|
| 126 |
+
},
|
| 127 |
+
"wrong_penalty": -0.10,
|
| 128 |
+
"delay_penalty": 0.04,
|
| 129 |
+
"speed_bonus_threshold": 10,
|
| 130 |
+
"speed_bonus": 0.10,
|
| 131 |
+
"compromise_steps": 12,
|
| 132 |
+
},
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
# Environment
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
|
| 140 |
+
class IncidentEnvironment(Environment):
|
| 141 |
+
|
| 142 |
+
SUPPORTS_CONCURRENT_SESSIONS = True
|
| 143 |
+
|
| 144 |
+
def __init__(self):
|
| 145 |
+
super().__init__()
|
| 146 |
+
self._state: Optional[IncidentState] = None
|
| 147 |
+
self._task_def: Optional[dict] = None
|
| 148 |
+
|
| 149 |
+
# ---- openenv-core interface -----------------------------------------
|
| 150 |
+
|
| 151 |
+
def reset(
|
| 152 |
+
self,
|
| 153 |
+
seed: Optional[int] = None,
|
| 154 |
+
episode_id: Optional[str] = None,
|
| 155 |
+
**kwargs: Any,
|
| 156 |
+
) -> IncidentObservation:
|
| 157 |
+
task_id = int(kwargs.get("task_id", 1))
|
| 158 |
+
if task_id not in TASKS:
|
| 159 |
+
task_id = 1
|
| 160 |
+
|
| 161 |
+
td = TASKS[task_id]
|
| 162 |
+
self._task_def = td
|
| 163 |
+
self._state = IncidentState(
|
| 164 |
+
episode_id=episode_id or str(uuid.uuid4()),
|
| 165 |
+
task_id=task_id,
|
| 166 |
+
step_count=0,
|
| 167 |
+
max_steps=td["max_steps"],
|
| 168 |
+
attack_type=td["attack_type"],
|
| 169 |
+
attacker_ip=td["attacker_ip"],
|
| 170 |
+
target_user=td["target_user"],
|
| 171 |
+
target_service=td["target_service"],
|
| 172 |
+
)
|
| 173 |
+
return self._build_observation("Episode started. Investigate the alerts and logs.")
|
| 174 |
+
|
| 175 |
+
def step(
|
| 176 |
+
self,
|
| 177 |
+
action: IncidentAction,
|
| 178 |
+
timeout_s: Optional[float] = None,
|
| 179 |
+
**kwargs: Any,
|
| 180 |
+
) -> IncidentObservation:
|
| 181 |
+
if self._state is None:
|
| 182 |
+
raise RuntimeError("Call reset() before step().")
|
| 183 |
+
|
| 184 |
+
state = self._state
|
| 185 |
+
td = self._task_def
|
| 186 |
+
|
| 187 |
+
if state.done:
|
| 188 |
+
return self._build_observation("Episode already finished.", reward=0.0)
|
| 189 |
+
|
| 190 |
+
state.step_count += 1
|
| 191 |
+
action_key = f"{action.action_type}:{action.target}"
|
| 192 |
+
reward = 0.0
|
| 193 |
+
result_msg = ""
|
| 194 |
+
|
| 195 |
+
# Delay penalty every step
|
| 196 |
+
reward -= td["delay_penalty"]
|
| 197 |
+
|
| 198 |
+
atype = action.action_type
|
| 199 |
+
|
| 200 |
+
if atype == ActionType.ANALYZE_LOG:
|
| 201 |
+
r, result_msg = self._handle_analyze_log(action.target, action_key)
|
| 202 |
+
reward += r
|
| 203 |
+
elif atype == ActionType.TRACE_USER:
|
| 204 |
+
r, result_msg = self._handle_trace_user(action.target, action_key)
|
| 205 |
+
reward += r
|
| 206 |
+
elif atype == ActionType.BLOCK_IP:
|
| 207 |
+
r, result_msg = self._handle_block_ip(action.target, action_key)
|
| 208 |
+
reward += r
|
| 209 |
+
elif atype == ActionType.DISABLE_ACCOUNT:
|
| 210 |
+
r, result_msg = self._handle_disable_account(action.target, action_key)
|
| 211 |
+
reward += r
|
| 212 |
+
elif atype == ActionType.RESTART_SERVICE:
|
| 213 |
+
r, result_msg = self._handle_restart_service(action.target, action_key)
|
| 214 |
+
reward += r
|
| 215 |
+
elif atype == ActionType.IGNORE:
|
| 216 |
+
state.progress_level = min(state.progress_level + 1, state.max_progress)
|
| 217 |
+
reward -= 0.10
|
| 218 |
+
result_msg = "No action taken. Attack progresses!"
|
| 219 |
+
else:
|
| 220 |
+
state.wrong_actions += 1
|
| 221 |
+
reward += td["wrong_penalty"]
|
| 222 |
+
result_msg = f"Unknown action: {atype}"
|
| 223 |
+
|
| 224 |
+
reward = round(reward, 4)
|
| 225 |
+
state.total_reward += reward
|
| 226 |
+
|
| 227 |
+
done, info = self._check_termination()
|
| 228 |
+
if done:
|
| 229 |
+
state.done = True
|
| 230 |
+
|
| 231 |
+
return self._build_observation(result_msg, reward=reward, done=done, info=info)
|
| 232 |
+
|
| 233 |
+
@property
|
| 234 |
+
def state(self) -> IncidentState:
|
| 235 |
+
if self._state is None:
|
| 236 |
+
raise RuntimeError("Call reset() first.")
|
| 237 |
+
return self._state
|
| 238 |
+
|
| 239 |
+
# ---- Action handlers -----------------------------------------------
|
| 240 |
+
|
| 241 |
+
def _handle_analyze_log(self, target: str, key: str):
|
| 242 |
+
state = self._state
|
| 243 |
+
td = self._task_def
|
| 244 |
+
log_ids = [l["log_id"] for l in td["logs"]]
|
| 245 |
+
|
| 246 |
+
if target not in log_ids:
|
| 247 |
+
state.wrong_actions += 1
|
| 248 |
+
return td["wrong_penalty"], f"Log {target} does not exist."
|
| 249 |
+
if target in state.revealed_logs:
|
| 250 |
+
return -0.05, f"Log {target} already analyzed."
|
| 251 |
+
|
| 252 |
+
state.revealed_logs.append(target)
|
| 253 |
+
if key in td["correct_sequence"]:
|
| 254 |
+
state.correct_detections += 1
|
| 255 |
+
msg = next(l["message"] for l in td["logs"] if l["log_id"] == target)
|
| 256 |
+
return td["correct_sequence"][key], f"[ANALYSIS] {target}: {msg}"
|
| 257 |
+
return 0.05, f"Log {target} analyzed β no significant findings."
|
| 258 |
+
|
| 259 |
+
def _handle_trace_user(self, target: str, key: str):
|
| 260 |
+
state = self._state
|
| 261 |
+
td = self._task_def
|
| 262 |
+
|
| 263 |
+
if target not in td["active_users"]:
|
| 264 |
+
state.wrong_actions += 1
|
| 265 |
+
return td["wrong_penalty"], f"User {target!r} not found."
|
| 266 |
+
if target in state.revealed_users:
|
| 267 |
+
return -0.05, f"User {target} already traced."
|
| 268 |
+
|
| 269 |
+
state.revealed_users.append(target)
|
| 270 |
+
if key in td["correct_sequence"]:
|
| 271 |
+
state.correct_detections += 1
|
| 272 |
+
return td["correct_sequence"][key], f"[TRACE] {target}: Confirmed suspicious activity."
|
| 273 |
+
return 0.05, f"User {target} traced β activity appears normal."
|
| 274 |
+
|
| 275 |
+
def _handle_block_ip(self, target: str, key: str):
|
| 276 |
+
state = self._state
|
| 277 |
+
td = self._task_def
|
| 278 |
+
|
| 279 |
+
if target in state.blocked_ips:
|
| 280 |
+
return -0.05, f"IP {target} already blocked."
|
| 281 |
+
state.blocked_ips.append(target)
|
| 282 |
+
|
| 283 |
+
if key in td["correct_sequence"]:
|
| 284 |
+
state.correct_detections += 1
|
| 285 |
+
multiplier = 1.0 if state.correct_detections > 1 else 0.6
|
| 286 |
+
return td["correct_sequence"][key] * multiplier, f"[BLOCKED] IP {target} blocked."
|
| 287 |
+
state.wrong_actions += 1
|
| 288 |
+
return td["wrong_penalty"], f"Blocking {target} was incorrect."
|
| 289 |
+
|
| 290 |
+
def _handle_disable_account(self, target: str, key: str):
|
| 291 |
+
state = self._state
|
| 292 |
+
td = self._task_def
|
| 293 |
+
|
| 294 |
+
if target in state.disabled_accounts:
|
| 295 |
+
return -0.05, f"Account {target} already disabled."
|
| 296 |
+
state.disabled_accounts.append(target)
|
| 297 |
+
|
| 298 |
+
if key in td["correct_sequence"]:
|
| 299 |
+
state.correct_detections += 1
|
| 300 |
+
multiplier = 1.0 if state.correct_detections > 1 else 0.6
|
| 301 |
+
return td["correct_sequence"][key] * multiplier, f"[DISABLED] Account {target} disabled."
|
| 302 |
+
state.wrong_actions += 1
|
| 303 |
+
return td["wrong_penalty"], f"Disabling {target} was incorrect."
|
| 304 |
+
|
| 305 |
+
def _handle_restart_service(self, target: str, key: str):
|
| 306 |
+
state = self._state
|
| 307 |
+
td = self._task_def
|
| 308 |
+
|
| 309 |
+
valid = [s["name"] for s in td["services"]]
|
| 310 |
+
if target not in valid:
|
| 311 |
+
state.wrong_actions += 1
|
| 312 |
+
return td["wrong_penalty"], f"Service {target!r} not found."
|
| 313 |
+
if target in state.restarted_services:
|
| 314 |
+
return -0.05, f"Service {target} already restarted."
|
| 315 |
+
|
| 316 |
+
state.restarted_services.append(target)
|
| 317 |
+
if key in td["correct_sequence"]:
|
| 318 |
+
state.correct_detections += 1
|
| 319 |
+
return td["correct_sequence"][key], f"[RESTARTED] Service {target} restored."
|
| 320 |
+
state.wrong_actions += 1
|
| 321 |
+
return td["wrong_penalty"], f"Restarting {target} was not necessary."
|
| 322 |
+
|
| 323 |
+
# ---- Termination ---------------------------------------------------
|
| 324 |
+
|
| 325 |
+
def _check_termination(self):
|
| 326 |
+
state = self._state
|
| 327 |
+
td = self._task_def
|
| 328 |
+
seq = td["correct_sequence"]
|
| 329 |
+
|
| 330 |
+
all_done = all(self._action_completed(k) for k in seq)
|
| 331 |
+
|
| 332 |
+
speed_bonus = 0.0
|
| 333 |
+
if all_done and state.step_count <= td["speed_bonus_threshold"]:
|
| 334 |
+
speed_bonus = td["speed_bonus"]
|
| 335 |
+
state.total_reward += speed_bonus
|
| 336 |
+
|
| 337 |
+
compromise = (
|
| 338 |
+
state.progress_level >= state.max_progress
|
| 339 |
+
or state.step_count >= td["compromise_steps"]
|
| 340 |
+
) and not all_done
|
| 341 |
+
|
| 342 |
+
timeout = state.step_count >= state.max_steps
|
| 343 |
+
done = all_done or compromise or timeout
|
| 344 |
+
|
| 345 |
+
if done:
|
| 346 |
+
state.success = all_done and not compromise
|
| 347 |
+
state.compromise = compromise
|
| 348 |
+
|
| 349 |
+
max_possible = sum(seq.values()) + td["speed_bonus"]
|
| 350 |
+
final_score = round(min(max(state.total_reward, 0.0) / max_possible, 1.0), 4) if max_possible > 0 else 0.0
|
| 351 |
+
|
| 352 |
+
info = {
|
| 353 |
+
"final_score": final_score,
|
| 354 |
+
"success": state.success,
|
| 355 |
+
"compromise": state.compromise,
|
| 356 |
+
"wrong_actions": state.wrong_actions,
|
| 357 |
+
"speed_bonus": speed_bonus,
|
| 358 |
+
} if done else {}
|
| 359 |
+
|
| 360 |
+
return done, info
|
| 361 |
+
|
| 362 |
+
def _action_completed(self, key: str) -> bool:
|
| 363 |
+
action_type, target = key.split(":", 1)
|
| 364 |
+
s = self._state
|
| 365 |
+
return {
|
| 366 |
+
"analyze_log": target in s.revealed_logs,
|
| 367 |
+
"trace_user": target in s.revealed_users,
|
| 368 |
+
"block_ip": target in s.blocked_ips,
|
| 369 |
+
"disable_account": target in s.disabled_accounts,
|
| 370 |
+
"restart_service": target in s.restarted_services,
|
| 371 |
+
}.get(action_type, False)
|
| 372 |
+
|
| 373 |
+
# ---- Observation builder -------------------------------------------
|
| 374 |
+
|
| 375 |
+
def _build_observation(
|
| 376 |
+
self,
|
| 377 |
+
result_msg: str,
|
| 378 |
+
reward: float = 0.0,
|
| 379 |
+
done: bool = False,
|
| 380 |
+
info: dict = None,
|
| 381 |
+
) -> IncidentObservation:
|
| 382 |
+
td = self._task_def
|
| 383 |
+
return IncidentObservation(
|
| 384 |
+
logs=td["logs"],
|
| 385 |
+
alerts=td["alerts"],
|
| 386 |
+
services=td["services"],
|
| 387 |
+
active_users=td["active_users"],
|
| 388 |
+
step_count=self._state.step_count,
|
| 389 |
+
reward=reward,
|
| 390 |
+
done=done,
|
| 391 |
+
info=info or {},
|
| 392 |
+
last_action_result=result_msg,
|
| 393 |
+
)
|
tasks.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task definitions for the Incident Response environment.
|
| 3 |
+
Each task defines a concrete scenario (easy β medium β hard)
|
| 4 |
+
with pre-seeded logs, alerts, hidden state, and success criteria.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import uuid
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Dict, List
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class TaskScenario:
|
| 16 |
+
task_id: int
|
| 17 |
+
name: str
|
| 18 |
+
description: str
|
| 19 |
+
attack_type: str
|
| 20 |
+
attacker_ip: str
|
| 21 |
+
target_user: str
|
| 22 |
+
target_service: str
|
| 23 |
+
max_progress: int
|
| 24 |
+
max_steps: int
|
| 25 |
+
|
| 26 |
+
# Visible at reset
|
| 27 |
+
initial_logs: List[Dict]
|
| 28 |
+
initial_alerts: List[Dict]
|
| 29 |
+
initial_services: List[Dict]
|
| 30 |
+
initial_active_users: List[str]
|
| 31 |
+
|
| 32 |
+
# Incremental logs revealed via analyze_log / trace_user
|
| 33 |
+
hidden_logs: Dict[str, List[Dict]] # keyed by trigger_action
|
| 34 |
+
hidden_user_logs: Dict[str, List[Dict]] # keyed by user_id
|
| 35 |
+
|
| 36 |
+
# Correct targets the agent must identify
|
| 37 |
+
correct_ips: List[str]
|
| 38 |
+
correct_users: List[str]
|
| 39 |
+
correct_services: List[str]
|
| 40 |
+
|
| 41 |
+
# Stage info for multi-stage attacks
|
| 42 |
+
stages: int = 1
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# TASK 1 β Easy (Brute Force Attack)
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
TASK_1 = TaskScenario(
|
| 50 |
+
task_id=1,
|
| 51 |
+
name="Brute Force Detection",
|
| 52 |
+
description=(
|
| 53 |
+
"A brute-force attack is underway against the authentication service. "
|
| 54 |
+
"Multiple failed login attempts are visible in the logs. "
|
| 55 |
+
"Identify the attacker IP and block it before the admin account is compromised."
|
| 56 |
+
),
|
| 57 |
+
attack_type="brute_force",
|
| 58 |
+
attacker_ip="192.168.1.47",
|
| 59 |
+
target_user="admin",
|
| 60 |
+
target_service="auth-service",
|
| 61 |
+
max_progress=3,
|
| 62 |
+
max_steps=12,
|
| 63 |
+
|
| 64 |
+
initial_logs=[
|
| 65 |
+
{"log_id": "L001", "message": "Failed login for user 'admin' from 192.168.1.47", "timestamp": "09:01:02"},
|
| 66 |
+
{"log_id": "L002", "message": "Failed login for user 'admin' from 192.168.1.47", "timestamp": "09:01:05"},
|
| 67 |
+
{"log_id": "L003", "message": "Normal request from 10.0.0.5", "timestamp": "09:01:08"},
|
| 68 |
+
{"log_id": "L004", "message": "Failed login for user 'admin' from 192.168.1.47", "timestamp": "09:01:11"},
|
| 69 |
+
],
|
| 70 |
+
initial_alerts=[
|
| 71 |
+
{"alert_id": "A001", "message": "Multiple failed logins detected for 'admin'", "severity": "high"},
|
| 72 |
+
],
|
| 73 |
+
initial_services=[
|
| 74 |
+
{"name": "auth-service", "status": "running"},
|
| 75 |
+
{"name": "web-server", "status": "running"},
|
| 76 |
+
{"name": "database", "status": "running"},
|
| 77 |
+
],
|
| 78 |
+
initial_active_users=["admin", "alice", "bob"],
|
| 79 |
+
|
| 80 |
+
hidden_logs={
|
| 81 |
+
"L001": [
|
| 82 |
+
{"log_id": "L005", "message": "192.168.1.47 tried 120 passwords in last 60 seconds", "timestamp": "09:01:15"},
|
| 83 |
+
{"log_id": "L006", "message": "Rate limit triggered for 192.168.1.47 (threshold exceeded)", "timestamp": "09:01:16"},
|
| 84 |
+
],
|
| 85 |
+
"L002": [
|
| 86 |
+
{"log_id": "L007", "message": "Automated tool signature detected: hydra/1.0 user-agent from 192.168.1.47", "timestamp": "09:01:17"},
|
| 87 |
+
],
|
| 88 |
+
},
|
| 89 |
+
hidden_user_logs={
|
| 90 |
+
"admin": [
|
| 91 |
+
{"log_id": "L008", "message": "Admin account has 0 successful logins in last 10 min (all blocked)", "timestamp": "09:01:20"},
|
| 92 |
+
],
|
| 93 |
+
},
|
| 94 |
+
|
| 95 |
+
correct_ips=["192.168.1.47"],
|
| 96 |
+
correct_users=[],
|
| 97 |
+
correct_services=[],
|
| 98 |
+
stages=1,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
# TASK 2 β Medium (Suspicious Login / Account Takeover)
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
|
| 106 |
+
TASK_2 = TaskScenario(
|
| 107 |
+
task_id=2,
|
| 108 |
+
name="Suspicious Login β Account Takeover",
|
| 109 |
+
description=(
|
| 110 |
+
"A legitimate-looking login has occurred from an unusual geographic location. "
|
| 111 |
+
"Behavioral signals suggest account compromise. "
|
| 112 |
+
"Investigate the user's activity and disable the account if you confirm anomalous behavior "
|
| 113 |
+
"before data exfiltration occurs."
|
| 114 |
+
),
|
| 115 |
+
attack_type="suspicious_login",
|
| 116 |
+
attacker_ip="203.0.113.88",
|
| 117 |
+
target_user="carol",
|
| 118 |
+
target_service="file-server",
|
| 119 |
+
max_progress=3,
|
| 120 |
+
max_steps=14,
|
| 121 |
+
|
| 122 |
+
initial_logs=[
|
| 123 |
+
{"log_id": "L001", "message": "Successful login for 'carol' from 203.0.113.88 (Singapore)", "timestamp": "14:22:01"},
|
| 124 |
+
{"log_id": "L002", "message": "carol accessed /finance/Q3_report.xlsx", "timestamp": "14:22:30"},
|
| 125 |
+
{"log_id": "L003", "message": "Normal request from alice at 10.0.0.8", "timestamp": "14:22:35"},
|
| 126 |
+
{"log_id": "L004", "message": "carol accessed /hr/salary_data.csv", "timestamp": "14:22:58"},
|
| 127 |
+
],
|
| 128 |
+
initial_alerts=[
|
| 129 |
+
{"alert_id": "A001", "message": "Login from new geolocation for 'carol' (usual: India, now: Singapore)", "severity": "medium"},
|
| 130 |
+
{"alert_id": "A002", "message": "Unusual file access volume for 'carol'", "severity": "low"},
|
| 131 |
+
],
|
| 132 |
+
initial_services=[
|
| 133 |
+
{"name": "file-server", "status": "running"},
|
| 134 |
+
{"name": "auth-service", "status": "running"},
|
| 135 |
+
{"name": "web-server", "status": "running"},
|
| 136 |
+
],
|
| 137 |
+
initial_active_users=["carol", "alice", "bob", "dave"],
|
| 138 |
+
|
| 139 |
+
hidden_logs={
|
| 140 |
+
"L001": [
|
| 141 |
+
{"log_id": "L005", "message": "carol's last login was 6 days ago from Hyderabad, India (10.5.1.2)", "timestamp": "14:23:00"},
|
| 142 |
+
{"log_id": "L006", "message": "Geo-distance between sessions: 4,200 km β impossible travel in 2 h", "timestamp": "14:23:01"},
|
| 143 |
+
],
|
| 144 |
+
"L002": [
|
| 145 |
+
{"log_id": "L007", "message": "carol has downloaded 340 MB of files in 3 minutes (normal avg: 2 MB/session)", "timestamp": "14:23:10"},
|
| 146 |
+
{"log_id": "L008", "message": "carol attempted bulk-download of /finance/* directory", "timestamp": "14:23:15"},
|
| 147 |
+
],
|
| 148 |
+
"L004": [
|
| 149 |
+
{"log_id": "L009", "message": "Exfil pattern detected: files zipped and queued to external SFTP 203.0.113.90", "timestamp": "14:23:20"},
|
| 150 |
+
],
|
| 151 |
+
},
|
| 152 |
+
hidden_user_logs={
|
| 153 |
+
"carol": [
|
| 154 |
+
{"log_id": "L010", "message": "carol's password was reset via phishing link 4 h ago β MFA bypassed", "timestamp": "14:23:25"},
|
| 155 |
+
{"log_id": "L011", "message": "carol's account has API token generated from 203.0.113.88", "timestamp": "14:23:26"},
|
| 156 |
+
],
|
| 157 |
+
},
|
| 158 |
+
|
| 159 |
+
correct_ips=[],
|
| 160 |
+
correct_users=["carol"],
|
| 161 |
+
correct_services=[],
|
| 162 |
+
stages=1,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ---------------------------------------------------------------------------
|
| 167 |
+
# TASK 3 β Hard (Multi-Stage Attack: Phishing β Escalation β Exfiltration)
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
TASK_3 = TaskScenario(
|
| 171 |
+
task_id=3,
|
| 172 |
+
name="Multi-Stage Attack β APT Kill Chain",
|
| 173 |
+
description=(
|
| 174 |
+
"A sophisticated multi-stage attack is in progress: "
|
| 175 |
+
"Stage 1: An attacker used phishing to steal credentials. "
|
| 176 |
+
"Stage 2: The compromised account is performing privilege escalation. "
|
| 177 |
+
"Stage 3: The attacker is moving laterally to access the database. "
|
| 178 |
+
"You must analyze logs, connect the events, identify the compromised account, "
|
| 179 |
+
"and take targeted actions to stop the attack chain before database exfiltration completes."
|
| 180 |
+
),
|
| 181 |
+
attack_type="multi_stage",
|
| 182 |
+
attacker_ip="198.51.100.23",
|
| 183 |
+
target_user="eve",
|
| 184 |
+
target_service="database",
|
| 185 |
+
max_progress=4,
|
| 186 |
+
max_steps=18,
|
| 187 |
+
|
| 188 |
+
initial_logs=[
|
| 189 |
+
{"log_id": "L001", "message": "eve clicked a link in email from 'it-support@company-secure.net'", "timestamp": "10:05:00"},
|
| 190 |
+
{"log_id": "L002", "message": "Successful login for 'eve' from 198.51.100.23", "timestamp": "10:07:12"},
|
| 191 |
+
{"log_id": "L003", "message": "Normal build pipeline run by ci-bot", "timestamp": "10:08:00"},
|
| 192 |
+
{"log_id": "L004", "message": "eve modified /etc/sudoers on host prod-01", "timestamp": "10:09:44"},
|
| 193 |
+
{"log_id": "L005", "message": "Database query spike: 2,400 queries/min from prod-01 (normal: 120)", "timestamp": "10:11:00"},
|
| 194 |
+
],
|
| 195 |
+
initial_alerts=[
|
| 196 |
+
{"alert_id": "A001", "message": "Phishing link clicked by 'eve' β domain flagged by threat intel", "severity": "high"},
|
| 197 |
+
{"alert_id": "A002", "message": "Privilege escalation attempt detected on prod-01", "severity": "high"},
|
| 198 |
+
{"alert_id": "A003", "message": "Abnormal database query rate from prod-01", "severity": "medium"},
|
| 199 |
+
],
|
| 200 |
+
initial_services=[
|
| 201 |
+
{"name": "auth-service", "status": "running"},
|
| 202 |
+
{"name": "web-server", "status": "running"},
|
| 203 |
+
{"name": "database", "status": "degraded"},
|
| 204 |
+
{"name": "prod-01", "status": "running"},
|
| 205 |
+
],
|
| 206 |
+
initial_active_users=["eve", "alice", "ci-bot", "frank", "grace"],
|
| 207 |
+
|
| 208 |
+
hidden_logs={
|
| 209 |
+
"L001": [
|
| 210 |
+
{"log_id": "L006", "message": "Phishing page at company-secure.net harvested eve's credentials + session token", "timestamp": "10:05:30"},
|
| 211 |
+
{"log_id": "L007", "message": "company-secure.net resolves to 198.51.100.23 (attacker-controlled)", "timestamp": "10:05:31"},
|
| 212 |
+
],
|
| 213 |
+
"L002": [
|
| 214 |
+
{"log_id": "L008", "message": "Session from 198.51.100.23 is using stolen token (different device fingerprint)", "timestamp": "10:07:15"},
|
| 215 |
+
{"log_id": "L009", "message": "10 concurrent sessions opened from 198.51.100.23 within 30 s", "timestamp": "10:07:45"},
|
| 216 |
+
],
|
| 217 |
+
"L004": [
|
| 218 |
+
{"log_id": "L010", "message": "sudoers modification grants root to eve on ALL hosts β lateral movement risk", "timestamp": "10:09:50"},
|
| 219 |
+
{"log_id": "L011", "message": "eve's sudo session on prod-01 installed reverse shell (python3 -c '...')", "timestamp": "10:10:00"},
|
| 220 |
+
],
|
| 221 |
+
"L005": [
|
| 222 |
+
{"log_id": "L012", "message": "Queries from prod-01 selecting PII columns: SSN, credit_card, email", "timestamp": "10:11:10"},
|
| 223 |
+
{"log_id": "L013", "message": "Data staged at /tmp/.x/ β 1.2 GB; upload to 198.51.100.24 in progress", "timestamp": "10:11:20"},
|
| 224 |
+
],
|
| 225 |
+
},
|
| 226 |
+
hidden_user_logs={
|
| 227 |
+
"eve": [
|
| 228 |
+
{"log_id": "L014", "message": "eve's account created 3 backdoor API keys in last 5 minutes", "timestamp": "10:10:30"},
|
| 229 |
+
{"log_id": "L015", "message": "eve added 198.51.100.23 to trusted-IPs whitelist (stealth persistence)", "timestamp": "10:10:35"},
|
| 230 |
+
],
|
| 231 |
+
"ci-bot": [
|
| 232 |
+
{"log_id": "L016", "message": "ci-bot credentials were NOT compromised β red herring", "timestamp": "10:08:10"},
|
| 233 |
+
],
|
| 234 |
+
},
|
| 235 |
+
|
| 236 |
+
correct_ips=["198.51.100.23"],
|
| 237 |
+
correct_users=["eve"],
|
| 238 |
+
correct_services=["database", "prod-01"],
|
| 239 |
+
stages=3,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
ALL_TASKS: Dict[int, TaskScenario] = {
|
| 244 |
+
1: TASK_1,
|
| 245 |
+
2: TASK_2,
|
| 246 |
+
3: TASK_3,
|
| 247 |
+
}
|