Lars Talian commited on
Commit
dabed55
·
1 Parent(s): 7d5c3bd

Make builder/runtime service semantics manifest-driven

Browse files
src/open_range/builder/builder.py CHANGED
@@ -14,6 +14,7 @@ import json
14
  import logging
15
  import os
16
  import random
 
17
  from copy import deepcopy
18
  from pathlib import Path, PurePosixPath
19
  from typing import Any, Optional
@@ -40,7 +41,10 @@ from open_range.protocols import (
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
 
@@ -569,7 +573,8 @@ def _parse_llm_response(raw_json: str) -> SnapshotSpec:
569
  elif isinstance(vc, str) and vc.strip():
570
  ip = v.injection_point
571
  if ip.startswith("/") and v.host == "web":
572
- container_key = f"web:/var/www/portal{ip}"
 
573
  if container_key not in files:
574
  files[container_key] = vc
575
 
@@ -893,13 +898,13 @@ _DEFAULT_VULN_POOL: list[dict[str, Any]] = [
893
  },
894
  {
895
  "step": 3,
896
- "cmd": "ldapsearch -x -H ldap://ldap -D 'cn=webapp,dc=corp,dc=local' -w 'Svc!Ldap2024' -b 'dc=corp,dc=local' '(uid=*)'",
897
  "expect_stdout": "dn: uid=",
898
  "host": "attacker",
899
  },
900
  {
901
  "step": 4,
902
- "cmd": "sshpass -p 'Svc!Ldap2024' ssh svc_webapp@db 'cat /var/flags/flag1.txt'",
903
  "expect_stdout": "FLAG{cr3d_r3us3_p1v0t}",
904
  "host": "attacker",
905
  },
@@ -998,7 +1003,7 @@ class TemplateOnlyBuilder:
998
  z = h.get("zone", "default")
999
  zones.setdefault(z, []).append(h["name"])
1000
 
1001
- topology = {
1002
  "tier": int(manifest.get("tier", context.tier) or context.tier),
1003
  "hosts": hosts,
1004
  "zones": zones,
@@ -1012,6 +1017,9 @@ class TemplateOnlyBuilder:
1012
  ),
1013
  "mysql_root_password": "root" if any(v["type"] == "weak_creds" for v in chosen) else "r00tP@ss!",
1014
  }
 
 
 
1015
 
1016
  # Build truth graph
1017
  vulns = []
@@ -1020,7 +1028,12 @@ class TemplateOnlyBuilder:
1020
  golden_path: list[GoldenPathStep] = []
1021
  step_offset = 0
1022
 
1023
- for idx, v in enumerate(chosen):
 
 
 
 
 
1024
  vulns.append(
1025
  Vulnerability(
1026
  id=v["id"],
@@ -1038,7 +1051,7 @@ class TemplateOnlyBuilder:
1038
  {
1039
  "vuln_id": v["id"],
1040
  "command": v.get("injection_point", ""),
1041
- "description": f"Exploit {v['type']} on {v['host']}",
1042
  }
1043
  )
1044
  flags.append(
@@ -1046,7 +1059,7 @@ class TemplateOnlyBuilder:
1046
  id=v.get("flag_id", f"flag{idx+1}"),
1047
  value=v.get("flag_value", f"FLAG{{test_{idx+1}}}"),
1048
  path=v.get("flag_path", f"/var/flags/flag{idx+1}.txt"),
1049
- host=v.get("host", "web"),
1050
  )
1051
  )
1052
  for gs in v.get("golden_path_steps", []):
@@ -1060,6 +1073,7 @@ class TemplateOnlyBuilder:
1060
  step=step_offset,
1061
  command=cmd,
1062
  expect_in_stdout=gs["expect_stdout"],
 
1063
  description=gs.get("description", ""),
1064
  )
1065
  )
