Lars Talian commited on
Commit
5b99233
·
unverified ·
1 Parent(s): e5d4fbe

fix(runtime): stabilize live admission boot path (#102)

Browse files
src/open_range/builder/builder.py CHANGED
@@ -618,10 +618,10 @@ _DEFAULT_VULN_POOL: list[dict[str, Any]] = [
618
  ),
619
  "root_cause": "Unsanitized user input in SQL query",
620
  "blast_radius": "Full database read access",
621
- "remediation": "Use parameterized queries",
622
- "flag_id": "flag1",
623
  "flag_value": "FLAG{sql1_t3st_f1ag}",
624
- "flag_path": "/var/flags/flag1.txt",
625
  "golden_path_steps": [
626
  {
627
  "step": 1,
@@ -723,10 +723,10 @@ _DEFAULT_VULN_POOL: list[dict[str, Any]] = [
723
  ),
724
  "root_cause": "No path sanitization on file parameter",
725
  "blast_radius": "Arbitrary file read on web server",
726
- "remediation": "Validate and sanitize file path, use basename()",
727
- "flag_id": "flag1",
728
  "flag_value": "FLAG{p4th_tr4v3rs4l_fl4g}",
729
- "flag_path": "/var/flags/flag1.txt",
730
  "golden_path_steps": [
731
  {
732
  "step": 1,
@@ -754,7 +754,7 @@ _DEFAULT_VULN_POOL: list[dict[str, Any]] = [
754
  },
755
  {
756
  "step": 5,
757
- "cmd": "curl 'http://web/download?file=../../../var/flags/flag1.txt'",
758
  "expect_stdout": "FLAG{p4th_tr4v3rs4l_fl4g}",
759
  "host": "attacker",
760
  },
@@ -956,7 +956,9 @@ class TemplateOnlyBuilder:
956
  )
957
 
958
  if "prefer_live_admission_compatible_vulns" in context.narrative_hints:
959
- live_supported = {"sqli", "idor", "path_traversal", "weak_creds"}
 
 
960
  supported = [v for v in candidates if v["type"] in live_supported]
961
  if supported:
962
  candidates = supported
 
618
  ),
619
  "root_cause": "Unsanitized user input in SQL query",
620
  "blast_radius": "Full database read access",
621
+ "remediation": "mv /var/www/portal/search.php /var/www/portal/search.php.disabled",
622
+ "flag_id": "flag_sqli",
623
  "flag_value": "FLAG{sql1_t3st_f1ag}",
624
+ "flag_path": "/var/flags/sqli_flag.txt",
625
  "golden_path_steps": [
626
  {
627
  "step": 1,
 
723
  ),
724
  "root_cause": "No path sanitization on file parameter",
725
  "blast_radius": "Arbitrary file read on web server",
726
+ "remediation": "mv /var/www/portal/download.php /var/www/portal/download.php.disabled",
727
+ "flag_id": "flag_path_traversal",
728
  "flag_value": "FLAG{p4th_tr4v3rs4l_fl4g}",
729
+ "flag_path": "/var/flags/path_traversal_flag.txt",
730
  "golden_path_steps": [
731
  {
732
  "step": 1,
 
754
  },
755
  {
756
  "step": 5,
757
+ "cmd": "curl 'http://web/download?file=../../../var/flags/path_traversal_flag.txt'",
758
  "expect_stdout": "FLAG{p4th_tr4v3rs4l_fl4g}",
759
  "host": "attacker",
760
  },
 
956
  )
957
 
958
  if "prefer_live_admission_compatible_vulns" in context.narrative_hints:
959
+ # Keep strict live admission on task paths the current zone policy
960
+ # can actually reach from the attacker host.
961
+ live_supported = {"sqli", "path_traversal"}
962
  supported = [v for v in candidates if v["type"] in live_supported]
963
  if supported:
964
  candidates = supported
src/open_range/builder/mutator.py CHANGED
@@ -57,7 +57,7 @@ _GENERIC_SERVICES = {
57
  "nikto",
58
  "sqlmap",
59
  }
60
- _LIVE_MUTATION_SUPPORTED_VULNS = {"sqli", "idor", "path_traversal", "weak_creds"}
61
 
62
 
63
  class Mutator:
 
57
  "nikto",
58
  "sqlmap",
59
  }
60
+ _LIVE_MUTATION_SUPPORTED_VULNS = {"sqli", "path_traversal"}
61
 
62
 
63
  class Mutator:
src/open_range/builder/templates/docker-compose.yml.j2 CHANGED
@@ -39,7 +39,7 @@ services:
39
  - -c
40
  - |
41
  apt-get update -qq && apt-get install -y -qq \
42
- nmap sqlmap hydra nikto smbclient curl wget netcat-openbsd \
43
  ssh dnsutils tcpdump python3 python3-pip iproute2 sshpass \
44
  default-mysql-client ldap-utils \
45
  > /dev/null 2>&1
@@ -47,9 +47,24 @@ services:
47
  ip route add 10.0.2.0/24 via 10.0.0.2 2>/dev/null || true
48
  ip route add 10.0.3.0/24 via 10.0.0.2 2>/dev/null || true
49
  tail -f /dev/null
 
 
 
 
 
 
 
 
50
  networks:
51
  external:
52
  ipv4_address: 10.0.0.10
 
 
 
 
 
 
 
53
  restart: unless-stopped
54
 
55
  firewall:
@@ -62,9 +77,10 @@ services:
62
  - |
63
  apt-get update -qq && apt-get install -y -qq iptables iproute2 > /dev/null 2>&1
64
  echo 1 > /proc/sys/net/ipv4/ip_forward
65
- iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE
66
- iptables -t nat -A POSTROUTING -o eth2 -j MASQUERADE
67
- iptables -t nat -A POSTROUTING -o eth3 -j MASQUERADE
 
68
  iptables -A FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT
69
  iptables -A FORWARD -s 10.0.0.0/24 -d 10.0.1.0/24 -j ACCEPT
70
  iptables -A FORWARD -s 10.0.1.0/24 -d 10.0.2.0/24 -j ACCEPT
@@ -81,6 +97,13 @@ services:
81
  ipv4_address: 10.0.2.2
82
  management:
83
  ipv4_address: 10.0.3.2
 
 
 
 
 
 
 
84
  restart: unless-stopped
85
 
86
  web:
@@ -101,7 +124,9 @@ services:
101
  management:
102
  ipv4_address: 10.0.3.10
103
  healthcheck:
104
- test: ["CMD", "curl", "-sf", "http://localhost/"]
 
 
105
  interval: 10s
106
  timeout: 5s
107
  retries: 3
 
39
  - -c
40
  - |
41
  apt-get update -qq && apt-get install -y -qq \
42
+ libblas3 nmap sqlmap hydra nikto smbclient curl wget netcat-openbsd \
43
  ssh dnsutils tcpdump python3 python3-pip iproute2 sshpass \
44
  default-mysql-client ldap-utils \
45
  > /dev/null 2>&1
 
47
  ip route add 10.0.2.0/24 via 10.0.0.2 2>/dev/null || true
48
  ip route add 10.0.3.0/24 via 10.0.0.2 2>/dev/null || true
49
  tail -f /dev/null
50
+ extra_hosts:
51
+ - "firewall:10.0.0.2"
52
+ - "web:10.0.1.10"
53
+ - "mail:10.0.1.11"
54
+ - "db:10.0.2.20"
55
+ - "files:10.0.2.21"
56
+ - "ldap:10.0.3.20"
57
+ - "siem:10.0.3.21"
58
  networks:
59
  external:
60
  ipv4_address: 10.0.0.10
61
+ healthcheck:
62
+ test:
63
+ - "CMD-SHELL"
64
+ - "nmap --version >/dev/null 2>&1 && ip route | grep -q '10.0.1.0/24 via 10.0.0.2' && getent hosts web db files ldap siem >/dev/null 2>&1"
65
+ interval: 10s
66
+ timeout: 5s
67
+ retries: 12
68
  restart: unless-stopped
69
 
70
  firewall:
 
77
  - |
78
  apt-get update -qq && apt-get install -y -qq iptables iproute2 > /dev/null 2>&1
79
  echo 1 > /proc/sys/net/ipv4/ip_forward
80
+ iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -d 10.0.1.0/24 -j MASQUERADE
81
+ iptables -t nat -A POSTROUTING -s 10.0.1.0/24 -d 10.0.2.0/24 -j MASQUERADE
82
+ iptables -t nat -A POSTROUTING -s 10.0.1.0/24 -d 10.0.3.0/24 -j MASQUERADE
83
+ iptables -t nat -A POSTROUTING -s 10.0.2.0/24 -d 10.0.3.0/24 -j MASQUERADE
84
  iptables -A FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT
85
  iptables -A FORWARD -s 10.0.0.0/24 -d 10.0.1.0/24 -j ACCEPT
86
  iptables -A FORWARD -s 10.0.1.0/24 -d 10.0.2.0/24 -j ACCEPT
 
97
  ipv4_address: 10.0.2.2
98
  management:
99
  ipv4_address: 10.0.3.2
100
+ healthcheck:
101
+ test:
102
+ - "CMD-SHELL"
103
+ - "grep -qx '1' /proc/sys/net/ipv4/ip_forward && iptables -C FORWARD -s 10.0.0.0/24 -d 10.0.1.0/24 -j ACCEPT >/dev/null 2>&1 && iptables -t nat -C POSTROUTING -s 10.0.0.0/24 -d 10.0.1.0/24 -j MASQUERADE >/dev/null 2>&1"
104
+ interval: 10s
105
+ timeout: 5s
106
+ retries: 12
107
  restart: unless-stopped
108
 
109
  web:
 
124
  management:
125
  ipv4_address: 10.0.3.10
126
  healthcheck:
127
+ test:
128
+ - "CMD-SHELL"
129
+ - "status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost/ || true); case \"$$status\" in 2*|3*|4*) exit 0;; *) exit 1;; esac"
130
  interval: 10s
131
  timeout: 5s
132
  retries: 3
src/open_range/server/runtime.py CHANGED
@@ -896,16 +896,36 @@ class ManagedSnapshotRuntime:
896
  topology["snapshot_id"] = snapshot_id
897
  rendered.topology = topology
898
  self.renderer.render(rendered, snapshot_dir)
 
 
899
 
900
- compose_file = snapshot_dir / "docker-compose.yml"
901
- up_result = self._compose_up(snapshot_dir, compose_file, project_name)
902
- if up_result is not None:
903
- return up_result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
 
905
  try:
906
- containers = self._discover_containers(project_name)
907
- self._deploy_snapshot_artifacts(rendered, containers, snapshot_dir)
908
- return _run_coro_sync(self.validator.validate(rendered, containers))
909
  except Exception as exc: # noqa: BLE001
910
  return ValidationResult(
911
  passed=False,
@@ -918,7 +938,37 @@ class ManagedSnapshotRuntime:
918
  ],
919
  )
920
  finally:
921
- self._compose_down(snapshot_dir, compose_file, project_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
922
 
923
  def _project_name(self, snapshot_id: str) -> str:
924
  safe = "".join(ch if ch.isalnum() else "-" for ch in snapshot_id.lower()).strip("-")
 
896
  topology["snapshot_id"] = snapshot_id
897
  rendered.topology = topology
898
  self.renderer.render(rendered, snapshot_dir)
899
+ compose_path = snapshot_dir / "docker-compose.yml"
900
+ rendered.compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) or {}
901
 
902
+ project: BootedSnapshotProject | None = None
903
+ try:
904
+ project = self.compose_runner.boot(
905
+ snapshot_id=snapshot_id,
906
+ artifacts_dir=snapshot_dir,
907
+ compose=rendered.compose,
908
+ project_name=project_name,
909
+ )
910
+ except Exception as exc: # noqa: BLE001
911
+ self._best_effort_teardown_validation_project(
912
+ snapshot_dir=snapshot_dir,
913
+ project_name=project_name,
914
+ )
915
+ return ValidationResult(
916
+ passed=False,
917
+ checks=[
918
+ CheckResult(
919
+ name="build_boot",
920
+ passed=False,
921
+ error=str(exc),
922
+ )
923
+ ],
924
+ )
925
 
926
  try:
927
+ self._deploy_snapshot_artifacts(rendered, project.containers, snapshot_dir)
928
+ return _run_coro_sync(self.validator.validate(rendered, project.containers))
 
929
  except Exception as exc: # noqa: BLE001
930
  return ValidationResult(
931
  passed=False,
 
938
  ],
939
  )
940
  finally:
941
+ if project is not None:
942
+ try:
943
+ self.compose_runner.teardown(project)
944
+ except Exception: # noqa: BLE001
945
+ logger.warning(
946
+ "Failed to tear down validation project %s",
947
+ project.project_name,
948
+ )
949
+
950
+ def _best_effort_teardown_validation_project(
951
+ self,
952
+ *,
953
+ snapshot_dir: Path,
954
+ project_name: str,
955
+ ) -> None:
956
+ """Tear down a failed validation project when boot aborts mid-flight."""
957
+ compose_file = snapshot_dir / "docker-compose.yml"
958
+ try:
959
+ self.compose_runner.teardown(
960
+ BootedSnapshotProject(
961
+ project_name=project_name,
962
+ compose_file=compose_file,
963
+ artifacts_dir=snapshot_dir,
964
+ containers=ContainerSet(project_name=project_name),
965
+ )
966
+ )
967
+ except Exception: # noqa: BLE001
968
+ logger.warning(
969
+ "Failed to tear down validation project %s after boot failure",
970
+ project_name,
971
+ )
972
 
973
  def _project_name(self, snapshot_id: str) -> str:
974
  safe = "".join(ch if ch.isalnum() else "-" for ch in snapshot_id.lower()).strip("-")
src/open_range/validator/exploitability.py CHANGED
@@ -12,6 +12,10 @@ logger = logging.getLogger(__name__)
12
  _META_COMMANDS = {"submit_flag", "submit_evidence", "submit_finding", "auth", "logout"}
13
 
14
 
 
 
 
 
15
  class ExploitabilityCheck:
16
  """Execute every golden-path step and verify ``expect_in_stdout`` appears."""
17
 
@@ -75,7 +79,7 @@ class ExploitabilityCheck:
75
  message,
76
  )
