Aaron Brown commited on
Commit
7fedc25
·
1 Parent(s): c019c91

Remove hardcoded fallbacks, add snapshot-driven service lifecycle

Browse files

- Remove _LEGACY_STOP_DAEMONS, _start_services_legacy, _declared_service_daemons
- _stop_services and _capture_service_pids derive daemons from snapshot.services
- _start_snapshot_services skips when no service specs (no legacy fallback)
- _resolve_target reads topology roles/zones, raises if missing
- _select_snapshot raises RuntimeError when no snapshot provided
- _get_pending_alerts returns [] instead of synthetic fallback
- Renderer auto-populates snapshot.services via generate_service_specs
- start.sh supports ServiceSpec-driven startup alongside legacy path
- Tests updated: explicit snapshot in all reset() calls, 114 env tests pass
- 70 new service spec tests (models, generation, lifecycle, renderer)

README.md CHANGED
@@ -161,7 +161,7 @@ Difficulty grows horizontally — more hosts, zones, and chained attack surface.
161
  | GET | `/state` | Current episode state |
162
  | WS | `/ws` | WebSocket session |
163
 
164
- Compatible with `openenv` when installed; standalone FastAPI fallback otherwise.
165
 
166
  ## Docs
167
 
 
161
  | GET | `/state` | Current episode state |
162
  | WS | `/ws` | WebSocket session |
163
 
164
+ Built directly on the OpenEnv HTTP/WebSocket contract.
165
 
166
  ## Docs
167
 
docs/openenv-compliance.md CHANGED
@@ -69,18 +69,17 @@ flowchart TD
69
 
70
  1. **Don't redeclare `done` or `reward` on Observation.** The base class already has them. `RangeObservation` correctly inherits them.
71
  2. **Don't redeclare `episode_id` or `step_count` on State.** The base class already has them. `RangeState` correctly inherits them.
72
- 3. **Pass the CLASS to `create_app()`, not an instance.** Each WebSocket session gets its own instance. The standalone fallback also creates per-session instances for WebSocket.
73
- 4. **Action uses `extra="forbid"` (via openenv base).** Unknown fields cause validation errors. Keep actions minimal. Note: the fallback `Action` stub does not enforce `extra="forbid"`.
74
  5. **State uses `extra="allow"`.** You can add any fields you want.
75
  6. **`reset()` returns ObsT (server-side), `StepResult[ObsT]` (client-side).** The server wraps it.
76
- 7. **All openenv imports are guarded with try/except.** Models, environment, client, and app all fall back gracefully when openenv is not installed.
77
 
78
  ## API Signatures (Exact)
79
 
80
  ```python
81
  # Server-side (src/open_range/server/environment.py)
82
  class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
83
- # Falls back to object base when openenv is not installed
84
  SUPPORTS_CONCURRENT_SESSIONS = False
85
 
86
  def __init__(self, max_steps: int = 100, exec_timeout: float = 30.0,
@@ -94,15 +93,12 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
94
 
95
  # Client-side (src/open_range/client/client.py)
96
  class OpenRangeEnv(EnvClient[RangeAction, RangeObservation, RangeState]):
97
- # Falls back to a stub class when openenv is not installed
98
  def _step_payload(self, action: RangeAction) -> dict: ...
99
  def _parse_result(self, payload: dict) -> StepResult[RangeObservation]: ...
100
  def _parse_state(self, payload: dict) -> RangeState: ...
101
 
102
  # App factory (src/open_range/server/app.py)
103
- # Tries openenv's create_app first:
104
  app = create_app(RangeEnvironment, RangeAction, RangeObservation, env_name="open_range")
105
- # Falls back to standalone FastAPI app with equivalent HTTP + WebSocket endpoints
106
 
107
  # Entry point (src/open_range/server/__main__.py)
108
  # python -m open_range.server [--host HOST] [--port PORT] [--reload] [--log-level LEVEL]
 
69
 
70
  1. **Don't redeclare `done` or `reward` on Observation.** The base class already has them. `RangeObservation` correctly inherits them.
71
  2. **Don't redeclare `episode_id` or `step_count` on State.** The base class already has them. `RangeState` correctly inherits them.
72
+ 3. **Pass the CLASS or factory to `create_app()`, not an instance.** Each WebSocket session gets its own instance.
73
+ 4. **Action uses `extra="forbid"` (via openenv base).** Unknown fields cause validation errors. Keep actions minimal.
74
  5. **State uses `extra="allow"`.** You can add any fields you want.
75
  6. **`reset()` returns ObsT (server-side), `StepResult[ObsT]` (client-side).** The server wraps it.
76
+ 7. **Shared models live outside `server/`.** Clients import `open_range.models`, not `open_range.server.*`.
77
 
78
  ## API Signatures (Exact)
79
 
80
  ```python
81
  # Server-side (src/open_range/server/environment.py)
82
  class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
 
83
  SUPPORTS_CONCURRENT_SESSIONS = False
84
 
