Spaces:
Runtime error
Runtime error
Lars Talian commited on
Commit ·
f0faee1
1
Parent(s): d42b272
fix(runtime): preserve per-host service specs and role-aware routing
Browse files
src/open_range/builder/service_manifest.py
CHANGED
|
@@ -235,14 +235,14 @@ def generate_service_specs(
|
|
| 235 |
services dict (or the topology hosts list as fallback).
|
| 236 |
"""
|
| 237 |
specs: list[ServiceSpec] = []
|
| 238 |
-
|
| 239 |
|
| 240 |
services = compose.get("services", {}) if compose else {}
|
| 241 |
|
| 242 |
if services:
|
| 243 |
-
specs = _from_compose(services,
|
| 244 |
else:
|
| 245 |
-
specs = _from_topology(topology,
|
| 246 |
|
| 247 |
return specs
|
| 248 |
|
|
@@ -322,7 +322,7 @@ def _build_service_spec(
|
|
| 322 |
|
| 323 |
def _from_compose(
|
| 324 |
services: dict[str, Any],
|
| 325 |
-
|
| 326 |
) -> list[ServiceSpec]:
|
| 327 |
"""Generate specs from the compose services section."""
|
| 328 |
specs: list[ServiceSpec] = []
|
|
@@ -348,9 +348,10 @@ def _from_compose(
|
|
| 348 |
continue
|
| 349 |
|
| 350 |
daemon = hint[0]
|
| 351 |
-
|
|
|
|
| 352 |
continue
|
| 353 |
-
|
| 354 |
|
| 355 |
env_vars = _env_from_compose_service(svc_def)
|
| 356 |
spec = _build_service_spec(
|
|
@@ -365,7 +366,7 @@ def _from_compose(
|
|
| 365 |
|
| 366 |
def _from_topology(
|
| 367 |
topology: dict[str, Any],
|
| 368 |
-
|
| 369 |
) -> list[ServiceSpec]:
|
| 370 |
"""Generate specs from the topology hosts list (fallback path)."""
|
| 371 |
specs: list[ServiceSpec] = []
|
|
@@ -385,9 +386,10 @@ def _from_topology(
|
|
| 385 |
continue
|
| 386 |
|
| 387 |
daemon = hint[0]
|
| 388 |
-
|
|
|
|
| 389 |
continue
|
| 390 |
-
|
| 391 |
|
| 392 |
spec = _build_service_spec(host=host_name, hint=hint)
|
| 393 |
specs.append(spec)
|
|
|
|
| 235 |
services dict (or the topology hosts list as fallback).
|
| 236 |
"""
|
| 237 |
specs: list[ServiceSpec] = []
|
| 238 |
+
seen_identities: set[tuple[str, str]] = set()
|
| 239 |
|
| 240 |
services = compose.get("services", {}) if compose else {}
|
| 241 |
|
| 242 |
if services:
|
| 243 |
+
specs = _from_compose(services, seen_identities)
|
| 244 |
else:
|
| 245 |
+
specs = _from_topology(topology, seen_identities)
|
| 246 |
|
| 247 |
return specs
|
| 248 |
|
|
|
|
| 322 |
|
| 323 |
def _from_compose(
|
| 324 |
services: dict[str, Any],
|
| 325 |
+
seen_identities: set[tuple[str, str]],
|
| 326 |
) -> list[ServiceSpec]:
|
| 327 |
"""Generate specs from the compose services section."""
|
| 328 |
specs: list[ServiceSpec] = []
|
|
|
|
| 348 |
continue
|
| 349 |
|
| 350 |
daemon = hint[0]
|
| 351 |
+
identity = (svc_name, daemon)
|
| 352 |
+
if identity in seen_identities:
|
| 353 |
continue
|
| 354 |
+
seen_identities.add(identity)
|
| 355 |
|
| 356 |
env_vars = _env_from_compose_service(svc_def)
|
| 357 |
spec = _build_service_spec(
|
|
|
|
| 366 |
|
| 367 |
def _from_topology(
|
| 368 |
topology: dict[str, Any],
|
| 369 |
+
seen_identities: set[tuple[str, str]],
|
| 370 |
) -> list[ServiceSpec]:
|
| 371 |
"""Generate specs from the topology hosts list (fallback path)."""
|
| 372 |
specs: list[ServiceSpec] = []
|
|
|
|
| 386 |
continue
|
| 387 |
|
| 388 |
daemon = hint[0]
|
| 389 |
+
identity = (host_name, daemon)
|
| 390 |
+
if identity in seen_identities:
|
| 391 |
continue
|
| 392 |
+
seen_identities.add(identity)
|
| 393 |
|
| 394 |
spec = _build_service_spec(host=host_name, hint=hint)
|
| 395 |
specs.append(spec)
|
src/open_range/server/environment.py
CHANGED
|
@@ -1111,23 +1111,36 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
|
|
| 1111 |
"""Determine which container to route the command to.
|
| 1112 |
|
| 1113 |
Reads from the snapshot topology to find the appropriate host:
|
| 1114 |
-
- Red: host with role=attacker or zone=external
|
| 1115 |
-
- Blue: host with role=siem or zone=management
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
|
|
|
|
|
|
|
|
|
| 1120 |
"""
|
| 1121 |
if not self._snapshot or not isinstance(self._snapshot.topology, dict):
|
| 1122 |
raise RuntimeError("Cannot resolve target — no snapshot topology loaded")
|
| 1123 |
|
| 1124 |
-
|
|
|
|
| 1125 |
if not hosts:
|
| 1126 |
raise RuntimeError("Cannot resolve target — snapshot topology has no hosts")
|
| 1127 |
|
| 1128 |
target_role = "attacker" if action.mode == "red" else "siem"
|
| 1129 |
target_zone = "external" if action.mode == "red" else "management"
|
| 1130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1131 |
# Look for a host with matching role or zone
|
| 1132 |
for h in hosts:
|
| 1133 |
if isinstance(h, dict):
|
|
@@ -1142,6 +1155,15 @@ class RangeEnvironment(Environment[RangeAction, RangeObservation, RangeState]):
|
|
| 1142 |
if name == target_role:
|
| 1143 |
return self._container_name(name)
|
| 1144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1145 |
# Use positional convention: first host for Red, last for Blue
|
| 1146 |
fallback = hosts[0] if action.mode == "red" else hosts[-1]
|
| 1147 |
name = fallback if isinstance(fallback, str) else fallback.get("name", fallback.get("hostname", ""))
|
|
|
|
| 1111 |
"""Determine which container to route the command to.
|
| 1112 |
|
| 1113 |
Reads from the snapshot topology to find the appropriate host:
|
| 1114 |
+
- Red: host with role=attacker or zone=external
|
| 1115 |
+
- Blue: host with role=siem or zone=management
|
| 1116 |
+
|
| 1117 |
+
Resolution priority:
|
| 1118 |
+
1. host_catalog metadata (compiled from manifest)
|
| 1119 |
+
2. dict entries in topology["hosts"] with role/zone
|
| 1120 |
+
3. literal host-name match ("attacker"/"siem")
|
| 1121 |
+
4. zone membership fallback via topology["zones"]
|
| 1122 |
+
5. positional fallback (first host for Red, last for Blue)
|
| 1123 |
"""
|
| 1124 |
if not self._snapshot or not isinstance(self._snapshot.topology, dict):
|
| 1125 |
raise RuntimeError("Cannot resolve target — no snapshot topology loaded")
|
| 1126 |
|
| 1127 |
+
topology = self._snapshot.topology
|
| 1128 |
+
hosts = topology.get("hosts", [])
|
| 1129 |
if not hosts:
|
| 1130 |
raise RuntimeError("Cannot resolve target — snapshot topology has no hosts")
|
| 1131 |
|
| 1132 |
target_role = "attacker" if action.mode == "red" else "siem"
|
| 1133 |
target_zone = "external" if action.mode == "red" else "management"
|
| 1134 |
|
| 1135 |
+
host_catalog = topology.get("host_catalog", {})
|
| 1136 |
+
if isinstance(host_catalog, dict):
|
| 1137 |
+
for host_name, meta in host_catalog.items():
|
| 1138 |
+
if not isinstance(meta, dict):
|
| 1139 |
+
continue
|
| 1140 |
+
if meta.get("role") == target_role or meta.get("zone") == target_zone:
|
| 1141 |
+
if host_name:
|
| 1142 |
+
return self._container_name(str(host_name))
|
| 1143 |
+
|
| 1144 |
# Look for a host with matching role or zone
|
| 1145 |
for h in hosts:
|
| 1146 |
if isinstance(h, dict):
|
|
|
|
| 1155 |
if name == target_role:
|
| 1156 |
return self._container_name(name)
|
| 1157 |
|
| 1158 |
+
zones = topology.get("zones", {})
|
| 1159 |
+
if isinstance(zones, dict):
|
| 1160 |
+
candidates = zones.get(target_zone, [])
|
| 1161 |
+
if isinstance(candidates, list):
|
| 1162 |
+
for candidate in candidates:
|
| 1163 |
+
host_name = str(candidate).strip()
|
| 1164 |
+
if host_name:
|
| 1165 |
+
return self._container_name(host_name)
|
| 1166 |
+
|
| 1167 |
# Use positional convention: first host for Red, last for Blue
|
| 1168 |
fallback = hosts[0] if action.mode == "red" else hosts[-1]
|
| 1169 |
name = fallback if isinstance(fallback, str) else fallback.get("name", fallback.get("hostname", ""))
|
tests/test_environment.py
CHANGED
|
@@ -67,6 +67,46 @@ class TestReset:
|
|
| 67 |
assert env.snapshot is not None
|
| 68 |
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
class TestRedStep:
|
| 71 |
"""Red agent actions."""
|
| 72 |
|
|
|
|
| 67 |
assert env.snapshot is not None
|
| 68 |
|
| 69 |
|
| 70 |
+
class TestTargetResolution:
|
| 71 |
+
"""Target selection should honor manifest-compiled metadata."""
|
| 72 |
+
|
| 73 |
+
def test_resolve_target_uses_host_catalog_roles(self):
|
| 74 |
+
env = RangeEnvironment(docker_available=False)
|
| 75 |
+
env.reset(
|
| 76 |
+
snapshot=SnapshotSpec(
|
| 77 |
+
topology={
|
| 78 |
+
"hosts": ["web", "kali1", "socbox"],
|
| 79 |
+
"host_catalog": {
|
| 80 |
+
"web": {"role": "web", "zone": "dmz"},
|
| 81 |
+
"kali1": {"role": "attacker", "zone": "external"},
|
| 82 |
+
"socbox": {"role": "siem", "zone": "management"},
|
| 83 |
+
},
|
| 84 |
+
},
|
| 85 |
+
task=TaskSpec(red_briefing="Go.", blue_briefing="Watch."),
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
+
assert env._resolve_target(RangeAction(command="id", mode="red")) == "kali1"
|
| 89 |
+
assert env._resolve_target(RangeAction(command="id", mode="blue")) == "socbox"
|
| 90 |
+
|
| 91 |
+
def test_resolve_target_uses_zone_mapping_for_string_hosts(self):
|
| 92 |
+
env = RangeEnvironment(docker_available=False)
|
| 93 |
+
env.reset(
|
| 94 |
+
snapshot=SnapshotSpec(
|
| 95 |
+
topology={
|
| 96 |
+
"hosts": ["web", "kali1", "socbox"],
|
| 97 |
+
"zones": {
|
| 98 |
+
"dmz": ["web"],
|
| 99 |
+
"external": ["kali1"],
|
| 100 |
+
"management": ["socbox"],
|
| 101 |
+
},
|
| 102 |
+
},
|
| 103 |
+
task=TaskSpec(red_briefing="Go.", blue_briefing="Watch."),
|
| 104 |
+
)
|
| 105 |
+
)
|
| 106 |
+
assert env._resolve_target(RangeAction(command="id", mode="red")) == "kali1"
|
| 107 |
+
assert env._resolve_target(RangeAction(command="id", mode="blue")) == "socbox"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
class TestRedStep:
|
| 111 |
"""Red agent actions."""
|
| 112 |
|
tests/test_service_spec.py
CHANGED
|
@@ -281,8 +281,8 @@ class TestGenerateFromCompose:
|
|
| 281 |
assert specs[0].env_vars["MYSQL_ROOT_PASSWORD"] == "secret"
|
| 282 |
assert specs[0].env_vars["MYSQL_DATABASE"] == "app"
|
| 283 |
|
| 284 |
-
def
|
| 285 |
-
"""
|
| 286 |
compose = {
|
| 287 |
"services": {
|
| 288 |
"siem": {"image": "rsyslog:latest"},
|
|
@@ -290,8 +290,21 @@ class TestGenerateFromCompose:
|
|
| 290 |
}
|
| 291 |
}
|
| 292 |
specs = generate_service_specs(compose, {"hosts": []})
|
| 293 |
-
assert len(specs) ==
|
| 294 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
|
| 297 |
# ---------------------------------------------------------------------------
|
|
|
|
| 281 |
assert specs[0].env_vars["MYSQL_ROOT_PASSWORD"] == "secret"
|
| 282 |
assert specs[0].env_vars["MYSQL_DATABASE"] == "app"
|
| 283 |
|
| 284 |
+
def test_repeated_daemons_on_different_hosts_are_preserved(self):
|
| 285 |
+
"""Two hosts may intentionally run the same daemon family."""
|
| 286 |
compose = {
|
| 287 |
"services": {
|
| 288 |
"siem": {"image": "rsyslog:latest"},
|
|
|
|
| 290 |
}
|
| 291 |
}
|
| 292 |
specs = generate_service_specs(compose, {"hosts": []})
|
| 293 |
+
assert len(specs) == 2
|
| 294 |
+
assert {spec.host for spec in specs} == {"siem", "firewall"}
|
| 295 |
+
assert all(spec.daemon == "rsyslogd" for spec in specs)
|
| 296 |
+
|
| 297 |
+
def test_same_daemon_across_multiple_web_hosts(self):
|
| 298 |
+
compose = {
|
| 299 |
+
"services": {
|
| 300 |
+
"web1": {"image": "nginx:1.25"},
|
| 301 |
+
"web2": {"image": "nginx:1.25"},
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
specs = generate_service_specs(compose, {"hosts": ["web1", "web2"]})
|
| 305 |
+
assert len(specs) == 2
|
| 306 |
+
assert {spec.host for spec in specs} == {"web1", "web2"}
|
| 307 |
+
assert all(spec.daemon == "nginx" for spec in specs)
|
| 308 |
|
| 309 |
|
| 310 |
# ---------------------------------------------------------------------------
|