Spaces:
Runtime error
Runtime error
Lars Talian commited on
Commit ·
e37c7b1
1
Parent(s): 0fd9230
Wire console to real episode state
Browse files- src/open_range/server/console.py +89 -3
- src/open_range/server/environment.py +29 -6
- tests/test_console.py +93 -7
- tests/test_console_bridge.py +100 -0
- tests/test_console_context.py +32 -1
src/open_range/server/console.py
CHANGED
|
@@ -6,6 +6,7 @@ the range environment state, viewing action history, and triggering resets.
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
import time
|
| 10 |
from typing import Any
|
| 11 |
|
|
@@ -20,6 +21,7 @@ console_router = APIRouter(prefix="/console", tags=["console"])
|
|
| 20 |
|
| 21 |
_action_history: list[dict[str, Any]] = []
|
| 22 |
_MAX_HISTORY = 50 # keep more than 20 internally, but serve 20
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def record_action(action_record: dict[str, Any]) -> None:
|
|
@@ -39,6 +41,49 @@ def get_history(limit: int = 20) -> list[dict[str, Any]]:
|
|
| 39 |
return list(reversed(_action_history[-limit:]))
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# ---------------------------------------------------------------------------
|
| 43 |
# API routes
|
| 44 |
# ---------------------------------------------------------------------------
|
|
@@ -48,6 +93,15 @@ def get_history(limit: int = 20) -> list[dict[str, Any]]:
|
|
| 48 |
async def api_snapshot(request: Request) -> JSONResponse:
|
| 49 |
"""Return current snapshot metadata (no truth graph or flags)."""
|
| 50 |
ctx = _get_env_context(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
env = ctx["env"]
|
| 52 |
snapshot = env.snapshot
|
| 53 |
if snapshot is None:
|
|
@@ -84,6 +138,15 @@ async def api_snapshot(request: Request) -> JSONResponse:
|
|
| 84 |
async def api_episode(request: Request) -> JSONResponse:
|
| 85 |
"""Return current episode state."""
|
| 86 |
ctx = _get_env_context(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
env = ctx["env"]
|
| 88 |
state = env.state
|
| 89 |
return JSONResponse({
|
|
@@ -120,8 +183,9 @@ def _get_env_context(request: Request) -> dict[str, Any]:
|
|
| 120 |
|
| 121 |
Priority:
|
| 122 |
1. Active OpenEnv WebSocket session environment (session-scoped truth)
|
| 123 |
-
2.
|
| 124 |
-
3.
|
|
|
|
| 125 |
"""
|
| 126 |
app = request.app
|
| 127 |
|
|
@@ -132,6 +196,7 @@ def _get_env_context(request: Request) -> dict[str, Any]:
|
|
| 132 |
session_id, env = next(iter(sessions.items()))
|
| 133 |
return {
|
| 134 |
"env": env,
|
|
|
|
| 135 |
"state_scope": "websocket_session",
|
| 136 |
"session_id": session_id,
|
| 137 |
"warning": None,
|
|
@@ -144,6 +209,7 @@ def _get_env_context(request: Request) -> dict[str, Any]:
|
|
| 144 |
)
|
| 145 |
return {
|
| 146 |
"env": sessions[selected_id],
|
|
|
|
| 147 |
"state_scope": "websocket_session",
|
| 148 |
"session_id": selected_id,
|
| 149 |
"warning": (
|
|
@@ -152,9 +218,23 @@ def _get_env_context(request: Request) -> dict[str, Any]:
|
|
| 152 |
),
|
| 153 |
}
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
if hasattr(app.state, "env"):
|
| 156 |
return {
|
| 157 |
"env": app.state.env,
|
|
|
|
| 158 |
"state_scope": "app_state_env",
|
| 159 |
"session_id": None,
|
| 160 |
"warning": (
|
|
@@ -170,6 +250,7 @@ def _get_env_context(request: Request) -> dict[str, Any]:
|
|
| 170 |
app.state._fallback_env = RangeEnvironment(docker_available=False)
|
| 171 |
return {
|
| 172 |
"env": app.state._fallback_env,
|
|
|
|
| 173 |
"state_scope": "fallback_env",
|
| 174 |
"session_id": None,
|
| 175 |
"warning": "Console is using a fallback environment (no server session available).",
|
|
@@ -284,6 +365,7 @@ _CONSOLE_HTML = """\
|
|
| 284 |
}
|
| 285 |
.history-item .mode-red { color: var(--red); }
|
| 286 |
.history-item .mode-blue { color: var(--accent); }
|
|
|
|
| 287 |
.history-item .ts {
|
| 288 |
color: var(--text-dim);
|
| 289 |
font-size: 11px;
|
|
@@ -424,7 +506,11 @@ function renderHistory(items) {
|
|
| 424 |
return;
|
| 425 |
}
|
| 426 |
el.innerHTML = items.map(function(it) {
|
| 427 |
-
const modeClass = it.mode === "red"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
return '<div class="history-item">' +
|
| 429 |
'<span class="ts">' + fmtTime(it.time) + '</span>' +
|
| 430 |
'<span class="step">step ' + (it.step || "-") + '</span> ' +
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import copy
|
| 10 |
import time
|
| 11 |
from typing import Any
|
| 12 |
|
|
|
|
| 21 |
|
| 22 |
_action_history: list[dict[str, Any]] = []
|
| 23 |
_MAX_HISTORY = 50 # keep more than 20 internally, but serve 20
|
| 24 |
+
_published_episode: dict[str, dict[str, Any]] | None = None
|
| 25 |
|
| 26 |
|
| 27 |
def record_action(action_record: dict[str, Any]) -> None:
|
|
|
|
| 41 |
return list(reversed(_action_history[-limit:]))
|
| 42 |
|
| 43 |
|
| 44 |
+
def publish_episode(snapshot: Any, state: Any) -> None:
|
| 45 |
+
"""Publish the latest episode summary for console readers.
|
| 46 |
+
|
| 47 |
+
This is the bridge used by real reset/step traffic from HTTP handlers,
|
| 48 |
+
where OpenEnv creates short-lived environment instances per request.
|
| 49 |
+
"""
|
| 50 |
+
global _published_episode
|
| 51 |
+
|
| 52 |
+
topo = snapshot.topology if snapshot and isinstance(snapshot.topology, dict) else {}
|
| 53 |
+
hosts = topo.get("hosts", [])
|
| 54 |
+
zones = topo.get("zones", {})
|
| 55 |
+
vuln_count = 0
|
| 56 |
+
if snapshot is not None and getattr(snapshot, "truth_graph", None) is not None:
|
| 57 |
+
vuln_count = len(getattr(snapshot.truth_graph, "vulns", []) or [])
|
| 58 |
+
|
| 59 |
+
_published_episode = {
|
| 60 |
+
"snapshot": {
|
| 61 |
+
"id": getattr(state, "episode_id", None),
|
| 62 |
+
"tier": topo.get("tier", getattr(state, "tier", 1)),
|
| 63 |
+
"hosts": list(hosts) if isinstance(hosts, list) else [],
|
| 64 |
+
"zones": copy.deepcopy(zones) if isinstance(zones, dict) else {},
|
| 65 |
+
"vuln_count": vuln_count,
|
| 66 |
+
},
|
| 67 |
+
"episode": {
|
| 68 |
+
"step_count": int(getattr(state, "step_count", 0) or 0),
|
| 69 |
+
"flags_found": len(getattr(state, "flags_found", []) or []),
|
| 70 |
+
"mode": getattr(state, "mode", ""),
|
| 71 |
+
"services_status": copy.deepcopy(getattr(state, "services_status", {}) or {}),
|
| 72 |
+
},
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def clear_episode() -> None:
|
| 77 |
+
"""Clear the published episode summary."""
|
| 78 |
+
global _published_episode
|
| 79 |
+
_published_episode = None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def get_published_episode() -> dict[str, dict[str, Any]] | None:
|
| 83 |
+
"""Return a defensive copy of the published episode summary."""
|
| 84 |
+
return copy.deepcopy(_published_episode)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
# ---------------------------------------------------------------------------
|
| 88 |
# API routes
|
| 89 |
# ---------------------------------------------------------------------------
|
|
|
|
| 93 |
async def api_snapshot(request: Request) -> JSONResponse:
|
| 94 |
"""Return current snapshot metadata (no truth graph or flags)."""
|
| 95 |
ctx = _get_env_context(request)
|
| 96 |
+
published = ctx.get("published_episode")
|
| 97 |
+
if published is not None:
|
| 98 |
+
return JSONResponse({
|
| 99 |
+
**published["snapshot"],
|
| 100 |
+
"state_scope": ctx["state_scope"],
|
| 101 |
+
"session_id": ctx["session_id"],
|
| 102 |
+
"warning": ctx["warning"],
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
env = ctx["env"]
|
| 106 |
snapshot = env.snapshot
|
| 107 |
if snapshot is None:
|
|
|
|
| 138 |
async def api_episode(request: Request) -> JSONResponse:
|
| 139 |
"""Return current episode state."""
|
| 140 |
ctx = _get_env_context(request)
|
| 141 |
+
published = ctx.get("published_episode")
|
| 142 |
+
if published is not None:
|
| 143 |
+
return JSONResponse({
|
| 144 |
+
**published["episode"],
|
| 145 |
+
"state_scope": ctx["state_scope"],
|
| 146 |
+
"session_id": ctx["session_id"],
|
| 147 |
+
"warning": ctx["warning"],
|
| 148 |
+
})
|
| 149 |
+
|
| 150 |
env = ctx["env"]
|
| 151 |
state = env.state
|
| 152 |
return JSONResponse({
|
|
|
|
| 183 |
|
| 184 |
Priority:
|
| 185 |
1. Active OpenEnv WebSocket session environment (session-scoped truth)
|
| 186 |
+
2. Published reset/step state from real HTTP traffic
|
| 187 |
+
3. ``app.state.env`` fallback environment (global app scope)
|
| 188 |
+
4. Lazily created fallback environment (tests/dev)
|
| 189 |
"""
|
| 190 |
app = request.app
|
| 191 |
|
|
|
|
| 196 |
session_id, env = next(iter(sessions.items()))
|
| 197 |
return {
|
| 198 |
"env": env,
|
| 199 |
+
"published_episode": None,
|
| 200 |
"state_scope": "websocket_session",
|
| 201 |
"session_id": session_id,
|
| 202 |
"warning": None,
|
|
|
|
| 209 |
)
|
| 210 |
return {
|
| 211 |
"env": sessions[selected_id],
|
| 212 |
+
"published_episode": None,
|
| 213 |
"state_scope": "websocket_session",
|
| 214 |
"session_id": selected_id,
|
| 215 |
"warning": (
|
|
|
|
| 218 |
),
|
| 219 |
}
|
| 220 |
|
| 221 |
+
published = get_published_episode()
|
| 222 |
+
if published is not None:
|
| 223 |
+
return {
|
| 224 |
+
"env": None,
|
| 225 |
+
"published_episode": published,
|
| 226 |
+
"state_scope": "published_episode",
|
| 227 |
+
"session_id": None,
|
| 228 |
+
"warning": (
|
| 229 |
+
"No active WebSocket session found; console is showing the most "
|
| 230 |
+
"recent reset/step state observed by the server."
|
| 231 |
+
),
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
if hasattr(app.state, "env"):
|
| 235 |
return {
|
| 236 |
"env": app.state.env,
|
| 237 |
+
"published_episode": None,
|
| 238 |
"state_scope": "app_state_env",
|
| 239 |
"session_id": None,
|
| 240 |
"warning": (
|
|
|
|
| 250 |
app.state._fallback_env = RangeEnvironment(docker_available=False)
|
| 251 |
return {
|
| 252 |
"env": app.state._fallback_env,
|
| 253 |
+
"published_episode": None,
|
| 254 |
"state_scope": "fallback_env",
|
| 255 |
"session_id": None,
|
| 256 |
"warning": "Console is using a fallback environment (no server session available).",
|
|
|
|
| 365 |
}
|
| 366 |
.history-item .mode-red { color: var(--red); }
|
| 367 |
.history-item .mode-blue { color: var(--accent); }
|
| 368 |
+
.history-item .mode-system { color: var(--yellow); }
|
| 369 |
.history-item .ts {
|
| 370 |
color: var(--text-dim);
|
| 371 |
font-size: 11px;
|
|
|
|
| 506 |
return;
|
| 507 |
}
|
| 508 |
el.innerHTML = items.map(function(it) {
|
| 509 |
+
const modeClass = it.mode === "red"
|
| 510 |
+
? "mode-red"
|
| 511 |
+
: it.mode === "blue"
|
| 512 |
+
? "mode-blue"
|
| 513 |
+
: "mode-system";
|
| 514 |
return '<div class="history-item">' +
|
| 515 |
'<span class="ts">' + fmtTime(it.time) + '</span>' +
|
| 516 |
'<span class="step">step ' + (it.step || "-") + '</span> ' +
|
src/open_range/server/environment.py
CHANGED
|
@@ -924,6 +924,15 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
|
|
| 924 |
except Exception:
|
| 925 |
pass
|
| 926 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
# -----------------------------------------------------------------
|
| 928 |
# Snapshot selection
|
| 929 |
# -----------------------------------------------------------------
|
|
@@ -1462,6 +1471,16 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
|
|
| 1462 |
)
|
| 1463 |
|
| 1464 |
self._publish_console_state()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1465 |
return RangeObservation(stdout=briefing)
|
| 1466 |
|
| 1467 |
def step(
|
|
@@ -1520,6 +1539,15 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
|
|
| 1520 |
}
|
| 1521 |
|
| 1522 |
if cmd_name in meta_handlers:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1523 |
obs = meta_handlers[cmd_name](action)
|
| 1524 |
self._refresh_services_status()
|
| 1525 |
obs = self._apply_rewards(action, obs)
|
|
@@ -1550,12 +1578,7 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
|
|
| 1550 |
self._red_history.append(action_record)
|
| 1551 |
else:
|
| 1552 |
self._blue_history.append(action_record)
|
| 1553 |
-
|
| 1554 |
-
from open_range.server.console import record_action
|
| 1555 |
-
|
| 1556 |
-
record_action({"mode": action.mode, **action_record})
|
| 1557 |
-
except Exception:
|
| 1558 |
-
pass
|
| 1559 |
|
| 1560 |
# Check for milestone completion (#17)
|
| 1561 |
milestone = self._check_milestone(stdout)
|
|
|
|
| 924 |
except Exception:
|
| 925 |
pass
|
| 926 |
|
| 927 |
+
def _record_console_action(self, mode: str, action_record: dict[str, Any]) -> None:
|
| 928 |
+
"""Record a console-visible action without coupling to console internals."""
|
| 929 |
+
try:
|
| 930 |
+
from open_range.server.console import record_action
|
| 931 |
+
|
| 932 |
+
record_action({"mode": mode, **action_record})
|
| 933 |
+
except Exception:
|
| 934 |
+
pass
|
| 935 |
+
|
| 936 |
# -----------------------------------------------------------------
|
| 937 |
# Snapshot selection
|
| 938 |
# -----------------------------------------------------------------
|
|
|
|
| 1471 |
)
|
| 1472 |
|
| 1473 |
self._publish_console_state()
|
| 1474 |
+
self._record_console_action(
|
| 1475 |
+
"system",
|
| 1476 |
+
{
|
| 1477 |
+
"step": 0,
|
| 1478 |
+
"command": "reset",
|
| 1479 |
+
"cmd_name": "reset",
|
| 1480 |
+
"time": time.time(),
|
| 1481 |
+
"episode_id": eid,
|
| 1482 |
+
},
|
| 1483 |
+
)
|
| 1484 |
return RangeObservation(stdout=briefing)
|
| 1485 |
|
| 1486 |
def step(
|
|
|
|
| 1539 |
}
|
| 1540 |
|
| 1541 |
if cmd_name in meta_handlers:
|
| 1542 |
+
self._record_console_action(
|
| 1543 |
+
action.mode,
|
| 1544 |
+
{
|
| 1545 |
+
"step": self._state.step_count,
|
| 1546 |
+
"command": action.command,
|
| 1547 |
+
"cmd_name": cmd_name,
|
| 1548 |
+
"time": time.time(),
|
| 1549 |
+
},
|
| 1550 |
+
)
|
| 1551 |
obs = meta_handlers[cmd_name](action)
|
| 1552 |
self._refresh_services_status()
|
| 1553 |
obs = self._apply_rewards(action, obs)
|
|
|
|
| 1578 |
self._red_history.append(action_record)
|
| 1579 |
else:
|
| 1580 |
self._blue_history.append(action_record)
|
| 1581 |
+
self._record_console_action(action.mode, action_record)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1582 |
|
| 1583 |
# Check for milestone completion (#17)
|
| 1584 |
milestone = self._check_milestone(stdout)
|
tests/test_console.py
CHANGED
|
@@ -1,21 +1,22 @@
|
|
| 1 |
-
"""Tests for the operator debugging console
|
| 2 |
|
| 3 |
Uses Starlette's TestClient against the OpenEnv app with console router.
|
| 4 |
No Docker dependency.
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
via the module-level record_action() / clear_history() helpers.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
|
|
|
| 13 |
import pytest
|
| 14 |
from starlette.testclient import TestClient
|
| 15 |
|
| 16 |
from open_range.protocols import SnapshotSpec
|
| 17 |
from open_range.server.app import create_app
|
| 18 |
-
from open_range.server.console import clear_history, record_action
|
| 19 |
from open_range.server.environment import RangeEnvironment
|
| 20 |
|
| 21 |
_TEST_SNAPSHOT = SnapshotSpec(
|
|
@@ -36,8 +37,11 @@ def client():
|
|
| 36 |
# Store a shared env so console API endpoints can access state
|
| 37 |
env = RangeEnvironment(docker_available=False)
|
| 38 |
app.state.env = env
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
clear_history()
|
| 40 |
-
return TestClient(app)
|
| 41 |
|
| 42 |
|
| 43 |
@pytest.fixture()
|
|
@@ -101,6 +105,27 @@ class TestSnapshotAPI:
|
|
| 101 |
assert "flags" not in data
|
| 102 |
assert "golden_path" not in data
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
# ===================================================================
|
| 106 |
# GET /console/api/episode
|
|
@@ -133,6 +158,25 @@ class TestEpisodeAPI:
|
|
| 133 |
data = client.get("/console/api/episode").json()
|
| 134 |
assert data["step_count"] == 1
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
# ===================================================================
|
| 138 |
# GET /console/api/history
|
|
@@ -176,9 +220,32 @@ class TestHistoryAPI:
|
|
| 176 |
env.reset(snapshot=_TEST_SNAPSHOT)
|
| 177 |
env.step(RangeAction(command="nmap -sV web", mode="red"))
|
| 178 |
data = client.get("/console/api/history").json()
|
| 179 |
-
assert len(data) ==
|
| 180 |
assert data[0]["command"] == "nmap -sV web"
|
| 181 |
assert data[0]["mode"] == "red"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
def test_history_max_20(self, client: TestClient):
|
| 184 |
"""History API should return at most 20 entries."""
|
|
@@ -188,3 +255,22 @@ class TestHistoryAPI:
|
|
| 188 |
record_action({"step": i, "command": f"cmd_{i}", "mode": "red", "time": time.time()})
|
| 189 |
data = client.get("/console/api/history").json()
|
| 190 |
assert len(data) == 20
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the operator debugging console.
|
| 2 |
|
| 3 |
Uses Starlette's TestClient against the OpenEnv app with console router.
|
| 4 |
No Docker dependency.
|
| 5 |
|
| 6 |
+
The console prefers live WebSocket session state, then published reset/step
|
| 7 |
+
state from real traffic, then a local fallback env for dev/test use.
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
from unittest.mock import patch
|
| 13 |
+
|
| 14 |
import pytest
|
| 15 |
from starlette.testclient import TestClient
|
| 16 |
|
| 17 |
from open_range.protocols import SnapshotSpec
|
| 18 |
from open_range.server.app import create_app
|
| 19 |
+
from open_range.server.console import clear_episode, clear_history, record_action
|
| 20 |
from open_range.server.environment import RangeEnvironment
|
| 21 |
|
| 22 |
_TEST_SNAPSHOT = SnapshotSpec(
|
|
|
|
| 37 |
# Store a shared env so console API endpoints can access state
|
| 38 |
env = RangeEnvironment(docker_available=False)
|
| 39 |
app.state.env = env
|
| 40 |
+
clear_episode()
|
| 41 |
+
clear_history()
|
| 42 |
+
yield TestClient(app)
|
| 43 |
+
clear_episode()
|
| 44 |
clear_history()
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
@pytest.fixture()
|
|
|
|
| 105 |
assert "flags" not in data
|
| 106 |
assert "golden_path" not in data
|
| 107 |
|
| 108 |
+
def test_http_reset_publishes_snapshot_to_console(self):
|
| 109 |
+
with patch(
|
| 110 |
+
"open_range.server.environment.RangeEnvironment._select_snapshot",
|
| 111 |
+
return_value=_TEST_SNAPSHOT,
|
| 112 |
+
), patch(
|
| 113 |
+
"open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
|
| 114 |
+
):
|
| 115 |
+
app = create_app()
|
| 116 |
+
clear_episode()
|
| 117 |
+
clear_history()
|
| 118 |
+
client = TestClient(app)
|
| 119 |
+
resp = client.post("/reset", json={"episode_id": "http_reset_1"})
|
| 120 |
+
assert resp.status_code == 200
|
| 121 |
+
|
| 122 |
+
data = client.get("/console/api/snapshot").json()
|
| 123 |
+
assert data["id"] == "http_reset_1"
|
| 124 |
+
assert data["state_scope"] == "published_episode"
|
| 125 |
+
assert data["hosts"] == ["attacker", "siem"]
|
| 126 |
+
clear_episode()
|
| 127 |
+
clear_history()
|
| 128 |
+
|
| 129 |
|
| 130 |
# ===================================================================
|
| 131 |
# GET /console/api/episode
|
|
|
|
| 158 |
data = client.get("/console/api/episode").json()
|
| 159 |
assert data["step_count"] == 1
|
| 160 |
|
| 161 |
+
def test_http_reset_publishes_episode_to_console(self):
|
| 162 |
+
with patch(
|
| 163 |
+
"open_range.server.environment.RangeEnvironment._select_snapshot",
|
| 164 |
+
return_value=_TEST_SNAPSHOT,
|
| 165 |
+
), patch(
|
| 166 |
+
"open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
|
| 167 |
+
):
|
| 168 |
+
app = create_app()
|
| 169 |
+
clear_episode()
|
| 170 |
+
clear_history()
|
| 171 |
+
client = TestClient(app)
|
| 172 |
+
client.post("/reset", json={"episode_id": "http_reset_2"})
|
| 173 |
+
data = client.get("/console/api/episode").json()
|
| 174 |
+
assert data["step_count"] == 0
|
| 175 |
+
assert data["mode"] == "red"
|
| 176 |
+
assert data["state_scope"] == "published_episode"
|
| 177 |
+
clear_episode()
|
| 178 |
+
clear_history()
|
| 179 |
+
|
| 180 |
|
| 181 |
# ===================================================================
|
| 182 |
# GET /console/api/history
|
|
|
|
| 220 |
env.reset(snapshot=_TEST_SNAPSHOT)
|
| 221 |
env.step(RangeAction(command="nmap -sV web", mode="red"))
|
| 222 |
data = client.get("/console/api/history").json()
|
| 223 |
+
assert len(data) == 2
|
| 224 |
assert data[0]["command"] == "nmap -sV web"
|
| 225 |
assert data[0]["mode"] == "red"
|
| 226 |
+
assert data[1]["command"] == "reset"
|
| 227 |
+
assert data[1]["mode"] == "system"
|
| 228 |
+
|
| 229 |
+
def test_history_records_meta_step_commands(self, client: TestClient, env: RangeEnvironment):
|
| 230 |
+
from open_range.server.models import RangeAction
|
| 231 |
+
|
| 232 |
+
env.reset(snapshot=_TEST_SNAPSHOT)
|
| 233 |
+
env.step(RangeAction(command="submit_finding suspicious scan on web", mode="blue"))
|
| 234 |
+
data = client.get("/console/api/history").json()
|
| 235 |
+
assert data[0]["command"] == "submit_finding suspicious scan on web"
|
| 236 |
+
assert data[0]["mode"] == "blue"
|
| 237 |
+
|
| 238 |
+
def test_history_reset_clears_prior_entries_and_records_reset(self, client: TestClient, env: RangeEnvironment):
|
| 239 |
+
import time
|
| 240 |
+
|
| 241 |
+
record_action({"step": 99, "command": "old", "mode": "red", "time": time.time()})
|
| 242 |
+
|
| 243 |
+
env.reset(snapshot=_TEST_SNAPSHOT, episode_id="history_reset")
|
| 244 |
+
data = client.get("/console/api/history").json()
|
| 245 |
+
assert len(data) == 1
|
| 246 |
+
assert data[0]["command"] == "reset"
|
| 247 |
+
assert data[0]["mode"] == "system"
|
| 248 |
+
assert data[0]["episode_id"] == "history_reset"
|
| 249 |
|
| 250 |
def test_history_max_20(self, client: TestClient):
|
| 251 |
"""History API should return at most 20 entries."""
|
|
|
|
| 255 |
record_action({"step": i, "command": f"cmd_{i}", "mode": "red", "time": time.time()})
|
| 256 |
data = client.get("/console/api/history").json()
|
| 257 |
assert len(data) == 20
|
| 258 |
+
|
| 259 |
+
def test_http_reset_records_history(self):
|
| 260 |
+
with patch(
|
| 261 |
+
"open_range.server.environment.RangeEnvironment._select_snapshot",
|
| 262 |
+
return_value=_TEST_SNAPSHOT,
|
| 263 |
+
), patch(
|
| 264 |
+
"open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
|
| 265 |
+
):
|
| 266 |
+
app = create_app()
|
| 267 |
+
clear_episode()
|
| 268 |
+
clear_history()
|
| 269 |
+
client = TestClient(app)
|
| 270 |
+
client.post("/reset", json={"episode_id": "http_reset_3"})
|
| 271 |
+
data = client.get("/console/api/history").json()
|
| 272 |
+
assert len(data) == 1
|
| 273 |
+
assert data[0]["command"] == "reset"
|
| 274 |
+
assert data[0]["mode"] == "system"
|
| 275 |
+
clear_episode()
|
| 276 |
+
clear_history()
|
tests/test_console_bridge.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Focused console bridge tests without TestClient transport."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
|
| 9 |
+
from open_range.protocols import SnapshotSpec
|
| 10 |
+
from open_range.server.app import create_app
|
| 11 |
+
from open_range.server.console import (
|
| 12 |
+
api_episode,
|
| 13 |
+
api_history,
|
| 14 |
+
api_snapshot,
|
| 15 |
+
clear_episode,
|
| 16 |
+
clear_history,
|
| 17 |
+
get_history,
|
| 18 |
+
)
|
| 19 |
+
from open_range.server.environment import RangeEnvironment
|
| 20 |
+
from open_range.server.models import RangeAction
|
| 21 |
+
|
| 22 |
+
_TEST_SNAPSHOT = SnapshotSpec(
|
| 23 |
+
topology={"hosts": ["attacker", "siem"]},
|
| 24 |
+
flags=[],
|
| 25 |
+
golden_path=[],
|
| 26 |
+
task={
|
| 27 |
+
"red_briefing": "Console bridge test mode.",
|
| 28 |
+
"blue_briefing": "Console bridge test mode.",
|
| 29 |
+
},
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _request(app):
|
| 34 |
+
return SimpleNamespace(app=app)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _json_response_payload(response) -> dict | list:
|
| 38 |
+
return json.loads(response.body.decode())
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_http_reset_publishes_console_snapshot_and_episode():
|
| 42 |
+
clear_episode()
|
| 43 |
+
clear_history()
|
| 44 |
+
with patch(
|
| 45 |
+
"open_range.server.environment.RangeEnvironment._select_snapshot",
|
| 46 |
+
return_value=_TEST_SNAPSHOT,
|
| 47 |
+
), patch(
|
| 48 |
+
"open_range.server.environment.RangeEnvironment._ensure_clean_reset_path",
|
| 49 |
+
):
|
| 50 |
+
app = create_app()
|
| 51 |
+
env = app.state.openenv_server._env_factory()
|
| 52 |
+
try:
|
| 53 |
+
env.reset(episode_id="http_console_ep")
|
| 54 |
+
finally:
|
| 55 |
+
env.close()
|
| 56 |
+
|
| 57 |
+
snapshot = _json_response_payload(_run(api_snapshot(_request(app))))
|
| 58 |
+
episode = _json_response_payload(_run(api_episode(_request(app))))
|
| 59 |
+
|
| 60 |
+
assert snapshot["id"] == "http_console_ep"
|
| 61 |
+
assert snapshot["hosts"] == ["attacker", "siem"]
|
| 62 |
+
assert snapshot["state_scope"] == "published_episode"
|
| 63 |
+
assert episode["step_count"] == 0
|
| 64 |
+
assert episode["mode"] == "red"
|
| 65 |
+
assert episode["state_scope"] == "published_episode"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_environment_reset_clears_history_and_records_reset():
|
| 69 |
+
clear_episode()
|
| 70 |
+
clear_history()
|
| 71 |
+
env = RangeEnvironment(docker_available=False)
|
| 72 |
+
|
| 73 |
+
env.reset(snapshot=_TEST_SNAPSHOT, episode_id="console_reset_ep")
|
| 74 |
+
|
| 75 |
+
history = get_history()
|
| 76 |
+
assert len(history) == 1
|
| 77 |
+
assert history[0]["command"] == "reset"
|
| 78 |
+
assert history[0]["mode"] == "system"
|
| 79 |
+
assert history[0]["episode_id"] == "console_reset_ep"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_environment_meta_steps_record_console_history():
|
| 83 |
+
clear_episode()
|
| 84 |
+
clear_history()
|
| 85 |
+
env = RangeEnvironment(docker_available=False)
|
| 86 |
+
env.reset(snapshot=_TEST_SNAPSHOT, episode_id="console_meta_ep")
|
| 87 |
+
|
| 88 |
+
env.step(RangeAction(command="submit_finding suspicious scan on web", mode="blue"))
|
| 89 |
+
|
| 90 |
+
history = _json_response_payload(_run(api_history()))
|
| 91 |
+
assert len(history) == 2
|
| 92 |
+
assert history[0]["command"] == "submit_finding suspicious scan on web"
|
| 93 |
+
assert history[0]["mode"] == "blue"
|
| 94 |
+
assert history[1]["command"] == "reset"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _run(awaitable):
|
| 98 |
+
import asyncio
|
| 99 |
+
|
| 100 |
+
return asyncio.run(awaitable)
|
tests/test_console_context.py
CHANGED
|
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
from types import SimpleNamespace
|
| 6 |
|
| 7 |
-
from open_range.
|
|
|
|
| 8 |
from open_range.server.environment import RangeEnvironment
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class _Req:
|
|
@@ -18,6 +20,7 @@ def _app_with_state(**kwargs):
|
|
| 18 |
|
| 19 |
|
| 20 |
def test_prefers_active_websocket_session_env():
|
|
|
|
| 21 |
fallback_env = RangeEnvironment(docker_available=False)
|
| 22 |
ws_env = RangeEnvironment(docker_available=False)
|
| 23 |
server = SimpleNamespace(
|
|
@@ -34,6 +37,7 @@ def test_prefers_active_websocket_session_env():
|
|
| 34 |
|
| 35 |
|
| 36 |
def test_uses_app_state_env_when_no_active_session():
|
|
|
|
| 37 |
fallback_env = RangeEnvironment(docker_available=False)
|
| 38 |
server = SimpleNamespace(_sessions={}, _session_info={})
|
| 39 |
request = _Req(_app_with_state(env=fallback_env, openenv_server=server))
|
|
@@ -46,6 +50,7 @@ def test_uses_app_state_env_when_no_active_session():
|
|
| 46 |
|
| 47 |
|
| 48 |
def test_multiple_sessions_selects_most_recent_and_warns():
|
|
|
|
| 49 |
older_env = RangeEnvironment(docker_available=False)
|
| 50 |
newer_env = RangeEnvironment(docker_available=False)
|
| 51 |
server = SimpleNamespace(
|
|
@@ -62,3 +67,29 @@ def test_multiple_sessions_selects_most_recent_and_warns():
|
|
| 62 |
assert ctx["state_scope"] == "websocket_session"
|
| 63 |
assert ctx["session_id"] == "new"
|
| 64 |
assert "active sessions" in (ctx["warning"] or "").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
from types import SimpleNamespace
|
| 6 |
|
| 7 |
+
from open_range.protocols import SnapshotSpec
|
| 8 |
+
from open_range.server.console import _get_env_context, clear_episode, publish_episode
|
| 9 |
from open_range.server.environment import RangeEnvironment
|
| 10 |
+
from open_range.server.models import RangeState
|
| 11 |
|
| 12 |
|
| 13 |
class _Req:
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def test_prefers_active_websocket_session_env():
|
| 23 |
+
clear_episode()
|
| 24 |
fallback_env = RangeEnvironment(docker_available=False)
|
| 25 |
ws_env = RangeEnvironment(docker_available=False)
|
| 26 |
server = SimpleNamespace(
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def test_uses_app_state_env_when_no_active_session():
|
| 40 |
+
clear_episode()
|
| 41 |
fallback_env = RangeEnvironment(docker_available=False)
|
| 42 |
server = SimpleNamespace(_sessions={}, _session_info={})
|
| 43 |
request = _Req(_app_with_state(env=fallback_env, openenv_server=server))
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
def test_multiple_sessions_selects_most_recent_and_warns():
|
| 53 |
+
clear_episode()
|
| 54 |
older_env = RangeEnvironment(docker_available=False)
|
| 55 |
newer_env = RangeEnvironment(docker_available=False)
|
| 56 |
server = SimpleNamespace(
|
|
|
|
| 67 |
assert ctx["state_scope"] == "websocket_session"
|
| 68 |
assert ctx["session_id"] == "new"
|
| 69 |
assert "active sessions" in (ctx["warning"] or "").lower()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_uses_published_episode_before_app_state_fallback():
|
| 73 |
+
clear_episode()
|
| 74 |
+
snapshot = SnapshotSpec(
|
| 75 |
+
topology={"hosts": ["attacker"], "zones": {"dmz": ["web"]}, "tier": 2},
|
| 76 |
+
flags=[],
|
| 77 |
+
golden_path=[],
|
| 78 |
+
task={"red_briefing": "r", "blue_briefing": "b"},
|
| 79 |
+
)
|
| 80 |
+
publish_episode(
|
| 81 |
+
snapshot,
|
| 82 |
+
RangeState(episode_id="published_ep", step_count=4, mode="blue", tier=2),
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
fallback_env = RangeEnvironment(docker_available=False)
|
| 86 |
+
server = SimpleNamespace(_sessions={}, _session_info={})
|
| 87 |
+
request = _Req(_app_with_state(env=fallback_env, openenv_server=server))
|
| 88 |
+
|
| 89 |
+
ctx = _get_env_context(request)
|
| 90 |
+
assert ctx["env"] is None
|
| 91 |
+
assert ctx["published_episode"]["snapshot"]["id"] == "published_ep"
|
| 92 |
+
assert ctx["state_scope"] == "published_episode"
|
| 93 |
+
assert ctx["session_id"] is None
|
| 94 |
+
assert "most recent reset/step state" in (ctx["warning"] or "")
|
| 95 |
+
clear_episode()
|