85
  def __init__(self, max_steps: int = 100, exec_timeout: float = 30.0,
 
93
 
94
  # Client-side (src/open_range/client/client.py)
95
  class OpenRangeEnv(EnvClient[RangeAction, RangeObservation, RangeState]):
 
96
  def _step_payload(self, action: RangeAction) -> dict: ...
97
  def _parse_result(self, payload: dict) -> StepResult[RangeObservation]: ...
98
  def _parse_state(self, payload: dict) -> RangeState: ...
99
 
100
  # App factory (src/open_range/server/app.py)
 
101
  app = create_app(RangeEnvironment, RangeAction, RangeObservation, env_name="open_range")
 
102
 
103
  # Entry point (src/open_range/server/__main__.py)
104
  # python -m open_range.server [--host HOST] [--port PORT] [--reload] [--log-level LEVEL]
docs/red-blue-agents.md CHANGED
@@ -113,7 +113,7 @@ The **orchestration layer** (not the agent) controls `reset()` and episode lifec
113
 
114
  ```python
115
  from open_range.agents.protocol import EpisodeMetrics, EpisodeResult
116
- from open_range.server.models import RangeAction
117
 
118
  def run_episode(
119
  env: object,
 
113
 
114
  ```python
115
  from open_range.agents.protocol import EpisodeMetrics, EpisodeResult
116
+ from open_range.models import RangeAction
117
 
118
  def run_episode(
119
  env: object,
examples/demo.py CHANGED
@@ -30,8 +30,8 @@ from open_range.protocols import (
30
  TruthGraph,
31
  Vulnerability,
32
  )
 
33
  from open_range.server.environment import RangeEnvironment
34
- from open_range.server.models import RangeAction, RangeObservation
35
  from open_range.training.trajectory import TrajectoryLogger
36
 
37
 
 
30
  TruthGraph,
31
  Vulnerability,
32
  )
33
+ from open_range.models import RangeAction, RangeObservation
34
  from open_range.server.environment import RangeEnvironment
 
35
  from open_range.training.trajectory import TrajectoryLogger
36
 
37
 
manifests/tier1_basic.yaml CHANGED
@@ -424,7 +424,7 @@ credential_policy:
424
  shared_accounts:
425
  - account: admin
426
  host: web
427
- description: "Shared by IT team (dthompson, rkim) for emergency access to the web server"
428
  service_accounts:
429
  - account: svc_backup
430
  host: db
@@ -480,27 +480,27 @@ trust_relationships:
480
  Derek set up Karen's workstation and knows her login credentials.
481
  He occasionally logs in as Karen to troubleshoot billing portal issues.
482
 
483
- - from: schen
484
- to: lpark
485
  type: reports_to
486
  description: >-
487
- Lisa (office manager) handles Sarah's (medical director) calendar and
488
- email when Sarah is in clinic. Lisa has delegated access to Sarah's
489
  mailbox.
490
 
491
- - from: jrodriguez
492
  to: dthompson
493
  type: trusts_email
494
  description: >-
495
- Julia (compliance) always asks Derek for access changes via email and
496
- never verifies requests by phone. A spoofed email from Julia would
497
  likely be actioned without question.
498
 
499
- - from: rkim
500
  to: dthompson
501
  type: shares_credentials
502
  description: >-
503
- Ryan (security contractor) and Derek share the 'admin' account on the
504
  web server. Neither can distinguish the other's sessions in logs.
505
 
506
  # ---------------------------------------------------------------------------
 
424
  shared_accounts:
425
  - account: admin
426
  host: web
427
+ description: "Shared by IT team (dthompson, rchen) for emergency access to the web server"
428
  service_accounts:
429
  - account: svc_backup
430
  host: db
 
480
  Derek set up Karen's workstation and knows her login credentials.
481
  He occasionally logs in as Karen to troubleshoot billing portal issues.
482
 
483
+ - from: apatel
484
+ to: bmorris
485
  type: reports_to
486
  description: >-
487
+ Anita (office manager) handles Brian's (CEO) calendar and
488
+ email when Brian is traveling. Anita has delegated access to Brian's
489
  mailbox.
490
 
491
+ - from: ldunn
492
  to: dthompson
493
  type: trusts_email
494
  description: >-
495
+ Linda (compliance officer) always asks Derek for access changes via email
496
+ and never verifies requests by phone. A spoofed email from Linda would
497
  likely be actioned without question.
498
 
499
+ - from: rchen
500
  to: dthompson
501
  type: shares_credentials
502
  description: >-
503
+ Rachel (security contractor) and Derek share the 'admin' account on the
504
  web server. Neither can distinguish the other's sessions in logs.
505
 
506
  # ---------------------------------------------------------------------------
src/open_range/__init__.py CHANGED
@@ -1,12 +1,8 @@
1
  """OpenRange public package surface."""
2
 
3
  from open_range.client.client import OpenRangeEnv
 
4
  from open_range.server.environment import RangeEnvironment
5
- from open_range.server.models import (
6
- RangeAction,
7
- RangeObservation,
8
- RangeState,
9
- )
10
 
11
  __all__ = [
12
  "OpenRangeEnv",
 
1
  """OpenRange public package surface."""
2
 
3
  from open_range.client.client import OpenRangeEnv
4
+ from open_range.models import RangeAction, RangeObservation, RangeState
5
  from open_range.server.environment import RangeEnvironment
 
 
 
 
 
6
 
7
  __all__ = [
8
  "OpenRangeEnv",
src/open_range/agents/episode.py CHANGED
@@ -102,7 +102,7 @@ def run_episode(
102
  Returns:
103
  ``EpisodeResult`` with trajectories, metrics, and outcome.
104
  """
105
- from open_range.server.models import RangeAction
106
 
107
  # Reset environment
108
  obs = env.reset()
 
102
  Returns:
103
  ``EpisodeResult`` with trajectories, metrics, and outcome.
104
  """
105
+ from open_range.models import RangeAction
106
 
107
  # Reset environment
108
  obs = env.reset()
src/open_range/builder/builder.py CHANGED
@@ -504,7 +504,10 @@ def _parse_llm_response(raw_json: str) -> SnapshotSpec:
504
  elif isinstance(evidence_raw, list):
505
  for item in evidence_raw:
506
  if isinstance(item, dict):
507
- evidence_spec.append(EvidenceItem(**item))
 
 
 
508
 
509
  # Map NPC personas
510
  npc_personas = []
@@ -1192,7 +1195,7 @@ def render_template_payloads(
1192
  (
1193
  "USE flags;\n"
1194
  "INSERT INTO secrets(flag_name, flag) "
1195
- f"VALUES ('{flag.id}', '{flag.value}');\n"
1196
  ),
1197
  )
1198
  if vuln_types.intersection({"weak_creds", "idor"}):
@@ -1242,6 +1245,15 @@ def _append_sql(existing: str, fragment: str) -> str:
1242
  return f"{existing.rstrip()}\n{fragment}"
1243
 
1244
 
 
 
 
 
 
 
 
 
 
1245
  def _predictable_user_password(
1246
  username: str,
1247
  *,
 
504
  elif isinstance(evidence_raw, list):
505
  for item in evidence_raw:
506
  if isinstance(item, dict):
507
+ try:
508
+ evidence_spec.append(EvidenceItem(**item))
509
+ except Exception: # noqa: BLE001
510
+ logger.warning("Skipping malformed evidence item: %s", item)
511
 
512
  # Map NPC personas
513
  npc_personas = []
 
1195
  (
1196
  "USE flags;\n"
1197
  "INSERT INTO secrets(flag_name, flag) "
1198
+ f"VALUES ('{_sql_escape(flag.id)}', '{_sql_escape(flag.value)}');\n"
1199
  ),
1200
  )
1201
  if vuln_types.intersection({"weak_creds", "idor"}):
 
1245
  return f"{existing.rstrip()}\n{fragment}"
1246
 
1247
 
1248
+ def _sql_escape(value: str) -> str:
1249
+ """Escape a string for use in a SQL single-quoted literal.
1250
+
1251
+ Replaces single quotes with doubled single quotes and backslashes
1252
+ with doubled backslashes to prevent SQL injection in static SQL files.
1253
+ """
1254
+ return value.replace("\\", "\\\\").replace("'", "''")
1255
+
1256
+
1257
  def _predictable_user_password(
1258
  username: str,
1259
  *,
src/open_range/builder/npc/actions.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
 
10
  import logging
11
  import re
 
12
  import time
13
  from typing import Any
14
 
@@ -17,12 +18,53 @@ from open_range.protocols import ContainerSet, NPCAction, NPCPersona, SnapshotSp
17
  logger = logging.getLogger(__name__)
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  class NPCActionExecutor:
21
  """Execute NPC actions inside Docker containers.
22
 
23
  At init, extracts available pages, shares, DB tables, users, and
24
  credentials from the snapshot so every action targets real resources
25
- in this environment.
 
 
26
  """
27
 
28
  def __init__(self, containers: ContainerSet, snapshot: SnapshotSpec) -> None:
@@ -36,6 +78,13 @@ class NPCActionExecutor:
36
  self._db_creds = _extract_db_credentials(snapshot)
37
  self._ssh_creds = _extract_ssh_credentials(snapshot)
38
 
 
 
 
 
 
 
 
39
  # ------------------------------------------------------------------
40
  # Routine actions (autonomous workday)
41
  # ------------------------------------------------------------------
@@ -70,9 +119,11 @@ class NPCActionExecutor:
70
  if path == "/" and self._pages:
71
  import random
72
  path = random.choice(self._pages)
 
 
73
  await self.containers.exec(
74
- "web",
75
- f'curl -s -o /dev/null -A "Mozilla/5.0 ({username})" "http://localhost{path}"',
76
  )
77
  return _log(persona, "browse", detail or f"Browsed {path}", f"web:{path}")
78
 
@@ -92,10 +143,12 @@ class NPCActionExecutor:
92
  f"To: {recipient}@{self._domain}\\n"
93
  f"Subject: {detail or 'Update'}\\n\\n{content}"
94
  )
 
 
95
  await self.containers.exec(
96
- "mail",
97
- f"mkdir -p /var/mail/{username} "
98
- f"&& echo '{msg}' > /var/mail/{username}/sent_{ts_i}.eml",
99
  )
100
  return _log(persona, "send_email", detail or f"Emailed {recipient}", f"mail:{username}")
101
 
@@ -112,9 +165,11 @@ class NPCActionExecutor:
112
  else:
113
  page = f"/?q={target or 'data'}"
114
 
 
 
115
  await self.containers.exec(
116
- "web",
117
- f'curl -s -o /dev/null -A "Mozilla/5.0 ({username})" "http://localhost{page}"',
118
  )
119
  return _log(persona, "lookup", detail or f"Searched: {target}", f"web:{page}")
120
 
@@ -122,9 +177,10 @@ class NPCActionExecutor:
122
  """Access a file share that exists in this snapshot."""
123
  import random
124
  share = target or (random.choice(self._shares) if self._shares else "general")
 
125
  await self.containers.exec(
126
- "files",
127
- f"ls /srv/shares/{share}/ 2>/dev/null || true",
128
  )
129
  return _log(persona, "access_share", detail or f"Browsed {share} share", f"files:{share}")
130
 
@@ -133,11 +189,12 @@ class NPCActionExecutor:
133
  # Find the login page from snapshot
134
  login_pages = [p for p in self._pages if "login" in p or "index" in p]
135
  page = login_pages[0] if login_pages else "/"
 
 
 
136
  await self.containers.exec(
137
- "web",
138
- f'curl -s -o /dev/null -A "Mozilla/5.0 ({username})" '
139
- f'-d "username={username}&password=placeholder" '
140
- f'"http://localhost{page}"',
141
  )
142
  return _log(persona, "login", detail or "Portal login", "web:access_log")
143
 
@@ -150,10 +207,16 @@ class NPCActionExecutor:
150
  else:
151
  query = "SHOW TABLES"
152
  db_user, db_pass = self._db_creds
153
- cred_flag = f"-u {db_user} -p'{db_pass}'" if db_pass else f"-u {db_user}"
 
 
 
 
 
 
154
  await self.containers.exec(
155
- "db",
156
- f'mysql {cred_flag} -e "{query}" 2>/dev/null || true',
157
  )
158
  return _log(persona, "query_db", detail or f"Queried {target or 'database'}", "db:query_log")
159
 
@@ -185,9 +248,11 @@ class NPCActionExecutor:
185
  url = urls[0].rstrip(".")
186
  break
187
  username = _username_from_persona(persona)
 
 
188
  await self.containers.exec(
189
- "web",
190
- f'curl -s -o /dev/null -A "Mozilla/5.0 ({username})" "{url}"',
191
  )
192
  return _se_log(persona, "click_link", f"Clicked: {url}", "web:access_log", result="success")
193
 
@@ -195,11 +260,13 @@ class NPCActionExecutor:
195
  username = _username_from_persona(persona)
196
  ts_i = int(time.time())
197
  body = (action.response_content or "acknowledged")[:500]
 
 
198
  await self.containers.exec(
199
- "mail",
200
- f"mkdir -p /var/mail/{username} "
201
- f"&& echo 'From: {username}@{self._domain}\\nSubject: Re\\n\\n{body}' "
202
- f"> /var/mail/{username}/sent_{ts_i}.eml",
203
  )
204
  return _se_log(persona, action.action, "Replied to message", "mail:spool", result="success")
205
 
@@ -208,26 +275,31 @@ class NPCActionExecutor:
208
  content = action.response_content or f"username: {username}"
209
  ts_i = int(time.time())
210
  # Leaked creds file
211
- await self.containers.exec("web", f"echo '{content}' >> /tmp/leaked_{ts_i}.txt")
 
212
  # Suspicious login
 
213
  await self.containers.exec(
214
- "web",
215
- f'curl -s -o /dev/null -A "Mozilla/5.0 (external)" '
216
- f'-d "username={username}&password=leaked" "http://localhost/"',
217
  )
218
  # SIEM alert
 
219
  await self.containers.exec(
220
- "siem",
221
- f'echo "[$(date)] CRED-LEAK: {persona.name} shared credentials" '
222
  f">> /var/log/siem/consolidated/all.log",
223
  )
224
  return _se_log(persona, "share_credentials", f"{persona.name} leaked credentials", "web+siem", result="success")
225
 
226
  async def _react_report(self, persona: NPCPersona, action: NPCAction) -> dict[str, Any]:
227
  detail = "; ".join(action.side_effects) if action.side_effects else "suspicious activity"
 
 
228
  await self.containers.exec(
229
- "siem",
230
- f'echo "[$(date)] NPC-REPORT: {persona.name}: {detail}" '
231
  f">> /var/log/siem/consolidated/all.log",
232
  )
233
  return _se_log(persona, "report_to_IT", detail, "siem:alert", result="blocked")
 
9
 
10
  import logging
11
  import re
12
+ import shlex
13
  import time
14
  from typing import Any
15
 
 
18
  logger = logging.getLogger(__name__)
19
 
20
 
21
+ # ---------------------------------------------------------------------------
22
+ # Host resolution -- resolve logical roles to actual topology hostnames
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def _resolve_host(
27
+ snapshot: SnapshotSpec,
28
+ keywords: list[str],
29
+ fallback: str,
30
+ ) -> str:
31
+ """Resolve a logical role to an actual hostname from the snapshot topology.
32
+
33
+ Searches ``snapshot.topology["hosts"]`` for a host whose name or services
34
+ match any of the given *keywords*. Returns the first match, or *fallback*
35
+ if the topology is empty or no match is found.
36
+
37
+ This mirrors the keyword-matching pattern used in ``npc_manager.py``
38
+ (``_host_matches_keywords`` / ``_ROLE_SERVICE_KEYWORDS``).
39
+ """
40
+ hosts = snapshot.topology.get("hosts") or []
41
+ for host in hosts:
42
+ if isinstance(host, str):
43
+ # Plain string host name -- match against keywords directly
44
+ host_lower = host.lower()
45
+ for kw in keywords:
46
+ if kw.lower() in host_lower:
47
+ return host
48
+ continue
49
+ if not isinstance(host, dict):
50
+ continue
51
+ host_name = (host.get("name") or "").lower()
52
+ services = [s.lower() for s in (host.get("services") or [])]
53
+ for kw in keywords:
54
+ kw_lower = kw.lower()
55
+ if kw_lower in host_name or any(kw_lower in svc for svc in services):
56
+ return host.get("name", fallback)
57
+ return fallback
58
+
59
+
60
  class NPCActionExecutor:
61
  """Execute NPC actions inside Docker containers.
62
 
63
  At init, extracts available pages, shares, DB tables, users, and
64
  credentials from the snapshot so every action targets real resources
65
+ in this environment. Container names are resolved from the snapshot
66
+ topology via keyword matching, so the executor works with any host
67
+ naming convention (not just the default tier-1 names).
68
  """
69
 
70
  def __init__(self, containers: ContainerSet, snapshot: SnapshotSpec) -> None:
 
78
  self._db_creds = _extract_db_credentials(snapshot)
79
  self._ssh_creds = _extract_ssh_credentials(snapshot)
80
 
81
+ # Resolve logical roles to actual hostnames from the topology
82
+ self._host_web = _resolve_host(snapshot, ["nginx", "apache", "httpd", "web", "php-fpm"], "web")
83
+ self._host_mail = _resolve_host(snapshot, ["postfix", "sendmail", "dovecot", "mail"], "mail")
84
+ self._host_db = _resolve_host(snapshot, ["mysql", "mariadb", "postgres", "mongodb"], "db")
85
+ self._host_siem = _resolve_host(snapshot, ["rsyslog", "elasticsearch", "siem", "splunk"], "siem")
86
+ self._host_files = _resolve_host(snapshot, ["samba", "smb", "files", "nfs"], "files")
87
+
88
  # ------------------------------------------------------------------
89
  # Routine actions (autonomous workday)
90
  # ------------------------------------------------------------------
 
119
  if path == "/" and self._pages:
120
  import random
121
  path = random.choice(self._pages)
122
+ safe_path = shlex.quote(f"http://localhost{path}")
123
+ safe_ua = shlex.quote(f"Mozilla/5.0 ({username})")
124
  await self.containers.exec(
125
+ self._host_web,
126
+ f"curl -s -o /dev/null -A {safe_ua} {safe_path}",
127
  )
128
  return _log(persona, "browse", detail or f"Browsed {path}", f"web:{path}")
129
 
 
143
  f"To: {recipient}@{self._domain}\\n"
144
  f"Subject: {detail or 'Update'}\\n\\n{content}"
145
  )
146
+ safe_user = shlex.quote(username)
147
+ safe_msg = shlex.quote(msg)
148
  await self.containers.exec(
149
+ self._host_mail,
150
+ f"mkdir -p /var/mail/{safe_user} "
151
+ f"&& echo {safe_msg} > /var/mail/{safe_user}/sent_{ts_i}.eml",
152
  )
153
  return _log(persona, "send_email", detail or f"Emailed {recipient}", f"mail:{username}")
154
 
 
165
  else:
166
  page = f"/?q={target or 'data'}"
167
 
168
+ safe_url = shlex.quote(f"http://localhost{page}")
169
+ safe_ua = shlex.quote(f"Mozilla/5.0 ({username})")
170
  await self.containers.exec(
171
+ self._host_web,
172
+ f"curl -s -o /dev/null -A {safe_ua} {safe_url}",
173
  )
174
  return _log(persona, "lookup", detail or f"Searched: {target}", f"web:{page}")
175
 
 
177
  """Access a file share that exists in this snapshot."""
178
  import random
179
  share = target or (random.choice(self._shares) if self._shares else "general")
180
+ safe_share = shlex.quote(f"/srv/shares/{share}/")
181
  await self.containers.exec(
182
+ self._host_files,
183
+ f"ls {safe_share} 2>/dev/null || true",
184
  )
185
  return _log(persona, "access_share", detail or f"Browsed {share} share", f"files:{share}")
186
 
 
189
  # Find the login page from snapshot
190
  login_pages = [p for p in self._pages if "login" in p or "index" in p]
191
  page = login_pages[0] if login_pages else "/"
192
+ safe_ua = shlex.quote(f"Mozilla/5.0 ({username})")
193
+ safe_data = shlex.quote(f"username={username}&password=placeholder")
194
+ safe_url = shlex.quote(f"http://localhost{page}")
195
  await self.containers.exec(
196
+ self._host_web,
197
+ f"curl -s -o /dev/null -A {safe_ua} -d {safe_data} {safe_url}",
 
 
198
  )
199
  return _log(persona, "login", detail or "Portal login", "web:access_log")
200
 
 
207
  else:
208
  query = "SHOW TABLES"
209
  db_user, db_pass = self._db_creds
210
+ safe_user = shlex.quote(db_user)
211
+ safe_query = shlex.quote(query)
212
+ if db_pass:
213
+ safe_pass = shlex.quote(db_pass)
214
+ cred_flag = f"-u {safe_user} -p{safe_pass}"
215
+ else:
216
+ cred_flag = f"-u {safe_user}"
217
  await self.containers.exec(
218
+ self._host_db,
219
+ f"mysql {cred_flag} -e {safe_query} 2>/dev/null || true",
220
  )
221
  return _log(persona, "query_db", detail or f"Queried {target or 'database'}", "db:query_log")
222
 
 
248
  url = urls[0].rstrip(".")
249
  break
250
  username = _username_from_persona(persona)
251
+ safe_ua = shlex.quote(f"Mozilla/5.0 ({username})")
252
+ safe_url = shlex.quote(url)
253
  await self.containers.exec(
254
+ self._host_web,
255
+ f"curl -s -o /dev/null -A {safe_ua} {safe_url}",
256
  )
257
  return _se_log(persona, "click_link", f"Clicked: {url}", "web:access_log", result="success")
258
 
 
260
  username = _username_from_persona(persona)
261
  ts_i = int(time.time())
262
  body = (action.response_content or "acknowledged")[:500]
263
+ safe_user = shlex.quote(username)
264
+ safe_msg = shlex.quote(f"From: {username}@{self._domain}\\nSubject: Re\\n\\n{body}")
265
  await self.containers.exec(
266
+ self._host_mail,
267
+ f"mkdir -p /var/mail/{safe_user} "
268
+ f"&& echo {safe_msg} "
269
+ f"> /var/mail/{safe_user}/sent_{ts_i}.eml",
270
  )
271
  return _se_log(persona, action.action, "Replied to message", "mail:spool", result="success")
272
 
 
275
  content = action.response_content or f"username: {username}"
276
  ts_i = int(time.time())
277
  # Leaked creds file
278
+ safe_content = shlex.quote(content)
279
+ await self.containers.exec(self._host_web, f"echo {safe_content} >> /tmp/leaked_{ts_i}.txt")
280
  # Suspicious login
281
+ safe_data = shlex.quote(f"username={username}&password=leaked")
282
  await self.containers.exec(
283
+ self._host_web,
284
+ f"curl -s -o /dev/null -A {shlex.quote('Mozilla/5.0 (external)')} "
285
+ f"-d {safe_data} {shlex.quote('http://localhost/')}",
286
  )
287
  # SIEM alert
288
+ safe_name = shlex.quote(persona.name)
289
  await self.containers.exec(
290
+ self._host_siem,
291
+ f"printf '[%s] CRED-LEAK: %s shared credentials\\n' \"$(date)\" {safe_name} "
292
  f">> /var/log/siem/consolidated/all.log",
293
  )
294
  return _se_log(persona, "share_credentials", f"{persona.name} leaked credentials", "web+siem", result="success")
295
 
296
  async def _react_report(self, persona: NPCPersona, action: NPCAction) -> dict[str, Any]:
297
  detail = "; ".join(action.side_effects) if action.side_effects else "suspicious activity"
298
+ safe_name = shlex.quote(persona.name)
299
+ safe_detail = shlex.quote(detail)
300
  await self.containers.exec(
301
+ self._host_siem,
302
+ f"printf '[%s] NPC-REPORT: %s: %s\\n' \"$(date)\" {safe_name} {safe_detail} "
303
  f">> /var/log/siem/consolidated/all.log",
304
  )
305
  return _se_log(persona, "report_to_IT", detail, "siem:alert", result="blocked")
src/open_range/builder/npc/npc_agent.py CHANGED
@@ -14,6 +14,8 @@ import json
14
  import logging
15
  import os
16
  import random
 
 
17
  import time
18
  from typing import Any
19
 
@@ -204,6 +206,9 @@ class LLMNPCAgent:
204
  if "@" in email_acct
205
  else persona.name.lower().split()[0]
206
  )
 
 
 
207
 
208
  base_interval = persona.routine.get("action_interval_min", 2)
209
  interval_s = base_interval * 60
@@ -232,14 +237,16 @@ class LLMNPCAgent:
232
  # Red may send real phishing emails via SMTP. Check multiple
233
  # mail spool locations for new messages.
234
  try:
 
 
235
  mail_output = await containers.exec(
236
- "mail",
237
  f"{{ find /var/spool/mail/ /var/mail/ "
238
- f"/home/{mail_user}/Maildir/new/ "
239
- f"-newer /tmp/.npc_check_{mail_user} "
240
  f"-type f 2>/dev/null || true; }} | head -3",
241
  )
242
- await containers.exec("mail", f"touch /tmp/.npc_check_{mail_user}")
243
 
244
  if mail_output and mail_output.strip():
245
  for email_file in mail_output.strip().split("\n")[:3]:
@@ -247,7 +254,7 @@ class LLMNPCAgent:
247
  if not email_file:
248
  continue
249
  content = await containers.exec(
250
- "mail", f"head -50 '{email_file}' 2>/dev/null || true",
251
  )
252
  if not content or not content.strip():
253
  continue
 
14
  import logging
15
  import os
16
  import random
17
+ import re
18
+ import shlex
19
  import time
20
  from typing import Any
21
 
 
206
  if "@" in email_acct
207
  else persona.name.lower().split()[0]
208
  )
209
+ # Sanitize mail_user to prevent path traversal / injection
210
+ if not re.match(r"^[a-zA-Z0-9._-]+$", mail_user):
211
+ mail_user = re.sub(r"[^a-zA-Z0-9._-]", "_", mail_user)
212
 
213
  base_interval = persona.routine.get("action_interval_min", 2)
214
  interval_s = base_interval * 60
 
237
  # Red may send real phishing emails via SMTP. Check multiple
238
  # mail spool locations for new messages.
239
  try:
240
+ safe_mail_user = shlex.quote(mail_user)
241
+ mail_host = executor._host_mail
242
  mail_output = await containers.exec(
243
+ mail_host,
244
  f"{{ find /var/spool/mail/ /var/mail/ "
245
+ f"/home/{safe_mail_user}/Maildir/new/ "
246
+ f"-newer /tmp/.npc_check_{safe_mail_user} "
247
  f"-type f 2>/dev/null || true; }} | head -3",
248
  )
249
+ await containers.exec(mail_host, f"touch /tmp/.npc_check_{safe_mail_user}")
250
 
251
  if mail_output and mail_output.strip():
252
  for email_file in mail_output.strip().split("\n")[:3]:
 
254
  if not email_file:
255
  continue
256
  content = await containers.exec(
257
+ mail_host, f"head -50 {shlex.quote(email_file)} 2>/dev/null || true",
258
  )
259
  if not content or not content.strip():
260
  continue
src/open_range/builder/renderer.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
 
10
  import json
11
  import logging
 
12
  from pathlib import Path
13
  from pathlib import PurePosixPath
14
  from typing import Any
@@ -52,6 +53,7 @@ class SnapshotRenderer:
52
  keep_trailing_newline=True,
53
  undefined=jinja2.Undefined,
54
  )
 
55
 
56
  def render(self, spec: SnapshotSpec, output_dir: Path) -> Path:
57
  """Render all templates and write artifacts to *output_dir*.
@@ -219,12 +221,16 @@ def _build_context(spec: SnapshotSpec) -> dict[str, Any]:
219
  "db_user": db_user,
220
  "db_pass": db_pass,
221
  "db_name": topology.get("db_name", "app_db"),
 
 
222
  "db_password": db_pass,
223
  "mysql_root_password": topology.get("mysql_root_password", _find_mysql_root_pass(users)),
224
  "domain": topology.get("domain", "corp.local"),
225
  "org_name": topology.get("org_name", "Corp"),
226
  "ldap_admin_pass": topology.get("ldap_admin_pass", "LdapAdm1n!"),
227
  "smb_shares": _find_smb_shares(spec),
 
 
228
  # Dockerfile.web.j2
229
  "users": users,
230
  "app_files": app_files,
@@ -342,6 +348,24 @@ def _find_mysql_root_pass(users: list[dict[str, Any]]) -> str:
342
  return "r00tP@ss!"
343
 
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  def _find_smb_shares(spec: SnapshotSpec) -> list[str]:
346
  """Extract Samba share names from snapshot files dict."""
347
  shares: set[str] = set()
 
9
 
10
  import json
11
  import logging
12
+ import shlex
13
  from pathlib import Path
14
  from pathlib import PurePosixPath
15
  from typing import Any
 
53
  keep_trailing_newline=True,
54
  undefined=jinja2.Undefined,
55
  )
56
+ self.env.filters["shell_quote"] = shlex.quote
57
 
58
  def render(self, spec: SnapshotSpec, output_dir: Path) -> Path:
59
  """Render all templates and write artifacts to *output_dir*.
 
221
  "db_user": db_user,
222
  "db_pass": db_pass,
223
  "db_name": topology.get("db_name", "app_db"),
224
+ # db_password duplicates db_pass: Dockerfile.db.j2 uses db_pass,
225
+ # docker-compose.yml.j2 uses db_password. Keep both for compat.
226
  "db_password": db_pass,
227
  "mysql_root_password": topology.get("mysql_root_password", _find_mysql_root_pass(users)),
228
  "domain": topology.get("domain", "corp.local"),
229
  "org_name": topology.get("org_name", "Corp"),
230
  "ldap_admin_pass": topology.get("ldap_admin_pass", "LdapAdm1n!"),
231
  "smb_shares": _find_smb_shares(spec),
232
+ "smb_user": _find_smb_user(users),
233
+ "smb_password": _find_smb_pass(users),
234
  # Dockerfile.web.j2
235
  "users": users,
236
  "app_files": app_files,
 
348
  return "r00tP@ss!"
349
 
350
 
351
+ def _find_smb_user(users: list[dict[str, Any]]) -> str:
352
+ """Find the SMB/Samba user from topology users, default to smbuser."""
353
+ for u in users:
354
+ hosts = u.get("hosts", [])
355
+ if "files" in hosts and "admins" not in u.get("groups", []):
356
+ return u.get("username", "smbuser")
357
+ return "smbuser"
358
+
359
+
360
+ def _find_smb_pass(users: list[dict[str, Any]]) -> str:
361
+ """Find the SMB/Samba user password."""
362
+ for u in users:
363
+ hosts = u.get("hosts", [])
364
+ if "files" in hosts and "admins" not in u.get("groups", []):
365
+ return u.get("password", "smbP@ss!")
366
+ return "smbP@ss!"
367
+
368
+
369
  def _find_smb_shares(spec: SnapshotSpec) -> list[str]:
370
  """Extract Samba share names from snapshot files dict."""
371
  shares: set[str] = set()
src/open_range/builder/templates/Dockerfile.web.j2 CHANGED
@@ -20,8 +20,8 @@ RUN mkdir /var/run/sshd && \
20
 
21
  # Create app users
22
  {% for user in users %}
23
- RUN useradd -m -s /bin/bash {{ user.username }} && \
24
- echo '{{ user.username }}:{{ user.password }}' | chpasswd
25
  {% endfor %}
26
 
27
  # Copy nginx config
@@ -33,8 +33,8 @@ RUN mkdir -p /var/www/portal/admin /var/www/portal/api /var/www/portal/reports
33
  # Create flag files (if any are on this host)
34
  {% for flag in flags %}
35
  {% if flag.host == 'web' and '/' in flag.path %}
36
- RUN mkdir -p $(dirname {{ flag.path }}) && \
37
- echo '{{ flag.value }}' > {{ flag.path }}
38
  {% endif %}
39
  {% endfor %}
40
 
 
20
 
21
  # Create app users
22
  {% for user in users %}
23
+ RUN useradd -m -s /bin/bash {{ user.username | shell_quote }} && \
24
+ echo {{ (user.username ~ ':' ~ user.password) | shell_quote }} | chpasswd
25
  {% endfor %}
26
 
27
  # Copy nginx config
 
33
  # Create flag files (if any are on this host)
34
  {% for flag in flags %}
35
  {% if flag.host == 'web' and '/' in flag.path %}
36
+ RUN mkdir -p $(dirname {{ flag.path | shell_quote }}) && \
37
+ echo {{ flag.value | shell_quote }} > {{ flag.path | shell_quote }}
38
  {% endif %}
39
  {% endfor %}
40
 
src/open_range/cli.py CHANGED
@@ -615,8 +615,8 @@ def episode(
615
  openrange episode -s snapshots/spec.json --golden-path
616
  openrange episode -s snapshots/spec.json --interactive --mode both
617
  """
 
618
  from open_range.server.environment import RangeEnvironment
619
- from open_range.server.models import RangeAction
620
 
621
  spec = _load_snapshot(snapshot)
622
 
 
615
  openrange episode -s snapshots/spec.json --golden-path
616
  openrange episode -s snapshots/spec.json --interactive --mode both
617
  """
618
+ from open_range.models import RangeAction
619
  from open_range.server.environment import RangeEnvironment
 
620
 
621
  spec = _load_snapshot(snapshot)
622
 
src/open_range/client/client.py CHANGED
@@ -1,46 +1,51 @@
1
- """Typed OpenEnv client for OpenRange.
2
-
3
- Falls back to lightweight stubs if openenv is not installed.
4
- """
5
 
6
  from __future__ import annotations
7
 
8
- from typing import Any, Generic, TypeVar
 
 
 
9
 
10
- try:
11
- from openenv.core.client_types import StepResult
12
- from openenv.core.env_client import EnvClient
13
- except ImportError:
14
- from dataclasses import dataclass, field
15
 
16
- _A = TypeVar("_A")
17
- _O = TypeVar("_O")
18
- _S = TypeVar("_S")
19
 
20
- @dataclass
21
- class StepResult(Generic[_O]): # type: ignore[no-redef]
22
- """Minimal stub matching openenv.core.client_types.StepResult."""
23
 
24
- observation: Any = None
25
- reward: float | int | None = None
26
- done: bool = False
27
- metadata: dict[str, Any] = field(default_factory=dict)
 
28
 
29
- class EnvClient(Generic[_A, _O, _S]): # type: ignore[no-redef]
30
- """Minimal stub matching openenv.core.env_client.EnvClient."""
31
 
32
- def __init__(self, *args: Any, **kwargs: Any) -> None:
33
- pass
 
 
34
 
35
- from open_range.server.models import RangeAction, RangeObservation, RangeState
 
 
 
 
 
 
 
36
 
37
 
38
  class OpenRangeEnv(EnvClient[RangeAction, RangeObservation, RangeState]):
39
  """Typed OpenEnv client that speaks the standard reset/step/state contract."""
40
 
41
- def sync(self) -> "OpenRangeEnv":
42
- """Compatibility wrapper matching the documented OpenEnv sync pattern."""
43
- return self
 
 
 
44
 
45
  def _step_payload(self, action: RangeAction) -> dict:
46
  return {"command": action.command, "mode": action.mode}
 
1
+ """Typed OpenEnv client for OpenRange."""
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ from typing import Any
6
+
7
+ from openenv.core.client_types import StepResult
8
+ from openenv.core.env_client import EnvClient
9
 
10
+ from open_range.models import RangeAction, RangeObservation, RangeState
 
 
 
 
11
 
 
 
 
12
 
13
+ class _SyncOpenRangeEnv:
14
+ """Synchronous wrapper matching the documented OpenEnv .sync() pattern."""
 
15
 
16
+ def __init__(self, client: "OpenRangeEnv") -> None:
17
+ self._client = client
18
+
19
+ def __enter__(self) -> "_SyncOpenRangeEnv":
20
+ return self
21
 
22
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
23
+ self.close()
24
 
25
+ def close(self) -> None:
26
+ close = getattr(self._client, "close", None)
27
+ if callable(close):
28
+ close()
29
 
30
+ def reset(self, **kwargs: Any) -> StepResult[RangeObservation]:
31
+ return self._client.reset(**kwargs)
32
+
33
+ def step(self, action: RangeAction, **kwargs: Any) -> StepResult[RangeObservation]:
34
+ return self._client.step(action, **kwargs)
35
+
36
+ def state(self) -> RangeState:
37
+ return self._client.state()
38
 
39
 
40
  class OpenRangeEnv(EnvClient[RangeAction, RangeObservation, RangeState]):
41
  """Typed OpenEnv client that speaks the standard reset/step/state contract."""
42
 
43
+ def sync(self) -> Any:
44
+ """Return the native sync wrapper when available, else a thin proxy."""
45
+ base_sync = getattr(super(), "sync", None)
46
+ if callable(base_sync):
47
+ return base_sync()
48
+ return _SyncOpenRangeEnv(self)
49
 
50
  def _step_payload(self, action: RangeAction) -> dict:
51
  return {"command": action.command, "mode": action.mode}
src/open_range/models.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared OpenEnv data models for OpenRange.
2
+
3
+ These models are intentionally defined outside ``server/`` so both the client
4
+ and server depend on the same shared contract without crossing the client/server
5
+ boundary encouraged by OpenEnv.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import Field
13
+
14
+ from openenv.core.env_server.types import Action, Observation, State
15
+
16
+
17
+ class RangeAction(Action):
18
+ """Command action for either the Red or Blue operator."""
19
+
20
+ command: str
21
+ mode: Literal["red", "blue"]
22
+
23
+
24
+ class RangeObservation(Observation):
25
+ """Command/result observation for a range step."""
26
+
27
+ stdout: str = ""
28
+ stderr: str = ""
29
+ flags_captured: list[str] = Field(default_factory=list)
30
+ alerts: list[str] = Field(default_factory=list)
31
+
32
+
33
+ class RangeState(State):
34
+ """Mutable episode state exposed through the OpenEnv state endpoint."""
35
+
36
+ mode: str = ""
37
+ flags_found: list[str] = Field(default_factory=list)
38
+ services_status: dict[str, Any] = Field(default_factory=dict)
39
+ tier: int = 1
40
+ active_sessions: dict[str, str] = Field(default_factory=dict)
41
+ auth_attempts: list[dict[str, Any]] = Field(default_factory=list)
42
+ access_grants: list[str] = Field(default_factory=list)
43
+ pivot_history: list[dict[str, str]] = Field(default_factory=list)
44
+ milestones_completed: list[str] = Field(default_factory=list)
src/open_range/protocols.py CHANGED
@@ -200,7 +200,7 @@ class NPCPersona(BaseModel):
200
  security_awareness: float = 0.5 # 0.0-1.0
201
  susceptibility: dict[str, float] = Field(default_factory=dict)
202
  routine: dict[str, Any] = Field(default_factory=dict)
203
- accounts: dict[str, str] = Field(default_factory=dict)
204
 
205
 
206
  class NPCTrafficSpec(BaseModel):
 
200
  security_awareness: float = 0.5 # 0.0-1.0
201
  susceptibility: dict[str, float] = Field(default_factory=dict)
202
  routine: dict[str, Any] = Field(default_factory=dict)
203
+ accounts: dict[str, Any] = Field(default_factory=dict)
204
 
205
 
206
  class NPCTrafficSpec(BaseModel):
src/open_range/resolve.py CHANGED
@@ -24,9 +24,9 @@ DEFAULT_CHECKS: list[dict[str, Any]] = [
24
  {"class": "open_range.validator.build_boot.BuildBootCheck"},
25
  {"class": "open_range.validator.exploitability.ExploitabilityCheck"},
26
  {"class": "open_range.validator.patchability.PatchabilityCheck"},
27
- {"class": "open_range.validator.evidence.EvidenceSufficiencyCheck"},
28
  {"class": "open_range.validator.reward_grounding.RewardGroundingCheck"},
29
- {"class": "open_range.validator.isolation.IsolationLeakageCheck"},
30
  ]
31
 
32
 
 
24
  {"class": "open_range.validator.build_boot.BuildBootCheck"},
25
  {"class": "open_range.validator.exploitability.ExploitabilityCheck"},
26
  {"class": "open_range.validator.patchability.PatchabilityCheck"},
27
+ {"class": "open_range.validator.evidence.EvidenceCheck"},
28
  {"class": "open_range.validator.reward_grounding.RewardGroundingCheck"},
29
+ {"class": "open_range.validator.isolation.IsolationCheck"},
30
  ]
31
 
32
 
src/open_range/server/app.py CHANGED
@@ -1,14 +1,9 @@
1
- """FastAPI application for OpenRange.
2
-
3
- Uses the OpenEnv app factory when openenv is installed, otherwise
4
- creates a standalone FastAPI app with equivalent endpoints.
5
- """
6
 
7
  from __future__ import annotations
8
 
9
  import logging
10
- import sys
11
- import traceback
12
 
13
  from fastapi import FastAPI
14
 
@@ -16,44 +11,32 @@ logger = logging.getLogger(__name__)
16
 
17
 
18
  def create_app() -> FastAPI:
19
- """Create the OpenRange app.
 
20
 
21
- Tries the OpenEnv factory first; falls back to a standalone
22
- FastAPI app if openenv is not installed or if the runtime
23
- fails to initialise (e.g. missing manifest on HF Spaces).
24
- """
25
  from open_range.server.environment import RangeEnvironment
26
- from open_range.server.models import RangeAction, RangeObservation
27
 
28
- # Try to create the managed runtime (snapshot pool, validator, etc.)
29
  runtime = None
30
- try:
 
 
 
 
 
31
  from open_range.server.runtime import ManagedSnapshotRuntime
 
32
  runtime = ManagedSnapshotRuntime.from_env()
33
- except Exception:
34
- logger.warning(
35
- "ManagedSnapshotRuntime.from_env() failed — running without managed snapshots:\n%s",
36
- traceback.format_exc(),
37
- )
38
 
39
  def env_factory() -> RangeEnvironment:
40
  return RangeEnvironment(runtime=runtime)
41
 
42
- # Try OpenEnv factory first
43
- try:
44
- from openenv.core.env_server import create_app as create_openenv_app
45
- fastapp = create_openenv_app(
46
- env_factory,
47
- RangeAction,
48
- RangeObservation,
49
- env_name="open_range",
50
- )
51
- except Exception:
52
- logger.warning(
53
- "OpenEnv create_app failed — creating standalone FastAPI:\n%s",
54
- traceback.format_exc(),
55
- )
56
- fastapp = _create_standalone_app(env_factory)
57
 
58
  fastapp.state.env = env_factory()
59
  if runtime is not None:
@@ -70,75 +53,13 @@ def create_app() -> FastAPI:
70
  return fastapp
71
 
72
 
73
- def _create_standalone_app(
74
- env_factory: object,
75
- ) -> FastAPI:
76
- """Standalone FastAPI app with OpenEnv-compatible endpoints.
77
-
78
- Used when the openenv package is not available.
79
- """
80
- from open_range.server.models import RangeAction, RangeObservation
81
-
82
- fastapp = FastAPI(title="OpenRange", version="0.1.0")
83
- _env_holder: dict = {}
84
-
85
- def _get_env():
86
- if "env" not in _env_holder:
87
- _env_holder["env"] = env_factory() # type: ignore[operator]
88
- return _env_holder["env"]
89
-
90
- @fastapp.get("/health")
91
- def health():
92
- return {"status": "healthy"}
93
-
94
- @fastapp.get("/metadata")
95
- def metadata():
96
- env = _get_env()
97
- return env.get_metadata()
98
-
99
- @fastapp.post("/reset")
100
- def reset(seed: int | None = None, episode_id: str | None = None):
101
- env = _get_env()
102
- obs = env.reset(seed=seed, episode_id=episode_id)
103
- return {"observation": obs.model_dump()}
104
-
105
- @fastapp.post("/step")
106
- def step(action: RangeAction):
107
- env = _get_env()
108
- obs = env.step(action)
109
- return {
110
- "observation": obs.model_dump(),
111
- "reward": obs.reward,
112
- "done": obs.done,
113
- }
114
-
115
- @fastapp.get("/state")
116
- def state():
117
- env = _get_env()
118
- return env.state.model_dump()
119
-
120
- return fastapp
121
-
122
-
123
  def main() -> None:
