Spaces:
Runtime error
Runtime error
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 +1 -1
- docs/openenv-compliance.md +3 -7
- docs/red-blue-agents.md +1 -1
- examples/demo.py +1 -1
- manifests/tier1_basic.yaml +10 -10
- src/open_range/__init__.py +1 -5
- src/open_range/agents/episode.py +1 -1
- src/open_range/builder/builder.py +14 -2
- src/open_range/builder/npc/actions.py +103 -31
- src/open_range/builder/npc/npc_agent.py +12 -5
- src/open_range/builder/renderer.py +24 -0
- src/open_range/builder/templates/Dockerfile.web.j2 +4 -4
- src/open_range/cli.py +1 -1
- src/open_range/client/client.py +33 -28
- src/open_range/models.py +44 -0
- src/open_range/protocols.py +1 -1
- src/open_range/resolve.py +2 -2
- src/open_range/server/app.py +19 -98
- src/open_range/server/environment.py +124 -136
- src/open_range/server/models.py +3 -58
- src/open_range/server/rewards.py +13 -6
- src/open_range/server/runtime.py +1 -1
- src/open_range/training/runner.py +1 -1
- src/open_range/training/synthetic.py +1 -1
- src/open_range/validator/evidence.py +5 -2
- src/open_range/validator/exploitability.py +20 -1
- src/open_range/validator/patchability.py +10 -6
- src/open_range/validator/task_feasibility.py +12 -3
- start.sh +105 -4
- tests/test_builder.py +4 -3
- tests/test_client.py +25 -0
- tests/test_environment.py +23 -15
- tests/test_service_spec.py +597 -0
- tests/test_validator.py +3 -4
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 |
-
|
| 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.
|
| 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. **
|
| 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.
|
| 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,
|
| 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:
|
| 484 |
-
to:
|
| 485 |
type: reports_to
|
| 486 |
description: >-
|
| 487 |
-
|
| 488 |
-
email when
|
| 489 |
mailbox.
|
| 490 |
|
| 491 |
-
- from:
|
| 492 |
to: dthompson
|
| 493 |
type: trusts_email
|
| 494 |
description: >-
|
| 495 |
-
|
| 496 |
-
never verifies requests by phone. A spoofed email from
|
| 497 |
likely be actioned without question.
|
| 498 |
|
| 499 |
-
- from:
|
| 500 |
to: dthompson
|
| 501 |
type: shares_credentials
|
| 502 |
description: >-
|
| 503 |
-
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 75 |
-
f
|
| 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 |
-
|
| 97 |
-
f"mkdir -p /var/mail/{
|
| 98 |
-
f"&& echo
|
| 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 |
-
|
| 117 |
-
f
|
| 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 |
-
|
| 127 |
-
f"ls
|
| 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 |
-
|
| 138 |
-
f
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
await self.containers.exec(
|
| 155 |
-
|
| 156 |
-
f
|
| 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 |
-
|
| 190 |
-
f
|
| 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 |
-
|
| 200 |
-
f"mkdir -p /var/mail/{
|
| 201 |
-
f"&& echo
|
| 202 |
-
f"> /var/mail/{
|
| 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 |
-
|
|
|
|
| 212 |
# Suspicious login
|
|
|
|
| 213 |
await self.containers.exec(
|
| 214 |
-
|
| 215 |
-
f
|
| 216 |
-
f
|
| 217 |
)
|
| 218 |
# SIEM alert
|
|
|
|
| 219 |
await self.containers.exec(
|
| 220 |
-
|
| 221 |
-
f
|
| 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 |
-
|
| 230 |
-
f
|
| 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 |
-
|
| 237 |
f"{{ find /var/spool/mail/ /var/mail/ "
|
| 238 |
-
f"/home/{
|
| 239 |
-
f"-newer /tmp/.npc_check_{
|
| 240 |
f"-type f 2>/dev/null || true; }} | head -3",
|
| 241 |
)
|
| 242 |
-
await containers.exec(
|
| 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 |
-
|
| 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
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
"""Minimal stub matching openenv.core.client_types.StepResult."""
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) ->
|
| 42 |
-
"""
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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.
|
| 28 |
{"class": "open_range.validator.reward_grounding.RewardGroundingCheck"},
|
| 29 |
-
{"class": "open_range.validator.isolation.
|
| 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
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 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 |
-
|
| 536 |
-
|
| 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 |
-
|
| 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 |
-
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
|
| 588 |
# Run init commands
|
| 589 |
-
for cmd in
|
| 590 |
try:
|
| 591 |
result = sp.run(
|
| 592 |
["bash", "-c", cmd],
|
| 593 |
-
capture_output=True,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 607 |
-
capture_output=True,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 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 |
-
|
| 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
|
| 1124 |
-
- Blue: host with
|
| 1125 |
|
| 1126 |
-
|
| 1127 |
-
|
|
|
|
| 1128 |
"""
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
if self._snapshot and isinstance(self._snapshot.topology, dict):
|
| 1133 |
-
hosts = self._snapshot.topology.get("hosts", [])
|
| 1134 |
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
return self._container_name(
|
| 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 |
-
|
| 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 |
-
"""
|
| 2 |
|
| 3 |
-
RangeAction, RangeObservation,
|
| 4 |
-
types. Falls back to Pydantic stubs if openenv is not installed.
|
| 5 |
-
"""
|
| 6 |
|
| 7 |
-
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 242 |
-
|
| 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.
|
| 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.
|
| 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.
|
| 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
|
| 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 {
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# ---
|
| 101 |
-
if not vuln.remediation:
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
| 103 |
continue
|
| 104 |
|
| 105 |
-
# ---
|
| 106 |
if not _looks_executable(vuln.remediation):
|
| 107 |
msg = f"remediation is not executable: {vuln.remediation!r}"
|
| 108 |
-
logger.warning("patchability:
|
| 109 |
-
results.append({"vuln": vuln.id, "
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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
|
| 10 |
-
# Each host name maps to a known service (nginx, mysql, slapd, etc.).
|
| 11 |
#
|
| 12 |
-
#
|
|
|
|
| 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
|
| 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 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
| 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 |
-
|
| 430 |
-
# Verify it was recorded as skipped
|
| 431 |
vuln_results = result.details["vuln_results"]
|
| 432 |
assert len(vuln_results) == 1
|
| 433 |
-
assert
|
| 434 |
-
assert "not executable" in vuln_results[0]["
|
| 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
|