Spaces:
Runtime error
Runtime error
Merge pull request #52 from open-cybernauts/feat/issue50-canonical-base-graph-policy
Browse files- README.md +5 -5
- src/open_range/builder/manifest_graph.py +340 -0
- src/open_range/builder/mutation_policy.py +328 -0
- src/open_range/builder/mutator.py +48 -57
- src/open_range/builder/snapshot_store.py +13 -0
- src/open_range/lint.py +18 -11
- src/open_range/protocols.py +3 -0
- src/open_range/server/runtime.py +91 -9
- src/open_range/validator/graph_consistency.py +7 -6
- src/open_range/validator/graph_evidence.py +57 -0
- src/open_range/validator/graph_reward_grounding.py +47 -0
- src/open_range/validator/graphs.py +68 -0
- src/open_range/validator/manifest_compliance.py +37 -0
- src/open_range/validator/path_solvability.py +164 -0
- tests/test_builder.py +19 -0
- tests/test_lint.py +10 -9
- tests/test_runtime.py +24 -0
- tests/test_validator.py +128 -0
README.md
CHANGED
|
@@ -20,14 +20,14 @@ A multi-agent cybersecurity gymnasium on [OpenEnv](https://github.com/meta-pytor
|
|
| 20 |
|
| 21 |
## How It Works
|
| 22 |
|
| 23 |
-
A **manifest** declares a family of legal enterprise worlds — topology, services, identities, trust relationships, vulnerability classes, and mutation bounds. A shared **ManagedSnapshotRuntime** inside the shipped OpenEnv server process owns the admitted snapshot population. It compiles a root snapshot from the manifest, then derives child snapshots by applying explicit typed mutations to admitted parents. Each candidate child is
|
| 24 |
|
| 25 |
```mermaid
|
| 26 |
flowchart LR
|
| 27 |
M[Manifest<br/>legal family +<br/>mutation envelope] --> B[Base snapshot compiler]
|
| 28 |
B --> P[Admitted root snapshot]
|
| 29 |
P --> R[ManagedSnapshotRuntime<br/>shared inside server process]
|
| 30 |
-
R --> U[
|
| 31 |
U --> V{Validator<br/>manifest + graph +<br/>runtime checks}
|
| 32 |
V -->|fail| U
|
| 33 |
V -->|pass| S[Admitted snapshot population]
|
|
@@ -85,13 +85,13 @@ uv run pytest tests/ -v --tb=short
|
|
| 85 |
|
| 86 |
**Manifest** — YAML defining the legal world family and mutation envelope: hosts, zones, services, users, NPCs, data assets, credential policies, monitoring coverage, trust relationships, and which vulnerability classes the runtime may plant or extend. Three example manifests ship (healthcare, fintech, SaaS) at tiers 1-3.
|
| 87 |
|
| 88 |
-
**ManagedSnapshotRuntime** — Shared singleton created at server startup. Owns the `SnapshotStore`, base builder, parent-snapshot mutator, validator gate, `SnapshotRenderer`, snapshot preload, optional background refill, and episode-result feedback. This is the hidden orchestrator behind the env; callers still only see `reset()`, `step()`, and `state()`.
|
| 89 |
|
| 90 |
-
**Builder / Mutator** — The base builder compiles an initial `SnapshotSpec` from a manifest.
|
| 91 |
|
| 92 |
The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
|
| 93 |
|
| 94 |
-
**Validator** — Admission gate for candidate snapshots. The shipped runtime enforces manifest compliance
|
| 95 |
|
| 96 |
**Environment** — `RangeEnvironment(Environment)` following the OpenEnv contract. `reset()` asks the shared runtime for a frozen admitted snapshot. `step(action)` routes commands to the appropriate container — Red runs on the attacker box, Blue runs on the SIEM. No artificial command allowlists; the container's installed tools are the constraint.
|
| 97 |
|
|
|
|
| 20 |
|
| 21 |
## How It Works
|
| 22 |
|
| 23 |
+
A **manifest** declares a family of legal enterprise worlds — topology, services, identities, trust relationships, vulnerability classes, and mutation bounds. A shared **ManagedSnapshotRuntime** inside the shipped OpenEnv server process owns the admitted snapshot population. It compiles a graph-friendly root snapshot from the manifest, normalizing trust-only principals into a canonical principal catalog, then derives child snapshots by applying explicit typed mutations to admitted parents. Parent selection is policy-driven over the admitted population rather than raw latest/random sampling. Each candidate child is validated in layers: manifest compliance, canonical graph checks, structural/task checks, and, in managed-generation mode, booted runtime checks before admission. `reset()` selects one frozen admitted snapshot. `step()` runs commands inside it.
|
| 24 |
|
| 25 |
```mermaid
|
| 26 |
flowchart LR
|
| 27 |
M[Manifest<br/>legal family +<br/>mutation envelope] --> B[Base snapshot compiler]
|
| 28 |
B --> P[Admitted root snapshot]
|
| 29 |
P --> R[ManagedSnapshotRuntime<br/>shared inside server process]
|
| 30 |
+
R --> U[Policy-guided parent selector +<br/>typed mutator]
|
| 31 |
U --> V{Validator<br/>manifest + graph +<br/>runtime checks}
|
| 32 |
V -->|fail| U
|
| 33 |
V -->|pass| S[Admitted snapshot population]
|
|
|
|
| 85 |
|
| 86 |
**Manifest** — YAML defining the legal world family and mutation envelope: hosts, zones, services, users, NPCs, data assets, credential policies, monitoring coverage, trust relationships, and which vulnerability classes the runtime may plant or extend. Three example manifests ship (healthcare, fintech, SaaS) at tiers 1-3.
|
| 87 |
|
| 88 |
+
**ManagedSnapshotRuntime** — Shared singleton created at server startup. Owns the `SnapshotStore`, base builder, population-aware parent selector, parent-snapshot mutator, validator gate, `SnapshotRenderer`, snapshot preload, optional background refill, and episode-result feedback. This is the hidden orchestrator behind the env; callers still only see `reset()`, `step()`, and `state()`.
|
| 89 |
|
| 90 |
+
**Builder / Mutator** — The base builder compiles an initial `SnapshotSpec` from a manifest. Root hydration then expands that into canonical topology state: host details, dependency edges, trust edges, and a principal catalog that can represent trust-only people without inventing login accounts. The mutator derives child `SnapshotSpec`s from admitted parents using typed mutation plans plus an explicit mutation-policy layer that scores parents and candidate edits with curriculum, replay, novelty, and lineage signals. Each snapshot carries lineage metadata (`snapshot_id`, `parent_snapshot_id`, `root_snapshot_id`, generation depth, mutation summary) and can emit constrained service/app payloads through `SnapshotSpec.files`. Three base builders ship: `LLMSnapshotBuilder` (production, via litellm), `TemplateOnlyBuilder` (deterministic shipped default), `FileBuilder` (load from disk).
|
| 91 |
|
| 92 |
The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
|
| 93 |
|
| 94 |
+
**Validator** — Admission gate for candidate snapshots. The shipped runtime first enforces manifest compliance plus graph-native checks such as graph consistency, path solvability, evidence sufficiency, and reward grounding before structural/task checks. When `OPENRANGE_ENABLE_LIVE_ADMISSION=1`, the runtime also boots the rendered child bundle, applies rendered payload files, constructs a real `ContainerSet`, and runs live build/exploit/evidence/reward checks before admission. Public/HF mode can still rely on a prebuilt admitted pool with live admission disabled.
|
| 95 |
|
| 96 |
**Environment** — `RangeEnvironment(Environment)` following the OpenEnv contract. `reset()` asks the shared runtime for a frozen admitted snapshot. `step(action)` routes commands to the appropriate container — Red runs on the attacker box, Blue runs on the SIEM. No artificial command allowlists; the container's installed tools are the constraint.
|
| 97 |
|
src/open_range/builder/manifest_graph.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manifest-to-topology compilation helpers for root snapshot hydration.
|
| 2 |
+
|
| 3 |
+
These helpers turn a manifest's declared company world into the canonical
|
| 4 |
+
topology fields the mutator, validators, and runtime expect to reason about.
|
| 5 |
+
They intentionally keep "real login users" separate from trust-only narrative
|
| 6 |
+
principals so the trust graph can be compiled without silently creating extra
|
| 7 |
+
accounts in rendered services.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from copy import deepcopy
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def build_host_catalog(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
| 17 |
+
"""Return the manifest-defined host catalog keyed by host name."""
|
| 18 |
+
catalog: dict[str, dict[str, Any]] = {}
|
| 19 |
+
for raw in manifest.get("topology", {}).get("hosts", []):
|
| 20 |
+
if not isinstance(raw, dict):
|
| 21 |
+
continue
|
| 22 |
+
name = str(raw.get("name", "")).strip()
|
| 23 |
+
if not name:
|
| 24 |
+
continue
|
| 25 |
+
catalog[name] = {
|
| 26 |
+
"zone": str(raw.get("zone", "")),
|
| 27 |
+
"services": deepcopy(raw.get("services", [])),
|
| 28 |
+
"connects_to": deepcopy(raw.get("connects_to", [])),
|
| 29 |
+
"purpose": str(raw.get("purpose", "")),
|
| 30 |
+
"hostname": str(raw.get("hostname", "")),
|
| 31 |
+
"os": str(raw.get("os", "")),
|
| 32 |
+
"exposure": deepcopy(raw.get("exposure", {})),
|
| 33 |
+
}
|
| 34 |
+
return catalog
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_principal_catalog(
|
| 38 |
+
manifest: dict[str, Any],
|
| 39 |
+
existing: dict[str, Any] | None = None,
|
| 40 |
+
) -> tuple[dict[str, dict[str, Any]], list[str]]:
|
| 41 |
+
"""Return a canonical principal catalog plus normalized trust-only names."""
|
| 42 |
+
catalog: dict[str, dict[str, Any]] = {}
|
| 43 |
+
trust_only: set[str] = set()
|
| 44 |
+
|
| 45 |
+
if isinstance(existing, dict):
|
| 46 |
+
for name, raw in existing.items():
|
| 47 |
+
principal = str(name).strip()
|
| 48 |
+
if not principal or not isinstance(raw, dict):
|
| 49 |
+
continue
|
| 50 |
+
catalog[principal] = deepcopy(raw)
|
| 51 |
+
|
| 52 |
+
for raw in manifest.get("users", []):
|
| 53 |
+
if not isinstance(raw, dict):
|
| 54 |
+
continue
|
| 55 |
+
username = str(raw.get("username", "")).strip()
|
| 56 |
+
if not username:
|
| 57 |
+
continue
|
| 58 |
+
principal = catalog.setdefault(username, {})
|
| 59 |
+
principal.update(
|
| 60 |
+
{
|
| 61 |
+
"username": username,
|
| 62 |
+
"kind": "user",
|
| 63 |
+
"is_login_account": True,
|
| 64 |
+
"hosts": deepcopy(raw.get("hosts", [])),
|
| 65 |
+
"department": str(raw.get("department", "")),
|
| 66 |
+
"role": str(raw.get("role", "")),
|
| 67 |
+
"email": str(raw.get("email", "")),
|
| 68 |
+
"full_name": str(raw.get("full_name", "")),
|
| 69 |
+
}
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
for raw in manifest.get("trust_relationships", []):
|
| 73 |
+
if not isinstance(raw, dict):
|
| 74 |
+
continue
|
| 75 |
+
source = str(raw.get("source") or raw.get("from") or "").strip()
|
| 76 |
+
target = str(raw.get("target") or raw.get("to") or "").strip()
|
| 77 |
+
for principal_name in (source, target):
|
| 78 |
+
if not principal_name:
|
| 79 |
+
continue
|
| 80 |
+
principal = catalog.setdefault(principal_name, {})
|
| 81 |
+
if not principal.get("is_login_account", False):
|
| 82 |
+
trust_only.add(principal_name)
|
| 83 |
+
principal.setdefault("username", principal_name)
|
| 84 |
+
principal.setdefault("kind", "trust_principal")
|
| 85 |
+
principal.setdefault("is_login_account", False)
|
| 86 |
+
principal.setdefault("hosts", [])
|
| 87 |
+
principal.setdefault("department", "")
|
| 88 |
+
principal.setdefault("role", "")
|
| 89 |
+
principal.setdefault("email", "")
|
| 90 |
+
principal.setdefault("full_name", "")
|
| 91 |
+
|
| 92 |
+
return catalog, sorted(trust_only)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def compile_manifest_topology(
|
| 96 |
+
manifest: dict[str, Any],
|
| 97 |
+
topology: dict[str, Any] | None = None,
|
| 98 |
+
) -> dict[str, Any]:
|
| 99 |
+
"""Compile manifest state into graph-friendly topology fields.
|
| 100 |
+
|
| 101 |
+
Existing topology fields are preserved where possible so builder-generated
|
| 102 |
+
details such as passwords or payload-specific knobs survive root hydration.
|
| 103 |
+
"""
|
| 104 |
+
compiled = deepcopy(topology) if isinstance(topology, dict) else {}
|
| 105 |
+
company = manifest.get("company", {}) if isinstance(manifest.get("company"), dict) else {}
|
| 106 |
+
|
| 107 |
+
compiled.setdefault("tier", int(manifest.get("tier", compiled.get("tier", 1)) or 1))
|
| 108 |
+
compiled.setdefault("domain", company.get("domain", "acmecorp.local"))
|
| 109 |
+
compiled.setdefault("org_name", company.get("name", "AcmeCorp"))
|
| 110 |
+
compiled.setdefault("manifest_name", manifest.get("name", ""))
|
| 111 |
+
compiled.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
|
| 112 |
+
compiled.setdefault(
|
| 113 |
+
"networks",
|
| 114 |
+
deepcopy(manifest.get("topology", {}).get("networks", [])),
|
| 115 |
+
)
|
| 116 |
+
compiled.setdefault(
|
| 117 |
+
"firewall_rules",
|
| 118 |
+
deepcopy(manifest.get("topology", {}).get("firewall_rules", [])),
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
host_catalog = build_host_catalog(manifest)
|
| 122 |
+
compiled["host_catalog"] = host_catalog
|
| 123 |
+
compiled["hosts"] = _merge_hosts(compiled.get("hosts"), host_catalog)
|
| 124 |
+
compiled["zones"] = _merge_zones(compiled.get("zones"), host_catalog)
|
| 125 |
+
compiled["users"] = _merge_users(compiled.get("users"), manifest)
|
| 126 |
+
compiled["host_details"] = _merge_host_details(compiled.get("host_details"), host_catalog)
|
| 127 |
+
compiled["dependency_edges"] = _merge_dependency_edges(
|
| 128 |
+
compiled.get("dependency_edges"),
|
| 129 |
+
host_catalog,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
principal_catalog, trust_only = build_principal_catalog(
|
| 133 |
+
manifest,
|
| 134 |
+
existing=compiled.get("principal_catalog")
|
| 135 |
+
if isinstance(compiled.get("principal_catalog"), dict)
|
| 136 |
+
else None,
|
| 137 |
+
)
|
| 138 |
+
compiled["principal_catalog"] = principal_catalog
|
| 139 |
+
compiled["trust_edges"] = _merge_trust_edges(compiled.get("trust_edges"), manifest)
|
| 140 |
+
compiled["manifest_normalization"] = {
|
| 141 |
+
"trust_only_principals": trust_only,
|
| 142 |
+
"notes": [
|
| 143 |
+
(
|
| 144 |
+
"Normalized trust principals not present in manifest users into "
|
| 145 |
+
"principal_catalog only"
|
| 146 |
+
)
|
| 147 |
+
]
|
| 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]],
|
| 157 |
+
) -> list[str]:
|
| 158 |
+
hosts: list[str] = []
|
| 159 |
+
seen: set[str] = set()
|
| 160 |
+
if isinstance(raw_hosts, list):
|
| 161 |
+
for raw in raw_hosts:
|
| 162 |
+
if isinstance(raw, dict):
|
| 163 |
+
name = str(raw.get("name", "")).strip()
|
| 164 |
+
else:
|
| 165 |
+
name = str(raw).strip()
|
| 166 |
+
if not name or name in seen:
|
| 167 |
+
continue
|
| 168 |
+
seen.add(name)
|
| 169 |
+
hosts.append(name)
|
| 170 |
+
for host in host_catalog:
|
| 171 |
+
if host in seen:
|
| 172 |
+
continue
|
| 173 |
+
seen.add(host)
|
| 174 |
+
hosts.append(host)
|
| 175 |
+
return hosts
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _merge_zones(
|
| 179 |
+
raw_zones: object,
|
| 180 |
+
host_catalog: dict[str, dict[str, Any]],
|
| 181 |
+
) -> dict[str, list[str]]:
|
| 182 |
+
zones: dict[str, list[str]] = {}
|
| 183 |
+
if isinstance(raw_zones, dict):
|
| 184 |
+
for zone, raw_hosts in raw_zones.items():
|
| 185 |
+
zone_name = str(zone).strip()
|
| 186 |
+
if not zone_name:
|
| 187 |
+
continue
|
| 188 |
+
zone_hosts: list[str] = []
|
| 189 |
+
if isinstance(raw_hosts, list):
|
| 190 |
+
for raw_host in raw_hosts:
|
| 191 |
+
host = str(raw_host).strip()
|
| 192 |
+
if host and host not in zone_hosts:
|
| 193 |
+
zone_hosts.append(host)
|
| 194 |
+
zones[zone_name] = zone_hosts
|
| 195 |
+
|
| 196 |
+
for host, raw_catalog in host_catalog.items():
|
| 197 |
+
zone = str(raw_catalog.get("zone", "")).strip() or "default"
|
| 198 |
+
zone_hosts = zones.setdefault(zone, [])
|
| 199 |
+
if host not in zone_hosts:
|
| 200 |
+
zone_hosts.append(host)
|
| 201 |
+
return zones
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _merge_users(raw_users: object, manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
| 205 |
+
existing: dict[str, dict[str, Any]] = {}
|
| 206 |
+
extras: list[dict[str, Any]] = []
|
| 207 |
+
if isinstance(raw_users, list):
|
| 208 |
+
for raw in raw_users:
|
| 209 |
+
if not isinstance(raw, dict):
|
| 210 |
+
continue
|
| 211 |
+
username = str(raw.get("username", "")).strip()
|
| 212 |
+
if not username:
|
| 213 |
+
continue
|
| 214 |
+
existing[username] = deepcopy(raw)
|
| 215 |
+
|
| 216 |
+
merged: list[dict[str, Any]] = []
|
| 217 |
+
seen: set[str] = set()
|
| 218 |
+
for raw in manifest.get("users", []):
|
| 219 |
+
if not isinstance(raw, dict):
|
| 220 |
+
continue
|
| 221 |
+
username = str(raw.get("username", "")).strip()
|
| 222 |
+
if not username:
|
| 223 |
+
continue
|
| 224 |
+
record = existing.pop(username, {})
|
| 225 |
+
record.setdefault("username", username)
|
| 226 |
+
record.setdefault("password", "")
|
| 227 |
+
record.setdefault("groups", [])
|
| 228 |
+
record.setdefault("hosts", deepcopy(raw.get("hosts", [])))
|
| 229 |
+
record.setdefault("email", str(raw.get("email", "")))
|
| 230 |
+
record.setdefault("full_name", str(raw.get("full_name", "")))
|
| 231 |
+
record.setdefault("department", str(raw.get("department", "")))
|
| 232 |
+
record.setdefault("role", str(raw.get("role", "")))
|
| 233 |
+
merged.append(record)
|
| 234 |
+
seen.add(username)
|
| 235 |
+
|
| 236 |
+
for username, record in existing.items():
|
| 237 |
+
if username in seen:
|
| 238 |
+
continue
|
| 239 |
+
extras.append(record)
|
| 240 |
+
merged.extend(extras)
|
| 241 |
+
return merged
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _merge_host_details(
|
| 245 |
+
raw_details: object,
|
| 246 |
+
host_catalog: dict[str, dict[str, Any]],
|
| 247 |
+
) -> dict[str, dict[str, Any]]:
|
| 248 |
+
host_details: dict[str, dict[str, Any]] = {}
|
| 249 |
+
if isinstance(raw_details, dict):
|
| 250 |
+
for host, raw_detail in raw_details.items():
|
| 251 |
+
host_name = str(host).strip()
|
| 252 |
+
if not host_name or not isinstance(raw_detail, dict):
|
| 253 |
+
continue
|
| 254 |
+
host_details[host_name] = deepcopy(raw_detail)
|
| 255 |
+
|
| 256 |
+
for host, raw_catalog in host_catalog.items():
|
| 257 |
+
detail = host_details.setdefault(host, {})
|
| 258 |
+
detail.setdefault("zone", str(raw_catalog.get("zone", "")))
|
| 259 |
+
detail.setdefault("services", deepcopy(raw_catalog.get("services", [])))
|
| 260 |
+
detail.setdefault("connects_to", deepcopy(raw_catalog.get("connects_to", [])))
|
| 261 |
+
detail.setdefault("purpose", str(raw_catalog.get("purpose", "")))
|
| 262 |
+
detail.setdefault("hostname", str(raw_catalog.get("hostname", "")))
|
| 263 |
+
detail.setdefault("os", str(raw_catalog.get("os", "")))
|
| 264 |
+
detail.setdefault("exposure", deepcopy(raw_catalog.get("exposure", {})))
|
| 265 |
+
return host_details
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _merge_dependency_edges(
|
| 269 |
+
raw_edges: object,
|
| 270 |
+
host_catalog: dict[str, dict[str, Any]],
|
| 271 |
+
) -> list[dict[str, str]]:
|
| 272 |
+
edges: list[dict[str, str]] = []
|
| 273 |
+
seen: set[tuple[str, str]] = set()
|
| 274 |
+
if isinstance(raw_edges, list):
|
| 275 |
+
for raw in raw_edges:
|
| 276 |
+
if not isinstance(raw, dict):
|
| 277 |
+
continue
|
| 278 |
+
source = str(raw.get("source", "")).strip()
|
| 279 |
+
target = str(raw.get("target", "")).strip()
|
| 280 |
+
if not source or not target or (source, target) in seen:
|
| 281 |
+
continue
|
| 282 |
+
edges.append({"source": source, "target": target})
|
| 283 |
+
seen.add((source, target))
|
| 284 |
+
|
| 285 |
+
for source, raw_catalog in host_catalog.items():
|
| 286 |
+
raw_targets = raw_catalog.get("connects_to", [])
|
| 287 |
+
if not isinstance(raw_targets, list):
|
| 288 |
+
continue
|
| 289 |
+
for raw_target in raw_targets:
|
| 290 |
+
target = str(raw_target).strip()
|
| 291 |
+
if not target or (source, target) in seen:
|
| 292 |
+
continue
|
| 293 |
+
edges.append({"source": source, "target": target})
|
| 294 |
+
seen.add((source, target))
|
| 295 |
+
return edges
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def _merge_trust_edges(
|
| 299 |
+
raw_edges: object,
|
| 300 |
+
manifest: dict[str, Any],
|
| 301 |
+
) -> list[dict[str, str]]:
|
| 302 |
+
edges: list[dict[str, str]] = []
|
| 303 |
+
seen: set[tuple[str, str, str]] = set()
|
| 304 |
+
if isinstance(raw_edges, list):
|
| 305 |
+
for raw in raw_edges:
|
| 306 |
+
if not isinstance(raw, dict):
|
| 307 |
+
continue
|
| 308 |
+
source = str(raw.get("source", "")).strip()
|
| 309 |
+
target = str(raw.get("target", "")).strip()
|
| 310 |
+
edge_type = str(raw.get("type", "")).strip()
|
| 311 |
+
if not source or not target or (source, target, edge_type) in seen:
|
| 312 |
+
continue
|
| 313 |
+
edges.append(
|
| 314 |
+
{
|
| 315 |
+
"source": source,
|
| 316 |
+
"target": target,
|
| 317 |
+
"type": edge_type,
|
| 318 |
+
"context": str(raw.get("context", "")),
|
| 319 |
+
}
|
| 320 |
+
)
|
| 321 |
+
seen.add((source, target, edge_type))
|
| 322 |
+
|
| 323 |
+
for raw in manifest.get("trust_relationships", []):
|
| 324 |
+
if not isinstance(raw, dict):
|
| 325 |
+
continue
|
| 326 |
+
source = str(raw.get("source") or raw.get("from") or "").strip()
|
| 327 |
+
target = str(raw.get("target") or raw.get("to") or "").strip()
|
| 328 |
+
edge_type = str(raw.get("type", "")).strip()
|
| 329 |
+
if not source or not target or (source, target, edge_type) in seen:
|
| 330 |
+
continue
|
| 331 |
+
edges.append(
|
| 332 |
+
{
|
| 333 |
+
"source": source,
|
| 334 |
+
"target": target,
|
| 335 |
+
"type": edge_type,
|
| 336 |
+
"context": str(raw.get("context") or raw.get("description") or ""),
|
| 337 |
+
}
|
| 338 |
+
)
|
| 339 |
+
seen.add((source, target, edge_type))
|
| 340 |
+
return edges
|
src/open_range/builder/mutation_policy.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Population-aware parent and mutation selection policy."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import random
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from open_range.protocols import BuildContext, MutationOp, SnapshotSpec
|
| 11 |
+
from open_range.validator.graphs import compile_snapshot_graphs
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True, slots=True)
|
| 15 |
+
class ParentPolicyScore:
|
| 16 |
+
snapshot_id: str
|
| 17 |
+
total: float
|
| 18 |
+
components: dict[str, float]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True, slots=True)
|
| 22 |
+
class MutationChoice:
|
| 23 |
+
op: MutationOp
|
| 24 |
+
total: float
|
| 25 |
+
components: dict[str, float]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class PopulationMutationPolicy:
|
| 29 |
+
"""Simple population-guided policy for parent and op selection.
|
| 30 |
+
|
| 31 |
+
This is intentionally heuristic rather than learned. It gives the runtime
|
| 32 |
+
an explicit place to score parents and mutation candidates using curriculum,
|
| 33 |
+
replay, novelty, and lineage signals instead of relying on raw RNG.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
name = "population_guided_v1"
|
| 37 |
+
|
| 38 |
+
def select_parent(
|
| 39 |
+
self,
|
| 40 |
+
entries: list[Any],
|
| 41 |
+
*,
|
| 42 |
+
context: BuildContext,
|
| 43 |
+
snapshot_stats: dict[str, dict[str, Any]],
|
| 44 |
+
rng: random.Random,
|
| 45 |
+
) -> tuple[Any, ParentPolicyScore]:
|
| 46 |
+
scores = self.score_parents(
|
| 47 |
+
entries,
|
| 48 |
+
context=context,
|
| 49 |
+
snapshot_stats=snapshot_stats,
|
| 50 |
+
)
|
| 51 |
+
if not scores:
|
| 52 |
+
raise ValueError("No parent candidates available")
|
| 53 |
+
ordered = sorted(scores, key=lambda score: score.total, reverse=True)
|
| 54 |
+
top = ordered[: min(3, len(ordered))]
|
| 55 |
+
weights = [max(score.total, 0.05) for score in top]
|
| 56 |
+
chosen_score = rng.choices(top, weights=weights, k=1)[0]
|
| 57 |
+
chosen_entry = next(
|
| 58 |
+
entry for entry in entries if entry.snapshot_id == chosen_score.snapshot_id
|
| 59 |
+
)
|
| 60 |
+
return chosen_entry, chosen_score
|
| 61 |
+
|
| 62 |
+
def score_parents(
|
| 63 |
+
self,
|
| 64 |
+
entries: list[Any],
|
| 65 |
+
*,
|
| 66 |
+
context: BuildContext,
|
| 67 |
+
snapshot_stats: dict[str, dict[str, Any]],
|
| 68 |
+
) -> list[ParentPolicyScore]:
|
| 69 |
+
if not entries:
|
| 70 |
+
return []
|
| 71 |
+
|
| 72 |
+
root_counts = Counter(
|
| 73 |
+
entry.snapshot.lineage.root_snapshot_id or entry.snapshot_id
|
| 74 |
+
for entry in entries
|
| 75 |
+
)
|
| 76 |
+
vuln_frequency = Counter()
|
| 77 |
+
for entry in entries:
|
| 78 |
+
vuln_frequency.update(v.type for v in entry.snapshot.truth_graph.vulns if v.type)
|
| 79 |
+
|
| 80 |
+
scores: list[ParentPolicyScore] = []
|
| 81 |
+
for entry in entries:
|
| 82 |
+
snapshot = entry.snapshot
|
| 83 |
+
stat = snapshot_stats.get(entry.snapshot_id, {})
|
| 84 |
+
vuln_types = {v.type for v in snapshot.truth_graph.vulns if v.type}
|
| 85 |
+
compiled = compile_snapshot_graphs(snapshot)
|
| 86 |
+
|
| 87 |
+
plays = float(stat.get("plays", 0))
|
| 88 |
+
red_rate = float(stat.get("red_solve_rate", 0.0))
|
| 89 |
+
blue_rate = float(stat.get("blue_detect_rate", 0.0))
|
| 90 |
+
frontier = (
|
| 91 |
+
0.4
|
| 92 |
+
if plays == 0
|
| 93 |
+
else (
|
| 94 |
+
self._frontier_score(red_rate)
|
| 95 |
+
+ self._frontier_score(blue_rate)
|
| 96 |
+
)
|
| 97 |
+
/ 2.0
|
| 98 |
+
)
|
| 99 |
+
replay = 1.0 / (plays + 1.0)
|
| 100 |
+
novelty = 1.0 / (
|
| 101 |
+
1.0 + sum(vuln_frequency[vuln] for vuln in vuln_types)
|
| 102 |
+
) if vuln_types else 0.25
|
| 103 |
+
weak_overlap = float(len(vuln_types.intersection(context.weak_areas)))
|
| 104 |
+
root_id = snapshot.lineage.root_snapshot_id or entry.snapshot_id
|
| 105 |
+
lineage_balance = 1.0 / max(root_counts[root_id], 1)
|
| 106 |
+
depth = float(snapshot.lineage.generation_depth)
|
| 107 |
+
depth_balance = 1.0 / (1.0 + max(depth - 3.0, 0.0))
|
| 108 |
+
recency = 1.0 / (1.0 + float(stat.get("plays_recent", 0)))
|
| 109 |
+
complexity = min(
|
| 110 |
+
(
|
| 111 |
+
len(snapshot.truth_graph.vulns) * 0.25
|
| 112 |
+
+ len(snapshot.golden_path) * 0.03
|
| 113 |
+
+ len(compiled.dependency_edges) * 0.02
|
| 114 |
+
+ len(compiled.trust_edges) * 0.02
|
| 115 |
+
),
|
| 116 |
+
1.0,
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
components = {
|
| 120 |
+
"frontier": frontier,
|
| 121 |
+
"replay": replay,
|
| 122 |
+
"novelty": novelty,
|
| 123 |
+
"weak_overlap": weak_overlap,
|
| 124 |
+
"lineage_balance": lineage_balance,
|
| 125 |
+
"depth_balance": depth_balance,
|
| 126 |
+
"recency": recency,
|
| 127 |
+
"complexity": complexity,
|
| 128 |
+
}
|
| 129 |
+
total = (
|
| 130 |
+
frontier * 0.28
|
| 131 |
+
+ replay * 0.18
|
| 132 |
+
+ novelty * 0.16
|
| 133 |
+
+ weak_overlap * 0.18
|
| 134 |
+
+ lineage_balance * 0.08
|
| 135 |
+
+ depth_balance * 0.04
|
| 136 |
+
+ recency * 0.04
|
| 137 |
+
+ complexity * 0.04
|
| 138 |
+
)
|
| 139 |
+
scores.append(
|
| 140 |
+
ParentPolicyScore(
|
| 141 |
+
snapshot_id=entry.snapshot_id,
|
| 142 |
+
total=round(max(total, 0.05), 4),
|
| 143 |
+
components={key: round(value, 4) for key, value in components.items()},
|
| 144 |
+
)
|
| 145 |
+
)
|
| 146 |
+
return scores
|
| 147 |
+
|
| 148 |
+
def choose_mutations(
|
| 149 |
+
self,
|
| 150 |
+
*,
|
| 151 |
+
structural_candidates: list[MutationOp],
|
| 152 |
+
security_candidates: list[MutationOp],
|
| 153 |
+
snapshot: SnapshotSpec,
|
| 154 |
+
context: BuildContext,
|
| 155 |
+
rng: random.Random,
|
| 156 |
+
) -> tuple[list[MutationOp], float, dict[str, float]]:
|
| 157 |
+
selected: list[MutationChoice] = []
|
| 158 |
+
|
| 159 |
+
structural = self._select_candidate(
|
| 160 |
+
structural_candidates,
|
| 161 |
+
snapshot=snapshot,
|
| 162 |
+
context=context,
|
| 163 |
+
rng=rng,
|
| 164 |
+
)
|
| 165 |
+
if structural is not None:
|
| 166 |
+
selected.append(structural)
|
| 167 |
+
|
| 168 |
+
security_pool = [
|
| 169 |
+
choice
|
| 170 |
+
for choice in (
|
| 171 |
+
self._select_candidate(
|
| 172 |
+
security_candidates,
|
| 173 |
+
snapshot=snapshot,
|
| 174 |
+
context=context,
|
| 175 |
+
rng=rng,
|
| 176 |
+
),
|
| 177 |
+
)
|
| 178 |
+
if choice is not None
|
| 179 |
+
]
|
| 180 |
+
selected.extend(security_pool)
|
| 181 |
+
|
| 182 |
+
if not selected and security_candidates:
|
| 183 |
+
fallback = self._select_candidate(
|
| 184 |
+
security_candidates,
|
| 185 |
+
snapshot=snapshot,
|
| 186 |
+
context=context,
|
| 187 |
+
rng=rng,
|
| 188 |
+
deterministic=True,
|
| 189 |
+
)
|
| 190 |
+
if fallback is not None:
|
| 191 |
+
selected.append(fallback)
|
| 192 |
+
|
| 193 |
+
if not structural and len(security_candidates) > 1:
|
| 194 |
+
ranked = self._rank_candidates(
|
| 195 |
+
security_candidates,
|
| 196 |
+
snapshot=snapshot,
|
| 197 |
+
context=context,
|
| 198 |
+
)
|
| 199 |
+
for choice in ranked:
|
| 200 |
+
if any(choice.op.mutation_id == existing.op.mutation_id for existing in selected):
|
| 201 |
+
continue
|
| 202 |
+
selected.append(choice)
|
| 203 |
+
break
|
| 204 |
+
|
| 205 |
+
ops = [choice.op for choice in selected]
|
| 206 |
+
if not ops:
|
| 207 |
+
return [], 0.0, {}
|
| 208 |
+
|
| 209 |
+
breakdown = {
|
| 210 |
+
"curriculum": round(sum(c.components["curriculum"] for c in selected), 4),
|
| 211 |
+
"novelty": round(sum(c.components["novelty"] for c in selected), 4),
|
| 212 |
+
"structural_gain": round(sum(c.components["structural_gain"] for c in selected), 4),
|
| 213 |
+
"lineage": round(sum(c.components["lineage"] for c in selected), 4),
|
| 214 |
+
}
|
| 215 |
+
total = round(sum(choice.total for choice in selected), 4)
|
| 216 |
+
return ops, total, breakdown
|
| 217 |
+
|
| 218 |
+
def _select_candidate(
|
| 219 |
+
self,
|
| 220 |
+
candidates: list[MutationOp],
|
| 221 |
+
*,
|
| 222 |
+
snapshot: SnapshotSpec,
|
| 223 |
+
context: BuildContext,
|
| 224 |
+
rng: random.Random,
|
| 225 |
+
deterministic: bool = False,
|
| 226 |
+
) -> MutationChoice | None:
|
| 227 |
+
ranked = self._rank_candidates(
|
| 228 |
+
candidates,
|
| 229 |
+
snapshot=snapshot,
|
| 230 |
+
context=context,
|
| 231 |
+
)
|
| 232 |
+
if not ranked:
|
| 233 |
+
return None
|
| 234 |
+
if deterministic or len(ranked) == 1:
|
| 235 |
+
return ranked[0]
|
| 236 |
+
top = ranked[: min(3, len(ranked))]
|
| 237 |
+
weights = [max(choice.total, 0.05) for choice in top]
|
| 238 |
+
return rng.choices(top, weights=weights, k=1)[0]
|
| 239 |
+
|
| 240 |
+
def _rank_candidates(
|
| 241 |
+
self,
|
| 242 |
+
candidates: list[MutationOp],
|
| 243 |
+
*,
|
| 244 |
+
snapshot: SnapshotSpec,
|
| 245 |
+
context: BuildContext,
|
| 246 |
+
) -> list[MutationChoice]:
|
| 247 |
+
ranked: list[MutationChoice] = []
|
| 248 |
+
existing_vulns = {v.type for v in snapshot.truth_graph.vulns if v.type}
|
| 249 |
+
for candidate in candidates:
|
| 250 |
+
curriculum = self._curriculum_bonus(candidate, context, existing_vulns)
|
| 251 |
+
novelty = self._novelty_bonus(candidate, context)
|
| 252 |
+
structural_gain = self._structural_gain(candidate)
|
| 253 |
+
lineage = 1.0 / (1.0 + snapshot.lineage.generation_depth)
|
| 254 |
+
components = {
|
| 255 |
+
"curriculum": curriculum,
|
| 256 |
+
"novelty": novelty,
|
| 257 |
+
"structural_gain": structural_gain,
|
| 258 |
+
"lineage": lineage,
|
| 259 |
+
}
|
| 260 |
+
total = (
|
| 261 |
+
curriculum * 0.38
|
| 262 |
+
+ novelty * 0.24
|
| 263 |
+
+ structural_gain * 0.28
|
| 264 |
+
+ lineage * 0.10
|
| 265 |
+
)
|
| 266 |
+
ranked.append(
|
| 267 |
+
MutationChoice(
|
| 268 |
+
op=candidate,
|
| 269 |
+
total=round(max(total, 0.05), 4),
|
| 270 |
+
components={key: round(value, 4) for key, value in components.items()},
|
| 271 |
+
)
|
| 272 |
+
)
|
| 273 |
+
ranked.sort(key=lambda choice: choice.total, reverse=True)
|
| 274 |
+
return ranked
|
| 275 |
+
|
| 276 |
+
@staticmethod
|
| 277 |
+
def _frontier_score(rate: float) -> float:
|
| 278 |
+
return max(0.0, 1.0 - abs(rate - 0.5) * 2.0)
|
| 279 |
+
|
| 280 |
+
@staticmethod
|
| 281 |
+
def _structural_gain(op: MutationOp) -> float:
|
| 282 |
+
mapping = {
|
| 283 |
+
"add_service": 1.0,
|
| 284 |
+
"add_dependency_edge": 0.9,
|
| 285 |
+
"add_trust_edge": 0.85,
|
| 286 |
+
"add_user": 0.8,
|
| 287 |
+
"seed_vuln": 0.7,
|
| 288 |
+
"add_benign_noise": 0.3,
|
| 289 |
+
}
|
| 290 |
+
return mapping.get(op.op_type, 0.2) * max(op.magnitude, 1)
|
| 291 |
+
|
| 292 |
+
@staticmethod
|
| 293 |
+
def _novelty_bonus(op: MutationOp, context: BuildContext) -> float:
|
| 294 |
+
bonus = 0.4
|
| 295 |
+
if op.op_type == "seed_vuln":
|
| 296 |
+
vuln_type = str(op.params.get("vuln_type", "")).strip()
|
| 297 |
+
if vuln_type and vuln_type not in context.previous_vuln_classes:
|
| 298 |
+
bonus += 1.0
|
| 299 |
+
if op.op_type == "add_benign_noise":
|
| 300 |
+
location = str(op.params.get("location", "")).strip()
|
| 301 |
+
if location and location not in context.recent_attack_surfaces:
|
| 302 |
+
bonus += 0.5
|
| 303 |
+
if op.op_type not in {"seed_vuln", "add_benign_noise"}:
|
| 304 |
+
bonus += 0.4
|
| 305 |
+
return bonus
|
| 306 |
+
|
| 307 |
+
@staticmethod
|
| 308 |
+
def _curriculum_bonus(
|
| 309 |
+
op: MutationOp,
|
| 310 |
+
context: BuildContext,
|
| 311 |
+
existing_vulns: set[str],
|
| 312 |
+
) -> float:
|
| 313 |
+
bonus = 0.35
|
| 314 |
+
if op.op_type == "seed_vuln":
|
| 315 |
+
vuln_type = str(op.params.get("vuln_type", "")).strip()
|
| 316 |
+
if vuln_type in context.weak_areas:
|
| 317 |
+
bonus += 1.5
|
| 318 |
+
if vuln_type and vuln_type not in existing_vulns:
|
| 319 |
+
bonus += 0.4
|
| 320 |
+
if op.op_type in {"add_dependency_edge", "add_trust_edge"} and context.require_chain_length > 1:
|
| 321 |
+
bonus += 0.6
|
| 322 |
+
if context.focus_layer == "identity" and op.op_type in {"add_user", "add_trust_edge"}:
|
| 323 |
+
bonus += 0.5
|
| 324 |
+
if context.focus_layer == "infra" and op.op_type in {"add_service", "add_dependency_edge"}:
|
| 325 |
+
bonus += 0.5
|
| 326 |
+
if context.focus_layer == "process" and op.op_type == "add_benign_noise":
|
| 327 |
+
bonus += 0.4
|
| 328 |
+
return bonus
|
src/open_range/builder/mutator.py
CHANGED
|
@@ -14,6 +14,8 @@ from copy import deepcopy
|
|
| 14 |
from typing import Any
|
| 15 |
|
| 16 |
from open_range.builder.builder import render_template_payloads
|
|
|
|
|
|
|
| 17 |
from open_range.protocols import (
|
| 18 |
BuildContext,
|
| 19 |
EvidenceItem,
|
|
@@ -49,6 +51,7 @@ class Mutator:
|
|
| 49 |
self,
|
| 50 |
builder: SnapshotBuilder,
|
| 51 |
max_retries: int = 3,
|
|
|
|
| 52 |
) -> None:
|
| 53 |
"""Initialize the mutator with a builder and retry limit.
|
| 54 |
|
|
@@ -58,6 +61,7 @@ class Mutator:
|
|
| 58 |
"""
|
| 59 |
self.builder = builder
|
| 60 |
self.max_retries = max_retries
|
|
|
|
| 61 |
self._history: list[str] = [] # recent vuln classes
|
| 62 |
self._attack_surfaces: list[str] = [] # recent injection points
|
| 63 |
self._episode_count: int = 0
|
|
@@ -230,23 +234,19 @@ class Mutator:
|
|
| 230 |
manifest: dict[str, Any],
|
| 231 |
) -> SnapshotSpec:
|
| 232 |
root = snapshot.model_copy(deep=True)
|
| 233 |
-
topology =
|
| 234 |
-
company = manifest.get("company", {}) if isinstance(manifest.get("company"), dict) else {}
|
| 235 |
-
topology.setdefault("domain", company.get("domain", "acmecorp.local"))
|
| 236 |
-
topology.setdefault("org_name", company.get("name", "AcmeCorp"))
|
| 237 |
-
topology.setdefault("manifest_name", manifest.get("name", ""))
|
| 238 |
-
topology.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
|
| 239 |
-
topology.setdefault("host_catalog", _build_host_catalog(manifest))
|
| 240 |
-
topology.setdefault("host_details", {})
|
| 241 |
-
topology.setdefault("dependency_edges", [])
|
| 242 |
-
topology.setdefault("trust_edges", [])
|
| 243 |
-
root.topology = topology
|
| 244 |
root.lineage = LineageMetadata(
|
| 245 |
manifest_id=str(manifest.get("name", "")),
|
| 246 |
generation_depth=0,
|
| 247 |
mutation_summary=["compile_base_snapshot"],
|
| 248 |
)
|
| 249 |
root.mutation_plan = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
return root
|
| 251 |
|
| 252 |
def _mutate_parent_snapshot(
|
|
@@ -293,7 +293,6 @@ class Mutator:
|
|
| 293 |
rng: random.Random,
|
| 294 |
) -> MutationPlan:
|
| 295 |
ops: list[MutationOp] = []
|
| 296 |
-
used_ids: set[str] = set()
|
| 297 |
|
| 298 |
structural_candidates = []
|
| 299 |
op = self._candidate_add_service(manifest, snapshot, rng)
|
|
@@ -309,11 +308,6 @@ class Mutator:
|
|
| 309 |
if op is not None:
|
| 310 |
structural_candidates.append(op)
|
| 311 |
|
| 312 |
-
if structural_candidates:
|
| 313 |
-
chosen = rng.choice(structural_candidates)
|
| 314 |
-
ops.append(chosen)
|
| 315 |
-
used_ids.add(chosen.mutation_id)
|
| 316 |
-
|
| 317 |
security_candidates = []
|
| 318 |
op = self._candidate_seed_vuln(manifest, snapshot, context, rng)
|
| 319 |
if op is not None:
|
|
@@ -322,10 +316,13 @@ class Mutator:
|
|
| 322 |
if op is not None:
|
| 323 |
security_candidates.append(op)
|
| 324 |
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
| 329 |
|
| 330 |
if not ops:
|
| 331 |
fallback = self._candidate_add_benign_noise(snapshot, rng)
|
|
@@ -338,6 +335,9 @@ class Mutator:
|
|
| 338 |
predicted_complexity_delta=len(ops),
|
| 339 |
predicted_chain_delta=sum(1 for op in ops if op.op_type == "seed_vuln"),
|
| 340 |
predicted_novelty=round(0.2 * len({op.op_type for op in ops}), 2),
|
|
|
|
|
|
|
|
|
|
| 341 |
)
|
| 342 |
|
| 343 |
def _candidate_add_service(
|
|
@@ -559,6 +559,7 @@ class Mutator:
|
|
| 559 |
host_details = topology.setdefault("host_details", {})
|
| 560 |
dependency_edges = topology.setdefault("dependency_edges", [])
|
| 561 |
trust_edges = topology.setdefault("trust_edges", [])
|
|
|
|
| 562 |
users = topology.setdefault("users", [])
|
| 563 |
|
| 564 |
if not isinstance(host_details, dict):
|
|
@@ -570,6 +571,9 @@ class Mutator:
|
|
| 570 |
if not isinstance(trust_edges, list):
|
| 571 |
trust_edges = []
|
| 572 |
topology["trust_edges"] = trust_edges
|
|
|
|
|
|
|
|
|
|
| 573 |
if not isinstance(users, list):
|
| 574 |
users = []
|
| 575 |
topology["users"] = users
|
|
@@ -587,18 +591,28 @@ class Mutator:
|
|
| 587 |
services.append(service)
|
| 588 |
|
| 589 |
elif op.op_type == "add_user":
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
|
| 603 |
elif op.op_type == "add_dependency_edge":
|
| 604 |
dependency_edges.append(
|
|
@@ -666,34 +680,11 @@ class Mutator:
|
|
| 666 |
snapshot.topology = topology
|
| 667 |
|
| 668 |
|
| 669 |
-
def _build_host_catalog(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
| 670 |
-
catalog: dict[str, dict[str, Any]] = {}
|
| 671 |
-
for raw in manifest.get("topology", {}).get("hosts", []):
|
| 672 |
-
if not isinstance(raw, dict):
|
| 673 |
-
continue
|
| 674 |
-
name = str(raw.get("name", "")).strip()
|
| 675 |
-
if not name:
|
| 676 |
-
continue
|
| 677 |
-
catalog[name] = {
|
| 678 |
-
"zone": str(raw.get("zone", "")),
|
| 679 |
-
"services": deepcopy(raw.get("services", [])),
|
| 680 |
-
"connects_to": deepcopy(raw.get("connects_to", [])),
|
| 681 |
-
}
|
| 682 |
-
return catalog
|
| 683 |
-
|
| 684 |
-
|
| 685 |
def _ensure_mutable_topology(
|
| 686 |
topology: dict[str, Any],
|
| 687 |
manifest: dict[str, Any],
|
| 688 |
) -> dict[str, Any]:
|
| 689 |
-
|
| 690 |
-
updated.setdefault("manifest_name", manifest.get("name", ""))
|
| 691 |
-
updated.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
|
| 692 |
-
updated.setdefault("host_catalog", _build_host_catalog(manifest))
|
| 693 |
-
updated.setdefault("host_details", {})
|
| 694 |
-
updated.setdefault("dependency_edges", [])
|
| 695 |
-
updated.setdefault("trust_edges", [])
|
| 696 |
-
return updated
|
| 697 |
|
| 698 |
|
| 699 |
def _existing_hosts(snapshot: SnapshotSpec) -> set[str]:
|
|
|
|
| 14 |
from typing import Any
|
| 15 |
|
| 16 |
from open_range.builder.builder import render_template_payloads
|
| 17 |
+
from open_range.builder.manifest_graph import compile_manifest_topology
|
| 18 |
+
from open_range.builder.mutation_policy import PopulationMutationPolicy
|
| 19 |
from open_range.protocols import (
|
| 20 |
BuildContext,
|
| 21 |
EvidenceItem,
|
|
|
|
| 51 |
self,
|
| 52 |
builder: SnapshotBuilder,
|
| 53 |
max_retries: int = 3,
|
| 54 |
+
policy: PopulationMutationPolicy | None = None,
|
| 55 |
) -> None:
|
| 56 |
"""Initialize the mutator with a builder and retry limit.
|
| 57 |
|
|
|
|
| 61 |
"""
|
| 62 |
self.builder = builder
|
| 63 |
self.max_retries = max_retries
|
| 64 |
+
self.policy = policy or PopulationMutationPolicy()
|
| 65 |
self._history: list[str] = [] # recent vuln classes
|
| 66 |
self._attack_surfaces: list[str] = [] # recent injection points
|
| 67 |
self._episode_count: int = 0
|
|
|
|
| 234 |
manifest: dict[str, Any],
|
| 235 |
) -> SnapshotSpec:
|
| 236 |
root = snapshot.model_copy(deep=True)
|
| 237 |
+
root.topology = compile_manifest_topology(manifest, root.topology)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
root.lineage = LineageMetadata(
|
| 239 |
manifest_id=str(manifest.get("name", "")),
|
| 240 |
generation_depth=0,
|
| 241 |
mutation_summary=["compile_base_snapshot"],
|
| 242 |
)
|
| 243 |
root.mutation_plan = None
|
| 244 |
+
normalization = root.topology.get("manifest_normalization", {})
|
| 245 |
+
if isinstance(normalization, dict):
|
| 246 |
+
notes = normalization.get("notes", [])
|
| 247 |
+
if isinstance(notes, list):
|
| 248 |
+
for note in notes:
|
| 249 |
+
logger.info("Mutator: manifest normalization applied: %s", note)
|
| 250 |
return root
|
| 251 |
|
| 252 |
def _mutate_parent_snapshot(
|
|
|
|
| 293 |
rng: random.Random,
|
| 294 |
) -> MutationPlan:
|
| 295 |
ops: list[MutationOp] = []
|
|
|
|
| 296 |
|
| 297 |
structural_candidates = []
|
| 298 |
op = self._candidate_add_service(manifest, snapshot, rng)
|
|
|
|
| 308 |
if op is not None:
|
| 309 |
structural_candidates.append(op)
|
| 310 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
security_candidates = []
|
| 312 |
op = self._candidate_seed_vuln(manifest, snapshot, context, rng)
|
| 313 |
if op is not None:
|
|
|
|
| 316 |
if op is not None:
|
| 317 |
security_candidates.append(op)
|
| 318 |
|
| 319 |
+
ops, policy_score, score_breakdown = self.policy.choose_mutations(
|
| 320 |
+
structural_candidates=structural_candidates,
|
| 321 |
+
security_candidates=security_candidates,
|
| 322 |
+
snapshot=snapshot,
|
| 323 |
+
context=context,
|
| 324 |
+
rng=rng,
|
| 325 |
+
)
|
| 326 |
|
| 327 |
if not ops:
|
| 328 |
fallback = self._candidate_add_benign_noise(snapshot, rng)
|
|
|
|
| 335 |
predicted_complexity_delta=len(ops),
|
| 336 |
predicted_chain_delta=sum(1 for op in ops if op.op_type == "seed_vuln"),
|
| 337 |
predicted_novelty=round(0.2 * len({op.op_type for op in ops}), 2),
|
| 338 |
+
policy_name=self.policy.name,
|
| 339 |
+
policy_score=policy_score,
|
| 340 |
+
score_breakdown=score_breakdown,
|
| 341 |
)
|
| 342 |
|
| 343 |
def _candidate_add_service(
|
|
|
|
| 559 |
host_details = topology.setdefault("host_details", {})
|
| 560 |
dependency_edges = topology.setdefault("dependency_edges", [])
|
| 561 |
trust_edges = topology.setdefault("trust_edges", [])
|
| 562 |
+
principal_catalog = topology.setdefault("principal_catalog", {})
|
| 563 |
users = topology.setdefault("users", [])
|
| 564 |
|
| 565 |
if not isinstance(host_details, dict):
|
|
|
|
| 571 |
if not isinstance(trust_edges, list):
|
| 572 |
trust_edges = []
|
| 573 |
topology["trust_edges"] = trust_edges
|
| 574 |
+
if not isinstance(principal_catalog, dict):
|
| 575 |
+
principal_catalog = {}
|
| 576 |
+
topology["principal_catalog"] = principal_catalog
|
| 577 |
if not isinstance(users, list):
|
| 578 |
users = []
|
| 579 |
topology["users"] = users
|
|
|
|
| 591 |
services.append(service)
|
| 592 |
|
| 593 |
elif op.op_type == "add_user":
|
| 594 |
+
username = str(op.params.get("username", ""))
|
| 595 |
+
user_record = {
|
| 596 |
+
"username": username,
|
| 597 |
+
"password": str(op.params.get("password", "")),
|
| 598 |
+
"groups": deepcopy(op.params.get("groups", [])),
|
| 599 |
+
"hosts": deepcopy(op.params.get("hosts", [])),
|
| 600 |
+
"email": str(op.params.get("email", "")),
|
| 601 |
+
"full_name": str(op.params.get("full_name", "")),
|
| 602 |
+
"department": str(op.params.get("department", "")),
|
| 603 |
+
"role": str(op.params.get("role", "")),
|
| 604 |
+
}
|
| 605 |
+
users.append(user_record)
|
| 606 |
+
principal_catalog[username] = {
|
| 607 |
+
"username": username,
|
| 608 |
+
"kind": "user",
|
| 609 |
+
"is_login_account": True,
|
| 610 |
+
"hosts": deepcopy(op.params.get("hosts", [])),
|
| 611 |
+
"department": str(op.params.get("department", "")),
|
| 612 |
+
"role": str(op.params.get("role", "")),
|
| 613 |
+
"email": str(op.params.get("email", "")),
|
| 614 |
+
"full_name": str(op.params.get("full_name", "")),
|
| 615 |
+
}
|
| 616 |
|
| 617 |
elif op.op_type == "add_dependency_edge":
|
| 618 |
dependency_edges.append(
|
|
|
|
| 680 |
snapshot.topology = topology
|
| 681 |
|
| 682 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
def _ensure_mutable_topology(
|
| 684 |
topology: dict[str, Any],
|
| 685 |
manifest: dict[str, Any],
|
| 686 |
) -> dict[str, Any]:
|
| 687 |
+
return compile_manifest_topology(manifest, topology)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
|
| 689 |
|
| 690 |
def _existing_hosts(snapshot: SnapshotSpec) -> set[str]:
|
src/open_range/builder/snapshot_store.py
CHANGED
|
@@ -119,6 +119,19 @@ class SnapshotStore:
|
|
| 119 |
snapshot=SnapshotSpec.model_validate(raw),
|
| 120 |
)
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
async def list_snapshots(self) -> list[dict[str, Any]]:
|
| 123 |
"""List all snapshots with their metadata.
|
| 124 |
|
|
|
|
| 119 |
snapshot=SnapshotSpec.model_validate(raw),
|
| 120 |
)
|
| 121 |
|
| 122 |
+
async def list_entries(self) -> list[StoredSnapshot]:
|
| 123 |
+
"""Return every stored snapshot plus its persisted ID."""
|
| 124 |
+
entries: list[StoredSnapshot] = []
|
| 125 |
+
for spec_path in sorted(self.store_dir.glob("*/spec.json")):
|
| 126 |
+
raw = json.loads(spec_path.read_text(encoding="utf-8"))
|
| 127 |
+
entries.append(
|
| 128 |
+
StoredSnapshot(
|
| 129 |
+
snapshot_id=spec_path.parent.name,
|
| 130 |
+
snapshot=SnapshotSpec.model_validate(raw),
|
| 131 |
+
)
|
| 132 |
+
)
|
| 133 |
+
return entries
|
| 134 |
+
|
| 135 |
async def list_snapshots(self) -> list[dict[str, Any]]:
|
| 136 |
"""List all snapshots with their metadata.
|
| 137 |
|
src/open_range/lint.py
CHANGED
|
@@ -12,6 +12,7 @@ Usage::
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import argparse
|
|
|
|
| 15 |
import sys
|
| 16 |
from pathlib import Path
|
| 17 |
from typing import Any
|
|
@@ -134,22 +135,28 @@ def _check_business_process_flows(manifest: Manifest) -> list[str]:
|
|
| 134 |
return errors
|
| 135 |
|
| 136 |
|
|
|
|
|
|
|
|
|
|
| 137 |
def _check_trust_relationships(manifest: Manifest) -> list[str]:
|
| 138 |
-
"""
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
errors: list[str] = []
|
| 141 |
for rel in manifest.trust_relationships:
|
| 142 |
-
if rel.source and rel.source
|
| 143 |
errors.append(
|
| 144 |
-
f"Trust relationship source '{rel.source}' "
|
| 145 |
-
|
| 146 |
-
f"Valid usernames: {sorted(user_names)}"
|
| 147 |
)
|
| 148 |
-
if rel.target and rel.target
|
| 149 |
errors.append(
|
| 150 |
-
f"Trust relationship target '{rel.target}' "
|
| 151 |
-
|
| 152 |
-
f"Valid usernames: {sorted(user_names)}"
|
| 153 |
)
|
| 154 |
return errors
|
| 155 |
|
|
@@ -165,7 +172,7 @@ ALL_CHECKS = [
|
|
| 165 |
("NPC persona usernames", _check_npc_usernames),
|
| 166 |
("data inventory hosts", _check_data_inventory_hosts),
|
| 167 |
("business process data flows", _check_business_process_flows),
|
| 168 |
-
("trust relationship
|
| 169 |
]
|
| 170 |
|
| 171 |
|
|
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import argparse
|
| 15 |
+
import re
|
| 16 |
import sys
|
| 17 |
from pathlib import Path
|
| 18 |
from typing import Any
|
|
|
|
| 135 |
return errors
|
| 136 |
|
| 137 |
|
| 138 |
+
_PRINCIPAL_RE = re.compile(r"^[A-Za-z0-9._@-]+$")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
def _check_trust_relationships(manifest: Manifest) -> list[str]:
|
| 142 |
+
"""Trust principals must be well-formed identifiers.
|
| 143 |
+
|
| 144 |
+
Trust edges may reference people who are not login accounts. Those are
|
| 145 |
+
normalized into the canonical principal catalog at build time, so lint
|
| 146 |
+
should validate identifier quality rather than requiring every principal to
|
| 147 |
+
appear in ``users``.
|
| 148 |
+
"""
|
| 149 |
errors: list[str] = []
|
| 150 |
for rel in manifest.trust_relationships:
|
| 151 |
+
if rel.source and not _PRINCIPAL_RE.match(rel.source):
|
| 152 |
errors.append(
|
| 153 |
+
f"Trust relationship source '{rel.source}' is not a valid "
|
| 154 |
+
"principal identifier"
|
|
|
|
| 155 |
)
|
| 156 |
+
if rel.target and not _PRINCIPAL_RE.match(rel.target):
|
| 157 |
errors.append(
|
| 158 |
+
f"Trust relationship target '{rel.target}' is not a valid "
|
| 159 |
+
"principal identifier"
|
|
|
|
| 160 |
)
|
| 161 |
return errors
|
| 162 |
|
|
|
|
| 172 |
("NPC persona usernames", _check_npc_usernames),
|
| 173 |
("data inventory hosts", _check_data_inventory_hosts),
|
| 174 |
("business process data flows", _check_business_process_flows),
|
| 175 |
+
("trust relationship principals", _check_trust_relationships),
|
| 176 |
]
|
| 177 |
|
| 178 |
|
src/open_range/protocols.py
CHANGED
|
@@ -66,6 +66,9 @@ class MutationPlan(BaseModel):
|
|
| 66 |
predicted_complexity_delta: int = 0
|
| 67 |
predicted_chain_delta: int = 0
|
| 68 |
predicted_novelty: float = 0.0
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
class LineageMetadata(BaseModel):
|
|
|
|
| 66 |
predicted_complexity_delta: int = 0
|
| 67 |
predicted_chain_delta: int = 0
|
| 68 |
predicted_novelty: float = 0.0
|
| 69 |
+
policy_name: str = ""
|
| 70 |
+
policy_score: float = 0.0
|
| 71 |
+
score_breakdown: dict[str, float] = Field(default_factory=dict)
|
| 72 |
|
| 73 |
|
| 74 |
class LineageMetadata(BaseModel):
|
src/open_range/server/runtime.py
CHANGED
|
@@ -11,6 +11,7 @@ import asyncio
|
|
| 11 |
import json
|
| 12 |
import logging
|
| 13 |
import os
|
|
|
|
| 14 |
import shlex
|
| 15 |
import shutil
|
| 16 |
import subprocess as sp
|
|
@@ -25,6 +26,7 @@ from typing import Any
|
|
| 25 |
import yaml
|
| 26 |
|
| 27 |
from open_range.builder.builder import LLMSnapshotBuilder, TemplateOnlyBuilder
|
|
|
|
| 28 |
from open_range.builder.mutator import Mutator
|
| 29 |
from open_range.builder.renderer import PAYLOAD_MANIFEST_NAME, SnapshotRenderer
|
| 30 |
from open_range.builder.snapshot_store import SnapshotStore
|
|
@@ -41,9 +43,14 @@ from open_range.validator.build_boot import BuildBootCheck
|
|
| 41 |
from open_range.validator.difficulty import DifficultyCheck
|
| 42 |
from open_range.validator.evidence import EvidenceCheck
|
| 43 |
from open_range.validator.exploitability import ExploitabilityCheck
|
|
|
|
|
|
|
|
|
|
| 44 |
from open_range.validator.isolation import IsolationCheck
|
|
|
|
| 45 |
from open_range.validator.npc_consistency import NPCConsistencyCheck
|
| 46 |
from open_range.validator.patchability import PatchabilityCheck
|
|
|
|
| 47 |
from open_range.validator.realism_review import RealismReviewCheck
|
| 48 |
from open_range.validator.reward_grounding import RewardGroundingCheck
|
| 49 |
from open_range.validator.task_feasibility import TaskFeasibilityCheck
|
|
@@ -207,6 +214,43 @@ class CurriculumTracker:
|
|
| 207 |
with self._lock:
|
| 208 |
return list(self._history)
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
@dataclass(frozen=True, slots=True)
|
| 212 |
class RuntimeSnapshot:
|
|
@@ -274,25 +318,38 @@ def _normalize_validator_profile(profile: str | None) -> str:
|
|
| 274 |
return normalized
|
| 275 |
|
| 276 |
|
| 277 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
normalized = _normalize_validator_profile(profile)
|
| 279 |
if normalized == "offline":
|
| 280 |
return ValidatorGate(
|
| 281 |
-
|
|
|
|
| 282 |
StructuralSnapshotCheck(),
|
| 283 |
TaskFeasibilityCheck(),
|
| 284 |
]
|
| 285 |
)
|
| 286 |
|
| 287 |
return ValidatorGate(
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
| 289 |
BuildBootCheck(),
|
| 290 |
ExploitabilityCheck(),
|
| 291 |
PatchabilityCheck(),
|
| 292 |
EvidenceCheck(),
|
| 293 |
RewardGroundingCheck(),
|
| 294 |
IsolationCheck(),
|
| 295 |
-
TaskFeasibilityCheck(),
|
| 296 |
DifficultyCheck(),
|
| 297 |
NPCConsistencyCheck(),
|
| 298 |
RealismReviewCheck(),
|
|
@@ -326,6 +383,7 @@ class ManagedSnapshotRuntime:
|
|
| 326 |
validator_profile: str | None = None,
|
| 327 |
pool_size: int = 3,
|
| 328 |
selection_strategy: str = "random",
|
|
|
|
| 329 |
refill_enabled: bool = False,
|
| 330 |
refill_interval_s: float = 2.0,
|
| 331 |
generation_retries: int = 3,
|
|
@@ -334,6 +392,7 @@ class ManagedSnapshotRuntime:
|
|
| 334 |
compose_runner: ComposeProjectRunner | None = None,
|
| 335 |
live_validator: ValidatorGate | None = None,
|
| 336 |
enable_patch_validation: bool = False,
|
|
|
|
| 337 |
) -> None:
|
| 338 |
self.manifest_path = (
|
| 339 |
Path(manifest_path).resolve()
|
|
@@ -344,15 +403,17 @@ class ManagedSnapshotRuntime:
|
|
| 344 |
self.store_dir = _resolve_store_dir(store_dir)
|
| 345 |
self.store = SnapshotStore(str(self.store_dir))
|
| 346 |
self.builder = builder or _default_builder()
|
| 347 |
-
self.
|
|
|
|
| 348 |
self.validator_profile = _normalize_validator_profile(
|
| 349 |
validator_profile or os.getenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline")
|
| 350 |
)
|
| 351 |
-
self.validator = validator or _build_validator(self.validator_profile)
|
| 352 |
self.renderer = SnapshotRenderer()
|
| 353 |
self.curriculum = CurriculumTracker()
|
| 354 |
self.pool_size = max(1, pool_size)
|
| 355 |
self.selection_strategy = selection_strategy
|
|
|
|
| 356 |
self.refill_enabled = refill_enabled
|
| 357 |
self.refill_interval_s = max(0.25, refill_interval_s)
|
| 358 |
self.generation_retries = max(1, generation_retries)
|
|
@@ -381,6 +442,7 @@ class ManagedSnapshotRuntime:
|
|
| 381 |
validator_profile=os.getenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline"),
|
| 382 |
pool_size=_env_int("OPENRANGE_SNAPSHOT_POOL_SIZE", 3),
|
| 383 |
selection_strategy=os.getenv("OPENRANGE_SNAPSHOT_SELECTION", "random"),
|
|
|
|
| 384 |
refill_enabled=_env_flag("OPENRANGE_ENABLE_MANAGED_REFILL", default=False),
|
| 385 |
refill_interval_s=float(os.getenv("OPENRANGE_REFILL_INTERVAL_S", "2.0")),
|
| 386 |
generation_retries=_env_int("OPENRANGE_GENERATION_RETRIES", 3),
|
|
@@ -541,6 +603,7 @@ class ManagedSnapshotRuntime:
|
|
| 541 |
"store_dir": str(self.store_dir),
|
| 542 |
"pool_size": self.pool_size,
|
| 543 |
"selection_strategy": self.selection_strategy,
|
|
|
|
| 544 |
"validator_profile": self.validator_profile,
|
| 545 |
"refill_enabled": self.refill_enabled,
|
| 546 |
"live_admission_enabled": self.live_admission_enabled,
|
|
@@ -623,7 +686,7 @@ class ManagedSnapshotRuntime:
|
|
| 623 |
|
| 624 |
for attempt in range(1, self.generation_retries + 1):
|
| 625 |
context = self._build_context()
|
| 626 |
-
parent_entry = self._select_parent_entry()
|
| 627 |
snapshot = _run_coro_sync(
|
| 628 |
self.mutator.mutate(
|
| 629 |
self.manifest,
|
|
@@ -930,10 +993,29 @@ class ManagedSnapshotRuntime:
|
|
| 930 |
prefix = "snap_" + "_".join(vuln_types[:3]) if vuln_types else "snap_generated"
|
| 931 |
return f"{prefix}_{int(time.time() * 1000)}"
|
| 932 |
|
| 933 |
-
def _select_parent_entry(self):
|
| 934 |
if self.snapshot_count() == 0:
|
| 935 |
return None
|
| 936 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 937 |
|
| 938 |
def _snapshot_dir(self, snapshot_id: str) -> Path:
|
| 939 |
return self.store_dir / snapshot_id
|
|
|
|
| 11 |
import json
|
| 12 |
import logging
|
| 13 |
import os
|
| 14 |
+
import random
|
| 15 |
import shlex
|
| 16 |
import shutil
|
| 17 |
import subprocess as sp
|
|
|
|
| 26 |
import yaml
|
| 27 |
|
| 28 |
from open_range.builder.builder import LLMSnapshotBuilder, TemplateOnlyBuilder
|
| 29 |
+
from open_range.builder.mutation_policy import PopulationMutationPolicy
|
| 30 |
from open_range.builder.mutator import Mutator
|
| 31 |
from open_range.builder.renderer import PAYLOAD_MANIFEST_NAME, SnapshotRenderer
|
| 32 |
from open_range.builder.snapshot_store import SnapshotStore
|
|
|
|
| 43 |
from open_range.validator.difficulty import DifficultyCheck
|
| 44 |
from open_range.validator.evidence import EvidenceCheck
|
| 45 |
from open_range.validator.exploitability import ExploitabilityCheck
|
| 46 |
+
from open_range.validator.graph_consistency import GraphConsistencyCheck
|
| 47 |
+
from open_range.validator.graph_evidence import GraphEvidenceSufficiencyCheck
|
| 48 |
+
from open_range.validator.graph_reward_grounding import GraphRewardGroundingCheck
|
| 49 |
from open_range.validator.isolation import IsolationCheck
|
| 50 |
+
from open_range.validator.manifest_compliance import ManifestComplianceCheck
|
| 51 |
from open_range.validator.npc_consistency import NPCConsistencyCheck
|
| 52 |
from open_range.validator.patchability import PatchabilityCheck
|
| 53 |
+
from open_range.validator.path_solvability import PathSolvabilityCheck
|
| 54 |
from open_range.validator.realism_review import RealismReviewCheck
|
| 55 |
from open_range.validator.reward_grounding import RewardGroundingCheck
|
| 56 |
from open_range.validator.task_feasibility import TaskFeasibilityCheck
|
|
|
|
| 214 |
with self._lock:
|
| 215 |
return list(self._history)
|
| 216 |
|
| 217 |
+
def snapshot_stats(self) -> dict[str, dict[str, Any]]:
|
| 218 |
+
with self._lock:
|
| 219 |
+
history = list(self._history)
|
| 220 |
+
|
| 221 |
+
now = time.time()
|
| 222 |
+
stats: dict[str, dict[str, Any]] = {}
|
| 223 |
+
for outcome in history:
|
| 224 |
+
if not outcome.snapshot_id:
|
| 225 |
+
continue
|
| 226 |
+
stat = stats.setdefault(
|
| 227 |
+
outcome.snapshot_id,
|
| 228 |
+
{
|
| 229 |
+
"plays": 0,
|
| 230 |
+
"completed": 0,
|
| 231 |
+
"red_solved": 0,
|
| 232 |
+
"blue_detected": 0,
|
| 233 |
+
"plays_recent": 0,
|
| 234 |
+
"last_seen_at": 0.0,
|
| 235 |
+
},
|
| 236 |
+
)
|
| 237 |
+
stat["plays"] += 1
|
| 238 |
+
if outcome.completed:
|
| 239 |
+
stat["completed"] += 1
|
| 240 |
+
if outcome.red_solved:
|
| 241 |
+
stat["red_solved"] += 1
|
| 242 |
+
if outcome.blue_detected:
|
| 243 |
+
stat["blue_detected"] += 1
|
| 244 |
+
if now - outcome.recorded_at <= 300:
|
| 245 |
+
stat["plays_recent"] += 1
|
| 246 |
+
stat["last_seen_at"] = max(float(stat["last_seen_at"]), outcome.recorded_at)
|
| 247 |
+
|
| 248 |
+
for stat in stats.values():
|
| 249 |
+
plays = max(int(stat["plays"]), 1)
|
| 250 |
+
stat["red_solve_rate"] = stat["red_solved"] / plays
|
| 251 |
+
stat["blue_detect_rate"] = stat["blue_detected"] / plays
|
| 252 |
+
return stats
|
| 253 |
+
|
| 254 |
|
| 255 |
@dataclass(frozen=True, slots=True)
|
| 256 |
class RuntimeSnapshot:
|
|
|
|
| 318 |
return normalized
|
| 319 |
|
| 320 |
|
| 321 |
+
def _graph_checks(manifest: dict[str, Any]) -> list[Any]:
|
| 322 |
+
return [
|
| 323 |
+
ManifestComplianceCheck(manifest),
|
| 324 |
+
GraphConsistencyCheck(),
|
| 325 |
+
PathSolvabilityCheck(),
|
| 326 |
+
GraphEvidenceSufficiencyCheck(),
|
| 327 |
+
GraphRewardGroundingCheck(),
|
| 328 |
+
]
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _build_validator(profile: str, manifest: dict[str, Any]) -> ValidatorGate:
|
| 332 |
normalized = _normalize_validator_profile(profile)
|
| 333 |
if normalized == "offline":
|
| 334 |
return ValidatorGate(
|
| 335 |
+
_graph_checks(manifest)
|
| 336 |
+
+ [
|
| 337 |
StructuralSnapshotCheck(),
|
| 338 |
TaskFeasibilityCheck(),
|
| 339 |
]
|
| 340 |
)
|
| 341 |
|
| 342 |
return ValidatorGate(
|
| 343 |
+
_graph_checks(manifest)
|
| 344 |
+
+ [
|
| 345 |
+
StructuralSnapshotCheck(),
|
| 346 |
+
TaskFeasibilityCheck(),
|
| 347 |
BuildBootCheck(),
|
| 348 |
ExploitabilityCheck(),
|
| 349 |
PatchabilityCheck(),
|
| 350 |
EvidenceCheck(),
|
| 351 |
RewardGroundingCheck(),
|
| 352 |
IsolationCheck(),
|
|
|
|
| 353 |
DifficultyCheck(),
|
| 354 |
NPCConsistencyCheck(),
|
| 355 |
RealismReviewCheck(),
|
|
|
|
| 383 |
validator_profile: str | None = None,
|
| 384 |
pool_size: int = 3,
|
| 385 |
selection_strategy: str = "random",
|
| 386 |
+
parent_selection_strategy: str = "policy",
|
| 387 |
refill_enabled: bool = False,
|
| 388 |
refill_interval_s: float = 2.0,
|
| 389 |
generation_retries: int = 3,
|
|
|
|
| 392 |
compose_runner: ComposeProjectRunner | None = None,
|
| 393 |
live_validator: ValidatorGate | None = None,
|
| 394 |
enable_patch_validation: bool = False,
|
| 395 |
+
mutation_policy: PopulationMutationPolicy | None = None,
|
| 396 |
) -> None:
|
| 397 |
self.manifest_path = (
|
| 398 |
Path(manifest_path).resolve()
|
|
|
|
| 403 |
self.store_dir = _resolve_store_dir(store_dir)
|
| 404 |
self.store = SnapshotStore(str(self.store_dir))
|
| 405 |
self.builder = builder or _default_builder()
|
| 406 |
+
self.mutation_policy = mutation_policy or PopulationMutationPolicy()
|
| 407 |
+
self.mutator = Mutator(self.builder, policy=self.mutation_policy)
|
| 408 |
self.validator_profile = _normalize_validator_profile(
|
| 409 |
validator_profile or os.getenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline")
|
| 410 |
)
|
| 411 |
+
self.validator = validator or _build_validator(self.validator_profile, self.manifest)
|
| 412 |
self.renderer = SnapshotRenderer()
|
| 413 |
self.curriculum = CurriculumTracker()
|
| 414 |
self.pool_size = max(1, pool_size)
|
| 415 |
self.selection_strategy = selection_strategy
|
| 416 |
+
self.parent_selection_strategy = parent_selection_strategy
|
| 417 |
self.refill_enabled = refill_enabled
|
| 418 |
self.refill_interval_s = max(0.25, refill_interval_s)
|
| 419 |
self.generation_retries = max(1, generation_retries)
|
|
|
|
| 442 |
validator_profile=os.getenv("OPENRANGE_RUNTIME_VALIDATOR_PROFILE", "offline"),
|
| 443 |
pool_size=_env_int("OPENRANGE_SNAPSHOT_POOL_SIZE", 3),
|
| 444 |
selection_strategy=os.getenv("OPENRANGE_SNAPSHOT_SELECTION", "random"),
|
| 445 |
+
parent_selection_strategy=os.getenv("OPENRANGE_PARENT_SELECTION", "policy"),
|
| 446 |
refill_enabled=_env_flag("OPENRANGE_ENABLE_MANAGED_REFILL", default=False),
|
| 447 |
refill_interval_s=float(os.getenv("OPENRANGE_REFILL_INTERVAL_S", "2.0")),
|
| 448 |
generation_retries=_env_int("OPENRANGE_GENERATION_RETRIES", 3),
|
|
|
|
| 603 |
"store_dir": str(self.store_dir),
|
| 604 |
"pool_size": self.pool_size,
|
| 605 |
"selection_strategy": self.selection_strategy,
|
| 606 |
+
"parent_selection_strategy": self.parent_selection_strategy,
|
| 607 |
"validator_profile": self.validator_profile,
|
| 608 |
"refill_enabled": self.refill_enabled,
|
| 609 |
"live_admission_enabled": self.live_admission_enabled,
|
|
|
|
| 686 |
|
| 687 |
for attempt in range(1, self.generation_retries + 1):
|
| 688 |
context = self._build_context()
|
| 689 |
+
parent_entry = self._select_parent_entry(context)
|
| 690 |
snapshot = _run_coro_sync(
|
| 691 |
self.mutator.mutate(
|
| 692 |
self.manifest,
|
|
|
|
| 993 |
prefix = "snap_" + "_".join(vuln_types[:3]) if vuln_types else "snap_generated"
|
| 994 |
return f"{prefix}_{int(time.time() * 1000)}"
|
| 995 |
|
| 996 |
+
def _select_parent_entry(self, context: BuildContext):
|
| 997 |
if self.snapshot_count() == 0:
|
| 998 |
return None
|
| 999 |
+
if self.parent_selection_strategy in {"latest", "random"}:
|
| 1000 |
+
return _run_coro_sync(self.store.select_entry(strategy=self.parent_selection_strategy))
|
| 1001 |
+
entries = _run_coro_sync(self.store.list_entries())
|
| 1002 |
+
if not entries:
|
| 1003 |
+
return None
|
| 1004 |
+
rng = random.Random(context.seed if context.seed is not None else self._generation_counter)
|
| 1005 |
+
selected, score = self.mutation_policy.select_parent(
|
| 1006 |
+
entries,
|
| 1007 |
+
context=context,
|
| 1008 |
+
snapshot_stats=self.curriculum.snapshot_stats(),
|
| 1009 |
+
rng=rng,
|
| 1010 |
+
)
|
| 1011 |
+
logger.info(
|
| 1012 |
+
"ManagedSnapshotRuntime selected parent %s via %s (score=%.3f components=%s)",
|
| 1013 |
+
selected.snapshot_id,
|
| 1014 |
+
self.mutation_policy.name,
|
| 1015 |
+
score.total,
|
| 1016 |
+
score.components,
|
| 1017 |
+
)
|
| 1018 |
+
return selected
|
| 1019 |
|
| 1020 |
def _snapshot_dir(self, snapshot_id: str) -> Path:
|
| 1021 |
return self.store_dir / snapshot_id
|
src/open_range/validator/graph_consistency.py
CHANGED
|
@@ -18,8 +18,8 @@ class GraphConsistencyCheck:
|
|
| 18 |
issues.append(f"dependency edge '{source}->{target}' references unknown host")
|
| 19 |
|
| 20 |
for source, target, _edge_type in compiled.trust_edges:
|
| 21 |
-
if source not in compiled.
|
| 22 |
-
issues.append(f"trust edge '{source}->{target}' references unknown
|
| 23 |
|
| 24 |
lineage = snapshot.lineage
|
| 25 |
if lineage.generation_depth == 0 and lineage.parent_snapshot_id:
|
|
@@ -50,13 +50,13 @@ class GraphConsistencyCheck:
|
|
| 50 |
if op.op_type == "add_trust_edge":
|
| 51 |
source = op.target_selector.get("source", "")
|
| 52 |
target = op.target_selector.get("target", "")
|
| 53 |
-
if source and source not in compiled.
|
| 54 |
issues.append(
|
| 55 |
-
f"mutation '{op.mutation_id}' source
|
| 56 |
)
|
| 57 |
-
if target and target not in compiled.
|
| 58 |
issues.append(
|
| 59 |
-
f"mutation '{op.mutation_id}' target
|
| 60 |
)
|
| 61 |
|
| 62 |
passed = len(issues) == 0
|
|
@@ -66,6 +66,7 @@ class GraphConsistencyCheck:
|
|
| 66 |
details={
|
| 67 |
"hosts": len(compiled.hosts),
|
| 68 |
"users": len(compiled.users),
|
|
|
|
| 69 |
"dependency_edges": len(compiled.dependency_edges),
|
| 70 |
"trust_edges": len(compiled.trust_edges),
|
| 71 |
},
|
|
|
|
| 18 |
issues.append(f"dependency edge '{source}->{target}' references unknown host")
|
| 19 |
|
| 20 |
for source, target, _edge_type in compiled.trust_edges:
|
| 21 |
+
if source not in compiled.principals or target not in compiled.principals:
|
| 22 |
+
issues.append(f"trust edge '{source}->{target}' references unknown principal")
|
| 23 |
|
| 24 |
lineage = snapshot.lineage
|
| 25 |
if lineage.generation_depth == 0 and lineage.parent_snapshot_id:
|
|
|
|
| 50 |
if op.op_type == "add_trust_edge":
|
| 51 |
source = op.target_selector.get("source", "")
|
| 52 |
target = op.target_selector.get("target", "")
|
| 53 |
+
if source and source not in compiled.principals:
|
| 54 |
issues.append(
|
| 55 |
+
f"mutation '{op.mutation_id}' source principal '{source}' missing"
|
| 56 |
)
|
| 57 |
+
if target and target not in compiled.principals:
|
| 58 |
issues.append(
|
| 59 |
+
f"mutation '{op.mutation_id}' target principal '{target}' missing"
|
| 60 |
)
|
| 61 |
|
| 62 |
passed = len(issues) == 0
|
|
|
|
| 66 |
details={
|
| 67 |
"hosts": len(compiled.hosts),
|
| 68 |
"users": len(compiled.users),
|
| 69 |
+
"principals": len(compiled.principals),
|
| 70 |
"dependency_edges": len(compiled.dependency_edges),
|
| 71 |
"trust_edges": len(compiled.trust_edges),
|
| 72 |
},
|
src/open_range/validator/graph_evidence.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph-native evidence sufficiency checks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
|
| 6 |
+
from open_range.validator.graphs import compile_snapshot_graphs
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class GraphEvidenceSufficiencyCheck:
|
| 10 |
+
"""Verify that the compiled world exposes enough evidence for key facts."""
|
| 11 |
+
|
| 12 |
+
async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
|
| 13 |
+
compiled = compile_snapshot_graphs(snapshot)
|
| 14 |
+
evidence_hosts = {
|
| 15 |
+
_location_host(location)
|
| 16 |
+
for location in compiled.evidence_locations
|
| 17 |
+
if _location_host(location)
|
| 18 |
+
}
|
| 19 |
+
issues: list[str] = []
|
| 20 |
+
|
| 21 |
+
if not compiled.evidence_locations:
|
| 22 |
+
return CheckResult(
|
| 23 |
+
name="graph_evidence_sufficiency",
|
| 24 |
+
passed=False,
|
| 25 |
+
error="snapshot has no evidence locations",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
for vuln in snapshot.truth_graph.vulns:
|
| 29 |
+
supporting_hosts = {vuln.host, "siem"}
|
| 30 |
+
if not evidence_hosts.intersection(supporting_hosts):
|
| 31 |
+
issues.append(
|
| 32 |
+
f"vuln '{vuln.id}' on host '{vuln.host}' has no supporting evidence host"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
for flag in snapshot.flags:
|
| 36 |
+
supporting_hosts = {flag.host, "siem"}
|
| 37 |
+
if not evidence_hosts.intersection(supporting_hosts):
|
| 38 |
+
issues.append(
|
| 39 |
+
f"flag '{flag.id}' on host '{flag.host}' has no supporting evidence host"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
passed = len(issues) == 0
|
| 43 |
+
return CheckResult(
|
| 44 |
+
name="graph_evidence_sufficiency",
|
| 45 |
+
passed=passed,
|
| 46 |
+
details={
|
| 47 |
+
"evidence_hosts": sorted(evidence_hosts),
|
| 48 |
+
"issues": issues,
|
| 49 |
+
},
|
| 50 |
+
error="" if passed else "; ".join(issues),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _location_host(location: str) -> str:
|
| 55 |
+
if ":" not in location:
|
| 56 |
+
return "siem"
|
| 57 |
+
return location.split(":", 1)[0].strip()
|
src/open_range/validator/graph_reward_grounding.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph-native reward grounding checks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
|
| 6 |
+
from open_range.validator.graphs import compile_snapshot_graphs
|
| 7 |
+
from open_range.validator.path_solvability import build_host_adjacency, has_host_path
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class GraphRewardGroundingCheck:
|
| 11 |
+
"""Verify rewards are grounded by graph facts before live checks run."""
|
| 12 |
+
|
| 13 |
+
async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
|
| 14 |
+
compiled = compile_snapshot_graphs(snapshot)
|
| 15 |
+
issues: list[str] = []
|
| 16 |
+
|
| 17 |
+
if not snapshot.flags:
|
| 18 |
+
return CheckResult(
|
| 19 |
+
name="graph_reward_grounding",
|
| 20 |
+
passed=False,
|
| 21 |
+
error="snapshot has no flags to ground",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
adjacency = build_host_adjacency(snapshot, compiled)
|
| 25 |
+
vuln_hosts = {v.host for v in snapshot.truth_graph.vulns if v.host}
|
| 26 |
+
for flag in snapshot.flags:
|
| 27 |
+
if flag.host not in compiled.hosts:
|
| 28 |
+
issues.append(f"flag '{flag.id}' references unknown host '{flag.host}'")
|
| 29 |
+
continue
|
| 30 |
+
|
| 31 |
+
if flag.host in vuln_hosts:
|
| 32 |
+
continue
|
| 33 |
+
|
| 34 |
+
if vuln_hosts and not any(
|
| 35 |
+
has_host_path(source, flag.host, adjacency) for source in vuln_hosts
|
| 36 |
+
):
|
| 37 |
+
issues.append(
|
| 38 |
+
f"flag '{flag.id}' on '{flag.host}' is not reachable from any vuln host"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
passed = len(issues) == 0
|
| 42 |
+
return CheckResult(
|
| 43 |
+
name="graph_reward_grounding",
|
| 44 |
+
passed=passed,
|
| 45 |
+
details={"issues": issues},
|
| 46 |
+
error="" if passed else "; ".join(issues),
|
| 47 |
+
)
|
src/open_range/validator/graphs.py
CHANGED
|
@@ -18,6 +18,8 @@ class CompiledGraphs:
|
|
| 18 |
|
| 19 |
hosts: frozenset[str]
|
| 20 |
users: frozenset[str]
|
|
|
|
|
|
|
| 21 |
services_by_host: dict[str, frozenset[str]]
|
| 22 |
dependency_edges: frozenset[tuple[str, str]]
|
| 23 |
trust_edges: frozenset[tuple[str, str, str]]
|
|
@@ -31,6 +33,8 @@ def compile_snapshot_graphs(snapshot: SnapshotSpec) -> CompiledGraphs:
|
|
| 31 |
topology = snapshot.topology or {}
|
| 32 |
hosts = _compile_hosts(topology)
|
| 33 |
users = _compile_users(topology)
|
|
|
|
|
|
|
| 34 |
services_by_host = _compile_services(topology, hosts)
|
| 35 |
dependency_edges = _compile_dependency_edges(topology)
|
| 36 |
trust_edges = _compile_trust_edges(topology)
|
|
@@ -40,6 +44,8 @@ def compile_snapshot_graphs(snapshot: SnapshotSpec) -> CompiledGraphs:
|
|
| 40 |
return CompiledGraphs(
|
| 41 |
hosts=hosts,
|
| 42 |
users=users,
|
|
|
|
|
|
|
| 43 |
services_by_host=services_by_host,
|
| 44 |
dependency_edges=dependency_edges,
|
| 45 |
trust_edges=trust_edges,
|
|
@@ -80,6 +86,7 @@ def _compile_services(
|
|
| 80 |
hosts: frozenset[str],
|
| 81 |
) -> dict[str, frozenset[str]]:
|
| 82 |
host_details = topology.get("host_details", {})
|
|
|
|
| 83 |
compiled: dict[str, frozenset[str]] = {}
|
| 84 |
for host in hosts:
|
| 85 |
detail = {}
|
|
@@ -87,6 +94,10 @@ def _compile_services(
|
|
| 87 |
raw_detail = host_details.get(host, {})
|
| 88 |
if isinstance(raw_detail, dict):
|
| 89 |
detail = raw_detail
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
services = detail.get("services", [])
|
| 91 |
if not isinstance(services, list):
|
| 92 |
services = []
|
|
@@ -104,6 +115,21 @@ def _compile_dependency_edges(topology: dict[str, object]) -> frozenset[tuple[st
|
|
| 104 |
target = str(raw.get("target", "")).strip()
|
| 105 |
if source and target:
|
| 106 |
edges.add((source, target))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
return frozenset(edges)
|
| 108 |
|
| 109 |
|
|
@@ -119,3 +145,45 @@ def _compile_trust_edges(topology: dict[str, object]) -> frozenset[tuple[str, st
|
|
| 119 |
if source and target:
|
| 120 |
edges.add((source, target, edge_type))
|
| 121 |
return frozenset(edges)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
hosts: frozenset[str]
|
| 20 |
users: frozenset[str]
|
| 21 |
+
principals: frozenset[str]
|
| 22 |
+
zones_by_host: dict[str, str]
|
| 23 |
services_by_host: dict[str, frozenset[str]]
|
| 24 |
dependency_edges: frozenset[tuple[str, str]]
|
| 25 |
trust_edges: frozenset[tuple[str, str, str]]
|
|
|
|
| 33 |
topology = snapshot.topology or {}
|
| 34 |
hosts = _compile_hosts(topology)
|
| 35 |
users = _compile_users(topology)
|
| 36 |
+
principals = _compile_principals(topology, users)
|
| 37 |
+
zones_by_host = _compile_zones(topology, hosts)
|
| 38 |
services_by_host = _compile_services(topology, hosts)
|
| 39 |
dependency_edges = _compile_dependency_edges(topology)
|
| 40 |
trust_edges = _compile_trust_edges(topology)
|
|
|
|
| 44 |
return CompiledGraphs(
|
| 45 |
hosts=hosts,
|
| 46 |
users=users,
|
| 47 |
+
principals=principals,
|
| 48 |
+
zones_by_host=zones_by_host,
|
| 49 |
services_by_host=services_by_host,
|
| 50 |
dependency_edges=dependency_edges,
|
| 51 |
trust_edges=trust_edges,
|
|
|
|
| 86 |
hosts: frozenset[str],
|
| 87 |
) -> dict[str, frozenset[str]]:
|
| 88 |
host_details = topology.get("host_details", {})
|
| 89 |
+
host_catalog = topology.get("host_catalog", {})
|
| 90 |
compiled: dict[str, frozenset[str]] = {}
|
| 91 |
for host in hosts:
|
| 92 |
detail = {}
|
|
|
|
| 94 |
raw_detail = host_details.get(host, {})
|
| 95 |
if isinstance(raw_detail, dict):
|
| 96 |
detail = raw_detail
|
| 97 |
+
if not detail and isinstance(host_catalog, dict):
|
| 98 |
+
raw_catalog = host_catalog.get(host, {})
|
| 99 |
+
if isinstance(raw_catalog, dict):
|
| 100 |
+
detail = raw_catalog
|
| 101 |
services = detail.get("services", [])
|
| 102 |
if not isinstance(services, list):
|
| 103 |
services = []
|
|
|
|
| 115 |
target = str(raw.get("target", "")).strip()
|
| 116 |
if source and target:
|
| 117 |
edges.add((source, target))
|
| 118 |
+
if edges:
|
| 119 |
+
return frozenset(edges)
|
| 120 |
+
|
| 121 |
+
host_details = topology.get("host_details", {})
|
| 122 |
+
if isinstance(host_details, dict):
|
| 123 |
+
for source, raw_detail in host_details.items():
|
| 124 |
+
if not isinstance(raw_detail, dict):
|
| 125 |
+
continue
|
| 126 |
+
raw_targets = raw_detail.get("connects_to", [])
|
| 127 |
+
if not isinstance(raw_targets, list):
|
| 128 |
+
continue
|
| 129 |
+
for raw_target in raw_targets:
|
| 130 |
+
target = str(raw_target).strip()
|
| 131 |
+
if source and target:
|
| 132 |
+
edges.add((str(source).strip(), target))
|
| 133 |
return frozenset(edges)
|
| 134 |
|
| 135 |
|
|
|
|
| 145 |
if source and target:
|
| 146 |
edges.add((source, target, edge_type))
|
| 147 |
return frozenset(edges)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _compile_principals(
|
| 151 |
+
topology: dict[str, object],
|
| 152 |
+
users: frozenset[str],
|
| 153 |
+
) -> frozenset[str]:
|
| 154 |
+
principals = set(users)
|
| 155 |
+
raw_catalog = topology.get("principal_catalog", {})
|
| 156 |
+
if isinstance(raw_catalog, dict):
|
| 157 |
+
for raw_name in raw_catalog:
|
| 158 |
+
name = str(raw_name).strip()
|
| 159 |
+
if name:
|
| 160 |
+
principals.add(name)
|
| 161 |
+
return frozenset(principals)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _compile_zones(
|
| 165 |
+
topology: dict[str, object],
|
| 166 |
+
hosts: frozenset[str],
|
| 167 |
+
) -> dict[str, str]:
|
| 168 |
+
zones_by_host: dict[str, str] = {}
|
| 169 |
+
raw_zones = topology.get("zones", {})
|
| 170 |
+
if isinstance(raw_zones, dict):
|
| 171 |
+
for raw_zone, raw_hosts in raw_zones.items():
|
| 172 |
+
zone = str(raw_zone).strip()
|
| 173 |
+
if not zone or not isinstance(raw_hosts, list):
|
| 174 |
+
continue
|
| 175 |
+
for raw_host in raw_hosts:
|
| 176 |
+
host = str(raw_host).strip()
|
| 177 |
+
if host:
|
| 178 |
+
zones_by_host[host] = zone
|
| 179 |
+
|
| 180 |
+
host_details = topology.get("host_details", {})
|
| 181 |
+
if isinstance(host_details, dict):
|
| 182 |
+
for raw_host, raw_detail in host_details.items():
|
| 183 |
+
host = str(raw_host).strip()
|
| 184 |
+
if not host or host not in hosts or not isinstance(raw_detail, dict):
|
| 185 |
+
continue
|
| 186 |
+
zone = str(raw_detail.get("zone", "")).strip()
|
| 187 |
+
if zone and host not in zones_by_host:
|
| 188 |
+
zones_by_host[host] = zone
|
| 189 |
+
return zones_by_host
|
src/open_range/validator/manifest_compliance.py
CHANGED
|
@@ -32,6 +32,7 @@ class ManifestComplianceCheck:
|
|
| 32 |
manifest_hosts = _manifest_hosts(self.manifest)
|
| 33 |
allowed_bug_families = set(str(v) for v in self.manifest.get("bug_families", []))
|
| 34 |
allowed_users = set(_manifest_users(self.manifest))
|
|
|
|
| 35 |
allowed_services = _manifest_services(self.manifest)
|
| 36 |
allowed_dependency_edges = _manifest_dependency_edges(self.manifest)
|
| 37 |
allowed_trust_edges = _manifest_trust_edges(self.manifest)
|
|
@@ -55,6 +56,20 @@ class ManifestComplianceCheck:
|
|
| 55 |
f"host '{host}' has services outside manifest family: {sorted(illegal)}"
|
| 56 |
)
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
for vuln in snapshot.truth_graph.vulns:
|
| 59 |
if vuln.type and allowed_bug_families and vuln.type not in allowed_bug_families:
|
| 60 |
issues.append(f"vuln '{vuln.id}' uses disallowed family '{vuln.type}'")
|
|
@@ -93,6 +108,14 @@ class ManifestComplianceCheck:
|
|
| 93 |
source = op.target_selector.get("source", "")
|
| 94 |
target = op.target_selector.get("target", "")
|
| 95 |
edge_type = str(op.params.get("type", "")).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
if (source, target, edge_type) not in allowed_trust_edges:
|
| 97 |
issues.append(
|
| 98 |
f"add_trust_edge introduces illegal trust edge "
|
|
@@ -139,6 +162,20 @@ def _manifest_users(manifest: dict[str, Any]) -> set[str]:
|
|
| 139 |
return users
|
| 140 |
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
def _manifest_services(manifest: dict[str, Any]) -> dict[str, frozenset[str]]:
|
| 143 |
services: dict[str, frozenset[str]] = {}
|
| 144 |
for raw in manifest.get("topology", {}).get("hosts", []):
|
|
|
|
| 32 |
manifest_hosts = _manifest_hosts(self.manifest)
|
| 33 |
allowed_bug_families = set(str(v) for v in self.manifest.get("bug_families", []))
|
| 34 |
allowed_users = set(_manifest_users(self.manifest))
|
| 35 |
+
allowed_principals = set(_manifest_principals(self.manifest))
|
| 36 |
allowed_services = _manifest_services(self.manifest)
|
| 37 |
allowed_dependency_edges = _manifest_dependency_edges(self.manifest)
|
| 38 |
allowed_trust_edges = _manifest_trust_edges(self.manifest)
|
|
|
|
| 56 |
f"host '{host}' has services outside manifest family: {sorted(illegal)}"
|
| 57 |
)
|
| 58 |
|
| 59 |
+
illegal_dependency_edges = sorted(
|
| 60 |
+
edge for edge in compiled.dependency_edges if edge not in allowed_dependency_edges
|
| 61 |
+
)
|
| 62 |
+
if illegal_dependency_edges:
|
| 63 |
+
issues.append(
|
| 64 |
+
f"dependency edges outside manifest family: {illegal_dependency_edges}"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
illegal_trust_edges = sorted(
|
| 68 |
+
edge for edge in compiled.trust_edges if edge not in allowed_trust_edges
|
| 69 |
+
)
|
| 70 |
+
if illegal_trust_edges:
|
| 71 |
+
issues.append(f"trust edges outside manifest family: {illegal_trust_edges}")
|
| 72 |
+
|
| 73 |
for vuln in snapshot.truth_graph.vulns:
|
| 74 |
if vuln.type and allowed_bug_families and vuln.type not in allowed_bug_families:
|
| 75 |
issues.append(f"vuln '{vuln.id}' uses disallowed family '{vuln.type}'")
|
|
|
|
| 108 |
source = op.target_selector.get("source", "")
|
| 109 |
target = op.target_selector.get("target", "")
|
| 110 |
edge_type = str(op.params.get("type", "")).strip()
|
| 111 |
+
if source and source not in allowed_principals:
|
| 112 |
+
issues.append(
|
| 113 |
+
f"add_trust_edge introduces unknown principal '{source}'"
|
| 114 |
+
)
|
| 115 |
+
if target and target not in allowed_principals:
|
| 116 |
+
issues.append(
|
| 117 |
+
f"add_trust_edge introduces unknown principal '{target}'"
|
| 118 |
+
)
|
| 119 |
if (source, target, edge_type) not in allowed_trust_edges:
|
| 120 |
issues.append(
|
| 121 |
f"add_trust_edge introduces illegal trust edge "
|
|
|
|
| 162 |
return users
|
| 163 |
|
| 164 |
|
| 165 |
+
def _manifest_principals(manifest: dict[str, Any]) -> set[str]:
|
| 166 |
+
principals = set(_manifest_users(manifest))
|
| 167 |
+
for raw in manifest.get("trust_relationships", []):
|
| 168 |
+
if not isinstance(raw, dict):
|
| 169 |
+
continue
|
| 170 |
+
source = str(raw.get("source") or raw.get("from") or "").strip()
|
| 171 |
+
target = str(raw.get("target") or raw.get("to") or "").strip()
|
| 172 |
+
if source:
|
| 173 |
+
principals.add(source)
|
| 174 |
+
if target:
|
| 175 |
+
principals.add(target)
|
| 176 |
+
return principals
|
| 177 |
+
|
| 178 |
+
|
| 179 |
def _manifest_services(manifest: dict[str, Any]) -> dict[str, frozenset[str]]:
|
| 180 |
services: dict[str, frozenset[str]] = {}
|
| 181 |
for raw in manifest.get("topology", {}).get("hosts", []):
|
src/open_range/validator/path_solvability.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph-native path solvability checks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections import defaultdict, deque
|
| 6 |
+
|
| 7 |
+
from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
|
| 8 |
+
from open_range.validator.graphs import CompiledGraphs, compile_snapshot_graphs
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PathSolvabilityCheck:
|
| 12 |
+
"""Verify that vuln and flag hosts are reachable in the compiled host graph."""
|
| 13 |
+
|
| 14 |
+
async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
|
| 15 |
+
compiled = compile_snapshot_graphs(snapshot)
|
| 16 |
+
issues: list[str] = []
|
| 17 |
+
|
| 18 |
+
if not compiled.hosts:
|
| 19 |
+
return CheckResult(
|
| 20 |
+
name="path_solvability",
|
| 21 |
+
passed=False,
|
| 22 |
+
error="snapshot has no compiled hosts",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
start_hosts = _start_hosts(compiled)
|
| 26 |
+
vuln_hosts = {v.host for v in snapshot.truth_graph.vulns if v.host}
|
| 27 |
+
flag_hosts = {flag.host for flag in snapshot.flags if flag.host}
|
| 28 |
+
target_hosts = sorted(vuln_hosts.union(flag_hosts))
|
| 29 |
+
if not target_hosts:
|
| 30 |
+
return CheckResult(
|
| 31 |
+
name="path_solvability",
|
| 32 |
+
passed=False,
|
| 33 |
+
error="snapshot has no vuln or flag hosts to solve toward",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
adjacency = build_host_adjacency(snapshot, compiled)
|
| 37 |
+
unreachable = [
|
| 38 |
+
host
|
| 39 |
+
for host in target_hosts
|
| 40 |
+
if not _reachable_from_any(host, start_hosts, adjacency)
|
| 41 |
+
]
|
| 42 |
+
if unreachable:
|
| 43 |
+
issues.append(f"unreachable target hosts from start set {sorted(start_hosts)}: {unreachable}")
|
| 44 |
+
|
| 45 |
+
for flag_host in sorted(flag_hosts):
|
| 46 |
+
if not (
|
| 47 |
+
flag_host in vuln_hosts
|
| 48 |
+
or _reachable_from_any(flag_host, vuln_hosts or start_hosts, adjacency)
|
| 49 |
+
):
|
| 50 |
+
issues.append(
|
| 51 |
+
f"flag host '{flag_host}' is not grounded by any vuln host or start host"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
passed = len(issues) == 0
|
| 55 |
+
return CheckResult(
|
| 56 |
+
name="path_solvability",
|
| 57 |
+
passed=passed,
|
| 58 |
+
details={
|
| 59 |
+
"start_hosts": sorted(start_hosts),
|
| 60 |
+
"target_hosts": target_hosts,
|
| 61 |
+
"issues": issues,
|
| 62 |
+
},
|
| 63 |
+
error="" if passed else "; ".join(issues),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _start_hosts(compiled: CompiledGraphs) -> set[str]:
|
| 68 |
+
starts = {
|
| 69 |
+
host
|
| 70 |
+
for host in compiled.hosts
|
| 71 |
+
if host in {"attacker", "internet"}
|
| 72 |
+
or compiled.zones_by_host.get(host) == "external"
|
| 73 |
+
}
|
| 74 |
+
if starts:
|
| 75 |
+
return starts
|
| 76 |
+
if compiled.hosts:
|
| 77 |
+
return {sorted(compiled.hosts)[0]}
|
| 78 |
+
return set()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_host_adjacency(
|
| 82 |
+
snapshot: SnapshotSpec,
|
| 83 |
+
compiled: CompiledGraphs,
|
| 84 |
+
) -> dict[str, set[str]]:
|
| 85 |
+
adjacency: dict[str, set[str]] = defaultdict(set)
|
| 86 |
+
for source, target in compiled.dependency_edges:
|
| 87 |
+
adjacency[source].add(target)
|
| 88 |
+
|
| 89 |
+
principal_hosts = _principal_hosts(snapshot)
|
| 90 |
+
for source_principal, target_principal, _edge_type in compiled.trust_edges:
|
| 91 |
+
source_hosts = principal_hosts.get(source_principal, set())
|
| 92 |
+
target_hosts = principal_hosts.get(target_principal, set())
|
| 93 |
+
for source_host in source_hosts:
|
| 94 |
+
for target_host in target_hosts:
|
| 95 |
+
if source_host and target_host:
|
| 96 |
+
adjacency[source_host].add(target_host)
|
| 97 |
+
return adjacency
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def has_host_path(
|
| 101 |
+
start: str,
|
| 102 |
+
target: str,
|
| 103 |
+
adjacency: dict[str, set[str]],
|
| 104 |
+
) -> bool:
|
| 105 |
+
return _has_path(start, target, adjacency)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _principal_hosts(snapshot: SnapshotSpec) -> dict[str, set[str]]:
|
| 109 |
+
topology = snapshot.topology or {}
|
| 110 |
+
mapping: dict[str, set[str]] = defaultdict(set)
|
| 111 |
+
|
| 112 |
+
raw_users = topology.get("users", [])
|
| 113 |
+
if isinstance(raw_users, list):
|
| 114 |
+
for raw in raw_users:
|
| 115 |
+
if not isinstance(raw, dict):
|
| 116 |
+
continue
|
| 117 |
+
username = str(raw.get("username", "")).strip()
|
| 118 |
+
if not username:
|
| 119 |
+
continue
|
| 120 |
+
for raw_host in raw.get("hosts", []):
|
| 121 |
+
host = str(raw_host).strip()
|
| 122 |
+
if host:
|
| 123 |
+
mapping[username].add(host)
|
| 124 |
+
|
| 125 |
+
raw_catalog = topology.get("principal_catalog", {})
|
| 126 |
+
if isinstance(raw_catalog, dict):
|
| 127 |
+
for raw_name, raw_principal in raw_catalog.items():
|
| 128 |
+
name = str(raw_name).strip()
|
| 129 |
+
if not name or not isinstance(raw_principal, dict):
|
| 130 |
+
continue
|
| 131 |
+
for raw_host in raw_principal.get("hosts", []):
|
| 132 |
+
host = str(raw_host).strip()
|
| 133 |
+
if host:
|
| 134 |
+
mapping[name].add(host)
|
| 135 |
+
|
| 136 |
+
return mapping
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _reachable_from_any(
|
| 140 |
+
target: str,
|
| 141 |
+
starts: set[str],
|
| 142 |
+
adjacency: dict[str, set[str]],
|
| 143 |
+
) -> bool:
|
| 144 |
+
for start in starts:
|
| 145 |
+
if start == target:
|
| 146 |
+
return True
|
| 147 |
+
if _has_path(start, target, adjacency):
|
| 148 |
+
return True
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _has_path(start: str, target: str, adjacency: dict[str, set[str]]) -> bool:
|
| 153 |
+
queue: deque[str] = deque([start])
|
| 154 |
+
seen = {start}
|
| 155 |
+
while queue:
|
| 156 |
+
current = queue.popleft()
|
| 157 |
+
for neighbor in adjacency.get(current, set()):
|
| 158 |
+
if neighbor == target:
|
| 159 |
+
return True
|
| 160 |
+
if neighbor in seen:
|
| 161 |
+
continue
|
| 162 |
+
seen.add(neighbor)
|
| 163 |
+
queue.append(neighbor)
|
| 164 |
+
return False
|
tests/test_builder.py
CHANGED
|
@@ -174,6 +174,25 @@ async def test_mutator_builds_child_snapshot_with_lineage(tier1_manifest):
|
|
| 174 |
assert child.lineage.mutation_summary
|
| 175 |
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
@pytest.mark.asyncio
|
| 178 |
async def test_mutator_rebuilds_child_files_from_mutated_snapshot(tier1_manifest):
|
| 179 |
from open_range.builder.builder import TemplateOnlyBuilder
|
|
|
|
| 174 |
assert child.lineage.mutation_summary
|
| 175 |
|
| 176 |
|
| 177 |
+
@pytest.mark.asyncio
|
| 178 |
+
async def test_mutator_compiles_root_snapshot_from_manifest_graph(tier1_manifest):
|
| 179 |
+
from open_range.builder.builder import TemplateOnlyBuilder
|
| 180 |
+
from open_range.builder.mutator import Mutator
|
| 181 |
+
|
| 182 |
+
root = await Mutator(TemplateOnlyBuilder()).mutate(
|
| 183 |
+
tier1_manifest,
|
| 184 |
+
context=BuildContext(seed=1, tier=1),
|
| 185 |
+
)
|
| 186 |
+
topology = root.topology
|
| 187 |
+
assert topology["host_details"]["web"]["services"]
|
| 188 |
+
assert topology["dependency_edges"]
|
| 189 |
+
assert topology["trust_edges"]
|
| 190 |
+
assert "principal_catalog" in topology
|
| 191 |
+
assert "schen" in topology["principal_catalog"]
|
| 192 |
+
assert "schen" not in {user["username"] for user in topology["users"]}
|
| 193 |
+
assert topology["manifest_normalization"]["trust_only_principals"]
|
| 194 |
+
|
| 195 |
+
|
| 196 |
@pytest.mark.asyncio
|
| 197 |
async def test_mutator_rebuilds_child_files_from_mutated_snapshot(tier1_manifest):
|
| 198 |
from open_range.builder.builder import TemplateOnlyBuilder
|
tests/test_lint.py
CHANGED
|
@@ -106,9 +106,10 @@ class TestValidManifest:
|
|
| 106 |
assert errors == [], f"Check '{check_name}' failed: {errors}"
|
| 107 |
|
| 108 |
def test_tier1_manifest_loads(self):
|
| 109 |
-
"""Tier 1 manifest should
|
| 110 |
result = lint_file(ROOT / "manifests" / "tier1_basic.yaml")
|
| 111 |
assert result["schema_error"] is None, result["schema_error"]
|
|
|
|
| 112 |
|
| 113 |
|
| 114 |
# ---------------------------------------------------------------------------
|
|
@@ -177,35 +178,35 @@ class TestInvalidUserRefs:
|
|
| 177 |
assert len(errors) == 1
|
| 178 |
assert "ghost_user" in errors[0]
|
| 179 |
|
| 180 |
-
def
|
| 181 |
data = _minimal_manifest()
|
| 182 |
data["trust_relationships"] = [
|
| 183 |
{
|
| 184 |
"type": "delegates_access",
|
| 185 |
-
"from": "
|
| 186 |
"to": "admin",
|
| 187 |
},
|
| 188 |
]
|
| 189 |
manifest = Manifest(**data)
|
| 190 |
results = lint_manifest(manifest)
|
| 191 |
-
errors = results["trust relationship
|
| 192 |
assert len(errors) == 1
|
| 193 |
-
assert "
|
| 194 |
|
| 195 |
-
def
|
| 196 |
data = _minimal_manifest()
|
| 197 |
data["trust_relationships"] = [
|
| 198 |
{
|
| 199 |
"type": "delegates_access",
|
| 200 |
"from": "admin",
|
| 201 |
-
"to": "phantom",
|
| 202 |
},
|
| 203 |
]
|
| 204 |
manifest = Manifest(**data)
|
| 205 |
results = lint_manifest(manifest)
|
| 206 |
-
errors = results["trust relationship
|
| 207 |
assert len(errors) == 1
|
| 208 |
-
assert "phantom" in errors[0]
|
| 209 |
|
| 210 |
|
| 211 |
# ---------------------------------------------------------------------------
|
|
|
|
| 106 |
assert errors == [], f"Check '{check_name}' failed: {errors}"
|
| 107 |
|
| 108 |
def test_tier1_manifest_loads(self):
|
| 109 |
+
"""Tier 1 manifest should load and pass lint checks."""
|
| 110 |
result = lint_file(ROOT / "manifests" / "tier1_basic.yaml")
|
| 111 |
assert result["schema_error"] is None, result["schema_error"]
|
| 112 |
+
assert result["valid"] is True, result["checks"]
|
| 113 |
|
| 114 |
|
| 115 |
# ---------------------------------------------------------------------------
|
|
|
|
| 178 |
assert len(errors) == 1
|
| 179 |
assert "ghost_user" in errors[0]
|
| 180 |
|
| 181 |
+
def test_trust_relationship_invalid_source_identifier(self):
|
| 182 |
data = _minimal_manifest()
|
| 183 |
data["trust_relationships"] = [
|
| 184 |
{
|
| 185 |
"type": "delegates_access",
|
| 186 |
+
"from": "bad actor!",
|
| 187 |
"to": "admin",
|
| 188 |
},
|
| 189 |
]
|
| 190 |
manifest = Manifest(**data)
|
| 191 |
results = lint_manifest(manifest)
|
| 192 |
+
errors = results["trust relationship principals"]
|
| 193 |
assert len(errors) == 1
|
| 194 |
+
assert "bad actor!" in errors[0]
|
| 195 |
|
| 196 |
+
def test_trust_relationship_invalid_target_identifier(self):
|
| 197 |
data = _minimal_manifest()
|
| 198 |
data["trust_relationships"] = [
|
| 199 |
{
|
| 200 |
"type": "delegates_access",
|
| 201 |
"from": "admin",
|
| 202 |
+
"to": "phantom user",
|
| 203 |
},
|
| 204 |
]
|
| 205 |
manifest = Manifest(**data)
|
| 206 |
results = lint_manifest(manifest)
|
| 207 |
+
errors = results["trust relationship principals"]
|
| 208 |
assert len(errors) == 1
|
| 209 |
+
assert "phantom user" in errors[0]
|
| 210 |
|
| 211 |
|
| 212 |
# ---------------------------------------------------------------------------
|
tests/test_runtime.py
CHANGED
|
@@ -21,6 +21,11 @@ class TestManagedSnapshotRuntime:
|
|
| 21 |
)
|
| 22 |
names = [type(check).__name__ for check in runtime.validator.checks]
|
| 23 |
assert names == [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"StructuralSnapshotCheck",
|
| 25 |
"TaskFeasibilityCheck",
|
| 26 |
]
|
|
@@ -33,6 +38,13 @@ class TestManagedSnapshotRuntime:
|
|
| 33 |
refill_enabled=False,
|
| 34 |
)
|
| 35 |
names = [type(check).__name__ for check in runtime.validator.checks]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
assert "BuildBootCheck" in names
|
| 37 |
assert "ExploitabilityCheck" in names
|
| 38 |
assert "PatchabilityCheck" in names
|
|
@@ -117,6 +129,7 @@ class TestManagedSnapshotRuntime:
|
|
| 117 |
store_dir=tmp_path / "snapshots",
|
| 118 |
pool_size=2,
|
| 119 |
selection_strategy="latest",
|
|
|
|
| 120 |
refill_enabled=False,
|
| 121 |
)
|
| 122 |
|
|
@@ -131,6 +144,17 @@ class TestManagedSnapshotRuntime:
|
|
| 131 |
finally:
|
| 132 |
runtime.stop()
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
def test_acquire_snapshot_exposes_lineage_metadata(self, tier1_manifest, tmp_path):
|
| 135 |
runtime = ManagedSnapshotRuntime(
|
| 136 |
manifest=tier1_manifest,
|
|
|
|
| 21 |
)
|
| 22 |
names = [type(check).__name__ for check in runtime.validator.checks]
|
| 23 |
assert names == [
|
| 24 |
+
"ManifestComplianceCheck",
|
| 25 |
+
"GraphConsistencyCheck",
|
| 26 |
+
"PathSolvabilityCheck",
|
| 27 |
+
"GraphEvidenceSufficiencyCheck",
|
| 28 |
+
"GraphRewardGroundingCheck",
|
| 29 |
"StructuralSnapshotCheck",
|
| 30 |
"TaskFeasibilityCheck",
|
| 31 |
]
|
|
|
|
| 38 |
refill_enabled=False,
|
| 39 |
)
|
| 40 |
names = [type(check).__name__ for check in runtime.validator.checks]
|
| 41 |
+
assert names[:5] == [
|
| 42 |
+
"ManifestComplianceCheck",
|
| 43 |
+
"GraphConsistencyCheck",
|
| 44 |
+
"PathSolvabilityCheck",
|
| 45 |
+
"GraphEvidenceSufficiencyCheck",
|
| 46 |
+
"GraphRewardGroundingCheck",
|
| 47 |
+
]
|
| 48 |
assert "BuildBootCheck" in names
|
| 49 |
assert "ExploitabilityCheck" in names
|
| 50 |
assert "PatchabilityCheck" in names
|
|
|
|
| 129 |
store_dir=tmp_path / "snapshots",
|
| 130 |
pool_size=2,
|
| 131 |
selection_strategy="latest",
|
| 132 |
+
parent_selection_strategy="policy",
|
| 133 |
refill_enabled=False,
|
| 134 |
)
|
| 135 |
|
|
|
|
| 144 |
finally:
|
| 145 |
runtime.stop()
|
| 146 |
|
| 147 |
+
def test_status_reports_parent_selection_strategy(self, tier1_manifest, tmp_path):
|
| 148 |
+
runtime = ManagedSnapshotRuntime(
|
| 149 |
+
manifest=tier1_manifest,
|
| 150 |
+
store_dir=tmp_path / "snapshots",
|
| 151 |
+
pool_size=1,
|
| 152 |
+
parent_selection_strategy="policy",
|
| 153 |
+
refill_enabled=False,
|
| 154 |
+
)
|
| 155 |
+
status = runtime.status()
|
| 156 |
+
assert status["parent_selection_strategy"] == "policy"
|
| 157 |
+
|
| 158 |
def test_acquire_snapshot_exposes_lineage_metadata(self, tier1_manifest, tmp_path):
|
| 159 |
runtime = ManagedSnapshotRuntime(
|
| 160 |
manifest=tier1_manifest,
|
tests/test_validator.py
CHANGED
|
@@ -73,6 +73,134 @@ async def test_graph_consistency_rejects_missing_parent_lineage(sample_snapshot_
|
|
| 73 |
assert "missing parent_snapshot_id" in result.error
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
# ---------------------------------------------------------------------------
|
| 77 |
# Check 1: BuildBoot
|
| 78 |
# ---------------------------------------------------------------------------
|
|
|
|
| 73 |
assert "missing parent_snapshot_id" in result.error
|
| 74 |
|
| 75 |
|
| 76 |
+
@pytest.mark.asyncio
|
| 77 |
+
async def test_path_solvability_passes_for_reachable_flag_host(mock_containers):
|
| 78 |
+
from open_range.protocols import EvidenceItem, TruthGraph, Vulnerability
|
| 79 |
+
from open_range.validator.path_solvability import PathSolvabilityCheck
|
| 80 |
+
|
| 81 |
+
spec = SnapshotSpec(
|
| 82 |
+
topology={
|
| 83 |
+
"hosts": ["attacker", "web", "db"],
|
| 84 |
+
"zones": {"external": ["attacker"], "dmz": ["web"], "internal": ["db"]},
|
| 85 |
+
"dependency_edges": [
|
| 86 |
+
{"source": "attacker", "target": "web"},
|
| 87 |
+
{"source": "web", "target": "db"},
|
| 88 |
+
],
|
| 89 |
+
"host_details": {
|
| 90 |
+
"attacker": {"services": ["nmap"]},
|
| 91 |
+
"web": {"services": ["nginx"]},
|
| 92 |
+
"db": {"services": ["mysql"]},
|
| 93 |
+
},
|
| 94 |
+
},
|
| 95 |
+
truth_graph=TruthGraph(
|
| 96 |
+
vulns=[Vulnerability(id="v1", type="sqli", host="web", service="nginx")],
|
| 97 |
+
),
|
| 98 |
+
flags=[FlagSpec(id="f1", value="FLAG{ok}", path="/var/flags/flag1.txt", host="db")],
|
| 99 |
+
evidence_spec=[EvidenceItem(type="log_entry", location="siem:/var/log/siem/all.log")],
|
| 100 |
+
golden_path=[GoldenPathStep(step=1, command="nmap web", expect_in_stdout="80/tcp")],
|
| 101 |
+
task=TaskSpec(red_briefing="go", blue_briefing="watch"),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
result = await PathSolvabilityCheck().check(spec, mock_containers)
|
| 105 |
+
assert result.passed is True
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@pytest.mark.asyncio
|
| 109 |
+
async def test_graph_evidence_sufficiency_fails_without_supporting_hosts(mock_containers):
|
| 110 |
+
from open_range.protocols import TruthGraph, Vulnerability
|
| 111 |
+
from open_range.validator.graph_evidence import GraphEvidenceSufficiencyCheck
|
| 112 |
+
|
| 113 |
+
spec = SnapshotSpec(
|
| 114 |
+
topology={
|
| 115 |
+
"hosts": ["attacker", "web", "db"],
|
| 116 |
+
"zones": {"external": ["attacker"], "dmz": ["web"], "internal": ["db"]},
|
| 117 |
+
"dependency_edges": [{"source": "attacker", "target": "web"}],
|
| 118 |
+
"host_details": {
|
| 119 |
+
"attacker": {"services": ["nmap"]},
|
| 120 |
+
"web": {"services": ["nginx"]},
|
| 121 |
+
"db": {"services": ["mysql"]},
|
| 122 |
+
},
|
| 123 |
+
},
|
| 124 |
+
truth_graph=TruthGraph(
|
| 125 |
+
vulns=[Vulnerability(id="v1", type="sqli", host="db", service="mysql")],
|
| 126 |
+
),
|
| 127 |
+
flags=[FlagSpec(id="f1", value="FLAG{db}", path="/var/flags/flag1.txt", host="db")],
|
| 128 |
+
evidence_spec=[EvidenceItem(type="log_entry", location="web:/var/log/access.log")],
|
| 129 |
+
golden_path=[GoldenPathStep(step=1, command="scan", expect_in_stdout="ok")],
|
| 130 |
+
task=TaskSpec(red_briefing="go", blue_briefing="watch"),
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
result = await GraphEvidenceSufficiencyCheck().check(spec, mock_containers)
|
| 134 |
+
assert result.passed is False
|
| 135 |
+
assert "no supporting evidence host" in result.error
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@pytest.mark.asyncio
|
| 139 |
+
async def test_graph_reward_grounding_fails_when_flag_host_unreachable(mock_containers):
|
| 140 |
+
from open_range.protocols import TruthGraph, Vulnerability
|
| 141 |
+
from open_range.validator.graph_reward_grounding import GraphRewardGroundingCheck
|
| 142 |
+
|
| 143 |
+
spec = SnapshotSpec(
|
| 144 |
+
topology={
|
| 145 |
+
"hosts": ["attacker", "web", "db"],
|
| 146 |
+
"zones": {"external": ["attacker"], "dmz": ["web"], "internal": ["db"]},
|
| 147 |
+
"dependency_edges": [{"source": "attacker", "target": "web"}],
|
| 148 |
+
"host_details": {
|
| 149 |
+
"attacker": {"services": ["nmap"]},
|
| 150 |
+
"web": {"services": ["nginx"]},
|
| 151 |
+
"db": {"services": ["mysql"]},
|
| 152 |
+
},
|
| 153 |
+
},
|
| 154 |
+
truth_graph=TruthGraph(
|
| 155 |
+
vulns=[Vulnerability(id="v1", type="sqli", host="web", service="nginx")],
|
| 156 |
+
),
|
| 157 |
+
flags=[FlagSpec(id="f1", value="FLAG{db}", path="/var/flags/flag1.txt", host="db")],
|
| 158 |
+
evidence_spec=[EvidenceItem(type="log_entry", location="siem:/var/log/siem/all.log")],
|
| 159 |
+
golden_path=[GoldenPathStep(step=1, command="scan", expect_in_stdout="ok")],
|
| 160 |
+
task=TaskSpec(red_briefing="go", blue_briefing="watch"),
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
result = await GraphRewardGroundingCheck().check(spec, mock_containers)
|
| 164 |
+
assert result.passed is False
|
| 165 |
+
assert "not reachable from any vuln host" in result.error
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@pytest.mark.asyncio
|
| 169 |
+
async def test_graph_checks_allow_trust_based_host_pivots(mock_containers):
|
| 170 |
+
from open_range.validator.graph_reward_grounding import GraphRewardGroundingCheck
|
| 171 |
+
from open_range.validator.path_solvability import PathSolvabilityCheck
|
| 172 |
+
|
| 173 |
+
spec = SnapshotSpec(
|
| 174 |
+
topology={
|
| 175 |
+
"hosts": ["attacker", "web", "db"],
|
| 176 |
+
"zones": {"external": ["attacker"], "dmz": ["web"], "internal": ["db"]},
|
| 177 |
+
"dependency_edges": [{"source": "attacker", "target": "web"}],
|
| 178 |
+
"trust_edges": [{"source": "websvc", "target": "dbsvc", "type": "credential_reuse"}],
|
| 179 |
+
"host_details": {
|
| 180 |
+
"attacker": {"services": ["nmap"]},
|
| 181 |
+
"web": {"services": ["nginx"]},
|
| 182 |
+
"db": {"services": ["mysql"]},
|
| 183 |
+
},
|
| 184 |
+
"principal_catalog": {
|
| 185 |
+
"websvc": {"username": "websvc", "hosts": ["web"], "is_login_account": False},
|
| 186 |
+
"dbsvc": {"username": "dbsvc", "hosts": ["db"], "is_login_account": False},
|
| 187 |
+
},
|
| 188 |
+
},
|
| 189 |
+
truth_graph=TruthGraph(
|
| 190 |
+
vulns=[Vulnerability(id="v1", type="credential_reuse", host="web", service="nginx")],
|
| 191 |
+
),
|
| 192 |
+
flags=[FlagSpec(id="f1", value="FLAG{db}", path="/var/flags/flag1.txt", host="db")],
|
| 193 |
+
evidence_spec=[EvidenceItem(type="log_entry", location="db:/var/log/mysql.log")],
|
| 194 |
+
golden_path=[GoldenPathStep(step=1, command="scan", expect_in_stdout="ok")],
|
| 195 |
+
task=TaskSpec(red_briefing="go", blue_briefing="watch"),
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
path_result = await PathSolvabilityCheck().check(spec, mock_containers)
|
| 199 |
+
reward_result = await GraphRewardGroundingCheck().check(spec, mock_containers)
|
| 200 |
+
assert path_result.passed is True
|
| 201 |
+
assert reward_result.passed is True
|
| 202 |
+
|
| 203 |
+
|
| 204 |
# ---------------------------------------------------------------------------
|
| 205 |
# Check 1: BuildBoot
|
| 206 |
# ---------------------------------------------------------------------------
|