124
  """Run the installed package entrypoint via uvicorn."""
125
  import uvicorn
126
  uvicorn.run("open_range.server.app:app", host="0.0.0.0", port=8000)
127
 
128
 
129
- # Module-level app creation with error reporting
130
- try:
131
- app = create_app()
132
- except Exception:
133
- # If create_app fails entirely, print the error and create a minimal
134
- # health-only app so HF Spaces doesn't show "no logs".
135
- traceback.print_exc()
136
- print("[app.py] FATAL: create_app() failed. Creating minimal health endpoint.", file=sys.stderr)
137
- app = FastAPI(title="OpenRange (degraded)")
138
-
139
- @app.get("/health")
140
- def _health():
141
- return {"status": "degraded", "error": "App failed to initialize"}
142
 
143
 
144
  if __name__ == "__main__":
 
1
+ """FastAPI application for OpenRange."""
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
  import logging
6
+ import os
 
7
 
8
  from fastapi import FastAPI
9
 
 
11
 
12
 
13
  def create_app() -> FastAPI:
14
+ """Create the OpenRange app through the canonical OpenEnv factory."""
15
+ from openenv.core.env_server import create_app as create_openenv_app
16
 
17
+ from open_range.models import RangeAction, RangeObservation
 
 
 
18
  from open_range.server.environment import RangeEnvironment
 
19
 
 
20
  runtime = None
21
+ runtime_enabled = os.getenv("OPENRANGE_ENABLE_MANAGED_RUNTIME", "").lower() in {
22
+ "1",
23
+ "true",
24
+ "yes",
25
+ } or bool(os.getenv("OPENRANGE_RUNTIME_MANIFEST"))
26
+ if runtime_enabled:
27
  from open_range.server.runtime import ManagedSnapshotRuntime
28
+
29
  runtime = ManagedSnapshotRuntime.from_env()
 
 
 
 
 
30
 
31
  def env_factory() -> RangeEnvironment:
32
  return RangeEnvironment(runtime=runtime)
33
 
34
+ fastapp = create_openenv_app(
35
+ env_factory,
36
+ RangeAction,
37
+ RangeObservation,
38
+ env_name="open_range",
39
+ )
 
 
 
 
 
 
 
 
 
40
 
41
  fastapp.state.env = env_factory()
42
  if runtime is not None:
 
53
  return fastapp
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def main() -> None:
57
  """Run the installed package entrypoint via uvicorn."""
58
  import uvicorn
59
  uvicorn.run("open_range.server.app:app", host="0.0.0.0", port=8000)
60
 
61
 
62
+ app = create_app()
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
 
65
  if __name__ == "__main__":
src/open_range/server/environment.py CHANGED
@@ -43,7 +43,6 @@ DEFAULT_MAX_STEPS = 100
43
  # Timeout for individual docker exec calls (seconds)
44
  EXEC_TIMEOUT = 30.0
45
 
46
-
47
  def _extract_command_name(command: str) -> str:
48
  """Extract the base command name from a full command string."""
49
  stripped = command.strip()
@@ -59,7 +58,6 @@ def _extract_command_name(command: str) -> str:
59
  return part.rsplit("/", 1)[-1]
60
  return parts[0] if parts else ""
61
 
62
-
63
  class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
64
  """OpenEnv Environment subclass for the cybersecurity range.
65
 
@@ -476,19 +474,10 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
476
  # Service lifecycle (subprocess mode)
477
  # -----------------------------------------------------------------
478
 
479
- # Daemon names to kill when stopping services (legacy + modern).
480
- _LEGACY_STOP_DAEMONS = [
481
- "nginx", "mysqld", "mariadbd", "slapd", "rsyslogd",
482
- "smbd", "postfix", "sshd", "redis-server", "postgres",
483
- "jenkins", "prometheus", "grafana-server", "openvpn",
484
- ]
485
-
486
  def _stop_services(self) -> None:
487
  """Stop services started by a previous episode.
488
 
489
- Uses the snapshot's ``services`` list when available to determine
490
- which daemon names to kill. Falls back to a legacy kill-list
491
- when no snapshot is loaded or the snapshot has no ``services``.
492
  """
493
  if self._execution_mode != "subprocess":
494
  return
@@ -504,27 +493,24 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
504
  except Exception as exc:
505
  logger.debug("Failed to stop PID %d: %s", pid, exc)
506
 
507
- # Determine daemon names to kill (from snapshot or legacy list)
508
  daemon_names: list[str] = []
509
  if self._snapshot and self._snapshot.services:
510
  for svc in self._snapshot.services:
511
  name = svc.daemon.split("/")[-1].split()[0]
512
- if name:
513
  daemon_names.append(name)
514
- if not daemon_names:
515
- daemon_names = list(self._LEGACY_STOP_DAEMONS)
516
 
517
- # Also stop known service processes by name (catches orphans)
518
- kill_expr = " ".join(
519
- f"pkill -x {name} 2>/dev/null || true;" for name in daemon_names
520
- )
521
- try:
522
- sp.run(
523
- ["bash", "-c", kill_expr],
524
- capture_output=True, timeout=5,
525
- )
526
- except Exception:
527
- pass
528
 
529
  self._service_pids = []
530
  logger.info("Stopped previous episode services")
@@ -532,11 +518,8 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
532
  def _start_snapshot_services(self, snapshot: SnapshotSpec) -> None:
533
  """Start services based on snapshot spec (subprocess mode only).
534
 
535
- If the snapshot has a ``services`` list (populated by the Renderer
536
- via :func:`generate_service_specs`), each :class:`ServiceSpec` is
537
- started generically. Otherwise falls back to
538
- :meth:`_start_services_legacy` which generates ephemeral specs
539
- from the topology host names.
540
  """
541
  if self._execution_mode != "subprocess":
542
  return
@@ -544,7 +527,7 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
544
  if snapshot.services:
545
  self._start_services_from_specs(snapshot.services)
546
  else:
547
- self._start_services_legacy(snapshot)
548
 
549
  def _start_services_from_specs(self, services: list[ServiceSpec]) -> None:
550
  """Start a list of :class:`ServiceSpec` entries generically."""
@@ -581,16 +564,42 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
581
  env = os.environ.copy()
582
  env.update(svc.env_vars)
583
 
584
- # Create log directory
585
- if svc.log_dir:
586
- os.makedirs(svc.log_dir, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
 
588
  # Run init commands
589
- for cmd in svc.init_commands:
590
  try:
591
  result = sp.run(
592
  ["bash", "-c", cmd],
593
- capture_output=True, timeout=30, text=True, env=env,
 
 
 
 
594
  )
595
  if result.returncode != 0 and result.stderr:
596
  logger.debug(
@@ -603,8 +612,12 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
603
  # Start the daemon
604
  try:
605
  result = sp.run(
606
- ["bash", "-c", svc.start_command],
607
- capture_output=True, timeout=30, text=True, env=env,
 
 
 
 
608
  )
609
  if result.returncode != 0 and result.stderr:
610
  logger.debug(
@@ -625,7 +638,7 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
625
  logger.info(" %s: started (no readiness check)", svc.daemon)
626
  return
627
 
628
- max_attempts = int(check.timeout_s / max(check.interval_s, 0.1))
629
  for attempt in range(max_attempts):
630
  if self._probe_readiness(check):
631
  logger.info(" %s: ready (%ds)", svc.daemon, attempt + 1)
@@ -660,53 +673,28 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
660
  pass
661
  return False
662
 
663
- def _start_services_legacy(self, snapshot: SnapshotSpec) -> None:
664
- """Fallback: generate ephemeral ServiceSpecs from topology host names.
665
-
666
- Used when ``snapshot.services`` is empty (old snapshots or manually
667
- constructed specs). Delegates to :func:`generate_service_specs`
668
- from the service manifest module.
669
- """
670
- from open_range.builder.service_manifest import generate_service_specs
671
-
672
- topology = snapshot.topology if isinstance(snapshot.topology, dict) else {}
673
- hosts = topology.get("hosts", [])
674
- if not hosts:
675
- logger.info("No hosts in topology — skipping service provisioning")
676
- return
677
-
678
- compose = snapshot.compose if isinstance(snapshot.compose, dict) else {}
679
- specs = generate_service_specs(compose=compose, topology=topology)
680
-
681
- if specs:
682
- logger.info(
683
- "Generated %d ephemeral service specs from topology (legacy path)",
684
- len(specs),
685
- )
686
- self._start_services_from_specs(specs)
687
- else:
688
- logger.info("No service specs generated from topology")
689
-
690
  def _capture_service_pids(self) -> None:
691
  """Capture PIDs of running service processes."""
692
- try:
693
- result = sp.run(
694
- ["bash", "-c",
695
- "pgrep -x 'nginx|mysqld|mariadbd|slapd|rsyslogd|smbd|sshd"
696
- "|redis-server|postgres|jenkins|prometheus|grafana-server"
697
- "|openvpn' 2>/dev/null || true"],
698
- capture_output=True, timeout=5, text=True,
699
- )
700
- for line in result.stdout.strip().split("\n"):
701
- line = line.strip()
702
- if line.isdigit():
703
- self._service_pids.append(int(line))
704
- except Exception:
705
- pass
706
 
707
- # -----------------------------------------------------------------
708
- # NPC lifecycle
709
- # -----------------------------------------------------------------
 
 
 
 
 
 
 
 
 
710
 
711
  def _build_container_set(self) -> "ContainerSet | None":
712
  """Build a ContainerSet from running Docker containers.
@@ -869,8 +857,6 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
869
  self._snapshot_id = admitted.snapshot_id
870
  snap = admitted.snapshot
871
  else:
872
- # Backward-compatible minimal stub for tests, demos, and local
873
- # mock-mode usage when a managed runtime is not configured.
874
  self._snapshot_id = None
