ojasmov commited on
Commit
7f42e9d
Β·
1 Parent(s): d3d2d18

Add src to deployment

Browse files
Files changed (15) hide show
  1. Dockerfile +34 -0
  2. README.md +254 -9
  3. __init__.py +33 -0
  4. baseline.py +260 -0
  5. client.py +93 -0
  6. environment.py +393 -0
  7. graders.py +181 -0
  8. inference.py +200 -0
  9. models.py +112 -0
  10. openenv.yaml +68 -0
  11. pyproject.toml +36 -0
  12. requirements.txt +8 -0
  13. server/app.py +25 -0
  14. server/environment.py +393 -0
  15. 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
- title: Xsecure
3
- emoji: 😻
4
- colorFrom: indigo
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
- short_description: Incident response AI agent by 6-7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ [![OpenEnv](https://img.shields.io/badge/OpenEnv-compatible-blue)](https://github.com/meta-pytorch/OpenEnv)
6
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
7
+ [![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](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
+ }