Lars Talian commited on
Commit
8429075
·
1 Parent(s): 7106e5f

fix(builder): enforce bug-family contracts and decouple payload wiring

Browse files
src/open_range/builder/builder.py CHANGED
@@ -40,6 +40,7 @@ from open_range.protocols import (
40
  )
41
 
42
  from open_range.builder.prompts import BUILDER_SYSTEM_PROMPT
 
43
 
44
  logger = logging.getLogger(__name__)
45
 
@@ -928,14 +929,26 @@ class TemplateOnlyBuilder:
928
  manifest: dict,
929
  context: BuildContext,
930
  ) -> SnapshotSpec:
931
- """Build a snapshot deterministically from the vuln pool."""
932
  rng = random.Random(context.seed if context.seed is not None else 42)
933
 
934
  # Filter pool to allowed bug_families
935
- allowed = set(manifest.get("bug_families", []))
936
- candidates = [v for v in self.vuln_pool if v["type"] in allowed]
937
- if not candidates:
 
 
 
 
 
938
  candidates = list(self.vuln_pool)
 
 
 
 
 
 
 
939
 
940
  if "prefer_live_admission_compatible_vulns" in context.narrative_hints:
941
  live_supported = {"sqli", "idor", "path_traversal", "weak_creds"}
@@ -1092,6 +1105,7 @@ class TemplateOnlyBuilder:
1092
  npc_traffic=npc_traffic,
1093
  task=task,
1094
  )
 
1095
  snapshot.files = render_template_payloads(snapshot, manifest=manifest)