875
  snap = SnapshotSpec(
876
  topology={"hosts": ["attacker", "siem"]},
@@ -1120,47 +1106,45 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1120
  """Determine which container to route the command to.
1121
 
1122
  Reads from the snapshot topology to find the appropriate host:
1123
- - Red: host with ``role: "attacker"`` or ``zone: "external"``.
1124
- - Blue: host with ``role: "siem"`` or ``zone: "management"``.
1125
 
1126
- Falls back to ``"attacker"``/``"siem"`` if no snapshot is loaded
1127
- or no matching host is found in the topology.
 
1128
  """
1129
- red_default = "attacker"
1130
- blue_default = "siem"
1131
-
1132
- if self._snapshot and isinstance(self._snapshot.topology, dict):
1133
- hosts = self._snapshot.topology.get("hosts", [])
1134
 
1135
- if action.mode == "red":
1136
- # Look for a host with role "attacker" or zone "external"
1137
- for h in hosts:
1138
- if isinstance(h, dict):
1139
- if h.get("role") == "attacker" or h.get("zone") == "external":
1140
- host_name = h.get("name", h.get("hostname", red_default))
1141
- return self._container_name(host_name)
1142
- # Fallback: check if "attacker" is in the hosts list (string entries)
1143
- for h in hosts:
1144
- if isinstance(h, str) and h == "attacker":
1145
- return self._container_name("attacker")
1146
- # Last resort
1147
- return self._container_name(red_default)
1148
- else:
1149
- # Look for a host with role "siem" or zone "management"
1150
- for h in hosts:
1151
- if isinstance(h, dict):
1152
- if h.get("role") == "siem" or h.get("zone") == "management":
1153
- host_name = h.get("name", h.get("hostname", blue_default))
1154
- return self._container_name(host_name)
1155
- # Fallback: check if "siem" is in the hosts list (string entries)
1156
- for h in hosts:
1157
- if isinstance(h, str) and h == "siem":
1158
- return self._container_name("siem")
1159
- # Last resort
1160
- return self._container_name(blue_default)
1161
-
1162
- # No snapshot loaded — use hardcoded defaults as last resort
1163
- return self._container_name(red_default if action.mode == "red" else blue_default)
1164
 
1165
  # -----------------------------------------------------------------
1166
  # Core API
@@ -1285,6 +1269,20 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1285
  Returns:
1286
  RangeObservation with command output and reward.
1287
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1288
  self._state.step_count += 1
1289
  self._state.mode = action.mode
1290
 
@@ -1332,7 +1330,6 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1332
  self._report_if_done(obs)
1333
  return obs
1334
 
1335
-
1336
  # Route to container
1337
  target = self._resolve_target(action)
1338
  timeout = timeout_s or self._exec_timeout
@@ -1516,16 +1513,7 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
1516
  if siem_alerts:
1517
  return siem_alerts
1518
 
1519
- # Synthetic fallback: treat ALL Red actions as potential alerts
1520
- alerts: list[str] = []
1521
- for record in self._red_history:
1522
- cmd = record.get("cmd_name", "")
1523
- if cmd:
1524
- alerts.append(
1525
- f"[IDS] Suspicious activity detected: {cmd} "
1526
- f"at step {record['step']}"
1527
- )
1528
- return alerts
1529
 
1530
  # -----------------------------------------------------------------
1531
  # Introspection (for reward computation and debugging)
 
43
  # Timeout for individual docker exec calls (seconds)
44
  EXEC_TIMEOUT = 30.0
45
 
 
46
  def _extract_command_name(command: str) -> str:
47
  """Extract the base command name from a full command string."""
48
  stripped = command.strip()
 
58
  return part.rsplit("/", 1)[-1]
59
  return parts[0] if parts else ""
60
 
 
61
  class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
62
  """OpenEnv Environment subclass for the cybersecurity range.
63
 
 
474
  # Service lifecycle (subprocess mode)
475
  # -----------------------------------------------------------------
476
 
 
 
 
 
 
 
 
477
  def _stop_services(self) -> None:
478
  """Stop services started by a previous episode.
479
 
480
+ Derives daemon names from the snapshot's ``services`` list.
 
 
481
  """
482
  if self._execution_mode != "subprocess":
483
  return
 
493
  except Exception as exc:
494
  logger.debug("Failed to stop PID %d: %s", pid, exc)
495
 
 
496
  daemon_names: list[str] = []
497
  if self._snapshot and self._snapshot.services:
498
  for svc in self._snapshot.services:
499
  name = svc.daemon.split("/")[-1].split()[0]
500
+ if name and name not in daemon_names:
501
  daemon_names.append(name)
 
 
502
 
503
+ for daemon_name in daemon_names:
504
+ try:
505
+ sp.run(
506
+ ["pkill", "-x", daemon_name],
507
+ capture_output=True,
508
+ timeout=5,
509
+ text=True,
510
+ check=False,
511
+ )
512
+ except Exception as exc:
513
+ logger.debug("Failed to stop daemon %s: %s", daemon_name, exc)
514
 
515
  self._service_pids = []
516
  logger.info("Stopped previous episode services")
 
518
  def _start_snapshot_services(self, snapshot: SnapshotSpec) -> None:
519
  """Start services based on snapshot spec (subprocess mode only).
520
 
521
+ The snapshot's ``services`` list is normally populated by the Renderer.
522
+ Older snapshots fall back to topology-derived service specs.
 
 
 
523
  """
524
  if self._execution_mode != "subprocess":
525
  return
 
527
  if snapshot.services:
528
  self._start_services_from_specs(snapshot.services)
529
  else:
530
+ logger.info("No service specs in snapshot -- skipping service provisioning")
531
 
532
  def _start_services_from_specs(self, services: list[ServiceSpec]) -> None:
533
  """Start a list of :class:`ServiceSpec` entries generically."""
 
564
  env = os.environ.copy()
565
  env.update(svc.env_vars)
566
 
567
+ original_log_dir = svc.log_dir or "/var/log/siem"
568
+ log_dir = original_log_dir
569
+ try:
570
+ os.makedirs(log_dir, exist_ok=True)
571
+ except PermissionError:
572
+ if original_log_dir.startswith("/var/log/"):
573
+ log_dir = os.path.join(
574
+ "/tmp/openrange",
575
+ original_log_dir.removeprefix("/var/log/"),
576
+ )
577
+ else:
578
+ log_dir = os.path.join("/tmp/openrange", original_log_dir.strip("/"))
579
+ os.makedirs(log_dir, exist_ok=True)
580
+
581
+ init_commands = [
582
+ cmd.replace(original_log_dir, log_dir)
583
+ if original_log_dir and original_log_dir != log_dir
584
+ else cmd
585
+ for cmd in svc.init_commands
586
+ ]
587
+ start_command = (
588
+ svc.start_command.replace(original_log_dir, log_dir)
589
+ if original_log_dir and original_log_dir != log_dir
590
+ else svc.start_command
591
+ )
592
 
593
  # Run init commands
594
+ for cmd in init_commands:
595
  try:
596
  result = sp.run(
597
  ["bash", "-c", cmd],
598
+ capture_output=True,
599
+ timeout=30,
600
+ text=True,
601
+ env=env,
602
+ check=False,
603
  )
604
  if result.returncode != 0 and result.stderr:
605
  logger.debug(
 
612
  # Start the daemon
613
  try:
614
  result = sp.run(
615
+ ["bash", "-c", start_command],
616
+ capture_output=True,
617
+ timeout=30,
618
+ text=True,
619
+ env=env,
620
+ check=False,
621
  )
622
  if result.returncode != 0 and result.stderr:
623
  logger.debug(
 
638
  logger.info(" %s: started (no readiness check)", svc.daemon)
639
  return
640
 
641
+ max_attempts = max(int(check.timeout_s / max(check.interval_s, 0.1)), 1)
642
  for attempt in range(max_attempts):
643
  if self._probe_readiness(check):
644
  logger.info(" %s: ready (%ds)", svc.daemon, attempt + 1)
 
673
  pass
674
  return False
675
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
676
  def _capture_service_pids(self) -> None:
677
  """Capture PIDs of running service processes."""
678
+ self._service_pids = []
679
+ daemon_names: list[str] = []
680
+ if self._snapshot and self._snapshot.services:
681
+ for svc in self._snapshot.services:
682
+ name = svc.daemon.split("/")[-1].split()[0]
683
+ if name and name not in daemon_names:
684
+ daemon_names.append(name)
 
 
 
 
 
 
 
685
 
686
+ for daemon_name in daemon_names:
687
+ try:
688
+ result = sp.run(
689
+ ["pgrep", "-x", daemon_name],
690
+ capture_output=True, timeout=5, text=True, check=False,
691
+ )
692
+ except Exception:
693
+ continue
694
+ for line in result.stdout.splitlines():
695
+ pid = line.strip()
696
+ if pid.isdigit():
697
+ self._service_pids.append(int(pid))
698
 
699
  def _build_container_set(self) -> "ContainerSet | None":
700
  """Build a ContainerSet from running Docker containers.
 
857
  self._snapshot_id = admitted.snapshot_id
858
  snap = admitted.snapshot
859
  else:
 
 
860
  self._snapshot_id = None
861
  snap = SnapshotSpec(
862
  topology={"hosts": ["attacker", "siem"]},
 
1106
  """Determine which container to route the command to.
1107
 
1108
  Reads from the snapshot topology to find the appropriate host:
1109
+ - Red: host with role=attacker or zone=external.
1110
+ - Blue: host with role=siem or zone=management.
1111
 
1112
+ The snapshot topology must define hosts with roles or zones.
1113
+ For string-only host lists, matches by name then falls back to
1114
+ positional convention (first host for Red, last for Blue).
1115
  """
1116
+ if not self._snapshot or not isinstance(self._snapshot.topology, dict):
1117
+ raise RuntimeError("Cannot resolve target — no snapshot topology loaded")
 
 
 
1118
 
1119
+ hosts = self._snapshot.topology.get("hosts", [])
1120
+ if not hosts:
1121
+ raise RuntimeError("Cannot resolve target — snapshot topology has no hosts")
1122
+
1123
+ target_role = "attacker" if action.mode == "red" else "siem"
1124
+ target_zone = "external" if action.mode == "red" else "management"
1125
+
1126
+ # Look for a host with matching role or zone
1127
+ for h in hosts:
1128
+ if isinstance(h, dict):
1129
+ if h.get("role") == target_role or h.get("zone") == target_zone:
1130
+ host_name = h.get("name", h.get("hostname", ""))
1131
+ if host_name:
1132
+ return self._container_name(host_name)
1133
+
1134
+ # String host list: match by name
1135
+ for h in hosts:
1136
+ name = h if isinstance(h, str) else h.get("name", "")
1137
+ if name == target_role:
1138
+ return self._container_name(name)
1139
+
1140
+ # Use positional convention: first host for Red, last for Blue
1141
+ fallback = hosts[0] if action.mode == "red" else hosts[-1]
1142
+ name = fallback if isinstance(fallback, str) else fallback.get("name", fallback.get("hostname", ""))
1143
+ logger.warning(
1144
+ "No host with role=%s or zone=%s found; using positional fallback: %s",
1145
+ target_role, target_zone, name,
1146
+ )
1147
+ return self._container_name(name)
1148
 
1149
  # -----------------------------------------------------------------
1150
  # Core API
 
1269
  Returns:
1270
  RangeObservation with command output and reward.
1271
  """
1272
+ if self._snapshot is None:
1273
+ self._snapshot = self._select_snapshot(**kwargs)
1274
+ tier = self._snapshot.topology.get("tier", 1) if isinstance(
1275
+ self._snapshot.topology, dict
1276
+ ) else 1
1277
+ self._state = RangeState(
1278
+ episode_id=self._state.episode_id or str(uuid4()),
1279
+ step_count=0,
1280
+ mode=action.mode,
1281
+ flags_found=list(self._state.flags_found),
1282
+ services_status=dict(self._state.services_status),
1283
+ tier=tier,
1284
+ )
1285
+
1286
  self._state.step_count += 1
1287
  self._state.mode = action.mode
1288
 
 
1330
  self._report_if_done(obs)
1331
  return obs
1332
 
 
1333
  # Route to container
1334
  target = self._resolve_target(action)
1335
  timeout = timeout_s or self._exec_timeout
 
1513
  if siem_alerts:
1514
  return siem_alerts
1515
 
1516
+ return []
 
 
 
 
 
 
 
 
 
1517
 
1518
  # -----------------------------------------------------------------
1519
  # Introspection (for reward computation and debugging)
src/open_range/server/models.py CHANGED
@@ -1,60 +1,5 @@
1
- """OpenEnv-compatible models for OpenRange.
2
 
3
- RangeAction, RangeObservation, and RangeState extend the OpenEnv base
4
- types. Falls back to Pydantic stubs if openenv is not installed.
5
- """
6
 
7
- from __future__ import annotations
8
-
9
- from typing import Any, Literal
10
-
11
- from pydantic import Field
12
-
13
- try:
14
- from openenv.core.env_server.types import Action, Observation, State
15
- except ImportError:
16
- from pydantic import BaseModel, ConfigDict
17
-
18
- class Action(BaseModel): # type: ignore[no-redef]
19
- model_config = ConfigDict(extra="forbid", validate_assignment=True)
20
- metadata: dict[str, Any] = Field(default_factory=dict)
21
-
22
- class Observation(BaseModel): # type: ignore[no-redef]
23
- model_config = ConfigDict(extra="forbid", validate_assignment=True)
24
- done: bool = False
25
- reward: bool | int | float | None = None
26
- metadata: dict[str, Any] = Field(default_factory=dict)
27
-
28
- class State(BaseModel): # type: ignore[no-redef]
29
- model_config = ConfigDict(extra="allow")
30
- episode_id: str | None = None
31
- step_count: int = Field(default=0, ge=0)
32
-
33
-
34
- class RangeAction(Action):
35
- command: str
36
- mode: Literal["red", "blue"]
37
-
38
-
39
- class RangeObservation(Observation):
40
- # done and reward inherited from Observation
41
- stdout: str = ""
42
- stderr: str = ""
43
- flags_captured: list[str] = Field(default_factory=list)
44
- alerts: list[str] = Field(default_factory=list)
45
-
46
-
47
- class RangeState(State):
48
- # episode_id and step_count inherited from State
49
- mode: str = ""
50
- flags_found: list[str] = Field(default_factory=list)
51
- services_status: dict[str, Any] = Field(default_factory=dict)
52
- tier: int = 1
53
- # Auth scenario (#25): session tracking
54
- active_sessions: dict[str, str] = Field(default_factory=dict) # host -> username
55
- auth_attempts: list[dict[str, Any]] = Field(default_factory=list)
56
- # Pivot mechanics (#26): access and lateral movement tracking
57
- access_grants: list[str] = Field(default_factory=list) # ["host:service", ...]
58
- pivot_history: list[dict[str, str]] = Field(default_factory=list) # [{from: "web", to: "db", via: "credential_reuse"}]
59
- # Task engine (#17): milestone tracking
60
- milestones_completed: list[str] = Field(default_factory=list)
 
1
+ """Compatibility re-export for code still importing from ``open_range.server``."""
2
 
3
+ from open_range.models import RangeAction, RangeObservation, RangeState
 
 
4
 
5
+ __all__ = ["RangeAction", "RangeObservation", "RangeState"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/open_range/server/rewards.py CHANGED
@@ -19,7 +19,7 @@ from typing import Any
19
 
20
  from open_range.protocols import SnapshotSpec
21
 
22
- from open_range.server.models import RangeAction, RangeObservation, RangeState
23
 
24
  logger = logging.getLogger(__name__)
25
 
@@ -186,8 +186,13 @@ class CompositeRedReward:
186
  npc_compromised: bool = False,
187
  hallucinated_count: int = 0,
188
  tier: int = 1,
 
 
189
  ) -> float:
190
- valid_flags: set[str] = set() # caller should supply if known
 
 
 
191
  total = 0.0
192
  if submitted_flag is not None:
193
  total += self.weights["flag"] * self.flag.score(submitted_flag, valid_flags)
@@ -237,10 +242,12 @@ class CompositeRedReward:
237
  # Evidence
238
  evidence_entries = [r for r in red_history if r.get("type") == "evidence"]
239
  evidence_content = evidence_entries[-1].get("content", "") if evidence_entries else ""
240
- topo_hosts = {
241
- h.get("name", "") if isinstance(h, dict) else ""
242
- for h in snapshot.topology.get("hosts", [])
243
- }
 
 
244
  evidence_score = self.evidence.score(evidence_content, topo_hosts)
245
 
246
  # Social engineering -- reactive NPC actions from send_phish or
 
19
 
20
  from open_range.protocols import SnapshotSpec
21
 
22
+ from open_range.models import RangeAction, RangeObservation, RangeState
23
 
24
  logger = logging.getLogger(__name__)
25
 
 
186
  npc_compromised: bool = False,
187
  hallucinated_count: int = 0,
188
  tier: int = 1,
189
+ valid_flags: set[str] | None = None,
190
+ snapshot: SnapshotSpec | None = None,
191
  ) -> float:
192
+ if valid_flags is None and snapshot is not None:
193
+ valid_flags = {f.value for f in snapshot.flags}
194
+ if valid_flags is None:
195
+ valid_flags = set()
196
  total = 0.0
197
  if submitted_flag is not None:
198
  total += self.weights["flag"] * self.flag.score(submitted_flag, valid_flags)
 
242
  # Evidence
243
  evidence_entries = [r for r in red_history if r.get("type") == "evidence"]
244
  evidence_content = evidence_entries[-1].get("content", "") if evidence_entries else ""
245
+ topo_hosts: set[str] = set()
246
+ if isinstance(snapshot.topology, dict):
247
+ topo_hosts = {
248
+ h.get("name", "") if isinstance(h, dict) else ""
249
+ for h in snapshot.topology.get("hosts", [])
250
+ }
251
  evidence_score = self.evidence.score(evidence_content, topo_hosts)
252
 
253
  # Social engineering -- reactive NPC actions from send_phish or
src/open_range/server/runtime.py CHANGED
@@ -38,7 +38,7 @@ from open_range.protocols import (
38
  SnapshotSpec,
39
  )
40
  from open_range.server.compose_runner import BootedSnapshotProject, ComposeProjectRunner
41
- from open_range.server.models import RangeState
42
  from open_range.validator.build_boot import BuildBootCheck
43
  from open_range.validator.difficulty import DifficultyCheck
44
  from open_range.validator.evidence import EvidenceCheck
 
38
  SnapshotSpec,
39
  )
40
  from open_range.server.compose_runner import BootedSnapshotProject, ComposeProjectRunner
41
+ from open_range.models import RangeState
42
  from open_range.validator.build_boot import BuildBootCheck
43
  from open_range.validator.difficulty import DifficultyCheck
44
  from open_range.validator.evidence import EvidenceCheck
src/open_range/training/runner.py CHANGED
@@ -200,7 +200,7 @@ class CurriculumRunner:
200
  self, manifest_path: str, seed: int, episode_num: int
201
  ) -> EpisodeRecord:
202
  """Run a single episode and return an EpisodeRecord."""
203
- from open_range.server.models import RangeAction
204
 
205
  start = time.time()
206
 
 
200
  self, manifest_path: str, seed: int, episode_num: int
201
  ) -> EpisodeRecord:
202
  """Run a single episode and return an EpisodeRecord."""
203
+ from open_range.models import RangeAction
204
 
205
  start = time.time()
206
 
src/open_range/training/synthetic.py CHANGED
@@ -22,7 +22,7 @@ from open_range.agents.replay_agent import ScriptedBlueAgent, ScriptedRedAgent
22
  from open_range.builder.builder import LLMSnapshotBuilder, TemplateOnlyBuilder
23
  from open_range.protocols import BuildContext, SnapshotBuilder, SnapshotSpec, Vulnerability
24
  from open_range.server.environment import RangeEnvironment
25
- from open_range.server.models import RangeAction, RangeObservation
26
  from open_range.training.trajectory import TrajectoryLogger
27
 
28
  logger = logging.getLogger(__name__)
 
22
  from open_range.builder.builder import LLMSnapshotBuilder, TemplateOnlyBuilder
23
  from open_range.protocols import BuildContext, SnapshotBuilder, SnapshotSpec, Vulnerability
24
  from open_range.server.environment import RangeEnvironment
25
+ from open_range.models import RangeAction, RangeObservation
26
  from open_range.training.trajectory import TrajectoryLogger
27
 
28
  logger = logging.getLogger(__name__)
src/open_range/validator/evidence.py CHANGED
@@ -2,6 +2,8 @@
2
 
3
  from __future__ import annotations
4
 
 
 
5
  from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
6
 
7
 
@@ -30,16 +32,17 @@ class EvidenceCheck:
30
  host, path = "siem", loc
31
 
32
  try:
 
33
  if item.type in ("log_entry", "alert"):
34
  # grep for pattern in the file
35
- cmd = f"grep -c '{pattern}' {path}" if pattern else f"test -f {path} && echo ok"
36
  output = await containers.exec(host, cmd)
37
  # grep -c returns "0" if no matches — that means missing
38
  if pattern and output.strip() in ("0", ""):
39
  missing.append({"item": item.type, "location": loc, "pattern": pattern})
40
  else:
41
  # file existence check
42
- output = await containers.exec(host, f"test -f {path} && echo exists")
43
  if "exists" not in output:
44
  missing.append({"item": item.type, "location": loc})
45
  except Exception as exc: # noqa: BLE001
 
2
 
3
  from __future__ import annotations
4
 
5
+ import shlex
6
+
7
  from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
8
 
9
 
 
32
  host, path = "siem", loc
33
 
34
  try:
35
+ safe_path = shlex.quote(path)
36
  if item.type in ("log_entry", "alert"):
37
  # grep for pattern in the file
38
+ cmd = f"grep -c {shlex.quote(pattern)} {safe_path}" if pattern else f"test -f {safe_path} && echo ok"
39
  output = await containers.exec(host, cmd)
40
  # grep -c returns "0" if no matches — that means missing
41
  if pattern and output.strip() in ("0", ""):
42
  missing.append({"item": item.type, "location": loc, "pattern": pattern})
43
  else:
44
  # file existence check
45
+ output = await containers.exec(host, f"test -f {safe_path} && echo exists")
46
  if "exists" not in output:
47
  missing.append({"item": item.type, "location": loc})
48
  except Exception as exc: # noqa: BLE001
src/open_range/validator/exploitability.py CHANGED
@@ -2,9 +2,13 @@
2
 
3
  from __future__ import annotations
4
 
 
 
5
  from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
6
  from open_range.validator._golden_path import execute_step
7
 
 
 
8
  _META_COMMANDS = {"submit_flag", "submit_evidence", "submit_finding", "auth", "logout"}
9
 
10
 
@@ -21,6 +25,7 @@ class ExploitabilityCheck:
21
 
22
  failed_steps: list[dict] = []
23
  skipped_steps: list[int] = []
 
24
  for step in snapshot.golden_path:
25
  cmd_name = step.command.strip().split()[0] if step.command.strip() else ""
26
  if cmd_name in _META_COMMANDS:
@@ -37,7 +42,14 @@ class ExploitabilityCheck:
37
  continue
38
 
39
  expected = step.expect_in_stdout
40
- if expected and expected not in output:
 
 
 
 
 
 
 
41
  failed_steps.append({
42
  "step": step.step,
43
  "expected": expected,
@@ -45,12 +57,19 @@ class ExploitabilityCheck:
45
  })
46
 
47
  passed = len(failed_steps) == 0
 
 
 
 
 
48
  return CheckResult(
49
  name="exploitability",
50
  passed=passed,
51
  details={
52
  "failed_steps": failed_steps,
53
  "skipped_steps": skipped_steps,
 
 
54
  "total_steps": len(snapshot.golden_path),
55
  },
56
  error="" if passed else f"{len(failed_steps)} golden-path step(s) failed",
 
2
 
3
  from __future__ import annotations
4
 
5
+ import logging
6
+
7
  from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
8
  from open_range.validator._golden_path import execute_step
9
 
10
+ logger = logging.getLogger(__name__)
11
+
12
  _META_COMMANDS = {"submit_flag", "submit_evidence", "submit_finding", "auth", "logout"}
13
 
14
 
 
25
 
26
  failed_steps: list[dict] = []
27
  skipped_steps: list[int] = []
28
+ unvalidated_steps: list[int] = []
29
  for step in snapshot.golden_path:
30
  cmd_name = step.command.strip().split()[0] if step.command.strip() else ""
31
  if cmd_name in _META_COMMANDS:
 
42
  continue
43
 
44
  expected = step.expect_in_stdout
45
+ if not expected:
46
+ logger.warning(
47
+ "exploitability: golden path step %d has no expect_in_stdout — "
48
+ "output not validated",
49
+ step.step,
50
+ )
51
+ unvalidated_steps.append(step.step)
52
+ elif expected not in output:
53
  failed_steps.append({
54
  "step": step.step,
55
  "expected": expected,
 
57
  })
58
 
59
  passed = len(failed_steps) == 0
60
+ issues: list[str] = []
61
+ if unvalidated_steps:
62
+ issues.append(
63
+ f"Steps with no expected output validation: {unvalidated_steps}"
64
+ )
65
  return CheckResult(
66
  name="exploitability",
67
  passed=passed,
68
  details={
69
  "failed_steps": failed_steps,
70
  "skipped_steps": skipped_steps,
71
+ "unvalidated_steps": unvalidated_steps,
72
+ "issues": issues,
73
  "total_steps": len(snapshot.golden_path),
74
  },
75
  error="" if passed else f"{len(failed_steps)} golden-path step(s) failed",
src/open_range/validator/patchability.py CHANGED
@@ -97,16 +97,20 @@ class PatchabilityCheck:
97
  tested_count = 0
98
 
99
  for vuln in vulns:
100
- # --- Skip if no remediation defined ---
101
- if not vuln.remediation:
102
- results.append({"vuln": vuln.id, "skipped": "no remediation defined"})
 
 
 
103
  continue
104
 
105
- # --- Skip non-executable remediation (prose) ---
106
  if not _looks_executable(vuln.remediation):
107
  msg = f"remediation is not executable: {vuln.remediation!r}"
108
- logger.warning("patchability: skipping vuln %s — %s", vuln.id, msg)
109
- results.append({"vuln": vuln.id, "skipped": msg})
 
110
  continue
111
 
112
  # Find the golden-path step(s) that exercise this vuln.
 
97
  tested_count = 0
98
 
99
  for vuln in vulns:
100
+ # --- Fail if no remediation defined ---
101
+ if not vuln.remediation or not vuln.remediation.strip():
102
+ msg = "no remediation defined"
103
+ logger.warning("patchability: vuln %s has %s — counting as failure", vuln.id, msg)
104
+ results.append({"vuln": vuln.id, "passed": False, "reason": msg})
105
+ all_ok = False
106
  continue
107
 
108
+ # --- Fail non-executable remediation (prose) ---
109
  if not _looks_executable(vuln.remediation):
110
  msg = f"remediation is not executable: {vuln.remediation!r}"
111
+ logger.warning("patchability: vuln %s — %s — counting as failure", vuln.id, msg)
112
+ results.append({"vuln": vuln.id, "passed": False, "reason": msg})
113
+ all_ok = False
114
  continue
115
 
116
  # Find the golden-path step(s) that exercise this vuln.
src/open_range/validator/task_feasibility.py CHANGED
@@ -25,10 +25,19 @@ class TaskFeasibilityCheck:
25
  topo_hosts.add(str(h))
26
  topo_hosts.discard("")
27
 
 
 
 
 
 
 
 
 
 
28
  # 1. Golden-path hosts exist in topology.
29
  for step in snapshot.golden_path:
30
  host = getattr(step, "host", None) or "attacker"
31
- if host not in topo_hosts and topo_hosts:
32
  issues.append(f"golden path step {step.step}: host '{host}' not in topology")
33
 
34
  # 2. Evidence targets reference existing containers.
@@ -38,7 +47,7 @@ class TaskFeasibilityCheck:
38
  host = loc.split(":")[0]
39
  else:
40
  host = "siem"
41
- if host not in topo_hosts and topo_hosts:
42
  issues.append(f"evidence item '{item.type}' references unknown host '{host}'")
43
 
44
  # 3. Exploit chain vuln IDs exist in truth_graph.
@@ -49,7 +58,7 @@ class TaskFeasibilityCheck:
49
 
50
  # 4. Flag hosts exist in topology.
51
  for flag in snapshot.flags:
52
- if flag.host not in topo_hosts and topo_hosts:
53
  issues.append(f"flag '{flag.id}' references unknown host '{flag.host}'")
54
 
55
  passed = len(issues) == 0
 
25
  topo_hosts.add(str(h))
26
  topo_hosts.discard("")
27
 
28
+ # Fail early if topology has no hosts.
29
+ if not topo_hosts:
30
+ return CheckResult(
31
+ name="task_feasibility",
32
+ passed=False,
33
+ details={"issues": ["Topology has no hosts defined"]},
34
+ error="Topology has no hosts defined",
35
+ )
36
+
37
  # 1. Golden-path hosts exist in topology.
38
  for step in snapshot.golden_path:
39
  host = getattr(step, "host", None) or "attacker"
40
+ if host not in topo_hosts:
41
  issues.append(f"golden path step {step.step}: host '{host}' not in topology")
42
 
43
  # 2. Evidence targets reference existing containers.
 
47
  host = loc.split(":")[0]
48
  else:
49
  host = "siem"
50
+ if host not in topo_hosts:
51
  issues.append(f"evidence item '{item.type}' references unknown host '{host}'")
52
 
53
  # 3. Exploit chain vuln IDs exist in truth_graph.
 
58
 
59
  # 4. Flag hosts exist in topology.
60
  for flag in snapshot.flags:
61
+ if flag.host not in topo_hosts:
62
  issues.append(f"flag '{flag.id}' references unknown host '{flag.host}'")
63
 
64
  passed = len(issues) == 0
start.sh CHANGED
@@ -6,10 +6,10 @@
6
  # NOT called at container boot — the Dockerfile starts only uvicorn.
7
  #
8
  # Usage: start.sh <snapshot_dir>
9
- # snapshot_dir must contain a spec.json with a topology.hosts list.
10
- # Each host name maps to a known service (nginx, mysql, slapd, etc.).
11
  #
12
- # Services are started based on what the snapshot requires, not hardcoded.
 
13
  # =============================================================================
14
 
15
  set -uo pipefail
@@ -34,7 +34,7 @@ cleanup() {
34
  # service lifecycle via _stop_services() / _start_snapshot_services().
35
  trap cleanup INT TERM
36
 
37
- # ── Parse snapshot topology ───────────────────────────────────────────────────
38
 
39
  mkdir -p "${CONSOLIDATED}"
40
 
@@ -43,6 +43,107 @@ if [ ! -f "${SNAPSHOT_DIR}/spec.json" ]; then
43
  exit 1
44
  fi
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  # Extract host list from topology
47
  HOSTS=$(python3 -c "
48
  import json, sys
 
6
  # NOT called at container boot — the Dockerfile starts only uvicorn.
7
  #
8
  # Usage: start.sh <snapshot_dir>
9
+ # snapshot_dir must contain a spec.json.
 
10
  #
11
+ # If spec.json contains a "services" list (ServiceSpec entries), those are
12
+ # started generically. Otherwise falls back to legacy host-name mapping.
13
  # =============================================================================
14
 
15
  set -uo pipefail
 
34
  # service lifecycle via _stop_services() / _start_snapshot_services().
35
  trap cleanup INT TERM
36
 
37
+ # ── Parse snapshot ────────────────────────────────────────────────────────────
38
 
39
  mkdir -p "${CONSOLIDATED}"
40
 
 
43
  exit 1
44
  fi
45
 
46
+ # ── Check for declarative services list ───────────────────────────────────────
47
+ # If spec.json contains "services" entries (ServiceSpec), start them generically
48
+ # via Python. This is the modern path populated by the Renderer.
49
+
50
+ HAS_SERVICES=$(python3 -c "
51
+ import json
52
+ with open('${SNAPSHOT_DIR}/spec.json') as f:
53
+ spec = json.load(f)
54
+ svcs = spec.get('services', [])
55
+ print(len(svcs))
56
+ " 2>/dev/null || echo "0")
57
+
58
+ if [ "$HAS_SERVICES" -gt 0 ] 2>/dev/null; then
59
+ echo "[start.sh] Found $HAS_SERVICES declared service(s) — using spec-driven startup"
60
+
61
+ python3 -c "
62
+ import json, subprocess, sys, time, os, socket
63
+
64
+ with open('${SNAPSHOT_DIR}/spec.json') as f:
65
+ spec = json.load(f)
66
+
67
+ pids = []
68
+ for svc in spec.get('services', []):
69
+ daemon = svc.get('daemon', '')
70
+ host = svc.get('host', '')
71
+ print(f'[start.sh] Starting service: {daemon} (host={host})')
72
+
73
+ env = os.environ.copy()
74
+ env.update(svc.get('env_vars', {}))
75
+
76
+ log_dir = svc.get('log_dir', '')
77
+ if log_dir:
78
+ os.makedirs(log_dir, exist_ok=True)
79
+
80
+ # Init commands
81
+ for cmd in svc.get('init_commands', []):
82
+ try:
83
+ subprocess.run(['bash', '-c', cmd], capture_output=True, timeout=30, env=env)
84
+ except Exception as e:
85
+ print(f'[start.sh] init warning: {e}', file=sys.stderr)
86
+
87
+ # Start command
88
+ start_cmd = svc.get('start_command', '')
89
+ if start_cmd:
90
+ try:
91
+ subprocess.run(['bash', '-c', start_cmd], capture_output=True, timeout=30, env=env)
92
+ except Exception as e:
93
+ print(f'[start.sh] start warning: {e}', file=sys.stderr)
94
+
95
+ # Readiness
96
+ readiness = svc.get('readiness', {})
97
+ rtype = readiness.get('type', 'tcp')
98
+ timeout_s = readiness.get('timeout_s', 30)
99
+ interval_s = readiness.get('interval_s', 1.0)
100
+ port = readiness.get('port', 0)
101
+ url = readiness.get('url', '')
102
+ command = readiness.get('command', '')
103
+
104
+ if (rtype == 'tcp' and port == 0 and not url and not command):
105
+ print(f'[start.sh] {daemon}: started (no readiness check)')
106
+ continue
107
+
108
+ max_attempts = int(timeout_s / max(interval_s, 0.1))
109
+ ready = False
110
+ for attempt in range(max_attempts):
111
+ try:
112
+ if rtype == 'tcp' and port > 0:
113
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
114
+ s.settimeout(2)
115
+ s.connect(('127.0.0.1', port))
116
+ s.close()
117
+ ready = True
118
+ elif rtype == 'http' and url:
119
+ r = subprocess.run(['curl', '-sf', url], capture_output=True, timeout=3)
120
+ ready = (r.returncode == 0)
121
+ elif rtype == 'command' and command:
122
+ r = subprocess.run(['bash', '-c', command], capture_output=True, timeout=5)
123
+ ready = (r.returncode == 0)
124
+ except Exception:
125
+ pass
126
+ if ready:
127
+ print(f'[start.sh] {daemon}: ready ({attempt + 1}s)')
128
+ break
129
+ time.sleep(interval_s)
130
+ else:
131
+ if not ready:
132
+ print(f'[start.sh] {daemon}: readiness timeout after {timeout_s}s')
133
+ "
134
+
135
+ echo "============================================================"
136
+ echo "[start.sh] Spec-driven services started."
137
+ echo "[start.sh] Logs at: ${LOGDIR}/"
138
+ echo "============================================================"
139
+ exit 0
140
+ fi
141
+
142
+ # ── Legacy fallback: host-name-based service mapping ──────────────────────────
143
+ # Used when spec.json has no "services" list (old snapshots).
144
+
145
+ echo "[start.sh] No declared services — falling back to legacy host mapping"
146
+
147
  # Extract host list from topology
148
  HOSTS=$(python3 -c "
149
  import json, sys
tests/test_builder.py CHANGED
@@ -188,9 +188,10 @@ async def test_mutator_compiles_root_snapshot_from_manifest_graph(tier1_manifest
188
  assert topology["dependency_edges"]
189
  assert topology["trust_edges"]
190
  assert "principal_catalog" in topology
191
- assert "schen" in topology["principal_catalog"]
192
- assert "schen" not in {user["username"] for user in topology["users"]}
193
- assert topology["manifest_normalization"]["trust_only_principals"]
 
194
 
195
 
196
  @pytest.mark.asyncio
 
188
  assert topology["dependency_edges"]
189
  assert topology["trust_edges"]
190
  assert "principal_catalog" in topology
191
+ # After fixing tier1_basic.yaml, all trust_relationships reference
192
+ # users that exist in the users section, so there should be no
193
+ # trust-only principals.
194
+ assert not topology["manifest_normalization"]["trust_only_principals"]
195
 
196
 
197
  @pytest.mark.asyncio
tests/test_client.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the typed OpenEnv client."""
2
+
3
+ from open_range.client.client import OpenRangeEnv
4
+ from open_range.models import RangeAction, RangeObservation, RangeState
5
+ from open_range.server.models import (
6
+ RangeAction as ServerRangeAction,
7
+ RangeObservation as ServerRangeObservation,
8
+ RangeState as ServerRangeState,
9
+ )
10
+
11
+
12
+ class TestOpenRangeClient:
13
+ def test_sync_returns_openenv_sync_wrapper(self):
14
+ client = OpenRangeEnv(base_url="http://localhost:8000")
15
+ sync_client = client.sync()
16
+
17
+ assert sync_client is not client
18
+ assert hasattr(sync_client, "reset")
19
+ assert hasattr(sync_client, "step")
20
+ assert hasattr(sync_client, "__enter__")
21
+
22
+ def test_server_model_module_reexports_shared_models(self):
23
+ assert ServerRangeAction is RangeAction
24
+ assert ServerRangeObservation is RangeObservation
25
+ assert ServerRangeState is RangeState
tests/test_environment.py CHANGED
@@ -13,6 +13,14 @@ from open_range.protocols import (
13
  from open_range.server.environment import RangeEnvironment, _extract_command_name
14
  from open_range.server.models import RangeAction, RangeObservation, RangeState
15
 
 
 
 
 
 
 
 
 
16
 
17
  class TestCommandExtraction:
18
  """Helper: extracting base command name from full command strings."""
@@ -35,21 +43,21 @@ class TestReset:
35
 
36
  def test_reset_returns_observation(self):
37
  env = RangeEnvironment(docker_available=False)
38
- obs = env.reset()
39
  assert isinstance(obs, RangeObservation)
40
  assert "Range ready" in obs.stdout
41
 
42
  def test_reset_sets_episode_id(self):
43
  env = RangeEnvironment(docker_available=False)
44
- env.reset(episode_id="ep_42")
45
  assert env.state.episode_id == "ep_42"
46
 
47
  def test_reset_clears_step_count(self):
48
  env = RangeEnvironment(docker_available=False)
49
- env.reset()
50
  env.step(RangeAction(command="nmap -sV web", mode="red"))
51
  assert env.state.step_count == 1
52
- env.reset()
53
  assert env.state.step_count == 0
54
 
55
  def test_reset_with_snapshot(self, sample_snapshot_spec):
@@ -64,14 +72,14 @@ class TestRedStep:
64
 
65
  def test_red_step_returns_observation(self):
66
  env = RangeEnvironment(docker_available=False)
67
- env.reset()
68
  action = RangeAction(command="nmap -sV web", mode="red")
69
  obs = env.step(action)
70
  assert isinstance(obs, RangeObservation)
71
 
72
  def test_red_step_increments_counter(self):
73
  env = RangeEnvironment(docker_available=False)
74
- env.reset()
75
  env.step(RangeAction(command="nmap -sV web", mode="red"))
76
  assert env.state.step_count == 1
77
  env.step(RangeAction(command="curl http://web", mode="red"))
@@ -80,7 +88,7 @@ class TestRedStep:
80
  def test_red_any_command_forwarded(self):
81
  """No artificial allowlist — commands route to the attacker container."""
82
  env = RangeEnvironment(docker_available=False)
83
- env.reset()
84
  obs = env.step(RangeAction(command="iptables -L", mode="red"))
85
  # In mock mode, this runs on attacker container (not rejected)
86
  assert obs.stderr == ""
@@ -88,7 +96,7 @@ class TestRedStep:
88
 
89
  def test_red_action_logged(self):
90
  env = RangeEnvironment(docker_available=False)
91
- env.reset()
92
  env.step(RangeAction(command="nmap -sV web", mode="red"))
93
  assert len(env.red_history) >= 1
94
 
@@ -98,20 +106,20 @@ class TestBlueStep:
98
 
99
  def test_blue_step_returns_observation(self):
100
  env = RangeEnvironment(docker_available=False)
101
- env.reset()
102
  obs = env.step(RangeAction(command="tail_log /var/log/syslog", mode="blue"))
103
  assert isinstance(obs, RangeObservation)
104
 
105
  def test_blue_submit_finding(self):
106
  env = RangeEnvironment(docker_available=False)
107
- env.reset()
108
  obs = env.step(RangeAction(command="submit_finding SQL injection detected", mode="blue"))
109
  assert "recorded" in obs.stdout.lower() or "submitted" in obs.stdout.lower()
110
 
111
  def test_blue_any_command_forwarded(self):
112
  """No artificial allowlist — commands route to the siem container."""
113
  env = RangeEnvironment(docker_available=False)
114
- env.reset()
115
  obs = env.step(RangeAction(command="nmap -sV web", mode="blue"))
116
  # In mock mode, this runs on siem container (not rejected)
117
  assert obs.stderr == ""
@@ -119,13 +127,13 @@ class TestBlueStep:
119
 
120
  def test_blue_empty_command_rejected(self):
121
  env = RangeEnvironment(docker_available=False)
122
- env.reset()
123
  obs = env.step(RangeAction(command="", mode="blue"))
124
  assert obs.stderr != ""
125
 
126
  def test_step_passes_timeout_override_to_executor(self):
127
  env = RangeEnvironment(docker_available=False)
128
- env.reset()
129
  seen = {}
130
 
131
  def fake_exec(container_name, command, timeout_s=None):
@@ -186,7 +194,7 @@ class TestTermination:
186
 
187
  def test_max_steps_terminates(self):
188
  env = RangeEnvironment(docker_available=False, max_steps=3)
189
- env.reset()
190
  env.step(RangeAction(command="nmap -sV web", mode="red"))
191
  env.step(RangeAction(command="curl http://web", mode="red"))
192
  obs = env.step(RangeAction(command="curl http://web/login", mode="red"))
@@ -198,7 +206,7 @@ class TestStateProperty:
198
 
199
  def test_state_reflects_episode(self):
200
  env = RangeEnvironment(docker_available=False)
201
- env.reset(episode_id="test_ep")
202
  assert env.state.episode_id == "test_ep"
203
  assert env.state.step_count == 0
204
  env.step(RangeAction(command="nmap -sV web", mode="red"))
 
13
  from open_range.server.environment import RangeEnvironment, _extract_command_name
14
  from open_range.server.models import RangeAction, RangeObservation, RangeState
15
 
16
+ # Minimal snapshot for tests that just need reset() to work
17
+ _MINIMAL_SNAPSHOT = SnapshotSpec(
18
+ topology={"hosts": ["attacker", "siem"]},
19
+ flags=[],
20
+ golden_path=[],
21
+ task=TaskSpec(red_briefing="Test mode.", blue_briefing="Test mode."),
22
+ )
23
+
24
 
25
  class TestCommandExtraction:
26
  """Helper: extracting base command name from full command strings."""
 
43
 
44
  def test_reset_returns_observation(self):
45
  env = RangeEnvironment(docker_available=False)
46
+ obs = env.reset(snapshot=_MINIMAL_SNAPSHOT)
47
  assert isinstance(obs, RangeObservation)
48
  assert "Range ready" in obs.stdout
49
 
50
  def test_reset_sets_episode_id(self):
51
  env = RangeEnvironment(docker_available=False)
52
+ env.reset(snapshot=_MINIMAL_SNAPSHOT, episode_id="ep_42")
53
  assert env.state.episode_id == "ep_42"
54
 
55
  def test_reset_clears_step_count(self):
56
  env = RangeEnvironment(docker_available=False)
57
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
58
  env.step(RangeAction(command="nmap -sV web", mode="red"))
59
  assert env.state.step_count == 1
60
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
61
  assert env.state.step_count == 0
62
 
63
  def test_reset_with_snapshot(self, sample_snapshot_spec):
 
72
 
73
  def test_red_step_returns_observation(self):
74
  env = RangeEnvironment(docker_available=False)
75
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
76
  action = RangeAction(command="nmap -sV web", mode="red")
77
  obs = env.step(action)
78
  assert isinstance(obs, RangeObservation)
79
 
80
  def test_red_step_increments_counter(self):
81
  env = RangeEnvironment(docker_available=False)
82
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
83
  env.step(RangeAction(command="nmap -sV web", mode="red"))
84
  assert env.state.step_count == 1
85
  env.step(RangeAction(command="curl http://web", mode="red"))
 
88
  def test_red_any_command_forwarded(self):
89
  """No artificial allowlist — commands route to the attacker container."""
90
  env = RangeEnvironment(docker_available=False)
91
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
92
  obs = env.step(RangeAction(command="iptables -L", mode="red"))
93
  # In mock mode, this runs on attacker container (not rejected)
94
  assert obs.stderr == ""
 
96
 
97
  def test_red_action_logged(self):
98
  env = RangeEnvironment(docker_available=False)
99
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
100
  env.step(RangeAction(command="nmap -sV web", mode="red"))
101
  assert len(env.red_history) >= 1
102
 
 
106
 
107
  def test_blue_step_returns_observation(self):
108
  env = RangeEnvironment(docker_available=False)
109
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
110
  obs = env.step(RangeAction(command="tail_log /var/log/syslog", mode="blue"))
111
  assert isinstance(obs, RangeObservation)
112
 
113
  def test_blue_submit_finding(self):
114
  env = RangeEnvironment(docker_available=False)
115
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
116
  obs = env.step(RangeAction(command="submit_finding SQL injection detected", mode="blue"))
117
  assert "recorded" in obs.stdout.lower() or "submitted" in obs.stdout.lower()
118
 
119
  def test_blue_any_command_forwarded(self):
120
  """No artificial allowlist — commands route to the siem container."""
121
  env = RangeEnvironment(docker_available=False)
122
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
123
  obs = env.step(RangeAction(command="nmap -sV web", mode="blue"))
124
  # In mock mode, this runs on siem container (not rejected)
125
  assert obs.stderr == ""
 
127
 
128
  def test_blue_empty_command_rejected(self):
129
  env = RangeEnvironment(docker_available=False)
130
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
131
  obs = env.step(RangeAction(command="", mode="blue"))
132
  assert obs.stderr != ""
133
 
134
  def test_step_passes_timeout_override_to_executor(self):
135
  env = RangeEnvironment(docker_available=False)
136
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
137
  seen = {}
138
 
139
  def fake_exec(container_name, command, timeout_s=None):
 
194
 
195
  def test_max_steps_terminates(self):
196
  env = RangeEnvironment(docker_available=False, max_steps=3)
197
+ env.reset(snapshot=_MINIMAL_SNAPSHOT)
198
  env.step(RangeAction(command="nmap -sV web", mode="red"))
199
  env.step(RangeAction(command="curl http://web", mode="red"))
200
  obs = env.step(RangeAction(command="curl http://web/login", mode="red"))
 
206
 
207
  def test_state_reflects_episode(self):
208
  env = RangeEnvironment(docker_available=False)
209
+ env.reset(snapshot=_MINIMAL_SNAPSHOT, episode_id="test_ep")
210
  assert env.state.episode_id == "test_ep"
211
  assert env.state.step_count == 0
212
  env.step(RangeAction(command="nmap -sV web", mode="red"))
tests/test_service_spec.py ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for ServiceSpec, ReadinessCheck, and generate_service_specs().
2
+
3
+ Covers:
4
+ - ServiceSpec / ReadinessCheck serialization round-trips
5
+ - generate_service_specs() with compose input (tier-1 and tier-3 services)
6
+ - generate_service_specs() with topology fallback (no compose)
7
+ - Backward compatibility: SnapshotSpec without services field
8
+ - Unknown images produce no specs (graceful skip)
9
+ - Environment service lifecycle integration
10
+ - Renderer generates services field in snapshot
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import tempfile
17
+ from pathlib import Path
18
+ from unittest.mock import patch
19
+
20
+ import pytest
21
+
22
+ from open_range.builder.service_manifest import (
23
+ _HOST_NAME_HINTS,
24
+ _IMAGE_SERVICE_HINTS,
25
+ _match_image_hint,
26
+ generate_service_specs,
27
+ )
28
+ from open_range.protocols import (
29
+ ReadinessCheck,
30
+ ServiceSpec,
31
+ SnapshotSpec,
32
+ TaskSpec,
33
+ )
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # ServiceSpec / ReadinessCheck serialization
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class TestReadinessCheck:
42
+ """ReadinessCheck model basics and serialization."""
43
+
44
+ def test_defaults(self):
45
+ rc = ReadinessCheck()
46
+ assert rc.type == "tcp"
47
+ assert rc.port == 0
48
+ assert rc.url == ""
49
+ assert rc.command == ""
50
+ assert rc.timeout_s == 30
51
+ assert rc.interval_s == 1.0
52
+
53
+ def test_tcp_check(self):
54
+ rc = ReadinessCheck(type="tcp", port=80, timeout_s=10)
55
+ assert rc.type == "tcp"
56
+ assert rc.port == 80
57
+
58
+ def test_http_check(self):
59
+ rc = ReadinessCheck(type="http", url="http://localhost:8080/health")
60
+ assert rc.type == "http"
61
+ assert rc.url == "http://localhost:8080/health"
62
+
63
+ def test_command_check(self):
64
+ rc = ReadinessCheck(type="command", command="pgrep -x nginx")
65
+ assert rc.type == "command"
66
+ assert rc.command == "pgrep -x nginx"
67
+
68
+ def test_roundtrip_json(self):
69
+ rc = ReadinessCheck(type="http", url="http://localhost:9090", timeout_s=15)
70
+ data = rc.model_dump()
71
+ rc2 = ReadinessCheck(**data)
72
+ assert rc2.type == rc.type
73
+ assert rc2.url == rc.url
74
+ assert rc2.timeout_s == rc.timeout_s
75
+
76
+
77
+ class TestServiceSpec:
78
+ """ServiceSpec model basics and serialization."""
79
+
80
+ def test_required_fields(self):
81
+ svc = ServiceSpec(host="web", daemon="nginx", start_command="nginx &")
82
+ assert svc.host == "web"
83
+ assert svc.daemon == "nginx"
84
+ assert svc.start_command == "nginx &"
85
+
86
+ def test_defaults(self):
87
+ svc = ServiceSpec(host="web", daemon="nginx", start_command="nginx &")
88
+ assert svc.packages == []
89
+ assert svc.init_commands == []
90
+ assert svc.env_vars == {}
91
+ assert svc.log_dir == ""
92
+ assert isinstance(svc.readiness, ReadinessCheck)
93
+
94
+ def test_full_spec(self):
95
+ svc = ServiceSpec(
96
+ host="db",
97
+ daemon="mysqld",
98
+ packages=["default-mysql-server"],
99
+ init_commands=["mkdir -p /var/run/mysqld"],
100
+ start_command="mysqld --user=mysql &",
101
+ readiness=ReadinessCheck(
102
+ type="command",
103
+ command="mysqladmin ping",
104
+ timeout_s=30,
105
+ ),
106
+ log_dir="/var/log/siem",
107
+ env_vars={"MYSQL_ROOT_PASSWORD": "secret"},
108
+ )
109
+ assert svc.daemon == "mysqld"
110
+ assert len(svc.init_commands) == 1
111
+ assert svc.readiness.type == "command"
112
+ assert svc.env_vars["MYSQL_ROOT_PASSWORD"] == "secret"
113
+
114
+ def test_roundtrip_json(self):
115
+ svc = ServiceSpec(
116
+ host="web",
117
+ daemon="nginx",
118
+ packages=["nginx"],
119
+ init_commands=["mkdir -p /var/log/nginx"],
120
+ start_command="nginx -g 'daemon off;' &",
121
+ readiness=ReadinessCheck(type="tcp", port=80),
122
+ log_dir="/var/log/siem",
123
+ env_vars={"SERVER_NAME": "web.corp.local"},
124
+ )
125
+ data = json.loads(svc.model_dump_json())
126
+ svc2 = ServiceSpec(**data)
127
+ assert svc2.host == svc.host
128
+ assert svc2.daemon == svc.daemon
129
+ assert svc2.packages == svc.packages
130
+ assert svc2.readiness.port == 80
131
+ assert svc2.env_vars == svc.env_vars
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # SnapshotSpec backward compatibility
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ class TestSnapshotSpecServices:
140
+ """SnapshotSpec.services field: default and serialization."""
141
+
142
+ def test_default_empty(self):
143
+ spec = SnapshotSpec()
144
+ assert spec.services == []
145
+
146
+ def test_with_services(self):
147
+ spec = SnapshotSpec(
148
+ topology={"hosts": ["web"]},
149
+ services=[
150
+ ServiceSpec(host="web", daemon="nginx", start_command="nginx &"),
151
+ ],
152
+ )
153
+ assert len(spec.services) == 1
154
+ assert spec.services[0].daemon == "nginx"
155
+
156
+ def test_roundtrip_preserves_services(self):
157
+ svc = ServiceSpec(
158
+ host="db",
159
+ daemon="mysqld",
160
+ start_command="mysqld &",
161
+ readiness=ReadinessCheck(type="tcp", port=3306),
162
+ )
163
+ spec = SnapshotSpec(
164
+ topology={"hosts": ["db"]},
165
+ services=[svc],
166
+ )
167
+ data = json.loads(spec.model_dump_json())
168
+ spec2 = SnapshotSpec(**data)
169
+ assert len(spec2.services) == 1
170
+ assert spec2.services[0].daemon == "mysqld"
171
+ assert spec2.services[0].readiness.port == 3306
172
+
173
+ def test_old_snapshot_without_services_parses(self):
174
+ """Simulate loading a JSON snapshot that predates the services field."""
175
+ old_data = {
176
+ "topology": {"hosts": ["web", "db"]},
177
+ "flags": [],
178
+ "golden_path": [],
179
+ }
180
+ spec = SnapshotSpec(**old_data)
181
+ assert spec.services == []
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # generate_service_specs() — compose input
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ class TestGenerateFromCompose:
190
+ """generate_service_specs() with compose services dict."""
191
+
192
+ def test_tier1_basic_compose(self):
193
+ """Tier 1 compose with common services maps correctly."""
194
+ compose = {
195
+ "services": {
196
+ "web": {"image": "nginx:1.25"},
197
+ "db": {"image": "mysql:8.0"},
198
+ "ldap": {"image": "osixia/openldap:1.5"},
199
+ "siem": {"image": "rsyslog:latest"},
200
+ "files": {"image": "samba:latest"},
201
+ "mail": {"image": "postfix:latest"},
202
+ "attacker": {"image": "kali:latest"},
203
+ }
204
+ }
205
+ topology = {"hosts": ["attacker", "web", "db", "ldap", "siem", "files", "mail"]}
206
+ specs = generate_service_specs(compose, topology)
207
+
208
+ daemon_names = {s.daemon for s in specs}
209
+ assert "nginx" in daemon_names
210
+ assert "mysqld" in daemon_names
211
+ assert "slapd" in daemon_names
212
+ assert "rsyslogd" in daemon_names
213
+ assert "smbd" in daemon_names
214
+ assert "master" in daemon_names # postfix
215
+
216
+ def test_tier3_compose_with_extra_services(self):
217
+ """Tier 3 compose with redis, postgres, jenkins."""
218
+ compose = {
219
+ "services": {
220
+ "web": {"image": "nginx:1.25"},
221
+ "cache": {"image": "redis:7"},
222
+ "db": {"image": "postgres:16"},
223
+ "ci_cd": {"image": "jenkins/jenkins:lts"},
224
+ "monitoring": {"image": "prometheus:latest"},
225
+ }
226
+ }
227
+ topology = {"hosts": ["web", "cache", "db", "ci_cd", "monitoring"]}
228
+ specs = generate_service_specs(compose, topology)
229
+
230
+ daemon_names = {s.daemon for s in specs}
231
+ assert "nginx" in daemon_names
232
+ assert "redis-server" in daemon_names
233
+ assert "postgres" in daemon_names
234
+ assert "java" in daemon_names # jenkins
235
+ assert "prometheus" in daemon_names
236
+
237
+ def test_unknown_image_skipped(self):
238
+ """Custom images with no hint produce no specs."""
239
+ compose = {
240
+ "services": {
241
+ "custom_app": {"image": "mycompany/custom-app:1.0"},
242
+ "web": {"image": "nginx:1.25"},
243
+ }
244
+ }
245
+ specs = generate_service_specs(compose, {"hosts": []})
246
+ assert len(specs) == 1
247
+ assert specs[0].daemon == "nginx"
248
+
249
+ def test_empty_compose(self):
250
+ """Empty compose falls through to topology."""
251
+ specs = generate_service_specs({}, {"hosts": ["web", "db"]})
252
+ daemon_names = {s.daemon for s in specs}
253
+ assert "nginx" in daemon_names
254
+ assert "mysqld" in daemon_names
255
+
256
+ def test_compose_env_vars_extracted(self):
257
+ """Environment variables from compose are passed to ServiceSpec."""
258
+ compose = {
259
+ "services": {
260
+ "db": {
261
+ "image": "mysql:8.0",
262
+ "environment": {"MYSQL_ROOT_PASSWORD": "secret"},
263
+ },
264
+ }
265
+ }
266
+ specs = generate_service_specs(compose, {"hosts": []})
267
+ assert len(specs) == 1
268
+ assert specs[0].env_vars.get("MYSQL_ROOT_PASSWORD") == "secret"
269
+
270
+ def test_compose_env_vars_list_form(self):
271
+ """Environment in list form (KEY=VALUE) is handled."""
272
+ compose = {
273
+ "services": {
274
+ "db": {
275
+ "image": "mysql:8.0",
276
+ "environment": ["MYSQL_ROOT_PASSWORD=secret", "MYSQL_DATABASE=app"],
277
+ },
278
+ }
279
+ }
280
+ specs = generate_service_specs(compose, {"hosts": []})
281
+ assert specs[0].env_vars["MYSQL_ROOT_PASSWORD"] == "secret"
282
+ assert specs[0].env_vars["MYSQL_DATABASE"] == "app"
283
+
284
+ def test_no_duplicate_daemons(self):
285
+ """If two compose services map to the same daemon, only one spec is produced."""
286
+ compose = {
287
+ "services": {
288
+ "siem": {"image": "rsyslog:latest"},
289
+ "firewall": {"image": "rsyslog:latest"},
290
+ }
291
+ }
292
+ specs = generate_service_specs(compose, {"hosts": []})
293
+ assert len(specs) == 1
294
+ assert specs[0].daemon == "rsyslogd"
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # generate_service_specs() — topology fallback
299
+ # ---------------------------------------------------------------------------
300
+
301
+
302
+ class TestGenerateFromTopology:
303
+ """generate_service_specs() falls back to topology when compose is empty."""
304
+
305
+ def test_basic_topology_hosts(self):
306
+ topology = {
307
+ "hosts": ["attacker", "web", "db", "ldap", "siem", "files", "mail"],
308
+ }
309
+ specs = generate_service_specs({}, topology)
310
+
311
+ daemon_names = {s.daemon for s in specs}
312
+ assert "nginx" in daemon_names
313
+ assert "mysqld" in daemon_names
314
+ assert "slapd" in daemon_names
315
+ assert "rsyslogd" in daemon_names
316
+ assert "smbd" in daemon_names
317
+ assert "master" in daemon_names
318
+
319
+ def test_unknown_host_skipped(self):
320
+ topology = {"hosts": ["attacker", "custom_box"]}
321
+ specs = generate_service_specs({}, topology)
322
+ assert len(specs) == 0
323
+
324
+ def test_dict_hosts(self):
325
+ """Hosts as dicts with 'name' key."""
326
+ topology = {
327
+ "hosts": [
328
+ {"name": "web", "zone": "dmz"},
329
+ {"name": "db", "zone": "internal"},
330
+ ],
331
+ }
332
+ specs = generate_service_specs({}, topology)
333
+ daemon_names = {s.daemon for s in specs}
334
+ assert "nginx" in daemon_names
335
+ assert "mysqld" in daemon_names
336
+
337
+ def test_empty_topology(self):
338
+ specs = generate_service_specs({}, {})
339
+ assert specs == []
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # _match_image_hint internals
344
+ # ---------------------------------------------------------------------------
345
+
346
+
347
+ class TestMatchImageHint:
348
+ """_match_image_hint matches Docker image strings to hint entries."""
349
+
350
+ def test_exact_match(self):
351
+ hint = _match_image_hint("nginx")
352
+ assert hint is not None
353
+ assert hint[0] == "nginx"
354
+
355
+ def test_tagged_image(self):
356
+ hint = _match_image_hint("mysql:8.0")
357
+ assert hint is not None
358
+ assert hint[0] == "mysqld"
359
+
360
+ def test_namespaced_image(self):
361
+ hint = _match_image_hint("osixia/openldap:1.5")
362
+ assert hint is not None
363
+ assert hint[0] == "slapd"
364
+
365
+ def test_basename_fallback(self):
366
+ """bitnami/redis:7 should match via basename 'redis'."""
367
+ hint = _match_image_hint("bitnami/redis:7")
368
+ assert hint is not None
369
+ assert hint[0] == "redis-server"
370
+
371
+ def test_unknown_image(self):
372
+ hint = _match_image_hint("mycompany/custom-service:v2")
373
+ assert hint is None
374
+
375
+ def test_empty_image(self):
376
+ hint = _match_image_hint("")
377
+ assert hint is None
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # Environment integration: service lifecycle methods
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ class TestEnvironmentServiceLifecycle:
386
+ """RangeEnvironment service lifecycle methods."""
387
+
388
+ def test_start_snapshot_services_noop_in_docker_mode(self):
389
+ """_start_snapshot_services is a no-op when execution_mode != subprocess."""
390
+ from open_range.server.environment import RangeEnvironment
391
+
392
+ env = RangeEnvironment(docker_available=False)
393
+ # execution_mode defaults to "docker" when docker_available=False (mock)
394
+ snapshot = SnapshotSpec(
395
+ topology={"hosts": ["web"]},
396
+ services=[ServiceSpec(host="web", daemon="nginx", start_command="nginx &")],
397
+ )
398
+ # Should not raise or attempt to start anything
399
+ env._start_snapshot_services(snapshot)
400
+
401
+ @patch("subprocess.run")
402
+ def test_start_snapshot_services_subprocess_mode(self, mock_run):
403
+ """_start_snapshot_services starts declared services in subprocess mode."""
404
+ from open_range.server.environment import RangeEnvironment
405
+
406
+ env = RangeEnvironment(docker_available=False, execution_mode="subprocess")
407
+ snapshot = SnapshotSpec(
408
+ topology={"hosts": ["web"]},
409
+ services=[
410
+ ServiceSpec(
411
+ host="web",
412
+ daemon="nginx",
413
+ init_commands=["mkdir -p /var/log/nginx"],
414
+ start_command="nginx &",
415
+ readiness=ReadinessCheck(type="tcp", port=80, timeout_s=0),
416
+ ),
417
+ ],
418
+ )
419
+ env._start_snapshot_services(snapshot)
420
+ # Should have called subprocess.run at least for init + start
421
+ assert mock_run.call_count >= 2
422
+
423
+ def test_start_services_empty_skips(self):
424
+ """When no services are declared, logs and skips provisioning."""
425
+ from open_range.server.environment import RangeEnvironment
426
+
427
+ env = RangeEnvironment(docker_available=False, execution_mode="subprocess")
428
+ snapshot = SnapshotSpec(
429
+ topology={"hosts": ["web", "db"]},
430
+ services=[], # empty
431
+ )
432
+ # Should not raise — just logs and returns
433
+ env._start_snapshot_services(snapshot)
434
+
435
+ @patch("subprocess.run")
436
+ def test_stop_services_uses_snapshot_daemons(self, mock_run):
437
+ """_stop_services uses daemon names from snapshot.services."""
438
+ from open_range.server.environment import RangeEnvironment
439
+
440
+ env = RangeEnvironment(docker_available=False, execution_mode="subprocess")
441
+ env._snapshot = SnapshotSpec(
442
+ topology={"hosts": ["web"]},
443
+ services=[
444
+ ServiceSpec(host="web", daemon="nginx", start_command="nginx &"),
445
+ ServiceSpec(host="db", daemon="mysqld", start_command="mysqld &"),
446
+ ],
447
+ )
448
+ env._stop_services()
449
+
450
+ # Should have called pkill for each daemon (either individually or via bash -c)
451
+ all_call_strs = []
452
+ for call in mock_run.call_args_list:
453
+ args = call[0][0] if call[0] else call.kwargs.get("args", [])
454
+ all_call_strs.append(" ".join(str(a) for a in args))
455
+ combined = " ".join(all_call_strs)
456
+ assert "nginx" in combined
457
+ assert "mysqld" in combined
458
+
459
+ def test_stop_services_no_services_skips_pkill(self):
460
+ """_stop_services skips pkill when snapshot has no services."""
461
+ from open_range.server.environment import RangeEnvironment
462
+
463
+ env = RangeEnvironment(docker_available=False, execution_mode="subprocess")
464
+ env._snapshot = SnapshotSpec(topology={"hosts": ["web"]})
465
+ # Should not raise — just skips pkill since no service specs
466
+ env._stop_services()
467
+
468
+ def test_stop_services_no_snapshot(self):
469
+ """_stop_services handles None snapshot gracefully."""
470
+ from open_range.server.environment import RangeEnvironment
471
+
472
+ env = RangeEnvironment(docker_available=False, execution_mode="subprocess")
473
+ env._snapshot = None
474
+ # Should not raise
475
+ env._stop_services()
476
+
477
+ def test_probe_readiness_tcp_unreachable(self):
478
+ """TCP probe returns False for unreachable port."""
479
+ from open_range.server.environment import RangeEnvironment
480
+
481
+ check = ReadinessCheck(type="tcp", port=19999)
482
+ assert RangeEnvironment._probe_readiness(check) is False
483
+
484
+ def test_probe_readiness_command_success(self):
485
+ """Command probe returns True for 'true' command."""
486
+ from open_range.server.environment import RangeEnvironment
487
+
488
+ check = ReadinessCheck(type="command", command="true")
489
+ assert RangeEnvironment._probe_readiness(check) is True
490
+
491
+ def test_probe_readiness_command_failure(self):
492
+ """Command probe returns False for 'false' command."""
493
+ from open_range.server.environment import RangeEnvironment
494
+
495
+ check = ReadinessCheck(type="command", command="false")
496
+ assert RangeEnvironment._probe_readiness(check) is False
497
+
498
+ def test_reset_calls_service_lifecycle(self):
499
+ """reset() calls _stop_services and _start_snapshot_services."""
500
+ from open_range.server.environment import RangeEnvironment
501
+
502
+ env = RangeEnvironment(docker_available=False)
503
+ stop_called = []
504
+ start_called = []
505
+
506
+ env._stop_services = lambda: stop_called.append(True) # type: ignore
507
+ env._start_snapshot_services = lambda s: start_called.append(s) # type: ignore
508
+
509
+ snapshot = SnapshotSpec(
510
+ topology={"hosts": ["attacker", "web"]},
511
+ task=TaskSpec(red_briefing="Test.", blue_briefing="Test."),
512
+ )
513
+ env.reset(snapshot=snapshot)
514
+ assert len(stop_called) == 1
515
+ assert len(start_called) == 1
516
+
517
+
518
+ # ---------------------------------------------------------------------------
519
+ # Renderer generates services in snapshot
520
+ # ---------------------------------------------------------------------------
521
+
522
+
523
+ class TestRendererServiceGeneration:
524
+ """SnapshotRenderer._build_service_specs() populates spec.services."""
525
+
526
+ def test_renderer_populates_services_from_topology(self):
527
+ from open_range.builder.renderer import SnapshotRenderer
528
+
529
+ renderer = SnapshotRenderer()
530
+ spec = SnapshotSpec(
531
+ topology={
532
+ "hosts": ["web", "db", "ldap"],
533
+ "zones": {"dmz": ["web"], "internal": ["db", "ldap"]},
534
+ "users": [],
535
+ "firewall_rules": [],
536
+ },
537
+ )
538
+
539
+ with tempfile.TemporaryDirectory() as tmpdir:
540
+ renderer.render(spec, Path(tmpdir) / "out")
541
+
542
+ # After rendering, services should be populated
543
+ assert len(spec.services) >= 2
544
+ daemon_names = {s.daemon for s in spec.services}
545
+ assert "nginx" in daemon_names
546
+ assert "mysqld" in daemon_names
547
+
548
+ def test_renderer_skips_if_services_already_present(self):
549
+ from open_range.builder.renderer import SnapshotRenderer
550
+
551
+ renderer = SnapshotRenderer()
552
+ existing_svc = ServiceSpec(host="web", daemon="nginx", start_command="nginx &")
553
+ spec = SnapshotSpec(
554
+ topology={
555
+ "hosts": ["web", "db"],
556
+ "zones": {"dmz": ["web"], "internal": ["db"]},
557
+ "users": [],
558
+ "firewall_rules": [],
559
+ },
560
+ services=[existing_svc],
561
+ )
562
+
563
+ with tempfile.TemporaryDirectory() as tmpdir:
564
+ renderer.render(spec, Path(tmpdir) / "out")
565
+
566
+ # Should not have overwritten — still just the one we provided
567
+ assert len(spec.services) == 1
568
+ assert spec.services[0].daemon == "nginx"
569
+
570
+
571
+ # ---------------------------------------------------------------------------
572
+ # Hint table coverage
573
+ # ---------------------------------------------------------------------------
574
+
575
+
576
+ class TestHintTableCoverage:
577
+ """All image hints produce valid ServiceSpec entries."""
578
+
579
+ @pytest.mark.parametrize("image_key", list(_IMAGE_SERVICE_HINTS.keys()))
580
+ def test_hint_produces_valid_spec(self, image_key):
581
+ """Each entry in the hint table produces a valid ServiceSpec."""
582
+ compose = {"services": {"svc": {"image": image_key}}}
583
+ specs = generate_service_specs(compose, {"hosts": []})
584
+ assert len(specs) == 1
585
+ svc = specs[0]
586
+ assert svc.daemon
587
+ assert svc.start_command
588
+ assert isinstance(svc.readiness, ReadinessCheck)
589
+
590
+ @pytest.mark.parametrize("host_name", list(_HOST_NAME_HINTS.keys()))
591
+ def test_host_hint_produces_valid_spec(self, host_name):
592
+ """Each entry in the host-name hint table produces a valid ServiceSpec."""
593
+ specs = generate_service_specs({}, {"hosts": [host_name]})
594
+ assert len(specs) >= 1
595
+ svc = specs[0]
596
+ assert svc.daemon
597
+ assert svc.start_command
tests/test_validator.py CHANGED
@@ -426,12 +426,11 @@ async def test_patchability_skips_prose_remediation(mock_containers):
426
 
427
  result = await PatchabilityCheck().check(spec, mock_containers)
428
  assert result.passed is False
429
- assert "no vulns had testable remediation" in result.error
430
- # Verify it was recorded as skipped
431
  vuln_results = result.details["vuln_results"]
432
  assert len(vuln_results) == 1
433
- assert "skipped" in vuln_results[0]
434
- assert "not executable" in vuln_results[0]["skipped"]
435
 
436
 
437
  @pytest.mark.asyncio
 
426
 
427
  result = await PatchabilityCheck().check(spec, mock_containers)
428
  assert result.passed is False
429
+ # Verify it was recorded as a failure (not silently skipped)
 
430
  vuln_results = result.details["vuln_results"]
431
  assert len(vuln_results) == 1
432
+ assert vuln_results[0]["passed"] is False
433
+ assert "not executable" in vuln_results[0]["reason"]
434
 
435
 
436
  @pytest.mark.asyncio