77
  unvalidated_steps.append(step.step)
78
- elif expected not in output:
79
  failed_steps.append({
80
  "step": step.step,
81
  "expected": expected,
 
12
  _META_COMMANDS = {"submit_flag", "submit_evidence", "submit_finding", "auth", "logout"}
13
 
14
 
15
+ def _collapse_whitespace(value: str) -> str:
16
+ return " ".join(value.split())
17
+
18
+
19
  class ExploitabilityCheck:
20
  """Execute every golden-path step and verify ``expect_in_stdout`` appears."""
21
 
 
79
  message,
80
  )
81
  unvalidated_steps.append(step.step)
82
+ elif expected not in output and _collapse_whitespace(expected) not in _collapse_whitespace(output):
83
  failed_steps.append({
84
  "step": step.step,
85
  "expected": expected,
tests/test_builder.py CHANGED
@@ -124,6 +124,49 @@ async def test_template_builder_avoids_previous_vulns(tier1_manifest):
124
  assert len(vuln_types) >= 1
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  @pytest.mark.asyncio
128
  async def test_template_builder_deterministic_with_seed(tier1_manifest):
129
  from open_range.builder.builder import TemplateOnlyBuilder
@@ -522,6 +565,30 @@ async def test_mutator_fails_fast_on_illegal_add_service_target(tier1_manifest):
522
  )
523
 
524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  # ---------------------------------------------------------------------------
526
  # FileBuilder
527
  # ---------------------------------------------------------------------------
 
124
  assert len(vuln_types) >= 1
125
 
126
 
127
+ @pytest.mark.asyncio
128
+ async def test_template_builder_live_admission_hints_skip_unreachable_weak_creds(tier1_manifest):
129
+ from open_range.builder.builder import TemplateOnlyBuilder
130
+
131
+ builder = TemplateOnlyBuilder()
132
+ manifest = {
133
+ **tier1_manifest,
134
+ "bug_families": ["weak_creds", "sqli"],
135
+ "difficulty": {**tier1_manifest.get("difficulty", {}), "min_vulns": 1, "max_vulns": 1},
136
+ }
137
+ ctx = BuildContext(
138
+ seed=3,
139
+ tier=1,
140
+ narrative_hints=["prefer_live_admission_compatible_vulns"],
141
+ )
142
+ spec = await builder.build(manifest, ctx)
143
+ assert [v.type for v in spec.truth_graph.vulns] == ["sqli"]
144
+
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_template_builder_live_admission_vulns_have_executable_remediations(tier1_manifest):
148
+ from open_range.builder.builder import TemplateOnlyBuilder
149
+ from open_range.validator.patchability import _looks_executable
150
+
151
+ builder = TemplateOnlyBuilder()
152
+ manifest = {
153
+ **tier1_manifest,
154
+ "bug_families": ["path_traversal", "sqli"],
155
+ "difficulty": {**tier1_manifest.get("difficulty", {}), "min_vulns": 2, "max_vulns": 2},
156
+ }
157
+ spec = await builder.build(
158
+ manifest,
159
+ BuildContext(
160
+ seed=4,
161
+ tier=1,
162
+ narrative_hints=["prefer_live_admission_compatible_vulns"],
163
+ ),
164
+ )
165
+ assert {v.type for v in spec.truth_graph.vulns} == {"path_traversal", "sqli"}
166
+ assert all(_looks_executable(v.remediation) for v in spec.truth_graph.vulns)
167
+ assert len({flag.path for flag in spec.flags}) == len(spec.flags)
168
+
169
+
170
  @pytest.mark.asyncio
171
  async def test_template_builder_deterministic_with_seed(tier1_manifest):
172
  from open_range.builder.builder import TemplateOnlyBuilder
 
565
  )
566
 
567
 
568
+ @pytest.mark.asyncio
569
+ async def test_mutator_live_only_templates_exclude_weak_creds(tier1_manifest):
570
+ from open_range.builder.builder import TemplateOnlyBuilder
571
+ from open_range.builder.mutator import Mutator
572
+
573
+ mutator = Mutator(TemplateOnlyBuilder())
574
+ root = await mutator.mutate(
575
+ tier1_manifest,
576
+ context=BuildContext(seed=1, tier=1),
577
+ )
578
+ templates = mutator._compatible_vuln_templates( # type: ignore[attr-defined]
579
+ root,
580
+ BuildContext(
581
+ seed=2,
582
+ tier=1,
583
+ narrative_hints=["prefer_live_admission_compatible_vulns"],
584
+ ),
585
+ )
586
+ assert templates
587
+ assert {template["type"] for template in templates}.issubset(
588
+ {"sqli", "path_traversal"}
589
+ )
590
+
591
+
592
  # ---------------------------------------------------------------------------
593
  # FileBuilder
594
  # ---------------------------------------------------------------------------
tests/test_renderer.py CHANGED
@@ -270,6 +270,32 @@ def test_compose_web_depends_on_db(renderer, sqli_spec):
270
  assert "depends_on:" in compose
271
 
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  # ---------------------------------------------------------------------------
274
  # Dockerfile.web content checks
275
  # ---------------------------------------------------------------------------
@@ -335,6 +361,16 @@ def test_nginx_no_download_for_sqli(renderer, sqli_spec):
335
  assert "download.php" not in nginx
336
 
337
 
 
 
 
 
 
 
 
 
 
 
338
  # ---------------------------------------------------------------------------
339
  # init.sql content checks
340
  # ---------------------------------------------------------------------------
 
270
  assert "depends_on:" in compose
271
 
272
 
273
+ def test_compose_web_healthcheck_accepts_pre_overlay_http_statuses(renderer, sqli_spec):
274
+ with tempfile.TemporaryDirectory() as tmpdir:
275
+ out = Path(tmpdir) / "out"
276
+ renderer.render(sqli_spec, out)
277
+ compose = (out / "docker-compose.yml").read_text()
278
+ assert "CMD-SHELL" in compose
279
+ assert "http://localhost/ || true" in compose
280
+ assert '$$status' in compose
281
+ assert '2*|3*|4*) exit 0' in compose
282
+ assert 'curl", "-sf", "http://localhost/"' not in compose
283
+
284
+
285
+ def test_compose_attacker_has_routed_host_aliases_and_nmap_runtime_lib(renderer, sqli_spec):
286
+ with tempfile.TemporaryDirectory() as tmpdir:
287
+ out = Path(tmpdir) / "out"
288
+ renderer.render(sqli_spec, out)
289
+ compose = (out / "docker-compose.yml").read_text()
290
+ assert "libblas3 nmap" in compose
291
+ assert 'extra_hosts:' in compose
292
+ assert '"web:10.0.1.10"' in compose
293
+ assert '"db:10.0.2.20"' in compose
294
+ assert '"files:10.0.2.21"' in compose
295
+ assert "nmap --version" in compose
296
+ assert "iptables -C FORWARD" in compose
297
+
298
+
299
  # ---------------------------------------------------------------------------
300
  # Dockerfile.web content checks
301
  # ---------------------------------------------------------------------------
 
361
  assert "download.php" not in nginx
362
 
363
 
364
+ def test_compose_firewall_nat_is_subnet_based(renderer, sqli_spec):
365
+ with tempfile.TemporaryDirectory() as tmpdir:
366
+ out = Path(tmpdir) / "out"
367
+ renderer.render(sqli_spec, out)
368
+ compose = (out / "docker-compose.yml").read_text()
369
+ assert "-s 10.0.0.0/24 -d 10.0.1.0/24 -j MASQUERADE" in compose
370
+ assert "-s 10.0.1.0/24 -d 10.0.2.0/24 -j MASQUERADE" in compose
371
+ assert "-o eth1 -j MASQUERADE" not in compose
372
+
373
+
374
  # ---------------------------------------------------------------------------
375
  # init.sql content checks
376
  # ---------------------------------------------------------------------------
tests/test_renderer_integration.py CHANGED
@@ -203,6 +203,14 @@ class TestDockerCompose:
203
  compose = (rendered_dir / "docker-compose.yml").read_text()
204
  assert "healthcheck:" in compose
205
 
 
 
 
 
 
 
 
 
206
  def test_attacker_has_net_admin(self, rendered_dir):
207
  compose = (rendered_dir / "docker-compose.yml").read_text()
208
  assert "NET_ADMIN" in compose
 
203
  compose = (rendered_dir / "docker-compose.yml").read_text()
204
  assert "healthcheck:" in compose
205
 
206
+ def test_web_healthcheck_does_not_require_pre_overlay_2xx(self, rendered_dir):
207
+ compose = (rendered_dir / "docker-compose.yml").read_text()
208
+ assert "CMD-SHELL" in compose
209
+ assert "http://localhost/ || true" in compose
210
+ assert "$$status" in compose
211
+ assert '2*|3*|4*) exit 0' in compose
212
+ assert 'curl", "-sf", "http://localhost/"' not in compose
213
+
214
  def test_attacker_has_net_admin(self, rendered_dir):
215
  compose = (rendered_dir / "docker-compose.yml").read_text()
216
  assert "NET_ADMIN" in compose
tests/test_runtime.py CHANGED
@@ -412,6 +412,89 @@ class TestManagedSnapshotRuntime:
412
  finally:
413
  runtime.stop()
414
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  def test_activate_snapshot_project_uses_unique_episode_project_name(
416
  self,
417
  tier1_manifest,
 
412
  finally:
413
  runtime.stop()
414
 
415
+ def test_training_live_validation_uses_compose_runner_boot(
416
+ self,
417
+ tier1_manifest,
418
+ tmp_path,
419
+ ):
420
+ class FakeContainers:
421
+ def __init__(self) -> None:
422
+ self.exec_calls: list[tuple[str, str]] = []
423
+ self.cp_calls: list[tuple[str, str, str]] = []
424
+
425
+ async def exec(self, container: str, cmd: str, **kwargs) -> str:
426
+ self.exec_calls.append((container, cmd))
427
+ return "ok"
428
+
429
+ async def cp(self, container: str, src: str, dest: str) -> None:
430
+ self.cp_calls.append((container, src, dest))
431
+
432
+ async def is_healthy(self, container: str) -> bool:
433
+ return True
434
+
435
+ class FakeComposeRunner:
436
+ def __init__(self) -> None:
437
+ self.boot_calls: list[tuple[str, str, str | None]] = []
438
+ self.compose_payloads: list[dict[str, object]] = []
439
+ self.teardown_calls: list[str] = []
440
+ self.containers = FakeContainers()
441
+
442
+ def boot(self, *, snapshot_id, artifacts_dir, compose, project_name=None):
443
+ self.boot_calls.append((snapshot_id, str(artifacts_dir), project_name))
444
+ self.compose_payloads.append(compose)
445
+ return BootedSnapshotProject(
446
+ project_name=project_name or f"openrange-{snapshot_id}",
447
+ compose_file=artifacts_dir / "docker-compose.yml",
448
+ artifacts_dir=artifacts_dir,
449
+ containers=self.containers, # type: ignore[arg-type]
450
+ )
451
+
452
+ def teardown(self, project):
453
+ self.teardown_calls.append(project.project_name)
454
+
455
+ class FakeTrainingValidator:
456
+ def __init__(self) -> None:
457
+ self.calls: list[tuple[SnapshotSpec, object]] = []
458
+
459
+ async def validate(self, snapshot, containers):
460
+ self.calls.append((snapshot, containers))
461
+ return ValidationResult(
462
+ passed=True,
463
+ checks=[CheckResult(name="build_boot", passed=True)],
464
+ total_time_s=0.0,
465
+ )
466
+
467
+ compose_runner = FakeComposeRunner()
468
+ validator = FakeTrainingValidator()
469
+ runtime = ManagedSnapshotRuntime(
470
+ manifest=tier1_manifest,
471
+ store_dir=tmp_path / "snapshots",
472
+ validator_profile="training",
473
+ pool_size=1,
474
+ refill_enabled=False,
475
+ compose_runner=compose_runner, # type: ignore[arg-type]
476
+ validator=validator, # type: ignore[arg-type]
477
+ )
478
+
479
+ runtime.start()
480
+ try:
481
+ listing = runtime.list_snapshots()
482
+ assert listing
483
+ assert compose_runner.boot_calls
484
+ snapshot_id, artifacts_dir, project_name = compose_runner.boot_calls[0]
485
+ assert Path(artifacts_dir).name.startswith(f"openrange-validate-{snapshot_id}")
486
+ expected_project_name = runtime._project_name(snapshot_id)
487
+ assert project_name == expected_project_name
488
+ assert compose_runner.compose_payloads[0]["services"]
489
+ assert compose_runner.teardown_calls == [expected_project_name]
490
+ assert validator.calls
491
+ assert any(
492
+ dest.endswith("/var/www/portal/index.php")
493
+ for _, _, dest in compose_runner.containers.cp_calls
494
+ )
495
+ finally:
496
+ runtime.stop()
497
+
498
  def test_activate_snapshot_project_uses_unique_episode_project_name(
499
  self,
500
  tier1_manifest,
tests/test_validator.py CHANGED
@@ -289,6 +289,27 @@ async def test_exploitability_passes_when_golden_path_succeeds(mock_containers):
289
  assert result.passed is True
290
 
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  @pytest.mark.asyncio
293
  async def test_exploitability_fails_when_step_output_missing(
294
  sample_snapshot_spec, mock_containers
 
289
  assert result.passed is True
290
 
291
 
292
+ @pytest.mark.asyncio
293
+ async def test_exploitability_normalizes_whitespace_for_tool_output(mock_containers):
294
+ from open_range.validator.exploitability import ExploitabilityCheck
295
+
296
+ spec = SnapshotSpec(
297
+ golden_path=[
298
+ GoldenPathStep(
299
+ step=1,
300
+ command="nmap -sV 10.0.1.0/24",
301
+ expect_in_stdout="80/tcp open http",
302
+ ),
303
+ ],
304
+ )
305
+ mock_containers.exec_results[("attacker", "nmap -sV 10.0.1.0/24")] = (
306
+ "80/tcp open http nginx"
307
+ )
308
+
309
+ result = await ExploitabilityCheck().check(spec, mock_containers)
310
+ assert result.passed is True
311
+
312
+
313
  @pytest.mark.asyncio
314
  async def test_exploitability_fails_when_step_output_missing(
315
  sample_snapshot_spec, mock_containers