1096
  logger.info(
1097
  "TemplateOnlyBuilder: built snapshot with %d vulns (seed=%s)",
@@ -1197,11 +1211,16 @@ def render_template_payloads(
1197
  _flag_value_for_type(snapshot, "sqli")
1198
  )
1199
 
1200
- if vuln_types.intersection({"path_traversal", "credential_reuse"}):
1201
  files["web:/var/www/portal/download.php"] = _download_php(
1202
  path_flag=_flag_value_for_type(snapshot, "path_traversal"),
1203
  flag_names=_flag_names_for_type(snapshot, "path_traversal"),
1204
  )
 
 
 
 
 
1205
 
1206
  if "idor" in vuln_types:
1207
  files["web:/var/www/portal/api/index.php"] = _idor_api_php(
@@ -1357,7 +1376,7 @@ if (stripos($q, "union") !== false || stripos($q, "flag") !== false) {{
1357
 
1358
  def _download_php(path_flag: str, flag_names: list[str] | None = None) -> str:
1359
  flag = path_flag or "FLAG{placeholder}"
1360
- raw_names = flag_names or ["flag1.txt"]
1361
  cases = "\n".join(
1362
  f"""elseif (strpos($file, "{name}") !== false) {{
1363
  echo "{flag}";
 
40
  )
41
 
42
  from open_range.builder.prompts import BUILDER_SYSTEM_PROMPT
43
+ from open_range.builder.manifest_graph import compile_manifest_topology
44
 
45
  logger = logging.getLogger(__name__)
46
 
 
929
  manifest: dict,
930
  context: BuildContext,
931
  ) -> SnapshotSpec:
932
+ """Build a canonicalized snapshot deterministically from templates."""
933
  rng = random.Random(context.seed if context.seed is not None else 42)
934
 
935
  # Filter pool to allowed bug_families
936
+ allowed = {
937
+ str(v).strip()
938
+ for v in manifest.get("bug_families", [])
939
+ if str(v).strip()
940
+ }
941
+ if allowed:
942
+ candidates = [v for v in self.vuln_pool if v["type"] in allowed]
943
+ else:
944
  candidates = list(self.vuln_pool)
945
+ if allowed and not candidates:
946
+ available = sorted({str(v.get("type", "")).strip() for v in self.vuln_pool if v.get("type")})
947
+ requested = sorted(allowed)
948
+ raise ValueError(
949
+ "No template vulnerabilities match manifest bug_families. "
950
+ f"requested={requested}, available={available}"
951
+ )
952
 
953
  if "prefer_live_admission_compatible_vulns" in context.narrative_hints:
954
  live_supported = {"sqli", "idor", "path_traversal", "weak_creds"}
 
1105
  npc_traffic=npc_traffic,
1106
  task=task,
1107
  )
1108
+ snapshot.topology = compile_manifest_topology(manifest, snapshot.topology)
1109
  snapshot.files = render_template_payloads(snapshot, manifest=manifest)
1110
  logger.info(
1111
  "TemplateOnlyBuilder: built snapshot with %d vulns (seed=%s)",
 
1211
  _flag_value_for_type(snapshot, "sqli")
1212
  )
1213
 
1214
+ if "path_traversal" in vuln_types:
1215
  files["web:/var/www/portal/download.php"] = _download_php(
1216
  path_flag=_flag_value_for_type(snapshot, "path_traversal"),
1217
  flag_names=_flag_names_for_type(snapshot, "path_traversal"),
1218
  )
1219
+ elif "credential_reuse" in vuln_types:
1220
+ files["web:/var/www/portal/download.php"] = _download_php(
1221
+ path_flag="",
1222
+ flag_names=[],
1223
+ )
1224
 
1225
  if "idor" in vuln_types:
1226
  files["web:/var/www/portal/api/index.php"] = _idor_api_php(
 
1376
 
1377
  def _download_php(path_flag: str, flag_names: list[str] | None = None) -> str:
1378
  flag = path_flag or "FLAG{placeholder}"
1379
+ raw_names = ["flag1.txt"] if flag_names is None else flag_names
1380
  cases = "\n".join(
1381
  f"""elseif (strpos($file, "{name}") !== false) {{
1382
  echo "{flag}";
tests/test_builder.py CHANGED
@@ -82,6 +82,33 @@ async def test_template_builder_clamps_min_vulns_to_candidate_pool(tier1_manifes
82
  assert all(v.type == "sqli" for v in spec.truth_graph.vulns)
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  @pytest.mark.asyncio
86
  async def test_template_builder_avoids_previous_vulns(tier1_manifest):
87
  from open_range.builder.builder import TemplateOnlyBuilder
@@ -168,6 +195,55 @@ async def test_template_builder_uses_manifest_company_context(tier1_manifest):
168
  assert company["name"] in spec.files["web:/var/www/portal/index.php"]
169
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  @pytest.mark.asyncio
172
  async def test_mutator_builds_child_snapshot_with_lineage(tier1_manifest):
173
  from open_range.builder.builder import TemplateOnlyBuilder
 
82
  assert all(v.type == "sqli" for v in spec.truth_graph.vulns)
83
 
84
 
85
+ @pytest.mark.asyncio
86
+ async def test_template_builder_fails_when_bug_family_has_no_template_match(tier1_manifest):
87
+ from open_range.builder.builder import TemplateOnlyBuilder
88
+
89
+ builder = TemplateOnlyBuilder()
90
+ manifest = {
91
+ **tier1_manifest,
92
+ "bug_families": ["totally_fake_bug_family"],
93
+ }
94
+ with pytest.raises(ValueError, match="No template vulnerabilities match manifest bug_families"):
95
+ await builder.build(manifest, BuildContext(seed=7, tier=1))
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_template_builder_empty_bug_families_uses_default_pool(tier1_manifest):
100
+ from open_range.builder.builder import TemplateOnlyBuilder
101
+
102
+ builder = TemplateOnlyBuilder()
103
+ manifest = {
104
+ **tier1_manifest,
105
+ "bug_families": [],
106
+ "difficulty": {**tier1_manifest.get("difficulty", {}), "min_vulns": 1, "max_vulns": 1},
107
+ }
108
+ spec = await builder.build(manifest, BuildContext(seed=7, tier=1))
109
+ assert len(spec.truth_graph.vulns) == 1
110
+
111
+
112
  @pytest.mark.asyncio
113
  async def test_template_builder_avoids_previous_vulns(tier1_manifest):
114
  from open_range.builder.builder import TemplateOnlyBuilder
 
195
  assert company["name"] in spec.files["web:/var/www/portal/index.php"]
196
 
197
 
198
+ @pytest.mark.asyncio
199
+ async def test_template_builder_output_is_manifest_canonicalized(tier1_manifest):
200
+ from open_range.builder.builder import TemplateOnlyBuilder
201
+
202
+ builder = TemplateOnlyBuilder()
203
+ spec = await builder.build(tier1_manifest, BuildContext(seed=1, tier=1))
204
+ assert "host_catalog" in spec.topology
205
+ assert "host_details" in spec.topology
206
+ assert "dependency_edges" in spec.topology
207
+ assert "principal_catalog" in spec.topology
208
+
209
+
210
+ def test_render_payloads_credential_reuse_not_coupled_to_path_traversal_flag():
211
+ from open_range.builder.builder import render_template_payloads
212
+ from open_range.protocols import TruthGraph, Vulnerability
213
+
214
+ snapshot = SnapshotSpec(
215
+ topology={"tier": 1, "hosts": ["web"], "org_name": "OpenRange", "domain": "corp.local"},
216
+ truth_graph=TruthGraph(
217
+ vulns=[Vulnerability(id="v1", type="credential_reuse", host="ldap")]
218
+ ),
219
+ flags=[FlagSpec(id="f1", value="FLAG{cred_only}", path="/var/flags/flag1.txt", host="db")],
220
+ golden_path=[],
221
+ )
222
+ files = render_template_payloads(snapshot)
223
+ download = files["web:/var/www/portal/download.php"]
224
+ assert "config.php" in download
225
+ assert "FLAG{cred_only}" not in download
226
+ assert "flag1.txt" not in download
227
+
228
+
229
+ def test_render_payloads_path_traversal_keeps_flag_wiring():
230
+ from open_range.builder.builder import render_template_payloads
231
+ from open_range.protocols import TruthGraph, Vulnerability
232
+
233
+ snapshot = SnapshotSpec(
234
+ topology={"tier": 1, "hosts": ["web"], "org_name": "OpenRange", "domain": "corp.local"},
235
+ truth_graph=TruthGraph(
236
+ vulns=[Vulnerability(id="v1", type="path_traversal", host="web")]
237
+ ),
238
+ flags=[FlagSpec(id="f1", value="FLAG{path}", path="/var/flags/path_flag.txt", host="web")],
239
+ golden_path=[],
240
+ )
241
+ files = render_template_payloads(snapshot)
242
+ download = files["web:/var/www/portal/download.php"]
243
+ assert "FLAG{path}" in download
244
+ assert "path_flag.txt" in download
245
+
246
+
247
  @pytest.mark.asyncio
248
  async def test_mutator_builds_child_snapshot_with_lineage(tier1_manifest):
249
  from open_range.builder.builder import TemplateOnlyBuilder