@@ -1069,7 +1083,7 @@ class TemplateOnlyBuilder:
1069
  evidence_spec = [
1070
  EvidenceItem(
1071
  type="log_entry",
1072
- location="web:/var/log/app/access.log",
1073
  pattern="attack pattern from attacker IP",
1074
  ),
1075
  EvidenceItem(
@@ -1125,6 +1139,179 @@ class TemplateOnlyBuilder:
1125
  # ---------------------------------------------------------------------------
1126
 
1127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
  def _manifest_topology_users(
1129
  manifest: dict[str, Any],
1130
  *,
@@ -1194,6 +1381,7 @@ def render_template_payloads(
1194
  manifest: dict[str, Any] | None = None,
1195
  ) -> dict[str, str]:
1196
  topology = snapshot.topology if isinstance(snapshot.topology, dict) else {}
 
1197
  flags = snapshot.flags
1198
  evidence_spec = snapshot.evidence_spec
1199
  vuln_types = {v.type for v in snapshot.truth_graph.vulns}
@@ -1204,31 +1392,46 @@ def render_template_payloads(
1204
  )
1205
  company_name = str(topology.get("org_name") or company.get("name") or "OpenRange")
1206
  domain = str(topology.get("domain") or company.get("domain") or "corp.local")
 
 
 
 
 
1207
 
1208
  files: dict[str, str] = {
1209
- "web:/var/www/portal/index.php": _default_index_php(company_name),
1210
- "web:/var/www/portal/login.php": _default_login_php(),
1211
- "web:/var/www/config.php": _default_config_php(domain=domain),
 
 
 
 
 
 
 
 
1212
  }
1213
 
1214
  if "sqli" in vuln_types:
1215
- files["web:/var/www/portal/search.php"] = _search_php(
1216
  _flag_value_for_type(snapshot, "sqli")
1217
  )
1218
 
1219
  if "path_traversal" in vuln_types:
1220
- files["web:/var/www/portal/download.php"] = _download_php(
1221
  path_flag=_flag_value_for_type(snapshot, "path_traversal"),
1222
  flag_names=_flag_names_for_type(snapshot, "path_traversal"),
 
1223
  )
1224
  elif "credential_reuse" in vuln_types:
1225
- files["web:/var/www/portal/download.php"] = _download_php(
1226
  path_flag="",
1227
  flag_names=[],
 
1228
  )
1229
 
1230
  if "idor" in vuln_types:
1231
- files["web:/var/www/portal/api/index.php"] = _idor_api_php(
1232
  _flag_value_for_type(snapshot, "idor"),
1233
  )
1234
 
@@ -1249,7 +1452,7 @@ def render_template_payloads(
1249
  "CREATE USER IF NOT EXISTS 'leaked_user'@'%' "
1250
  "IDENTIFIED BY 'leaked_pass';\n"
1251
  "GRANT SELECT ON flags.* TO 'leaked_user'@'%';\n"
1252
- "GRANT SELECT ON referral_db.* TO 'leaked_user'@'%';\n"
1253
  "FLUSH PRIVILEGES;\n"
1254
  ),
1255
  )
@@ -1265,7 +1468,7 @@ def render_template_payloads(
1265
  )
1266
 
1267
  if "weak_creds" in vuln_types:
1268
- files["db:/tmp/openrange-root-password.txt"] = "root\n"
1269
 
1270
  return files
1271
 
@@ -1315,6 +1518,15 @@ def _append_sql(existing: str, fragment: str) -> str:
1315
  return f"{existing.rstrip()}\n{fragment}"
1316
 
1317
 
 
 
 
 
 
 
 
 
 
1318
  def _sql_escape(value: str) -> str:
1319
  """Escape a string for use in a SQL single-quoted literal.
1320
 
@@ -1356,15 +1568,26 @@ echo "Login";
1356
  ?>"""
1357
 
1358
 
1359
- def _default_config_php(*, domain: str) -> str:
 
 
 
 
 
 
 
 
 
1360
  ldap_dn = ",".join(f"dc={part}" for part in domain.split(".") if part) or "dc=corp,dc=local"
 
 
1361
  return f"""<?php
1362
- $db_host = "db";
1363
- $db_name = "referral_db";
1364
- $db_user = "app_user";
1365
- $db_pass = "AppUs3r!2024";
1366
- $ldap_bind_dn = "cn=webapp,{ldap_dn}";
1367
- $ldap_bind_pw = "Svc!Ldap2024";
1368
  ?>"""
1369
 
1370
 
@@ -1379,7 +1602,12 @@ if (stripos($q, "union") !== false || stripos($q, "flag") !== false) {{
1379
  ?>"""
1380
 
1381
 
1382
- def _download_php(path_flag: str, flag_names: list[str] | None = None) -> str:
 
 
 
 
 
1383
  flag = path_flag or "FLAG{placeholder}"
1384
  raw_names = ["flag1.txt"] if flag_names is None else flag_names
1385
  cases = "\n".join(
@@ -1393,7 +1621,7 @@ $file = $_GET["file"] ?? "";
1393
  if ($file === "report.pdf") {{
1394
  echo "PDF";
1395
  }} elseif (strpos($file, "config.php") !== false) {{
1396
- readfile("/var/www/config.php");
1397
  }} elseif (strpos($file, "/etc/passwd") !== false) {{
1398
  echo "root:x:0:0:root:/root:/bin/bash";
1399
  }} {cases} else {{
 
14
  import logging
15
  import os
16
  import random
17
+ import re
18
  from copy import deepcopy
19
  from pathlib import Path, PurePosixPath
20
  from typing import Any, Optional
 
41
  )
42
 
43
  from open_range.builder.prompts import BUILDER_SYSTEM_PROMPT
44
+ from open_range.builder.manifest_graph import (
45
+ compile_manifest_topology,
46
+ runtime_contract_from_topology,
47
+ )
48
 
49
  logger = logging.getLogger(__name__)
50
 
 
573
  elif isinstance(vc, str) and vc.strip():
574
  ip = v.injection_point
575
  if ip.startswith("/") and v.host == "web":
576
+ web_doc_root = str(llm_output.topology.get("web_doc_root", "/var/www/portal")).rstrip("/")
577
+ container_key = f"web:{web_doc_root}{ip}"
578
  if container_key not in files:
579
  files[container_key] = vc
580
 
 
898
  },
899
  {
900
  "step": 3,
901
+ "cmd": "ldapsearch -x -H ldap://{ldap_host} -D '{ldap_bind_dn}' -w '{ldap_bind_pw}' -b '{ldap_search_base_dn}' '(uid=*)'",
902
  "expect_stdout": "dn: uid=",
903
  "host": "attacker",
904
  },
905
  {
906
  "step": 4,
907
+ "cmd": "sshpass -p '{credential_reuse_password}' ssh {credential_reuse_user}@{credential_reuse_host} 'cat /var/flags/flag1.txt'",
908
  "expect_stdout": "FLAG{cr3d_r3us3_p1v0t}",
909
  "host": "attacker",
910
  },
 
1003
  z = h.get("zone", "default")
1004
  zones.setdefault(z, []).append(h["name"])
1005
 
1006
+ topology: dict[str, Any] = {
1007
  "tier": int(manifest.get("tier", context.tier) or context.tier),
1008
  "hosts": hosts,
1009
  "zones": zones,
 
1017
  ),
1018
  "mysql_root_password": "root" if any(v["type"] == "weak_creds" for v in chosen) else "r00tP@ss!",
1019
  }
1020
+ topology = compile_manifest_topology(manifest, topology)
1021
+ runtime_contract = runtime_contract_from_topology(topology, manifest=manifest)
1022
+ topology["runtime_contract"] = runtime_contract
1023
 
1024
  # Build truth graph
1025
  vulns = []
 
1028
  golden_path: list[GoldenPathStep] = []
1029
  step_offset = 0
1030
 
1031
+ for idx, raw in enumerate(chosen):
1032
+ v = _realize_template_vuln(
1033
+ raw,
1034
+ topology=topology,
1035
+ runtime_contract=runtime_contract,
1036
+ )
1037
  vulns.append(
1038
  Vulnerability(
1039
  id=v["id"],
 
1051
  {
1052
  "vuln_id": v["id"],
1053
  "command": v.get("injection_point", ""),
1054
+ "description": f"Exploit {v['type']} on {v.get('host', 'target')}",
1055
  }
1056
  )
1057
  flags.append(
 
1059
  id=v.get("flag_id", f"flag{idx+1}"),
1060
  value=v.get("flag_value", f"FLAG{{test_{idx+1}}}"),
1061
  path=v.get("flag_path", f"/var/flags/flag{idx+1}.txt"),
1062
+ host=v.get("flag_host", v.get("host", runtime_contract["web_host"])),
1063
  )
1064
  )
1065
  for gs in v.get("golden_path_steps", []):
 
1073
  step=step_offset,
1074
  command=cmd,
1075
  expect_in_stdout=gs["expect_stdout"],
1076
+ host=gs.get("host", "attacker"),
1077
  description=gs.get("description", ""),
1078
  )
1079
  )
 
1083
  evidence_spec = [
1084
  EvidenceItem(
1085
  type="log_entry",
1086
+ location=f"{runtime_contract['web_host']}:/var/log/app/access.log",
1087
  pattern="attack pattern from attacker IP",
1088
  ),
1089
  EvidenceItem(
 
1139
  # ---------------------------------------------------------------------------
1140
 
1141
 
1142
+ def _realize_template_vuln(
1143
+ template: dict[str, Any],
1144
+ *,
1145
+ topology: dict[str, Any],
1146
+ runtime_contract: dict[str, str],
1147
+ ) -> dict[str, Any]:
1148
+ realized = deepcopy(template)
1149
+ template_host = str(template.get("host", "")).strip()
1150
+ service = str(template.get("service", "")).strip().lower()
1151
+ resolved_host = _resolve_vuln_host(
1152
+ template_host,
1153
+ service=service,
1154
+ topology=topology,
1155
+ runtime_contract=runtime_contract,
1156
+ )
1157
+ realized["host"] = resolved_host
1158
+
1159
+ vuln_type = str(template.get("type", "")).strip()
1160
+ if vuln_type == "credential_reuse":
1161
+ realized["flag_host"] = runtime_contract.get(
1162
+ "credential_reuse_host",
1163
+ runtime_contract.get("db_host", resolved_host),
1164
+ )
1165
+ else:
1166
+ realized["flag_host"] = resolved_host
1167
+
1168
+ for field in (
1169
+ "injection_point",
1170
+ "vulnerable_code",
1171
+ "root_cause",
1172
+ "blast_radius",
1173
+ "remediation",
1174
+ ):
1175
+ value = realized.get(field)
1176
+ if isinstance(value, str):
1177
+ realized[field] = _rewrite_template_runtime_text(value, runtime_contract)
1178
+
1179
+ raw_steps = template.get("golden_path_steps", [])
1180
+ realized_steps: list[dict[str, Any]] = []
1181
+ if isinstance(raw_steps, list):
1182
+ for raw_step in raw_steps:
1183
+ if not isinstance(raw_step, dict):
1184
+ continue
1185
+ step = deepcopy(raw_step)
1186
+ cmd = str(step.get("cmd", ""))
1187
+ expect = str(step.get("expect_stdout", ""))
1188
+ step["cmd"] = _rewrite_template_runtime_text(cmd, runtime_contract)
1189
+ step["expect_stdout"] = _rewrite_template_runtime_text(expect, runtime_contract)
1190
+ realized_steps.append(step)
1191
+ realized["golden_path_steps"] = realized_steps
1192
+ return realized
1193
+
1194
+
1195
+ def _resolve_vuln_host(
1196
+ template_host: str,
1197
+ *,
1198
+ service: str,
1199
+ topology: dict[str, Any],
1200
+ runtime_contract: dict[str, str],
1201
+ ) -> str:
1202
+ hosts = _host_names(topology.get("hosts", []))
1203
+ alias_map = {
1204
+ "web": runtime_contract.get("web_host", "web"),
1205
+ "db": runtime_contract.get("db_host", "db"),
1206
+ "ldap": runtime_contract.get("ldap_host", "ldap"),
1207
+ }
1208
+ if template_host:
1209
+ if template_host in hosts:
1210
+ return template_host
1211
+ if template_host in alias_map and alias_map[template_host]:
1212
+ return alias_map[template_host]
1213
+
1214
+ if any(marker in service for marker in ("mysql", "mariadb", "postgres")):
1215
+ candidate = runtime_contract.get("db_host", "db")
1216
+ if not hosts or candidate in hosts:
1217
+ return candidate
1218
+ if any(marker in service for marker in ("ldap", "openldap")):
1219
+ candidate = runtime_contract.get("ldap_host", "ldap")
1220
+ if not hosts or candidate in hosts:
1221
+ return candidate
1222
+ if any(marker in service for marker in ("nginx", "apache", "http", "php")):
1223
+ candidate = runtime_contract.get("web_host", "web")
1224
+ if not hosts or candidate in hosts:
1225
+ return candidate
1226
+
1227
+ if template_host:
1228
+ return template_host
1229
+ if hosts:
1230
+ return hosts[0]
1231
+ return runtime_contract.get("web_host", "web")
1232
+
1233
+
1234
+ def _host_names(raw_hosts: object) -> list[str]:
1235
+ if not isinstance(raw_hosts, list):
1236
+ return []
1237
+ hosts: list[str] = []
1238
+ for raw in raw_hosts:
1239
+ if isinstance(raw, dict):
1240
+ host = str(raw.get("name", "")).strip()
1241
+ else:
1242
+ host = str(raw).strip()
1243
+ if host and host not in hosts:
1244
+ hosts.append(host)
1245
+ return hosts
1246
+
1247
+
1248
+ def _rewrite_template_runtime_text(text: str, runtime_contract: dict[str, str]) -> str:
1249
+ if not text:
1250
+ return text
1251
+
1252
+ web_host = runtime_contract.get("web_host", "web")
1253
+ db_host = runtime_contract.get("db_host", "db")
1254
+ ldap_host = runtime_contract.get("ldap_host", "ldap")
1255
+ web_doc_root = runtime_contract.get("web_doc_root", "/var/www/portal")
1256
+ web_config_path = runtime_contract.get("web_config_path", "/var/www/config.php")
1257
+ db_name = runtime_contract.get("db_name", "referral_db")
1258
+ db_user = runtime_contract.get("db_user", "svc_db")
1259
+ db_password = runtime_contract.get("db_password", "SvcDb!401")
1260
+ ldap_bind_dn = runtime_contract.get("ldap_bind_dn", f"cn={db_user},dc=corp,dc=local")
1261
+ ldap_bind_pw = runtime_contract.get("ldap_bind_pw", db_password)
1262
+ reuse_user = runtime_contract.get("credential_reuse_user", db_user)
1263
+ reuse_host = runtime_contract.get("credential_reuse_host", db_host)
1264
+ reuse_password = runtime_contract.get("credential_reuse_password", ldap_bind_pw)
1265
+
1266
+ updated = text
1267
+ placeholders = {
1268
+ "{web_host}": web_host,
1269
+ "{db_host}": db_host,
1270
+ "{ldap_host}": ldap_host,
1271
+ "{web_doc_root}": web_doc_root,
1272
+ "{web_config_path}": web_config_path.lstrip("/"),
1273
+ "{db_name}": db_name,
1274
+ "{db_user}": db_user,
1275
+ "{db_password}": db_password,
1276
+ "{ldap_bind_dn}": ldap_bind_dn,
1277
+ "{ldap_bind_pw}": ldap_bind_pw,
1278
+ "{ldap_search_base_dn}": runtime_contract.get("ldap_search_base_dn", "dc=corp,dc=local"),
1279
+ "{credential_reuse_user}": reuse_user,
1280
+ "{credential_reuse_host}": reuse_host,
1281
+ "{credential_reuse_password}": reuse_password,
1282
+ }
1283
+ for placeholder, value in placeholders.items():
1284
+ updated = updated.replace(placeholder, value)
1285
+
1286
+ replacements: list[tuple[str, str]] = [
1287
+ ("http://web/", f"http://{web_host}/"),
1288
+ ("http://web", f"http://{web_host}"),
1289
+ ("ldap://ldap", f"ldap://{ldap_host}"),
1290
+ ("svc_webapp@db", f"{reuse_user}@{reuse_host}"),
1291
+ ("@db ", f"@{db_host} "),
1292
+ ("@db'", f"@{db_host}'"),
1293
+ ('@db"', f'@{db_host}"'),
1294
+ (" -h db ", f" -h {db_host} "),
1295
+ (" -h db", f" -h {db_host}"),
1296
+ ("/var/www/portal", web_doc_root),
1297
+ ("/var/www/config.php", web_config_path),
1298
+ ("referral_db", db_name),
1299
+ ("app_user", db_user),
1300
+ ("AppUs3r!2024", db_password),
1301
+ ("Svc!Ldap2024", ldap_bind_pw),
1302
+ ]
1303
+ for old, new in replacements:
1304
+ updated = updated.replace(old, new)
1305
+
1306
+ updated = updated.replace("cn=webapp,dc=corp,dc=local", ldap_bind_dn)
1307
+ updated = re.sub(
1308
+ r"cn=webapp,dc=[A-Za-z0-9_-]+(?:,dc=[A-Za-z0-9_-]+)*",
1309
+ ldap_bind_dn,
1310
+ updated,
1311
+ )
1312
+ return updated
1313
+
1314
+
1315
  def _manifest_topology_users(
1316
  manifest: dict[str, Any],
1317
  *,
 
1381
  manifest: dict[str, Any] | None = None,
1382
  ) -> dict[str, str]:
1383
  topology = snapshot.topology if isinstance(snapshot.topology, dict) else {}
1384
+ runtime_contract = runtime_contract_from_topology(topology, manifest=manifest)
1385
  flags = snapshot.flags
1386
  evidence_spec = snapshot.evidence_spec
1387
  vuln_types = {v.type for v in snapshot.truth_graph.vulns}
 
1392
  )
1393
  company_name = str(topology.get("org_name") or company.get("name") or "OpenRange")
1394
  domain = str(topology.get("domain") or company.get("domain") or "corp.local")
1395
+ web_host = runtime_contract["web_host"]
1396
+ db_host = runtime_contract["db_host"]
1397
+ web_doc_root = runtime_contract["web_doc_root"]
1398
+ web_config_path = runtime_contract["web_config_path"]
1399
+ db_name = runtime_contract["db_name"]
1400
 
1401
  files: dict[str, str] = {
1402
+ f"{web_host}:{_join_posix(web_doc_root, 'index.php')}": _default_index_php(company_name),
1403
+ f"{web_host}:{_join_posix(web_doc_root, 'login.php')}": _default_login_php(),
1404
+ f"{web_host}:{web_config_path}": _default_config_php(
1405
+ domain=domain,
1406
+ db_host=runtime_contract["db_host"],
1407
+ db_name=runtime_contract["db_name"],
1408
+ db_user=runtime_contract["db_user"],
1409
+ db_pass=runtime_contract["db_password"],
1410
+ ldap_bind_dn=runtime_contract["ldap_bind_dn"],
1411
+ ldap_bind_pw=runtime_contract["ldap_bind_pw"],
1412
+ ),
1413
  }
1414
 
1415
  if "sqli" in vuln_types:
1416
+ files[f"{web_host}:{_join_posix(web_doc_root, 'search.php')}"] = _search_php(
1417
  _flag_value_for_type(snapshot, "sqli")
1418
  )
1419
 
1420
  if "path_traversal" in vuln_types:
1421
+ files[f"{web_host}:{_join_posix(web_doc_root, 'download.php')}"] = _download_php(
1422
  path_flag=_flag_value_for_type(snapshot, "path_traversal"),
1423
  flag_names=_flag_names_for_type(snapshot, "path_traversal"),
1424
+ config_path=web_config_path,
1425
  )
1426
  elif "credential_reuse" in vuln_types:
1427
+ files[f"{web_host}:{_join_posix(web_doc_root, 'download.php')}"] = _download_php(
1428
  path_flag="",
1429
  flag_names=[],
1430
+ config_path=web_config_path,
1431
  )
1432
 
1433
  if "idor" in vuln_types:
1434
+ files[f"{web_host}:{_join_posix(web_doc_root, 'api/index.php')}"] = _idor_api_php(
1435
  _flag_value_for_type(snapshot, "idor"),
1436
  )
1437
 
 
1452
  "CREATE USER IF NOT EXISTS 'leaked_user'@'%' "
1453
  "IDENTIFIED BY 'leaked_pass';\n"
1454
  "GRANT SELECT ON flags.* TO 'leaked_user'@'%';\n"
1455
+ f"GRANT SELECT ON {_sql_ident(db_name)}.* TO 'leaked_user'@'%';\n"
1456
  "FLUSH PRIVILEGES;\n"
1457
  ),
1458
  )
 
1468
  )
1469
 
1470
  if "weak_creds" in vuln_types:
1471
+ files[f"{db_host}:/tmp/openrange-root-password.txt"] = "root\n"
1472
 
1473
  return files
1474
 
 
1518
  return f"{existing.rstrip()}\n{fragment}"
1519
 
1520
 
1521
+ def _join_posix(base: str, leaf: str) -> str:
1522
+ return (PurePosixPath(base) / leaf).as_posix()
1523
+
1524
+
1525
+ def _sql_ident(value: str) -> str:
1526
+ token = re.sub(r"[^A-Za-z0-9_]", "", value)
1527
+ return token or "referral_db"
1528
+
1529
+
1530
  def _sql_escape(value: str) -> str:
1531
  """Escape a string for use in a SQL single-quoted literal.
1532
 
 
1568
  ?>"""
1569
 
1570
 
1571
+ def _default_config_php(
1572
+ *,
1573
+ domain: str,
1574
+ db_host: str,
1575
+ db_name: str,
1576
+ db_user: str,
1577
+ db_pass: str,
1578
+ ldap_bind_dn: str,
1579
+ ldap_bind_pw: str,
1580
+ ) -> str:
1581
  ldap_dn = ",".join(f"dc={part}" for part in domain.split(".") if part) or "dc=corp,dc=local"
1582
+ bind_dn = ldap_bind_dn or f"cn={db_user},{ldap_dn}"
1583
+ bind_pw = ldap_bind_pw or db_pass
1584
  return f"""<?php
1585
+ $db_host = "{db_host}";
1586
+ $db_name = "{db_name}";
1587
+ $db_user = "{db_user}";
1588
+ $db_pass = "{db_pass}";
1589
+ $ldap_bind_dn = "{bind_dn}";
1590
+ $ldap_bind_pw = "{bind_pw}";
1591
  ?>"""
1592
 
1593
 
 
1602
  ?>"""
1603
 
1604
 
1605
+ def _download_php(
1606
+ path_flag: str,
1607
+ flag_names: list[str] | None = None,
1608
+ *,
1609
+ config_path: str,
1610
+ ) -> str:
1611
  flag = path_flag or "FLAG{placeholder}"
1612
  raw_names = ["flag1.txt"] if flag_names is None else flag_names
1613
  cases = "\n".join(
 
1621
  if ($file === "report.pdf") {{
1622
  echo "PDF";
1623
  }} elseif (strpos($file, "config.php") !== false) {{
1624
+ readfile("{config_path}");
1625
  }} elseif (strpos($file, "/etc/passwd") !== false) {{
1626
  echo "root:x:0:0:root:/root:/bin/bash";
1627
  }} {cases} else {{
src/open_range/builder/manifest_graph.py CHANGED
@@ -10,6 +10,8 @@ accounts in rendered services.
10
  from __future__ import annotations
11
 
12
  from copy import deepcopy
 
 
13
  from typing import Any
14
 
15
 
@@ -148,9 +150,352 @@ def compile_manifest_topology(
148
  if trust_only
149
  else [],
150
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  return compiled
152
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  def _merge_hosts(
155
  raw_hosts: object,
156
  host_catalog: dict[str, dict[str, Any]],
 
10
  from __future__ import annotations
11
 
12
  from copy import deepcopy
13
+ from pathlib import PurePosixPath
14
+ import re
15
  from typing import Any
16
 
17
 
 
150
  if trust_only
151
  else [],
152
  }
153
+ runtime_contract = runtime_contract_from_topology(compiled, manifest=manifest)
154
+ compiled["runtime_contract"] = runtime_contract
155
+ compiled.setdefault("web_host", runtime_contract["web_host"])
156
+ compiled.setdefault("db_host", runtime_contract["db_host"])
157
+ compiled.setdefault("ldap_host", runtime_contract["ldap_host"])
158
+ compiled.setdefault("web_doc_root", runtime_contract["web_doc_root"])
159
+ compiled.setdefault("web_config_path", runtime_contract["web_config_path"])
160
+ compiled.setdefault("db_name", runtime_contract["db_name"])
161
+ compiled.setdefault("db_user", runtime_contract["db_user"])
162
+ compiled.setdefault("db_pass", runtime_contract["db_password"])
163
+ compiled.setdefault("db_password", runtime_contract["db_password"])
164
+ compiled.setdefault("ldap_bind_dn", runtime_contract["ldap_bind_dn"])
165
+ compiled.setdefault("ldap_bind_pw", runtime_contract["ldap_bind_pw"])
166
+ compiled.setdefault("ldap_search_base_dn", runtime_contract["ldap_search_base_dn"])
167
+ compiled.setdefault("credential_reuse_user", runtime_contract["credential_reuse_user"])
168
+ compiled.setdefault("credential_reuse_host", runtime_contract["credential_reuse_host"])
169
+ compiled.setdefault(
170
+ "credential_reuse_password",
171
+ runtime_contract["credential_reuse_password"],
172
+ )
173
+
174
+ service_accounts = compiled.get("service_accounts")
175
+ if not isinstance(service_accounts, dict):
176
+ service_accounts = {}
177
+ webapp = service_accounts.get("webapp")
178
+ if not isinstance(webapp, dict):
179
+ webapp = {}
180
+ webapp.setdefault("username", runtime_contract["db_user"])
181
+ webapp.setdefault("password", runtime_contract["db_password"])
182
+ webapp.setdefault("ldap_bind_dn", runtime_contract["ldap_bind_dn"])
183
+ webapp.setdefault("ldap_bind_pw", runtime_contract["ldap_bind_pw"])
184
+ service_accounts["webapp"] = webapp
185
+ compiled["service_accounts"] = service_accounts
186
  return compiled
187
 
188
 
189
+ def runtime_contract_from_topology(
190
+ topology: dict[str, Any] | None,
191
+ *,
192
+ manifest: dict[str, Any] | None = None,
193
+ ) -> dict[str, str]:
194
+ """Derive runtime service/account semantics from compiled topology state."""
195
+ source = topology if isinstance(topology, dict) else {}
196
+ runtime = deepcopy(source.get("runtime_contract", {}))
197
+ if not isinstance(runtime, dict):
198
+ runtime = {}
199
+
200
+ domain = _coerce_text(
201
+ runtime.get("domain"),
202
+ source.get("domain"),
203
+ _manifest_company_domain(manifest),
204
+ default="corp.local",
205
+ )
206
+ host_catalog = source.get("host_catalog", {})
207
+ if not isinstance(host_catalog, dict):
208
+ host_catalog = {}
209
+ host_details = source.get("host_details", {})
210
+ if not isinstance(host_details, dict):
211
+ host_details = {}
212
+ hosts = _normalized_hosts(source.get("hosts", []))
213
+
214
+ web_host = _select_core_host(
215
+ explicit=_coerce_text(runtime.get("web_host"), source.get("web_host")),
216
+ hosts=hosts,
217
+ host_maps=[host_catalog, host_details],
218
+ preferred_names=("web", "portal", "frontend"),
219
+ service_markers=("nginx", "apache", "http", "php", "gunicorn", "uvicorn"),
220
+ fallback="web",
221
+ )
222
+ db_host = _select_core_host(
223
+ explicit=_coerce_text(runtime.get("db_host"), source.get("db_host")),
224
+ hosts=hosts,
225
+ host_maps=[host_catalog, host_details],
226
+ preferred_names=("db", "database", "mysql"),
227
+ service_markers=("mysql", "mariadb", "postgres", "postgresql", "database"),
228
+ fallback="db",
229
+ )
230
+ ldap_host = _select_core_host(
231
+ explicit=_coerce_text(runtime.get("ldap_host"), source.get("ldap_host")),
232
+ hosts=hosts,
233
+ host_maps=[host_catalog, host_details],
234
+ preferred_names=("ldap", "directory", "idp"),
235
+ service_markers=("ldap", "openldap"),
236
+ fallback="ldap",
237
+ )
238
+
239
+ db_name = _coerce_text(
240
+ runtime.get("db_name"),
241
+ source.get("db_name"),
242
+ _infer_manifest_db_name(manifest),
243
+ default="referral_db",
244
+ )
245
+
246
+ service_accounts = source.get("service_accounts", {})
247
+ if not isinstance(service_accounts, dict):
248
+ service_accounts = {}
249
+ webapp_account = service_accounts.get("webapp", {})
250
+ if not isinstance(webapp_account, dict):
251
+ webapp_account = {}
252
+
253
+ db_user = _coerce_text(
254
+ runtime.get("db_user"),
255
+ source.get("db_user"),
256
+ source.get("db_app_user"),
257
+ webapp_account.get("username"),
258
+ )
259
+ db_password = _coerce_text(
260
+ runtime.get("db_password"),
261
+ runtime.get("db_pass"),
262
+ source.get("db_password"),
263
+ source.get("db_pass"),
264
+ source.get("db_app_password"),
265
+ webapp_account.get("password"),
266
+ )
267
+
268
+ users = source.get("users", [])
269
+ selected_user, selected_password = _pick_db_account(users, db_host)
270
+ if not db_user:
271
+ db_user = selected_user
272
+ if not db_password:
273
+ db_password = selected_password
274
+ if not db_user:
275
+ db_user = f"svc_{_slug_token(db_host or 'db')}"
276
+ if not db_password:
277
+ db_password = _predictable_service_password(db_user, domain)
278
+
279
+ web_doc_root = _coerce_text(
280
+ runtime.get("web_doc_root"),
281
+ source.get("web_doc_root"),
282
+ default="/var/www/portal",
283
+ )
284
+ if not web_doc_root.startswith("/"):
285
+ web_doc_root = f"/{web_doc_root}"
286
+ web_doc_parent = PurePosixPath(web_doc_root).parent
287
+ default_config_path = (web_doc_parent / "config.php").as_posix()
288
+ if not default_config_path.startswith("/"):
289
+ default_config_path = "/var/www/config.php"
290
+ web_config_path = _coerce_text(
291
+ runtime.get("web_config_path"),
292
+ source.get("web_config_path"),
293
+ default=default_config_path,
294
+ )
295
+ if not web_config_path.startswith("/"):
296
+ web_config_path = f"/{web_config_path}"
297
+
298
+ ldap_base_dn = _domain_to_ldap_dn(domain)
299
+ ldap_search_base_dn = _coerce_text(
300
+ runtime.get("ldap_search_base_dn"),
301
+ source.get("ldap_search_base_dn"),
302
+ default=ldap_base_dn,
303
+ )
304
+ ldap_bind_dn = _coerce_text(
305
+ runtime.get("ldap_bind_dn"),
306
+ source.get("ldap_bind_dn"),
307
+ webapp_account.get("ldap_bind_dn"),
308
+ default=f"cn={db_user},{ldap_base_dn}",
309
+ )
310
+ ldap_bind_pw = _coerce_text(
311
+ runtime.get("ldap_bind_pw"),
312
+ source.get("ldap_bind_pw"),
313
+ webapp_account.get("ldap_bind_pw"),
314
+ default=db_password,
315
+ )
316
+
317
+ credential_reuse_user = _coerce_text(
318
+ runtime.get("credential_reuse_user"),
319
+ source.get("credential_reuse_user"),
320
+ default=db_user,
321
+ )
322
+ credential_reuse_host = _coerce_text(
323
+ runtime.get("credential_reuse_host"),
324
+ source.get("credential_reuse_host"),
325
+ default=db_host,
326
+ )
327
+ credential_reuse_password = _coerce_text(
328
+ runtime.get("credential_reuse_password"),
329
+ source.get("credential_reuse_password"),
330
+ default=ldap_bind_pw,
331
+ )
332
+
333
+ return {
334
+ "domain": domain,
335
+ "web_host": web_host,
336
+ "db_host": db_host,
337
+ "ldap_host": ldap_host,
338
+ "web_doc_root": web_doc_root,
339
+ "web_config_path": web_config_path,
340
+ "db_name": db_name,
341
+ "db_user": db_user,
342
+ "db_password": db_password,
343
+ "ldap_bind_dn": ldap_bind_dn,
344
+ "ldap_bind_pw": ldap_bind_pw,
345
+ "ldap_search_base_dn": ldap_search_base_dn,
346
+ "credential_reuse_user": credential_reuse_user,
347
+ "credential_reuse_host": credential_reuse_host,
348
+ "credential_reuse_password": credential_reuse_password,
349
+ }
350
+
351
+
352
+ def _manifest_company_domain(manifest: dict[str, Any] | None) -> str:
353
+ if not isinstance(manifest, dict):
354
+ return ""
355
+ company = manifest.get("company", {})
356
+ if not isinstance(company, dict):
357
+ return ""
358
+ return str(company.get("domain", "")).strip()
359
+
360
+
361
+ def _normalized_hosts(raw_hosts: object) -> list[str]:
362
+ hosts: list[str] = []
363
+ if not isinstance(raw_hosts, list):
364
+ return hosts
365
+ for raw in raw_hosts:
366
+ if isinstance(raw, dict):
367
+ name = str(raw.get("name", "")).strip()
368
+ else:
369
+ name = str(raw).strip()
370
+ if name and name not in hosts:
371
+ hosts.append(name)
372
+ return hosts
373
+
374
+
375
+ def _select_core_host(
376
+ *,
377
+ explicit: str,
378
+ hosts: list[str],
379
+ host_maps: list[dict[str, Any]],
380
+ preferred_names: tuple[str, ...],
381
+ service_markers: tuple[str, ...],
382
+ fallback: str,
383
+ ) -> str:
384
+ if explicit and (not hosts or explicit in hosts):
385
+ return explicit
386
+ for name in preferred_names:
387
+ if name in hosts:
388
+ return name
389
+ for host in hosts:
390
+ services = _host_services(host, host_maps)
391
+ if not services:
392
+ continue
393
+ if any(
394
+ marker in service
395
+ for service in services
396
+ for marker in service_markers
397
+ ):
398
+ return host
399
+ for host in hosts:
400
+ lowered = host.lower()
401
+ if any(name in lowered for name in preferred_names):
402
+ return host
403
+ if hosts:
404
+ return hosts[0]
405
+ return fallback
406
+
407
+
408
+ def _host_services(host: str, host_maps: list[dict[str, Any]]) -> list[str]:
409
+ services: list[str] = []
410
+ for host_map in host_maps:
411
+ detail = host_map.get(host, {})
412
+ if not isinstance(detail, dict):
413
+ continue
414
+ raw_services = detail.get("services", [])
415
+ if not isinstance(raw_services, list):
416
+ continue
417
+ for raw_service in raw_services:
418
+ service = str(raw_service).strip().lower()
419
+ if service and service not in services:
420
+ services.append(service)
421
+ return services
422
+
423
+
424
+ def _pick_db_account(raw_users: object, db_host: str) -> tuple[str, str]:
425
+ if not isinstance(raw_users, list):
426
+ return "", ""
427
+ for raw in raw_users:
428
+ if not isinstance(raw, dict):
429
+ continue
430
+ username = str(raw.get("username", "")).strip()
431
+ if not username:
432
+ continue
433
+ hosts = raw.get("hosts", [])
434
+ if not isinstance(hosts, list) or db_host not in hosts:
435
+ continue
436
+ password = str(raw.get("password", "")).strip()
437
+ if not _is_privileged_account(raw):
438
+ return username, password
439
+ return "", ""
440
+
441
+
442
+ def _is_privileged_account(user: dict[str, Any]) -> bool:
443
+ groups = user.get("groups", [])
444
+ if isinstance(groups, list):
445
+ lowered = {str(group).strip().lower() for group in groups}
446
+ if {"admin", "admins"} & lowered:
447
+ return True
448
+ role = str(user.get("role", "")).lower()
449
+ return "admin" in role
450
+
451
+
452
+ def _infer_manifest_db_name(manifest: dict[str, Any] | None) -> str:
453
+ if not isinstance(manifest, dict):
454
+ return ""
455
+ for raw in manifest.get("data_inventory", []):
456
+ if not isinstance(raw, dict):
457
+ continue
458
+ location = str(raw.get("location", "")).strip()
459
+ lowered = location.lower()
460
+ for prefix in ("mysql:", "db:"):
461
+ if not lowered.startswith(prefix):
462
+ continue
463
+ raw_name = location[len(prefix):].split(".", 1)[0].strip()
464
+ if raw_name:
465
+ return raw_name
466
+ return ""
467
+
468
+
469
+ def _domain_to_ldap_dn(domain: str) -> str:
470
+ parts = [part for part in domain.split(".") if part]
471
+ if not parts:
472
+ return "dc=corp,dc=local"
473
+ return ",".join(f"dc={part}" for part in parts)
474
+
475
+
476
+ def _predictable_service_password(username: str, domain: str) -> str:
477
+ token = _slug_token(username).replace("_", "")
478
+ if not token:
479
+ token = "service"
480
+ suffix = 200 + (sum(ord(ch) for ch in f"{username}:{domain}") % 700)
481
+ return f"{token.capitalize()}!{suffix}"
482
+
483
+
484
+ def _slug_token(value: str) -> str:
485
+ token = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
486
+ return token or "service"
487
+
488
+
489
+ def _coerce_text(*values: object, default: str = "") -> str:
490
+ for value in values:
491
+ if value is None:
492
+ continue
493
+ text = str(value).strip()
494
+ if text:
495
+ return text
496
+ return default
497
+
498
+
499
  def _merge_hosts(
500
  raw_hosts: object,
501
  host_catalog: dict[str, dict[str, Any]],
src/open_range/builder/renderer.py CHANGED
@@ -16,6 +16,7 @@ from typing import Any
16
 
17
  import jinja2
18
 
 
19
  from open_range.builder.service_manifest import generate_service_specs
20
  from open_range.protocols import SnapshotSpec
21
 
@@ -161,6 +162,7 @@ def _build_context(spec: SnapshotSpec) -> dict[str, Any]:
161
  hosts_raw = topology.get("hosts", [])
162
  zones = topology.get("zones", {})
163
  users = topology.get("users", [])
 
164
 
165
  # Build host objects with name, zone, networks, depends_on
166
  hosts = _build_hosts(hosts_raw, zones)
@@ -208,8 +210,8 @@ def _build_context(spec: SnapshotSpec) -> dict[str, Any]:
208
  has_download,
209
  )
210
 
211
- db_user = _find_db_user(users)
212
- db_pass = _find_db_pass(users)
213
 
214
  context: dict[str, Any] = {
215
  # docker-compose.yml.j2
@@ -217,26 +219,31 @@ def _build_context(spec: SnapshotSpec) -> dict[str, Any]:
217
  "networks": networks,
218
  "hosts": hosts,
219
  "host_names": host_names,
220
- "db_host": "db",
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,
237
  "flags": flags,
238
  # nginx.conf.j2
239
- "server_name": topology.get("domain", "web.corp.local"),
240
  # iptables.rules.j2
241
  "firewall_rules": firewall_rules,
242
  "zone_cidrs": zone_cidrs,
 
16
 
17
  import jinja2
18
 
19
+ from open_range.builder.manifest_graph import runtime_contract_from_topology
20
  from open_range.builder.service_manifest import generate_service_specs
21
  from open_range.protocols import SnapshotSpec
22
 
 
162
  hosts_raw = topology.get("hosts", [])
163
  zones = topology.get("zones", {})
164
  users = topology.get("users", [])
165
+ runtime_contract = runtime_contract_from_topology(topology)
166
 
167
  # Build host objects with name, zone, networks, depends_on
168
  hosts = _build_hosts(hosts_raw, zones)
 
210
  has_download,
211
  )
212
 
213
+ db_user = runtime_contract["db_user"]
214
+ db_pass = runtime_contract["db_password"]
215
 
216
  context: dict[str, Any] = {
217
  # docker-compose.yml.j2
 
219
  "networks": networks,
220
  "hosts": hosts,
221
  "host_names": host_names,
222
+ "db_host": runtime_contract["db_host"],
223
  "db_user": db_user,
224
  "db_pass": db_pass,
225
+ "db_name": runtime_contract["db_name"],
226
  # db_password duplicates db_pass: Dockerfile.db.j2 uses db_pass,
227
  # docker-compose.yml.j2 uses db_password. Keep both for compat.
228
  "db_password": db_pass,
229
  "mysql_root_password": topology.get("mysql_root_password", _find_mysql_root_pass(users)),
230
+ "domain": runtime_contract["domain"],
231
  "org_name": topology.get("org_name", "Corp"),
232
  "ldap_admin_pass": topology.get("ldap_admin_pass", "LdapAdm1n!"),
233
  "smb_shares": _find_smb_shares(spec),
234
  "smb_user": _find_smb_user(users),
235
  "smb_password": _find_smb_pass(users),
236
+ "web_doc_root": runtime_contract["web_doc_root"],
237
+ "web_config_path": runtime_contract["web_config_path"],
238
+ "ldap_bind_dn": runtime_contract["ldap_bind_dn"],
239
+ "ldap_bind_pw": runtime_contract["ldap_bind_pw"],
240
+ "ldap_search_base_dn": runtime_contract["ldap_search_base_dn"],
241
  # Dockerfile.web.j2
242
  "users": users,
243
  "app_files": app_files,
244
  "flags": flags,
245
  # nginx.conf.j2
246
+ "server_name": topology.get("domain", f"{runtime_contract['web_host']}.{runtime_contract['domain']}"),
247
  # iptables.rules.j2
248
  "firewall_rules": firewall_rules,
249
  "zone_cidrs": zone_cidrs,
src/open_range/builder/templates/Dockerfile.db.j2 CHANGED
@@ -1,9 +1,9 @@
1
  FROM mysql:8.0
2
 
3
  ENV MYSQL_ROOT_PASSWORD={{ mysql_root_password | default('r00tP@ss!') }}
4
- ENV MYSQL_DATABASE=referral_db
5
- ENV MYSQL_USER={{ db_user | default('app_user') }}
6
- ENV MYSQL_PASSWORD={{ db_pass | default('AppUs3r!2024') }}
7
 
8
  # Copy initialization SQL
9
  COPY init.sql /docker-entrypoint-initdb.d/01-init.sql
 
1
  FROM mysql:8.0
2
 
3
  ENV MYSQL_ROOT_PASSWORD={{ mysql_root_password | default('r00tP@ss!') }}
4
+ ENV MYSQL_DATABASE={{ db_name | default('referral_db') }}
5
+ ENV MYSQL_USER={{ db_user | default('svc_db') }}
6
+ ENV MYSQL_PASSWORD={{ db_pass | default('SvcDb!401') }}
7
 
8
  # Copy initialization SQL
9
  COPY init.sql /docker-entrypoint-initdb.d/01-init.sql
src/open_range/builder/templates/docker-compose.yml.j2 CHANGED
@@ -125,9 +125,9 @@ services:
125
  command: --default-authentication-plugin=mysql_native_password
126
  environment:
127
  - MYSQL_ROOT_PASSWORD={{ mysql_root_password | default('r00tP@ss!') }}
128
- - MYSQL_DATABASE={{ db_name | default('app_db') }}
129
- - MYSQL_USER={{ db_user | default('app_user') }}
130
- - MYSQL_PASSWORD={{ db_password | default('AppUs3r!2024') }}
131
  volumes:
132
  - db_data:/var/lib/mysql
133
  - shared_logs:/var/log/mysql
 
125
  command: --default-authentication-plugin=mysql_native_password
126
  environment:
127
  - MYSQL_ROOT_PASSWORD={{ mysql_root_password | default('r00tP@ss!') }}
128
+ - MYSQL_DATABASE={{ db_name | default('referral_db') }}
129
+ - MYSQL_USER={{ db_user | default('svc_db') }}
130
+ - MYSQL_PASSWORD={{ db_password | default('SvcDb!401') }}
131
  volumes:
132
  - db_data:/var/lib/mysql
133
  - shared_logs:/var/log/mysql
src/open_range/builder/templates/init.sql.j2 CHANGED
@@ -1,9 +1,9 @@
1
  -- OpenRange database initialization
2
  -- Generated from SnapshotSpec
3
 
4
- -- Application database (referral_db)
5
- CREATE DATABASE IF NOT EXISTS referral_db;
6
- USE referral_db;
7
 
8
  -- Users table (web app authentication)
9
  CREATE TABLE IF NOT EXISTS users (
@@ -86,9 +86,9 @@ CREATE TABLE IF NOT EXISTS secrets (
86
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
87
  );
88
 
89
- -- Create and grant app_user (used by PHP web app)
90
- CREATE USER IF NOT EXISTS 'app_user'@'%' IDENTIFIED WITH mysql_native_password BY 'AppUs3r!2024';
91
- GRANT SELECT, INSERT, UPDATE ON referral_db.* TO 'app_user'@'%';
92
- GRANT SELECT ON flags.* TO 'app_user'@'%';
93
 
94
  FLUSH PRIVILEGES;
 
1
  -- OpenRange database initialization
2
  -- Generated from SnapshotSpec
3
 
4
+ -- Application database
5
+ CREATE DATABASE IF NOT EXISTS {{ db_name | default('referral_db') }};
6
+ USE {{ db_name | default('referral_db') }};
7
 
8
  -- Users table (web app authentication)
9
  CREATE TABLE IF NOT EXISTS users (
 
86
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
87
  );
88
 
89
+ -- Create and grant application DB user (used by PHP web app)
90
+ CREATE USER IF NOT EXISTS '{{ db_user | default('svc_db') }}'@'%' IDENTIFIED WITH mysql_native_password BY '{{ db_password | default('SvcDb!401') }}';
91
+ GRANT SELECT, INSERT, UPDATE ON {{ db_name | default('referral_db') }}.* TO '{{ db_user | default('svc_db') }}'@'%';
92
+ GRANT SELECT ON flags.* TO '{{ db_user | default('svc_db') }}'@'%';
93
 
94
  FLUSH PRIVILEGES;
tests/test_builder.py CHANGED
@@ -196,6 +196,81 @@ async def test_template_builder_uses_manifest_company_context(tier1_manifest):
196
  assert company["name"] in spec.files["web:/var/www/portal/index.php"]
197
 
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  @pytest.mark.asyncio
200
  async def test_template_builder_output_is_manifest_canonicalized(tier1_manifest):
201
  from open_range.builder.builder import TemplateOnlyBuilder
 
196
  assert company["name"] in spec.files["web:/var/www/portal/index.php"]
197
 
198
 
199
+ @pytest.mark.asyncio
200
+ async def test_template_builder_credential_reuse_steps_use_runtime_contract(tier1_manifest):
201
+ from open_range.builder.builder import TemplateOnlyBuilder
202
+
203
+ manifest = {
204
+ **tier1_manifest,
205
+ "bug_families": ["credential_reuse"],
206
+ "difficulty": {**tier1_manifest.get("difficulty", {}), "min_vulns": 1, "max_vulns": 1},
207
+ }
208
+ spec = await TemplateOnlyBuilder().build(manifest, BuildContext(seed=3, tier=1))
209
+
210
+ runtime = spec.topology.get("runtime_contract", {})
211
+ assert runtime
212
+ joined = "\n".join(step.command for step in spec.golden_path)
213
+ assert runtime["ldap_bind_dn"] in joined
214
+ assert runtime["credential_reuse_user"] in joined
215
+ assert runtime["credential_reuse_host"] in joined
216
+ assert "cn=webapp,dc=corp,dc=local" not in joined
217
+ assert "Svc!Ldap2024" not in joined
218
+
219
+ config_key = f"{runtime['web_host']}:{runtime['web_config_path']}"
220
+ config_payload = spec.files[config_key]
221
+ assert runtime["db_user"] in config_payload
222
+ assert runtime["db_password"] in config_payload
223
+ assert runtime["ldap_bind_dn"] in config_payload
224
+ assert runtime["ldap_bind_pw"] in config_payload
225
+
226
+
227
+ def test_render_payloads_use_runtime_contract_paths_and_db_name():
228
+ from open_range.builder.builder import render_template_payloads
229
+ from open_range.protocols import TruthGraph, Vulnerability
230
+
231
+ snapshot = SnapshotSpec(
232
+ topology={
233
+ "tier": 1,
234
+ "hosts": ["frontend", "database", "directory"],
235
+ "org_name": "OpenRange",
236
+ "domain": "corp.local",
237
+ "runtime_contract": {
238
+ "domain": "corp.local",
239
+ "web_host": "frontend",
240
+ "db_host": "database",
241
+ "ldap_host": "directory",
242
+ "web_doc_root": "/srv/http/portal",
243
+ "web_config_path": "/srv/http/config.php",
244
+ "db_name": "clinic_db",
245
+ "db_user": "svc_portal",
246
+ "db_password": "SvcPortal!123",
247
+ "ldap_bind_dn": "cn=svc_portal,dc=corp,dc=local",
248
+ "ldap_bind_pw": "SvcPortal!123",
249
+ "ldap_search_base_dn": "dc=corp,dc=local",
250
+ "credential_reuse_user": "svc_portal",
251
+ "credential_reuse_host": "database",
252
+ "credential_reuse_password": "SvcPortal!123",
253
+ },
254
+ },
255
+ truth_graph=TruthGraph(
256
+ vulns=[
257
+ Vulnerability(id="v1", type="path_traversal", host="frontend"),
258
+ Vulnerability(id="v2", type="idor", host="frontend"),
259
+ ]
260
+ ),
261
+ flags=[
262
+ FlagSpec(id="f1", value="FLAG{path}", path="/var/flags/path_flag.txt", host="frontend"),
263
+ FlagSpec(id="f2", value="FLAG{db}", path="db:flags.secrets.flag", host="database"),
264
+ ],
265
+ golden_path=[],
266
+ )
267
+ files = render_template_payloads(snapshot)
268
+ assert "frontend:/srv/http/portal/index.php" in files
269
+ download = files["frontend:/srv/http/portal/download.php"]
270
+ assert 'readfile("/srv/http/config.php")' in download
271
+ assert "GRANT SELECT ON clinic_db.* TO 'leaked_user'@'%';" in files["db:sql"]
272
+
273
+
274
  @pytest.mark.asyncio
275
  async def test_template_builder_output_is_manifest_canonicalized(tier1_manifest):
276
  from open_range.builder.builder import TemplateOnlyBuilder
tests/test_renderer.py CHANGED
@@ -362,14 +362,15 @@ def test_init_sql_creates_referral_db(renderer, sqli_spec):
362
  assert "billing" in sql
363
 
364
 
365
- def test_init_sql_grants_app_user(renderer, db_flag_spec):
366
- """Template grants privileges to app_user."""
367
  with tempfile.TemporaryDirectory() as tmpdir:
368
  out = Path(tmpdir) / "out"
369
  renderer.render(db_flag_spec, out)
370
  sql = (out / "init.sql").read_text()
371
  assert "GRANT" in sql
372
- assert "app_user" in sql
 
373
 
374
 
375
  def test_init_sql_no_file_flag(renderer, sqli_spec):
 
362
  assert "billing" in sql
363
 
364
 
365
+ def test_init_sql_grants_runtime_db_user(renderer, db_flag_spec):
366
+ """Template grants privileges to the runtime-selected DB account."""
367
  with tempfile.TemporaryDirectory() as tmpdir:
368
  out = Path(tmpdir) / "out"
369
  renderer.render(db_flag_spec, out)
370
  sql = (out / "init.sql").read_text()
371
  assert "GRANT" in sql
372
+ assert "TO '" in sql
373
+ assert "app_user" not in sql
374
 
375
 
376
  def test_init_sql_no_file_flag(renderer, sqli_spec):
tests/test_renderer_edge_cases.py CHANGED
@@ -121,7 +121,7 @@ class TestNoUsers:
121
  assert "useradd" not in dockerfile
122
 
123
  def test_context_defaults_db_user(self):
124
- """With no users, _find_db_user should return 'app_user'."""
125
  spec = SnapshotSpec(
126
  topology=_minimal_topology(users=[]),
127
  truth_graph=TruthGraph(vulns=[]),
@@ -129,8 +129,9 @@ class TestNoUsers:
129
  golden_path=[],
130
  )
131
  ctx = _build_context(spec)
132
- assert ctx["db_user"] == "app_user"
133
- assert ctx["db_pass"] == "AppUs3r!2024"
 
134
 
135
 
136
  # ---------------------------------------------------------------------------
@@ -612,8 +613,8 @@ class TestDBUserResolution:
612
  golden_path=[],
613
  )
614
  ctx = _build_context(spec)
615
- # Should fall back to default since only admin user has db access
616
- assert ctx["db_user"] == "app_user"
617
 
618
  def test_non_admin_db_user_picked(self):
619
  """Non-admin user with db access should be picked."""
 
121
  assert "useradd" not in dockerfile
122
 
123
  def test_context_defaults_db_user(self):
124
+ """With no users, context should synthesize a service DB account."""
125
  spec = SnapshotSpec(
126
  topology=_minimal_topology(users=[]),
127
  truth_graph=TruthGraph(vulns=[]),
 
129
  golden_path=[],
130
  )
131
  ctx = _build_context(spec)
132
+ assert ctx["db_user"] == "svc_db"
133
+ assert ctx["db_pass"]
134
+ assert ctx["db_pass"] != "AppUs3r!2024"
135
 
136
 
137
  # ---------------------------------------------------------------------------
 
613
  golden_path=[],
614
  )
615
  ctx = _build_context(spec)
616
+ # Should use synthesized service account since only admin has db access.
617
+ assert ctx["db_user"] == "svc_db"
618
 
619
  def test_non_admin_db_user_picked(self):
620
  """Non-admin user with db access should be picked."""
tests/test_renderer_integration.py CHANGED
@@ -220,7 +220,7 @@ class TestDockerCompose:
220
 
221
 
222
  class TestInitSQL:
223
- """Verify rendered init.sql has referral_db and app_user."""
224
 
225
  def test_creates_referral_db(self, rendered_dir):
226
  sql = (rendered_dir / "init.sql").read_text()
@@ -242,10 +242,10 @@ class TestInitSQL:
242
  assert "patient_referrals" in sql
243
  assert "billing" in sql
244
 
245
- def test_grants_app_user(self, rendered_dir):
246
  sql = (rendered_dir / "init.sql").read_text()
247
- assert "app_user" in sql
248
  assert "GRANT" in sql
 
249
 
250
  def test_has_flush_privileges(self, rendered_dir):
251
  sql = (rendered_dir / "init.sql").read_text()
 
220
 
221
 
222
  class TestInitSQL:
223
+ """Verify rendered init.sql has referral_db and runtime-selected DB grants."""
224
 
225
  def test_creates_referral_db(self, rendered_dir):
226
  sql = (rendered_dir / "init.sql").read_text()
 
242
  assert "patient_referrals" in sql
243
  assert "billing" in sql
244
 
245
+ def test_grants_runtime_db_user(self, rendered_dir):
246
  sql = (rendered_dir / "init.sql").read_text()
 
247
  assert "GRANT" in sql
248
+ assert "TO '" in sql
249
 
250
  def test_has_flush_privileges(self, rendered_dir):
251
  sql = (rendered_dir / "init.sql").read_text()