nevernever69 commited on
Commit
5fe8213
·
verified ·
1 Parent(s): c1afcfb

add server/redveil_environment.py

Browse files
Files changed (1) hide show
  1. server/redveil_environment.py +698 -0
server/redveil_environment.py ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RedVeil Environment Implementation.
2
+
3
+ A cybersecurity-themed RL environment where agents make decisions under
4
+ uncertainty, use tools effectively, and avoid deceptive signals.
5
+
6
+ This environment runs a REAL vulnerable Flask web application and sends
7
+ REAL HTTP requests. SQL injections are genuine, login bypasses are real,
8
+ and honeypot responses come from actual HTTP endpoints.
9
+
10
+ KEY DESIGN: Endpoints are HIDDEN. The agent only sees ports at the start.
11
+ Scanning a port reveals the endpoints hosted on it (mix of real + honeypots).
12
+ Endpoint paths are randomized per episode -- the agent cannot memorize routes.
13
+ """
14
+
15
+ import threading
16
+ import time
17
+ from typing import Any, Optional
18
+ from uuid import uuid4
19
+
20
+ from openenv.core.env_server.interfaces import Environment
21
+ from openenv.core.env_server.types import State
22
+
23
+ try:
24
+ from ..models import ActionType, RedVeilAction, RedVeilObservation
25
+ from ..noise import DeceptionEngine, NoiseEngine
26
+ from ..tasks import ALL_TASKS, TaskConfig
27
+ from ..grader import grade_task
28
+ from ..vulnerable_app import create_vulnerable_app
29
+ except (ImportError, ModuleNotFoundError):
30
+ from models import ActionType, RedVeilAction, RedVeilObservation
31
+ from noise import DeceptionEngine, NoiseEngine
32
+ from tasks import ALL_TASKS, TaskConfig
33
+ from grader import grade_task
34
+ from vulnerable_app import create_vulnerable_app
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Vulnerable app management
39
+ # ---------------------------------------------------------------------------
40
+
41
+ _vuln_app_started = False
42
+ _vuln_app_lock = threading.Lock()
43
+ VULN_APP_PORT = 5000
44
+ VULN_APP_URL = f"http://127.0.0.1:{VULN_APP_PORT}"
45
+
46
+
47
+ def _ensure_vuln_app_running():
48
+ """Start the vulnerable Flask app in a background thread if not already running."""
49
+ global _vuln_app_started
50
+
51
+ with _vuln_app_lock:
52
+ if _vuln_app_started:
53
+ return
54
+
55
+ app = create_vulnerable_app()
56
+
57
+ def run_app():
58
+ import logging
59
+ log = logging.getLogger('werkzeug')
60
+ log.setLevel(logging.WARNING)
61
+ app.run(
62
+ host='127.0.0.1',
63
+ port=VULN_APP_PORT,
64
+ debug=False,
65
+ use_reloader=False,
66
+ threaded=True,
67
+ )
68
+
69
+ thread = threading.Thread(target=run_app, daemon=True)
70
+ thread.start()
71
+ _vuln_app_started = True
72
+
73
+ import requests
74
+ for _ in range(30):
75
+ try:
76
+ resp = requests.get(f"{VULN_APP_URL}/health", timeout=1)
77
+ if resp.status_code == 200:
78
+ return
79
+ except requests.RequestException:
80
+ pass
81
+ time.sleep(0.1)
82
+
83
+
84
+ class RedVeilEnvironment(Environment):
85
+ """RedVeil: Decision-making under uncertainty with real tool interaction.
86
+
87
+ Endpoints are HIDDEN until the agent scans the port they live on.
88
+ Paths are randomized per episode. Real HTTP requests are sent to a
89
+ genuine vulnerable Flask application with real SQL injection vulnerabilities.
90
+ """
91
+
92
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
93
+
94
+ def __init__(self):
95
+ super().__init__()
96
+ self._state = State(episode_id=str(uuid4()), step_count=0)
97
+ self._task: Optional[TaskConfig] = None
98
+ self._noise_engine: Optional[NoiseEngine] = None
99
+ self._deception_engine: Optional[DeceptionEngine] = None
100
+
101
+ # Game state tracking
102
+ self._budget_remaining: int = 0
103
+ self._scan_counts: dict = {}
104
+ self._revealed_endpoints: set = set() # Endpoints revealed by scanning
105
+ self._discovered_endpoints: set = set() # Endpoints the agent has fuzzed
106
+ self._fuzzed_endpoints: set = set()
107
+ self._identified_real_ports: set = set()
108
+ self._identified_fake_ports: set = set()
109
+ self._vuln_found: bool = False
110
+ self._vuln_endpoint: Optional[str] = None
111
+ self._exploit_success: bool = False
112
+ self._creds_extracted: bool = False
113
+ self._extracted_creds: Optional[dict] = None
114
+ self._admin_login: bool = False
115
+ self._flagged_honeypots: set = set()
116
+ self._action_log: list = []
117
+ self._session_token: Optional[str] = None # Token from /api/profile
118
+ self._config_fetched: bool = False # Found hidden paths via config
119
+ self._hidden_endpoints_found: set = set() # Endpoints found via config/robots
120
+ self._low_priv_login: bool = False # Logged in as non-admin user
121
+
122
+ # Endpoint path -> EndpointConfig lookup
123
+ self._endpoint_map: dict = {}
124
+
125
+ _ensure_vuln_app_running()
126
+
127
+ def reset(
128
+ self,
129
+ seed: Optional[int] = None,
130
+ episode_id: Optional[str] = None,
131
+ **kwargs: Any,
132
+ ) -> RedVeilObservation:
133
+ """Reset the environment with a specific task."""
134
+ task_id = kwargs.get("task_id", "easy_recon")
135
+ actual_seed = seed if seed is not None else 42
136
+
137
+ self._task = ALL_TASKS.get(task_id, ALL_TASKS["easy_recon"])
138
+ self._state = State(
139
+ episode_id=episode_id or str(uuid4()),
140
+ step_count=0,
141
+ )
142
+
143
+ self._noise_engine = NoiseEngine(
144
+ noise_level=self._task.noise_level,
145
+ conflicting_scans=self._task.conflicting_scans,
146
+ seed=actual_seed,
147
+ )
148
+ self._deception_engine = DeceptionEngine(
149
+ deception_active=self._task.deception_active,
150
+ target_base_url=VULN_APP_URL,
151
+ seed=actual_seed,
152
+ )
153
+
154
+ # Reset game state
155
+ self._budget_remaining = self._task.budget
156
+ self._scan_counts = {}
157
+ self._revealed_endpoints = set()
158
+ self._discovered_endpoints = set()
159
+ self._fuzzed_endpoints = set()
160
+ self._identified_real_ports = set()
161
+ self._identified_fake_ports = set()
162
+ self._vuln_found = False
163
+ self._vuln_endpoint = None
164
+ self._exploit_success = False
165
+ self._creds_extracted = False
166
+ self._extracted_creds = None
167
+ self._admin_login = False
168
+ self._flagged_honeypots = set()
169
+ self._action_log = []
170
+ self._session_token = None
171
+ self._config_fetched = False
172
+ self._hidden_endpoints_found = set()
173
+ self._low_priv_login = False
174
+
175
+ # Build endpoint lookup
176
+ self._endpoint_map = {e.path: e for e in self._task.endpoints}
177
+
178
+ # Build initial observation -- endpoints are HIDDEN
179
+ port_list = ", ".join(str(p.port) for p in self._task.ports)
180
+
181
+ if self._task.task_id == "easy_recon":
182
+ # Easy task: no endpoints, just ports
183
+ targets_info = f"Ports: {port_list}\nEndpoints: N/A (port scan task only)"
184
+ else:
185
+ # Medium/Hard: endpoints are hidden behind ports
186
+ targets_info = (
187
+ f"Ports: {port_list}\n"
188
+ f"Endpoints: UNKNOWN -- scan ports to discover web endpoints"
189
+ )
190
+
191
+ intro = (
192
+ f"[ENVIRONMENT INITIALIZED]\n"
193
+ f"Task: {self._task.description}\n"
194
+ f"Difficulty: {self._task.difficulty}\n"
195
+ f"Budget: {self._budget_remaining} actions\n\n"
196
+ f"[OBJECTIVE]\n{self._task.objective}\n\n"
197
+ f"[KNOWN TARGETS]\n"
198
+ f"{targets_info}\n\n"
199
+ f"[AVAILABLE ACTIONS]\n"
200
+ f"- scan <port>: Scan a port for services and discover endpoints\n"
201
+ f"- fuzz <endpoint>: Send probe requests to a discovered endpoint\n"
202
+ f"- inject_payload <endpoint>: Attempt SQL injection on an endpoint\n"
203
+ f"- login <username:password>: Attempt authentication with credentials\n"
204
+ f"- analyze <target>: Deep probe -- check status, get profile (user:pass), or query restricted endpoint (with payload)\n"
205
+ f"- fetch_config <target>: Retrieve config files (robots.txt, config) to discover hidden paths"
206
+ )
207
+
208
+ return RedVeilObservation(
209
+ observation_text=intro,
210
+ budget_remaining=self._budget_remaining,
211
+ task_id=self._task.task_id,
212
+ task_description=self._task.description,
213
+ milestones_reached=[],
214
+ done=False,
215
+ reward=0.0,
216
+ )
217
+
218
+ def step(
219
+ self,
220
+ action: RedVeilAction,
221
+ timeout_s: Optional[float] = None,
222
+ **kwargs: Any,
223
+ ) -> RedVeilObservation:
224
+ """Execute an action in the environment."""
225
+ self._state.step_count += 1
226
+
227
+ if self._budget_remaining <= 0:
228
+ return self._make_observation(
229
+ "[BUDGET EXHAUSTED] No actions remaining. Episode complete.",
230
+ done=True,
231
+ )
232
+
233
+ self._budget_remaining -= 1
234
+
235
+ self._action_log.append({
236
+ "step": self._state.step_count,
237
+ "action": action.action_type.value,
238
+ "target": action.target,
239
+ })
240
+
241
+ if action.action_type == ActionType.SCAN:
242
+ obs_text = self._handle_scan(action.target)
243
+ elif action.action_type == ActionType.FUZZ:
244
+ obs_text = self._handle_fuzz(action.target)
245
+ elif action.action_type == ActionType.INJECT_PAYLOAD:
246
+ obs_text = self._handle_inject(action.target, payload=action.payload)
247
+ # Honeypot penalty: injecting a honeypot triggers IDS, costs extra budget
248
+ target_path = action.target if action.target.startswith("/") else "/" + action.target
249
+ ep = self._endpoint_map.get(target_path)
250
+ if ep and ep.is_honeypot:
251
+ self._budget_remaining = max(0, self._budget_remaining - 1)
252
+ obs_text += "\n[IDS ALERT] Anomalous activity detected. Security response initiated."
253
+ elif action.action_type == ActionType.LOGIN:
254
+ obs_text = self._handle_login(action.target)
255
+ elif action.action_type == ActionType.ANALYZE:
256
+ obs_text = self._handle_analyze(action.target, payload=action.payload)
257
+ elif action.action_type == ActionType.FETCH_CONFIG:
258
+ obs_text = self._handle_fetch_config(action.target)
259
+ else:
260
+ obs_text = f"[ERROR] Unknown action: {action.action_type}"
261
+
262
+ done = self._budget_remaining <= 0 or self._admin_login
263
+
264
+ if self._task and self._task.task_id == "easy_recon":
265
+ if len(self._identified_real_ports) >= len(self._task.real_port_ids):
266
+ done = True
267
+
268
+ return self._make_observation(obs_text, done=done)
269
+
270
+ def _handle_scan(self, target: str) -> str:
271
+ """Handle scan: noise-modeled port scan + endpoint discovery."""
272
+ try:
273
+ port_num = int(target)
274
+ except ValueError:
275
+ return f"[ERROR] Invalid port: {target}. Provide a numeric port."
276
+
277
+ port_config = None
278
+ for p in self._task.ports:
279
+ if p.port == port_num:
280
+ port_config = p
281
+ break
282
+
283
+ if port_config is None:
284
+ return f"[SCAN RESULT]\nPort {port_num}: no response (host may be filtering)"
285
+
286
+ scan_count = self._scan_counts.get(port_num, 0)
287
+ self._scan_counts[port_num] = scan_count + 1
288
+
289
+ result = self._noise_engine.scan_port(port_config, scan_count)
290
+ formatted = self._noise_engine.format_scan_result(result)
291
+
292
+ if result.status in ("open", "open|filtered") and result.confidence > 0.6:
293
+ if port_config.is_real:
294
+ self._identified_real_ports.add(port_num)
295
+ else:
296
+ self._identified_fake_ports.add(port_num)
297
+
298
+ # PROGRESSIVE DISCOVERY: reveal endpoints hosted on this port
299
+ # Under high noise, only a fraction of endpoints are revealed per scan
300
+ if port_config.hosted_endpoints and result.status in ("open", "open|filtered"):
301
+ import random
302
+ rng = random.Random(self._state.step_count + port_num)
303
+
304
+ candidates = [ep for ep in port_config.hosted_endpoints if ep not in self._revealed_endpoints]
305
+
306
+ if candidates:
307
+ # Noise level determines discovery rate: 0.0 noise = 100%, 0.5 noise = 60%
308
+ discovery_rate = max(0.4, 1.0 - self._task.noise_level * 0.8)
309
+ num_to_reveal = max(1, int(len(candidates) * discovery_rate))
310
+ # On rescan, reveal different subset (seeded by step count)
311
+ to_reveal = rng.sample(candidates, min(num_to_reveal, len(candidates)))
312
+
313
+ newly_revealed = []
314
+ for ep_path in to_reveal:
315
+ self._revealed_endpoints.add(ep_path)
316
+ newly_revealed.append(ep_path)
317
+
318
+ if newly_revealed:
319
+ formatted += "\n\n[DISCOVERY] Web endpoints found on port " + str(port_num) + ":"
320
+ for ep in newly_revealed:
321
+ formatted += f"\n - {ep}"
322
+ unrevealed_count = len(port_config.hosted_endpoints) - len(
323
+ [e for e in port_config.hosted_endpoints if e in self._revealed_endpoints]
324
+ )
325
+ if unrevealed_count > 0:
326
+ formatted += f"\n[NOTE] Scan incomplete -- {unrevealed_count} additional endpoint(s) may exist. Rescan to discover more."
327
+ else:
328
+ formatted += "\n[NOTE] Endpoint purpose is unknown. Use fuzz to investigate."
329
+
330
+ return formatted
331
+
332
+ def _handle_fuzz(self, target: str) -> str:
333
+ """Handle fuzz: only works on revealed endpoints, sends real HTTP."""
334
+ if not target.startswith("/"):
335
+ target = "/" + target
336
+
337
+ # Check if endpoint has been revealed by scanning
338
+ if self._task.task_id != "easy_recon" and target not in self._revealed_endpoints:
339
+ return (
340
+ f"[FUZZ RESULT] {target}\n"
341
+ f"[ERROR] Endpoint not discovered. Scan ports first to discover endpoints."
342
+ )
343
+
344
+ endpoint = self._endpoint_map.get(target)
345
+ if endpoint is None:
346
+ return f"[FUZZ RESULT] {target}\n[HTTP 404] Endpoint not found on target server."
347
+
348
+ self._discovered_endpoints.add(target)
349
+ self._fuzzed_endpoints.add(target)
350
+
351
+ # Send REAL HTTP request using the endpoint's real_route
352
+ formatted = self._deception_engine.fuzz_endpoint(endpoint)
353
+
354
+ if endpoint.has_vulnerability and not endpoint.is_honeypot:
355
+ self._vuln_found = True
356
+ self._vuln_endpoint = target
357
+
358
+ return formatted
359
+
360
+ def _handle_inject(self, target: str, payload: str = None) -> str:
361
+ """Handle injection: only works on discovered endpoints, real SQLi."""
362
+ if not target.startswith("/"):
363
+ target = "/" + target
364
+
365
+ if self._task.task_id != "easy_recon" and target not in self._revealed_endpoints:
366
+ return (
367
+ f"[INJECT RESULT] {target}\n"
368
+ f"[ERROR] Endpoint not discovered. Scan ports first."
369
+ )
370
+
371
+ endpoint = self._endpoint_map.get(target)
372
+ if endpoint is None:
373
+ return f"[INJECT RESULT] Target {target} not found."
374
+
375
+ response_text, success, creds = self._deception_engine.inject_payload(endpoint, agent_payload=payload)
376
+
377
+ if success:
378
+ self._exploit_success = True
379
+ if creds:
380
+ self._creds_extracted = True
381
+ self._extracted_creds = creds
382
+
383
+ if endpoint.is_honeypot:
384
+ self._flagged_honeypots.add(target)
385
+
386
+ return response_text
387
+
388
+ def _handle_login(self, target: str) -> str:
389
+ """Handle login: sends real auth request. Requires login endpoint discovery."""
390
+ if ":" not in target:
391
+ return "[LOGIN RESULT] Invalid format. Use: login username:password"
392
+
393
+ # For non-easy tasks, agent must have discovered a login endpoint first
394
+ if self._task and self._task.task_id != "easy_recon":
395
+ login_discovered = False
396
+ for ep_path in self._revealed_endpoints:
397
+ ep = self._endpoint_map.get(ep_path)
398
+ if ep and ep.real_route == "/login":
399
+ login_discovered = True
400
+ break
401
+ if not login_discovered:
402
+ return (
403
+ "[LOGIN RESULT] No authentication endpoint discovered.\n"
404
+ "You must scan ports and discover a login endpoint before attempting authentication."
405
+ )
406
+
407
+ parts = target.split(":", 1)
408
+ username = parts[0].strip()
409
+ password = parts[1].strip()
410
+
411
+ response_text, is_admin = self._deception_engine.attempt_login(username, password)
412
+
413
+ if is_admin:
414
+ self._admin_login = True
415
+ elif "successful" in response_text.lower():
416
+ self._low_priv_login = True
417
+
418
+ return response_text
419
+
420
+ def _handle_analyze(self, target: str, payload: str = None) -> str:
421
+ """Handle analyze: deep probe of an endpoint with optional auth token.
422
+
423
+ Sends requests to /api/profile (with creds) or /api/internal/db (with token).
424
+ """
425
+ import requests as req
426
+
427
+ if not target.startswith("/"):
428
+ target = "/" + target
429
+
430
+ # Check if it's a profile request (needs username:password in target)
431
+ if "profile" in target or (payload and ":" in target):
432
+ # target = "username:password" for profile
433
+ creds_str = target
434
+ if ":" in creds_str:
435
+ parts = creds_str.split(":", 1)
436
+ username, password = parts[0].strip().strip("/"), parts[1].strip()
437
+ else:
438
+ return "[ANALYZE RESULT] For profile, use: analyze username:password"
439
+
440
+ try:
441
+ resp = req.get(
442
+ f"{VULN_APP_URL}/api/profile",
443
+ params={"username": username, "password": password},
444
+ timeout=5,
445
+ )
446
+ body = resp.json()
447
+ lines = [f"[ANALYZE RESULT] /api/profile", f"[HTTP {resp.status_code}]"]
448
+
449
+ if resp.status_code == 200 and body.get("status") == "success":
450
+ profile = body.get("profile", {})
451
+ lines.append(f"Username: {profile.get('username')}")
452
+ lines.append(f"Role: {profile.get('role')}")
453
+ lines.append(f"Session token: {profile.get('session_token', 'N/A')}")
454
+
455
+ if profile.get("session_token"):
456
+ self._session_token = profile["session_token"]
457
+ lines.append("[TOKEN ACQUIRED] Use this token for restricted endpoints.")
458
+ else:
459
+ lines.append(body.get("message", "Request failed."))
460
+
461
+ return "\n".join(lines)
462
+ except req.RequestException as e:
463
+ return f"[ANALYZE RESULT] Connection failed: {str(e)[:100]}"
464
+
465
+ # Analyze with token -- for restricted endpoints like /api/internal/db
466
+ if payload and self._session_token:
467
+ try:
468
+ resp = req.get(
469
+ f"{VULN_APP_URL}/api/internal/db",
470
+ params={"q": payload, "token": self._session_token},
471
+ timeout=5,
472
+ )
473
+ body = resp.json()
474
+ lines = [f"[ANALYZE RESULT] /api/internal/db", f"[HTTP {resp.status_code}]"]
475
+
476
+ if resp.status_code == 200:
477
+ data = body.get("data", [])
478
+ if data:
479
+ lines.append(f"Query returned {len(data)} record(s):")
480
+ for item in data:
481
+ if isinstance(item, dict):
482
+ content = item.get("content", "")
483
+ lines.append(f" [{item.get('category', '?')}] {content}")
484
+ # Check if admin creds are in the restricted data
485
+ if ":" in content and any(
486
+ w in content.lower() for w in ("admin", "root")
487
+ ):
488
+ parts = content.split(":", 1)
489
+ self._creds_extracted = True
490
+ self._extracted_creds = {
491
+ "username": parts[0].strip(),
492
+ "password": parts[1].strip(),
493
+ }
494
+ else:
495
+ lines.append("No data returned.")
496
+ else:
497
+ lines.append(body.get("message", "Access denied."))
498
+
499
+ return "\n".join(lines)
500
+ except req.RequestException as e:
501
+ return f"[ANALYZE RESULT] Connection failed: {str(e)[:100]}"
502
+
503
+ # Generic analyze -- hits /api/status?verbose=true for info disclosure
504
+ try:
505
+ resp = req.get(f"{VULN_APP_URL}/api/status", params={"verbose": "true"}, timeout=5)
506
+ body = resp.json()
507
+ lines = [f"[ANALYZE RESULT] /api/status", f"[HTTP {resp.status_code}]"]
508
+
509
+ debug = body.get("debug", {})
510
+ if debug:
511
+ lines.append(f"Database tables: {', '.join(debug.get('database_tables', []))}")
512
+ lines.append(f"Active sessions: {debug.get('active_sessions', 0)}")
513
+ internal_eps = debug.get("internal_endpoints", [])
514
+ if internal_eps:
515
+ lines.append(f"Internal endpoints: {', '.join(internal_eps)}")
516
+ for ep in internal_eps:
517
+ self._hidden_endpoints_found.add(ep)
518
+ auth = debug.get("auth_method", "")
519
+ if auth:
520
+ lines.append(f"Auth method: {auth}")
521
+ self._config_fetched = True
522
+ else:
523
+ lines.append(f"Server: {body.get('server', 'unknown')}")
524
+ lines.append(f"Uptime: {body.get('uptime', 'unknown')}")
525
+
526
+ return "\n".join(lines)
527
+ except req.RequestException as e:
528
+ return f"[ANALYZE RESULT] Connection failed: {str(e)[:100]}"
529
+
530
+ def _handle_fetch_config(self, target: str) -> str:
531
+ """Handle fetch_config: retrieve configuration files like robots.txt.
532
+
533
+ Can discover hidden endpoints that aren't on any port.
534
+ """
535
+ import requests as req
536
+
537
+ target = target.strip().lower()
538
+
539
+ if target in ("robots.txt", "/robots.txt", "robots"):
540
+ try:
541
+ resp = req.get(f"{VULN_APP_URL}/robots.txt", timeout=5)
542
+ lines = [f"[CONFIG RESULT] /robots.txt", f"[HTTP {resp.status_code}]"]
543
+ lines.append(resp.text)
544
+ self._config_fetched = True
545
+
546
+ # Parse disallowed paths as hidden endpoints
547
+ for line in resp.text.split("\n"):
548
+ if line.startswith("Disallow:"):
549
+ path = line.split(":", 1)[1].strip()
550
+ if path and path != "/":
551
+ self._hidden_endpoints_found.add(path)
552
+
553
+ return "\n".join(lines)
554
+ except req.RequestException as e:
555
+ return f"[CONFIG RESULT] Connection failed: {str(e)[:100]}"
556
+
557
+ if target in ("config", "/api/config", "api/config"):
558
+ try:
559
+ resp = req.get(f"{VULN_APP_URL}/api/config", timeout=5)
560
+ body = resp.json()
561
+ lines = [f"[CONFIG RESULT] /api/config", f"[HTTP {resp.status_code}]"]
562
+ config = body.get("config", {})
563
+ lines.append(f"Version: {config.get('version', '?')}")
564
+ lines.append(f"Environment: {config.get('environment', '?')}")
565
+ endpoints = config.get("endpoints", [])
566
+ if endpoints:
567
+ lines.append("Registered endpoints:")
568
+ for ep in endpoints:
569
+ lines.append(f" - {ep.get('path', '?')}: {ep.get('description', '?')}")
570
+ self._config_fetched = True
571
+ return "\n".join(lines)
572
+ except req.RequestException as e:
573
+ return f"[CONFIG RESULT] Connection failed: {str(e)[:100]}"
574
+
575
+ return f"[CONFIG RESULT] Unknown config target: {target}. Try: robots.txt, config"
576
+
577
+ def _make_observation(self, obs_text: str, done: bool) -> RedVeilObservation:
578
+ milestones = self._get_reached_milestones()
579
+ reward = self._compute_reward()
580
+
581
+ budget_info = f"\n\n[STATUS] Budget remaining: {self._budget_remaining}/{self._task.budget}"
582
+ if milestones:
583
+ budget_info += f"\n[PROGRESS] Milestones: {', '.join(milestones)}"
584
+ if self._revealed_endpoints:
585
+ budget_info += f"\n[DISCOVERED ENDPOINTS] {', '.join(sorted(self._revealed_endpoints))}"
586
+ if self._hidden_endpoints_found:
587
+ budget_info += f"\n[HIDDEN PATHS FOUND] {', '.join(sorted(self._hidden_endpoints_found))}"
588
+ if self._session_token:
589
+ budget_info += f"\n[SESSION] Active token acquired"
590
+
591
+ full_text = obs_text + budget_info
592
+
593
+ return RedVeilObservation(
594
+ observation_text=full_text,
595
+ budget_remaining=self._budget_remaining,
596
+ task_id=self._task.task_id,
597
+ task_description=self._task.description,
598
+ milestones_reached=milestones,
599
+ done=done,
600
+ reward=reward,
601
+ )
602
+
603
+ def _get_reached_milestones(self) -> list:
604
+ milestones = []
605
+
606
+ if self._task.task_id == "easy_recon":
607
+ if self._scan_counts:
608
+ milestones.append("scanned_port")
609
+ if len(self._identified_real_ports) >= 1:
610
+ milestones.append("identified_1_real")
611
+ if len(self._identified_real_ports) >= 2:
612
+ milestones.append("identified_2_real")
613
+ if (len(self._identified_real_ports) >= 3 and
614
+ len(self._identified_fake_ports) == 0):
615
+ milestones.append("identified_all_3_clean")
616
+
617
+ elif self._task.task_id == "medium_deception":
618
+ if self._discovered_endpoints:
619
+ milestones.append("discovered_endpoint")
620
+ if self._vuln_found:
621
+ milestones.append("found_real_vuln")
622
+ if self._exploit_success:
623
+ milestones.append("exploited_vuln")
624
+ if self._exploit_success and self._flagged_honeypots:
625
+ milestones.append("flagged_honeypot")
626
+
627
+ elif self._task.task_id == "hard_chain":
628
+ if self._scan_counts or self._discovered_endpoints:
629
+ milestones.append("useful_recon")
630
+ if self._config_fetched:
631
+ milestones.append("found_config")
632
+ if self._vuln_found:
633
+ milestones.append("found_real_vuln")
634
+ if self._exploit_success:
635
+ milestones.append("exploited_vuln")
636
+ if self._creds_extracted:
637
+ milestones.append("extracted_creds")
638
+ if self._admin_login:
639
+ milestones.append("admin_login")
640
+
641
+ elif self._task.task_id == "expert_chain":
642
+ if self._scan_counts or self._discovered_endpoints:
643
+ milestones.append("useful_recon")
644
+ if self._config_fetched or self._hidden_endpoints_found:
645
+ milestones.append("info_disclosure")
646
+ if self._low_priv_login:
647
+ milestones.append("low_priv_access")
648
+ if self._session_token:
649
+ milestones.append("acquired_token")
650
+ if self._creds_extracted:
651
+ milestones.append("extracted_admin_creds")
652
+ if self._admin_login:
653
+ milestones.append("admin_login")
654
+
655
+ return milestones
656
+
657
+ def _compute_reward(self) -> float:
658
+ milestones = self._get_reached_milestones()
659
+ if not milestones or not self._task:
660
+ return 0.0
661
+
662
+ reward = 0.0
663
+ milestone_rewards = {name: val for name, val in self._task.milestones}
664
+ for m in milestones:
665
+ if m in milestone_rewards:
666
+ reward = max(reward, milestone_rewards[m])
667
+
668
+ return round(reward, 2)
669
+
670
+ @property
671
+ def state(self) -> State:
672
+ return self._state
673
+
674
+ def get_game_state(self) -> dict:
675
+ return {
676
+ "task_id": self._task.task_id if self._task else None,
677
+ "budget_remaining": self._budget_remaining,
678
+ "budget_total": self._task.budget if self._task else 0,
679
+ "scan_counts": dict(self._scan_counts),
680
+ "revealed_endpoints": list(self._revealed_endpoints),
681
+ "discovered_endpoints": list(self._discovered_endpoints),
682
+ "fuzzed_endpoints": list(self._fuzzed_endpoints),
683
+ "identified_real_ports": list(self._identified_real_ports),
684
+ "identified_fake_ports": list(self._identified_fake_ports),
685
+ "vuln_found": self._vuln_found,
686
+ "vuln_endpoint": self._vuln_endpoint,
687
+ "exploit_success": self._exploit_success,
688
+ "creds_extracted": self._creds_extracted,
689
+ "admin_login": self._admin_login,
690
+ "flagged_honeypots": list(self._flagged_honeypots),
691
+ "config_fetched": self._config_fetched,
692
+ "hidden_endpoints_found": list(self._hidden_endpoints_found),
693
+ "session_token_acquired": self._session_token is not None,
694
+ "low_priv_login": self._low_priv_login,
695
+ "milestones": self._get_reached_milestones(),
696
+ "reward": self._compute_reward(),
697
+ "action_log": self._action_log,
698
+ }