Spaces:
Runtime error
Runtime error
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 +25 -6
- tests/test_builder.py +76 -0
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
|
| 932 |
rng = random.Random(context.seed if context.seed is not None else 42)
|
| 933 |
|
| 934 |
# Filter pool to allowed bug_families
|
| 935 |
-
allowed =
|
| 936 |
-
|
| 937 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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
|