Spaces:
Runtime error
Runtime error
Lars Talian commited on
Add snapshot lineage and parent-based mutation runtime (#49)
Browse files- README.md +12 -10
- src/open_range/builder/mutator.py +576 -4
- src/open_range/builder/snapshot_store.py +4 -0
- src/open_range/protocols.py +36 -0
- src/open_range/server/runtime.py +17 -2
- src/open_range/validator/graph_consistency.py +73 -0
- src/open_range/validator/graphs.py +121 -0
- src/open_range/validator/manifest_compliance.py +183 -0
- tests/test_builder.py +22 -0
- tests/test_runtime.py +36 -0
- tests/test_validator.py +54 -0
README.md
CHANGED
|
@@ -12,15 +12,17 @@ A multi-agent cybersecurity gymnasium on [OpenEnv](https://github.com/meta-pytor
|
|
| 12 |
|
| 13 |
## How It Works
|
| 14 |
|
| 15 |
-
A **manifest** declares a family of legal enterprise worlds — topology, services, identities, vulnerability classes,
|
| 16 |
|
| 17 |
```mermaid
|
| 18 |
flowchart LR
|
| 19 |
-
M[Manifest<br/>
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
S --> E["reset() → step() → obs + reward"]
|
| 25 |
|
| 26 |
style V fill:#ffd93d,color:#333
|
|
@@ -64,15 +66,15 @@ uv run pytest tests/ -v --tb=short
|
|
| 64 |
|
| 65 |
## Core Components
|
| 66 |
|
| 67 |
-
**Manifest** — YAML defining the legal world: hosts, zones, services, users, NPCs, data assets, credential policies, monitoring coverage, trust relationships, and which vulnerability classes the
|
| 68 |
|
| 69 |
-
**ManagedSnapshotRuntime** — Shared singleton created at server startup. Owns the `SnapshotStore`, builder
|
| 70 |
|
| 71 |
-
**Builder** —
|
| 72 |
|
| 73 |
The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
|
| 74 |
|
| 75 |
-
**Validator** — Admission gate for candidate snapshots. The shipped runtime
|
| 76 |
|
| 77 |
**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.
|
| 78 |
|
|
|
|
| 12 |
|
| 13 |
## How It Works
|
| 14 |
|
| 15 |
+
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 validated before render/materialization and then stored under `snapshots/<id>/`. `reset()` selects one frozen admitted snapshot. `step()` runs commands inside it.
|
| 16 |
|
| 17 |
```mermaid
|
| 18 |
flowchart LR
|
| 19 |
+
M[Manifest<br/>legal family +<br/>mutation envelope] --> B[Base snapshot compiler]
|
| 20 |
+
B --> P[Admitted root snapshot]
|
| 21 |
+
P --> R[ManagedSnapshotRuntime<br/>shared inside server process]
|
| 22 |
+
R --> U[Parent selector +<br/>typed mutator]
|
| 23 |
+
U --> V{Validator<br/>manifest + graph +<br/>runtime checks}
|
| 24 |
+
V -->|fail| U
|
| 25 |
+
V -->|pass| S[Admitted snapshot population]
|
| 26 |
S --> E["reset() → step() → obs + reward"]
|
| 27 |
|
| 28 |
style V fill:#ffd93d,color:#333
|
|
|
|
| 66 |
|
| 67 |
## Core Components
|
| 68 |
|
| 69 |
+
**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.
|
| 70 |
|
| 71 |
+
**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()`.
|
| 72 |
|
| 73 |
+
**Builder / Mutator** — The base builder compiles an initial `SnapshotSpec` from a manifest. The mutator then derives child `SnapshotSpec`s from admitted parents using typed mutation plans plus curriculum context. Each snapshot carries lineage metadata (`snapshot_id`, `parent_snapshot_id`, `root_snapshot_id`, generation depth, mutation summary). Three base builders ship: `LLMSnapshotBuilder` (production, via litellm), `TemplateOnlyBuilder` (deterministic shipped default), `FileBuilder` (load from disk).
|
| 74 |
|
| 75 |
The deployed package exposes the standard OpenEnv `reset()`, `step()`, and `state()` contract through `server.app:app`, which is the entrypoint referenced by `openenv.yaml`.
|
| 76 |
|
| 77 |
+
**Validator** — Admission gate for candidate snapshots. The shipped runtime now enforces manifest compliance and graph consistency before structural/task checks. These mechanical checks operate on the compiled `SnapshotSpec` without requiring live model calls; richer container-backed checks remain available for private/local generation workflows.
|
| 78 |
|
| 79 |
**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.
|
| 80 |
|
src/open_range/builder/mutator.py
CHANGED
|
@@ -8,12 +8,44 @@ context from failed validations.
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import logging
|
|
|
|
|
|
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
-
from open_range.protocols import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
class Mutator:
|
| 19 |
"""Orchestrate vuln mutation across resets.
|
|
@@ -38,16 +70,21 @@ class Mutator:
|
|
| 38 |
manifest: dict,
|
| 39 |
context: BuildContext | None = None,
|
| 40 |
error: dict[str, Any] | None = None,
|
|
|
|
|
|
|
| 41 |
) -> SnapshotSpec:
|
| 42 |
-
"""Generate a
|
| 43 |
|
| 44 |
Args:
|
| 45 |
manifest: Parsed manifest dict.
|
| 46 |
context: Optional base context (curriculum stats, etc.).
|
| 47 |
error: Error feedback from a failed validation attempt.
|
|
|
|
|
|
|
| 48 |
|
| 49 |
Returns:
|
| 50 |
-
A new SnapshotSpec
|
|
|
|
| 51 |
"""
|
| 52 |
if context is None:
|
| 53 |
context = BuildContext()
|
|
@@ -64,7 +101,16 @@ class Mutator:
|
|
| 64 |
except (AttributeError, ValueError):
|
| 65 |
pass # protocol version without error field
|
| 66 |
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
# Update history
|
| 70 |
new_classes = [v.type for v in snapshot.truth_graph.vulns]
|
|
@@ -90,3 +136,529 @@ class Mutator:
|
|
| 90 |
def history(self) -> list[str]:
|
| 91 |
"""All vuln classes used so far, in order."""
|
| 92 |
return list(self._history)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import logging
|
| 11 |
+
import random
|
| 12 |
+
from copy import deepcopy
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
+
from open_range.protocols import (
|
| 16 |
+
BuildContext,
|
| 17 |
+
EvidenceItem,
|
| 18 |
+
ExploitStep,
|
| 19 |
+
LineageMetadata,
|
| 20 |
+
MutationOp,
|
| 21 |
+
MutationPlan,
|
| 22 |
+
SnapshotBuilder,
|
| 23 |
+
SnapshotSpec,
|
| 24 |
+
Vulnerability,
|
| 25 |
+
)
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
+
_SUPPORTED_MUTATION_OPS = {
|
| 30 |
+
"add_service",
|
| 31 |
+
"add_user",
|
| 32 |
+
"add_dependency_edge",
|
| 33 |
+
"add_trust_edge",
|
| 34 |
+
"seed_vuln",
|
| 35 |
+
"add_benign_noise",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
_INJECTION_POINTS = {
|
| 39 |
+
"sqli": "/legacy/search.php?q=",
|
| 40 |
+
"idor": "/api/users/{id}",
|
| 41 |
+
"path_traversal": "/download?file=",
|
| 42 |
+
"command_injection": "/admin/diagnostics?host=",
|
| 43 |
+
"ssrf": "/fetch?url=",
|
| 44 |
+
"weak_creds": "ssh svc_app@host",
|
| 45 |
+
"broken_auth": "/admin/login",
|
| 46 |
+
"xss": "/search?q=",
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
|
| 50 |
class Mutator:
|
| 51 |
"""Orchestrate vuln mutation across resets.
|
|
|
|
| 70 |
manifest: dict,
|
| 71 |
context: BuildContext | None = None,
|
| 72 |
error: dict[str, Any] | None = None,
|
| 73 |
+
parent_snapshot: SnapshotSpec | None = None,
|
| 74 |
+
parent_snapshot_id: str | None = None,
|
| 75 |
) -> SnapshotSpec:
|
| 76 |
+
"""Generate a root or child snapshot, avoiding recent vuln classes.
|
| 77 |
|
| 78 |
Args:
|
| 79 |
manifest: Parsed manifest dict.
|
| 80 |
context: Optional base context (curriculum stats, etc.).
|
| 81 |
error: Error feedback from a failed validation attempt.
|
| 82 |
+
parent_snapshot: Admitted parent snapshot to mutate forward.
|
| 83 |
+
parent_snapshot_id: Persisted ID for *parent_snapshot*.
|
| 84 |
|
| 85 |
Returns:
|
| 86 |
+
A new SnapshotSpec. Root snapshots are compiled from the manifest;
|
| 87 |
+
child snapshots are mutated from the parent.
|
| 88 |
"""
|
| 89 |
if context is None:
|
| 90 |
context = BuildContext()
|
|
|
|
| 101 |
except (AttributeError, ValueError):
|
| 102 |
pass # protocol version without error field
|
| 103 |
|
| 104 |
+
if parent_snapshot is None:
|
| 105 |
+
snapshot = await self.builder.build(manifest, context)
|
| 106 |
+
snapshot = self._hydrate_root_snapshot(snapshot, manifest)
|
| 107 |
+
else:
|
| 108 |
+
snapshot = self._mutate_parent_snapshot(
|
| 109 |
+
manifest=manifest,
|
| 110 |
+
parent_snapshot=parent_snapshot,
|
| 111 |
+
parent_snapshot_id=parent_snapshot_id,
|
| 112 |
+
context=context,
|
| 113 |
+
)
|
| 114 |
|
| 115 |
# Update history
|
| 116 |
new_classes = [v.type for v in snapshot.truth_graph.vulns]
|
|
|
|
| 136 |
def history(self) -> list[str]:
|
| 137 |
"""All vuln classes used so far, in order."""
|
| 138 |
return list(self._history)
|
| 139 |
+
|
| 140 |
+
def _hydrate_root_snapshot(
|
| 141 |
+
self,
|
| 142 |
+
snapshot: SnapshotSpec,
|
| 143 |
+
manifest: dict[str, Any],
|
| 144 |
+
) -> SnapshotSpec:
|
| 145 |
+
root = snapshot.model_copy(deep=True)
|
| 146 |
+
topology = dict(root.topology)
|
| 147 |
+
company = manifest.get("company", {}) if isinstance(manifest.get("company"), dict) else {}
|
| 148 |
+
topology.setdefault("domain", company.get("domain", "acmecorp.local"))
|
| 149 |
+
topology.setdefault("org_name", company.get("name", "AcmeCorp"))
|
| 150 |
+
topology.setdefault("manifest_name", manifest.get("name", ""))
|
| 151 |
+
topology.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
|
| 152 |
+
topology.setdefault("host_catalog", _build_host_catalog(manifest))
|
| 153 |
+
topology.setdefault("host_details", {})
|
| 154 |
+
topology.setdefault("dependency_edges", [])
|
| 155 |
+
topology.setdefault("trust_edges", [])
|
| 156 |
+
root.topology = topology
|
| 157 |
+
root.lineage = LineageMetadata(
|
| 158 |
+
manifest_id=str(manifest.get("name", "")),
|
| 159 |
+
generation_depth=0,
|
| 160 |
+
mutation_summary=["compile_base_snapshot"],
|
| 161 |
+
)
|
| 162 |
+
root.mutation_plan = None
|
| 163 |
+
return root
|
| 164 |
+
|
| 165 |
+
def _mutate_parent_snapshot(
|
| 166 |
+
self,
|
| 167 |
+
*,
|
| 168 |
+
manifest: dict[str, Any],
|
| 169 |
+
parent_snapshot: SnapshotSpec,
|
| 170 |
+
parent_snapshot_id: str | None,
|
| 171 |
+
context: BuildContext,
|
| 172 |
+
) -> SnapshotSpec:
|
| 173 |
+
rng = random.Random(context.seed if context.seed is not None else self._episode_count + 1)
|
| 174 |
+
child = parent_snapshot.model_copy(deep=True)
|
| 175 |
+
child.topology = _ensure_mutable_topology(child.topology, manifest)
|
| 176 |
+
|
| 177 |
+
plan = self._plan_mutations(
|
| 178 |
+
manifest=manifest,
|
| 179 |
+
snapshot=child,
|
| 180 |
+
parent_snapshot_id=parent_snapshot_id,
|
| 181 |
+
context=context,
|
| 182 |
+
rng=rng,
|
| 183 |
+
)
|
| 184 |
+
self._apply_plan(child, plan, manifest, context)
|
| 185 |
+
|
| 186 |
+
lineage = parent_snapshot.lineage.model_copy(deep=True)
|
| 187 |
+
child.lineage = LineageMetadata(
|
| 188 |
+
parent_snapshot_id=parent_snapshot_id or parent_snapshot.lineage.snapshot_id or None,
|
| 189 |
+
root_snapshot_id=lineage.root_snapshot_id or parent_snapshot_id or "",
|
| 190 |
+
manifest_id=lineage.manifest_id or str(manifest.get("name", "")),
|
| 191 |
+
generation_depth=lineage.generation_depth + 1,
|
| 192 |
+
mutation_ids=[op.mutation_id for op in plan.ops],
|
| 193 |
+
mutation_summary=[_mutation_summary(op) for op in plan.ops],
|
| 194 |
+
)
|
| 195 |
+
child.mutation_plan = plan
|
| 196 |
+
return child
|
| 197 |
+
|
| 198 |
+
def _plan_mutations(
|
| 199 |
+
self,
|
| 200 |
+
*,
|
| 201 |
+
manifest: dict[str, Any],
|
| 202 |
+
snapshot: SnapshotSpec,
|
| 203 |
+
parent_snapshot_id: str | None,
|
| 204 |
+
context: BuildContext,
|
| 205 |
+
rng: random.Random,
|
| 206 |
+
) -> MutationPlan:
|
| 207 |
+
ops: list[MutationOp] = []
|
| 208 |
+
used_ids: set[str] = set()
|
| 209 |
+
|
| 210 |
+
structural_candidates = []
|
| 211 |
+
op = self._candidate_add_service(manifest, snapshot, rng)
|
| 212 |
+
if op is not None:
|
| 213 |
+
structural_candidates.append(op)
|
| 214 |
+
op = self._candidate_add_user(manifest, snapshot, context, rng)
|
| 215 |
+
if op is not None:
|
| 216 |
+
structural_candidates.append(op)
|
| 217 |
+
op = self._candidate_add_dependency_edge(manifest, snapshot, rng)
|
| 218 |
+
if op is not None:
|
| 219 |
+
structural_candidates.append(op)
|
| 220 |
+
op = self._candidate_add_trust_edge(manifest, snapshot, rng)
|
| 221 |
+
if op is not None:
|
| 222 |
+
structural_candidates.append(op)
|
| 223 |
+
|
| 224 |
+
if structural_candidates:
|
| 225 |
+
chosen = rng.choice(structural_candidates)
|
| 226 |
+
ops.append(chosen)
|
| 227 |
+
used_ids.add(chosen.mutation_id)
|
| 228 |
+
|
| 229 |
+
security_candidates = []
|
| 230 |
+
op = self._candidate_seed_vuln(manifest, snapshot, context, rng)
|
| 231 |
+
if op is not None:
|
| 232 |
+
security_candidates.append(op)
|
| 233 |
+
op = self._candidate_add_benign_noise(snapshot, rng)
|
| 234 |
+
if op is not None:
|
| 235 |
+
security_candidates.append(op)
|
| 236 |
+
|
| 237 |
+
if security_candidates:
|
| 238 |
+
chosen = rng.choice(security_candidates)
|
| 239 |
+
if chosen.mutation_id not in used_ids:
|
| 240 |
+
ops.append(chosen)
|
| 241 |
+
|
| 242 |
+
if not ops:
|
| 243 |
+
fallback = self._candidate_add_benign_noise(snapshot, rng)
|
| 244 |
+
if fallback is not None:
|
| 245 |
+
ops.append(fallback)
|
| 246 |
+
|
| 247 |
+
return MutationPlan(
|
| 248 |
+
parent_snapshot_id=parent_snapshot_id,
|
| 249 |
+
ops=ops,
|
| 250 |
+
predicted_complexity_delta=len(ops),
|
| 251 |
+
predicted_chain_delta=sum(1 for op in ops if op.op_type == "seed_vuln"),
|
| 252 |
+
predicted_novelty=round(0.2 * len({op.op_type for op in ops}), 2),
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
def _candidate_add_service(
|
| 256 |
+
self,
|
| 257 |
+
manifest: dict[str, Any],
|
| 258 |
+
snapshot: SnapshotSpec,
|
| 259 |
+
rng: random.Random,
|
| 260 |
+
) -> MutationOp | None:
|
| 261 |
+
topology = snapshot.topology
|
| 262 |
+
host_catalog = topology.get("host_catalog", {})
|
| 263 |
+
host_details = topology.get("host_details", {})
|
| 264 |
+
candidates: list[tuple[str, str]] = []
|
| 265 |
+
if not isinstance(host_catalog, dict) or not isinstance(host_details, dict):
|
| 266 |
+
return None
|
| 267 |
+
for host, raw_catalog in host_catalog.items():
|
| 268 |
+
if not isinstance(raw_catalog, dict):
|
| 269 |
+
continue
|
| 270 |
+
allowed = raw_catalog.get("services", [])
|
| 271 |
+
detail = host_details.get(host, {})
|
| 272 |
+
current = detail.get("services", []) if isinstance(detail, dict) else []
|
| 273 |
+
if not isinstance(allowed, list) or not isinstance(current, list):
|
| 274 |
+
continue
|
| 275 |
+
for service in allowed:
|
| 276 |
+
if service and service not in current:
|
| 277 |
+
candidates.append((str(host), str(service)))
|
| 278 |
+
if not candidates:
|
| 279 |
+
return None
|
| 280 |
+
host, service = rng.choice(candidates)
|
| 281 |
+
return MutationOp(
|
| 282 |
+
mutation_id=f"mut_add_service_{host}_{service}",
|
| 283 |
+
op_type="add_service",
|
| 284 |
+
target_selector={"host": host},
|
| 285 |
+
params={"service": service},
|
| 286 |
+
expected_effects=[f"service {service} added to {host}"],
|
| 287 |
+
risk_tags=["surface_expansion"],
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
def _candidate_add_user(
|
| 291 |
+
self,
|
| 292 |
+
manifest: dict[str, Any],
|
| 293 |
+
snapshot: SnapshotSpec,
|
| 294 |
+
context: BuildContext,
|
| 295 |
+
rng: random.Random,
|
| 296 |
+
) -> MutationOp | None:
|
| 297 |
+
existing = _existing_usernames(snapshot)
|
| 298 |
+
candidates = [
|
| 299 |
+
raw for raw in manifest.get("users", [])
|
| 300 |
+
if isinstance(raw, dict) and raw.get("username") not in existing
|
| 301 |
+
]
|
| 302 |
+
if not candidates:
|
| 303 |
+
return None
|
| 304 |
+
user = deepcopy(rng.choice(candidates))
|
| 305 |
+
username = str(user.get("username", "")).strip()
|
| 306 |
+
if not username:
|
| 307 |
+
return None
|
| 308 |
+
password = _predictable_password(username, context.seed)
|
| 309 |
+
return MutationOp(
|
| 310 |
+
mutation_id=f"mut_add_user_{username}",
|
| 311 |
+
op_type="add_user",
|
| 312 |
+
target_selector={"user": username},
|
| 313 |
+
params={
|
| 314 |
+
"username": username,
|
| 315 |
+
"password": password,
|
| 316 |
+
"hosts": deepcopy(user.get("hosts", [])),
|
| 317 |
+
"groups": [str(user.get("department", "") or "users").lower().replace(" ", "_")],
|
| 318 |
+
"email": str(user.get("email", "")),
|
| 319 |
+
"full_name": str(user.get("full_name", "")),
|
| 320 |
+
"department": str(user.get("department", "")),
|
| 321 |
+
"role": str(user.get("role", "")),
|
| 322 |
+
},
|
| 323 |
+
expected_effects=[f"user {username} added to snapshot accounts"],
|
| 324 |
+
risk_tags=["identity_expansion"],
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
def _candidate_add_dependency_edge(
|
| 328 |
+
self,
|
| 329 |
+
manifest: dict[str, Any],
|
| 330 |
+
snapshot: SnapshotSpec,
|
| 331 |
+
rng: random.Random,
|
| 332 |
+
) -> MutationOp | None:
|
| 333 |
+
topology = snapshot.topology
|
| 334 |
+
current = {
|
| 335 |
+
(str(edge.get("source", "")), str(edge.get("target", "")))
|
| 336 |
+
for edge in topology.get("dependency_edges", [])
|
| 337 |
+
if isinstance(edge, dict)
|
| 338 |
+
}
|
| 339 |
+
candidates: list[tuple[str, str]] = []
|
| 340 |
+
for raw in manifest.get("topology", {}).get("hosts", []):
|
| 341 |
+
if not isinstance(raw, dict):
|
| 342 |
+
continue
|
| 343 |
+
source = str(raw.get("name", "")).strip()
|
| 344 |
+
raw_targets = raw.get("connects_to", [])
|
| 345 |
+
if not source or not isinstance(raw_targets, list):
|
| 346 |
+
continue
|
| 347 |
+
for target_raw in raw_targets:
|
| 348 |
+
target = str(target_raw).strip()
|
| 349 |
+
if target and (source, target) not in current:
|
| 350 |
+
candidates.append((source, target))
|
| 351 |
+
if not candidates:
|
| 352 |
+
return None
|
| 353 |
+
source, target = rng.choice(candidates)
|
| 354 |
+
return MutationOp(
|
| 355 |
+
mutation_id=f"mut_add_dep_{source}_{target}",
|
| 356 |
+
op_type="add_dependency_edge",
|
| 357 |
+
target_selector={"source": source, "target": target},
|
| 358 |
+
params={},
|
| 359 |
+
expected_effects=[f"dependency edge {source}->{target} added"],
|
| 360 |
+
risk_tags=["topology_expansion"],
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
def _candidate_add_trust_edge(
|
| 364 |
+
self,
|
| 365 |
+
manifest: dict[str, Any],
|
| 366 |
+
snapshot: SnapshotSpec,
|
| 367 |
+
rng: random.Random,
|
| 368 |
+
) -> MutationOp | None:
|
| 369 |
+
topology = snapshot.topology
|
| 370 |
+
current = {
|
| 371 |
+
(
|
| 372 |
+
str(edge.get("source", "")),
|
| 373 |
+
str(edge.get("target", "")),
|
| 374 |
+
str(edge.get("type", "")),
|
| 375 |
+
)
|
| 376 |
+
for edge in topology.get("trust_edges", [])
|
| 377 |
+
if isinstance(edge, dict)
|
| 378 |
+
}
|
| 379 |
+
candidates: list[dict[str, str]] = []
|
| 380 |
+
for raw in manifest.get("trust_relationships", []):
|
| 381 |
+
if not isinstance(raw, dict):
|
| 382 |
+
continue
|
| 383 |
+
source = str(raw.get("source") or raw.get("from") or "").strip()
|
| 384 |
+
target = str(raw.get("target") or raw.get("to") or "").strip()
|
| 385 |
+
edge_type = str(raw.get("type", "")).strip()
|
| 386 |
+
if source and target and (source, target, edge_type) not in current:
|
| 387 |
+
candidates.append(
|
| 388 |
+
{
|
| 389 |
+
"source": source,
|
| 390 |
+
"target": target,
|
| 391 |
+
"type": edge_type,
|
| 392 |
+
"context": str(raw.get("context") or raw.get("description") or ""),
|
| 393 |
+
}
|
| 394 |
+
)
|
| 395 |
+
if not candidates:
|
| 396 |
+
return None
|
| 397 |
+
choice = rng.choice(candidates)
|
| 398 |
+
return MutationOp(
|
| 399 |
+
mutation_id=f"mut_add_trust_{choice['source']}_{choice['target']}_{choice['type']}",
|
| 400 |
+
op_type="add_trust_edge",
|
| 401 |
+
target_selector={"source": choice["source"], "target": choice["target"]},
|
| 402 |
+
params={"type": choice["type"], "context": choice["context"]},
|
| 403 |
+
expected_effects=[f"trust edge {choice['source']}->{choice['target']} added"],
|
| 404 |
+
risk_tags=["trust_expansion"],
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
def _candidate_seed_vuln(
|
| 408 |
+
self,
|
| 409 |
+
manifest: dict[str, Any],
|
| 410 |
+
snapshot: SnapshotSpec,
|
| 411 |
+
context: BuildContext,
|
| 412 |
+
rng: random.Random,
|
| 413 |
+
) -> MutationOp | None:
|
| 414 |
+
allowed = [str(v) for v in manifest.get("bug_families", []) if v]
|
| 415 |
+
if not allowed:
|
| 416 |
+
return None
|
| 417 |
+
existing = {v.type for v in snapshot.truth_graph.vulns}
|
| 418 |
+
preferred = [v for v in context.weak_areas if v in allowed and v not in existing]
|
| 419 |
+
remaining = [v for v in allowed if v not in existing]
|
| 420 |
+
choices = preferred or remaining or allowed
|
| 421 |
+
vuln_type = rng.choice(choices)
|
| 422 |
+
|
| 423 |
+
host_catalog = snapshot.topology.get("host_catalog", {})
|
| 424 |
+
host_candidates = list(host_catalog.keys()) if isinstance(host_catalog, dict) else []
|
| 425 |
+
if not host_candidates:
|
| 426 |
+
host_candidates = list(_existing_hosts(snapshot))
|
| 427 |
+
if not host_candidates:
|
| 428 |
+
return None
|
| 429 |
+
host = str(rng.choice(host_candidates))
|
| 430 |
+
service = ""
|
| 431 |
+
if isinstance(host_catalog, dict):
|
| 432 |
+
raw_catalog = host_catalog.get(host, {})
|
| 433 |
+
if isinstance(raw_catalog, dict):
|
| 434 |
+
raw_services = raw_catalog.get("services", [])
|
| 435 |
+
if isinstance(raw_services, list) and raw_services:
|
| 436 |
+
service = str(raw_services[0])
|
| 437 |
+
|
| 438 |
+
return MutationOp(
|
| 439 |
+
mutation_id=f"mut_seed_vuln_{vuln_type}_{host}_{len(snapshot.truth_graph.vulns)+1}",
|
| 440 |
+
op_type="seed_vuln",
|
| 441 |
+
target_selector={"host": host},
|
| 442 |
+
params={"vuln_type": vuln_type, "service": service},
|
| 443 |
+
expected_effects=[f"new {vuln_type} foothold on {host}"],
|
| 444 |
+
risk_tags=["security_condition"],
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
def _candidate_add_benign_noise(
|
| 448 |
+
self,
|
| 449 |
+
snapshot: SnapshotSpec,
|
| 450 |
+
rng: random.Random,
|
| 451 |
+
) -> MutationOp | None:
|
| 452 |
+
locations = [item.location for item in snapshot.evidence_spec if item.location]
|
| 453 |
+
location = rng.choice(locations) if locations else "siem:background.log"
|
| 454 |
+
return MutationOp(
|
| 455 |
+
mutation_id=f"mut_add_noise_{len(snapshot.evidence_spec)+1}",
|
| 456 |
+
op_type="add_benign_noise",
|
| 457 |
+
target_selector={"location": location},
|
| 458 |
+
params={"location": location},
|
| 459 |
+
expected_effects=[f"benign evidence noise added at {location}"],
|
| 460 |
+
risk_tags=["observability_noise"],
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
def _apply_plan(
|
| 464 |
+
self,
|
| 465 |
+
snapshot: SnapshotSpec,
|
| 466 |
+
plan: MutationPlan,
|
| 467 |
+
manifest: dict[str, Any],
|
| 468 |
+
context: BuildContext,
|
| 469 |
+
) -> None:
|
| 470 |
+
topology = snapshot.topology
|
| 471 |
+
host_details = topology.setdefault("host_details", {})
|
| 472 |
+
dependency_edges = topology.setdefault("dependency_edges", [])
|
| 473 |
+
trust_edges = topology.setdefault("trust_edges", [])
|
| 474 |
+
users = topology.setdefault("users", [])
|
| 475 |
+
|
| 476 |
+
if not isinstance(host_details, dict):
|
| 477 |
+
host_details = {}
|
| 478 |
+
topology["host_details"] = host_details
|
| 479 |
+
if not isinstance(dependency_edges, list):
|
| 480 |
+
dependency_edges = []
|
| 481 |
+
topology["dependency_edges"] = dependency_edges
|
| 482 |
+
if not isinstance(trust_edges, list):
|
| 483 |
+
trust_edges = []
|
| 484 |
+
topology["trust_edges"] = trust_edges
|
| 485 |
+
if not isinstance(users, list):
|
| 486 |
+
users = []
|
| 487 |
+
topology["users"] = users
|
| 488 |
+
|
| 489 |
+
for op in plan.ops:
|
| 490 |
+
if op.op_type not in _SUPPORTED_MUTATION_OPS:
|
| 491 |
+
raise ValueError(f"Unsupported mutation op {op.op_type!r}")
|
| 492 |
+
|
| 493 |
+
if op.op_type == "add_service":
|
| 494 |
+
host = op.target_selector["host"]
|
| 495 |
+
detail = host_details.setdefault(host, {"services": [], "connects_to": []})
|
| 496 |
+
services = detail.setdefault("services", [])
|
| 497 |
+
service = str(op.params.get("service", "")).strip()
|
| 498 |
+
if service and service not in services:
|
| 499 |
+
services.append(service)
|
| 500 |
+
|
| 501 |
+
elif op.op_type == "add_user":
|
| 502 |
+
users.append(
|
| 503 |
+
{
|
| 504 |
+
"username": str(op.params.get("username", "")),
|
| 505 |
+
"password": str(op.params.get("password", "")),
|
| 506 |
+
"groups": deepcopy(op.params.get("groups", [])),
|
| 507 |
+
"hosts": deepcopy(op.params.get("hosts", [])),
|
| 508 |
+
"email": str(op.params.get("email", "")),
|
| 509 |
+
"full_name": str(op.params.get("full_name", "")),
|
| 510 |
+
"department": str(op.params.get("department", "")),
|
| 511 |
+
"role": str(op.params.get("role", "")),
|
| 512 |
+
}
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
elif op.op_type == "add_dependency_edge":
|
| 516 |
+
dependency_edges.append(
|
| 517 |
+
{
|
| 518 |
+
"source": op.target_selector["source"],
|
| 519 |
+
"target": op.target_selector["target"],
|
| 520 |
+
}
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
elif op.op_type == "add_trust_edge":
|
| 524 |
+
trust_edges.append(
|
| 525 |
+
{
|
| 526 |
+
"source": op.target_selector["source"],
|
| 527 |
+
"target": op.target_selector["target"],
|
| 528 |
+
"type": str(op.params.get("type", "")),
|
| 529 |
+
"context": str(op.params.get("context", "")),
|
| 530 |
+
}
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
elif op.op_type == "seed_vuln":
|
| 534 |
+
vuln_type = str(op.params.get("vuln_type", "")).strip()
|
| 535 |
+
host = op.target_selector["host"]
|
| 536 |
+
service = str(op.params.get("service", "")).strip()
|
| 537 |
+
vuln_id = f"{vuln_type}_{len(snapshot.truth_graph.vulns) + 1}"
|
| 538 |
+
snapshot.truth_graph.vulns.append(
|
| 539 |
+
Vulnerability(
|
| 540 |
+
id=vuln_id,
|
| 541 |
+
type=vuln_type,
|
| 542 |
+
host=host,
|
| 543 |
+
service=service,
|
| 544 |
+
injection_point=_INJECTION_POINTS.get(vuln_type, f"/debug/{vuln_type}"),
|
| 545 |
+
vulnerable_code=f"// mutation-added {vuln_type} surface on {host}",
|
| 546 |
+
root_cause=f"Mutation introduced {vuln_type} on {host}",
|
| 547 |
+
blast_radius=f"Additional foothold on {host}",
|
| 548 |
+
remediation=f"Remove the {vuln_type} issue and review dependent trust paths",
|
| 549 |
+
)
|
| 550 |
+
)
|
| 551 |
+
snapshot.truth_graph.exploit_chain.append(
|
| 552 |
+
ExploitStep(
|
| 553 |
+
vuln_id=vuln_id,
|
| 554 |
+
command=f"probe {host} for {vuln_type}",
|
| 555 |
+
description=f"Use the new {vuln_type} foothold on {host}",
|
| 556 |
+
)
|
| 557 |
+
)
|
| 558 |
+
snapshot.evidence_spec.append(
|
| 559 |
+
EvidenceItem(
|
| 560 |
+
type="log_entry",
|
| 561 |
+
location=f"{host}:app.log",
|
| 562 |
+
pattern=f"Mutation-added {vuln_type} activity on {host}",
|
| 563 |
+
)
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
elif op.op_type == "add_benign_noise":
|
| 567 |
+
location = str(op.params.get("location", "siem:background.log"))
|
| 568 |
+
snapshot.evidence_spec.append(
|
| 569 |
+
EvidenceItem(
|
| 570 |
+
type="log_entry",
|
| 571 |
+
location=location,
|
| 572 |
+
pattern=(
|
| 573 |
+
f"Benign background activity {context.episode_count + len(snapshot.evidence_spec)}"
|
| 574 |
+
),
|
| 575 |
+
)
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
snapshot.topology = topology
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
def _build_host_catalog(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
| 582 |
+
catalog: dict[str, dict[str, Any]] = {}
|
| 583 |
+
for raw in manifest.get("topology", {}).get("hosts", []):
|
| 584 |
+
if not isinstance(raw, dict):
|
| 585 |
+
continue
|
| 586 |
+
name = str(raw.get("name", "")).strip()
|
| 587 |
+
if not name:
|
| 588 |
+
continue
|
| 589 |
+
catalog[name] = {
|
| 590 |
+
"zone": str(raw.get("zone", "")),
|
| 591 |
+
"services": deepcopy(raw.get("services", [])),
|
| 592 |
+
"connects_to": deepcopy(raw.get("connects_to", [])),
|
| 593 |
+
}
|
| 594 |
+
return catalog
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def _ensure_mutable_topology(
|
| 598 |
+
topology: dict[str, Any],
|
| 599 |
+
manifest: dict[str, Any],
|
| 600 |
+
) -> dict[str, Any]:
|
| 601 |
+
updated = dict(topology)
|
| 602 |
+
updated.setdefault("manifest_name", manifest.get("name", ""))
|
| 603 |
+
updated.setdefault("difficulty", deepcopy(manifest.get("difficulty", {})))
|
| 604 |
+
updated.setdefault("host_catalog", _build_host_catalog(manifest))
|
| 605 |
+
updated.setdefault("host_details", {})
|
| 606 |
+
updated.setdefault("dependency_edges", [])
|
| 607 |
+
updated.setdefault("trust_edges", [])
|
| 608 |
+
return updated
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def _existing_hosts(snapshot: SnapshotSpec) -> set[str]:
|
| 612 |
+
hosts: set[str] = set()
|
| 613 |
+
for raw in snapshot.topology.get("hosts", []):
|
| 614 |
+
if isinstance(raw, dict):
|
| 615 |
+
name = str(raw.get("name", "")).strip()
|
| 616 |
+
if name:
|
| 617 |
+
hosts.add(name)
|
| 618 |
+
else:
|
| 619 |
+
name = str(raw).strip()
|
| 620 |
+
if name:
|
| 621 |
+
hosts.add(name)
|
| 622 |
+
return hosts
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def _existing_usernames(snapshot: SnapshotSpec) -> set[str]:
|
| 626 |
+
usernames: set[str] = set()
|
| 627 |
+
for raw in snapshot.topology.get("users", []):
|
| 628 |
+
if not isinstance(raw, dict):
|
| 629 |
+
continue
|
| 630 |
+
username = str(raw.get("username", "")).strip()
|
| 631 |
+
if username:
|
| 632 |
+
usernames.add(username)
|
| 633 |
+
return usernames
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def _predictable_password(username: str, seed: int | None) -> str:
|
| 637 |
+
suffix = 2025 if seed is None else 2025 + (seed % 3)
|
| 638 |
+
base = username.split("@", 1)[0] or "Welcome"
|
| 639 |
+
return f"{base.capitalize()}!{suffix}"
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
def _mutation_summary(op: MutationOp) -> str:
|
| 643 |
+
if op.op_type == "add_service":
|
| 644 |
+
return f"add service {op.params.get('service', '')} to {op.target_selector.get('host', '')}"
|
| 645 |
+
if op.op_type == "add_user":
|
| 646 |
+
return f"add user {op.params.get('username', '')}"
|
| 647 |
+
if op.op_type == "add_dependency_edge":
|
| 648 |
+
return (
|
| 649 |
+
f"add dependency {op.target_selector.get('source', '')}->"
|
| 650 |
+
f"{op.target_selector.get('target', '')}"
|
| 651 |
+
)
|
| 652 |
+
if op.op_type == "add_trust_edge":
|
| 653 |
+
return (
|
| 654 |
+
f"add trust {op.target_selector.get('source', '')}->"
|
| 655 |
+
f"{op.target_selector.get('target', '')}"
|
| 656 |
+
)
|
| 657 |
+
if op.op_type == "seed_vuln":
|
| 658 |
+
return (
|
| 659 |
+
f"seed {op.params.get('vuln_type', '')} on "
|
| 660 |
+
f"{op.target_selector.get('host', '')}"
|
| 661 |
+
)
|
| 662 |
+
if op.op_type == "add_benign_noise":
|
| 663 |
+
return f"add benign noise at {op.params.get('location', '')}"
|
| 664 |
+
return op.op_type
|
src/open_range/builder/snapshot_store.py
CHANGED
|
@@ -70,6 +70,10 @@ class SnapshotStore:
|
|
| 70 |
"flag_count": len(snapshot.flags),
|
| 71 |
"npc_count": len(snapshot.npc_personas),
|
| 72 |
"has_compose": bool(snapshot.compose),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
"stored_at": time.time(),
|
| 74 |
}
|
| 75 |
meta_path = snap_dir / "metadata.json"
|
|
|
|
| 70 |
"flag_count": len(snapshot.flags),
|
| 71 |
"npc_count": len(snapshot.npc_personas),
|
| 72 |
"has_compose": bool(snapshot.compose),
|
| 73 |
+
"parent_snapshot_id": snapshot.lineage.parent_snapshot_id,
|
| 74 |
+
"root_snapshot_id": snapshot.lineage.root_snapshot_id,
|
| 75 |
+
"generation_depth": snapshot.lineage.generation_depth,
|
| 76 |
+
"mutation_summary": list(snapshot.lineage.mutation_summary),
|
| 77 |
"stored_at": time.time(),
|
| 78 |
}
|
| 79 |
meta_path = snap_dir / "metadata.json"
|
src/open_range/protocols.py
CHANGED
|
@@ -46,6 +46,40 @@ class BuildContext(BaseModel):
|
|
| 46 |
)
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
class Vulnerability(BaseModel):
|
| 50 |
"""Single planted vulnerability in the truth graph."""
|
| 51 |
|
|
@@ -160,6 +194,8 @@ class SnapshotSpec(BaseModel):
|
|
| 160 |
task: TaskSpec = Field(default_factory=TaskSpec)
|
| 161 |
compose: dict[str, Any] = Field(default_factory=dict) # rendered docker-compose
|
| 162 |
files: dict[str, str] = Field(default_factory=dict) # path -> content
|
|
|
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
class Stimulus(BaseModel):
|
|
|
|
| 46 |
)
|
| 47 |
|
| 48 |
|
| 49 |
+
class MutationOp(BaseModel):
|
| 50 |
+
"""Single typed edit applied to derive a child snapshot from a parent."""
|
| 51 |
+
|
| 52 |
+
mutation_id: str
|
| 53 |
+
op_type: str
|
| 54 |
+
target_selector: dict[str, str] = Field(default_factory=dict)
|
| 55 |
+
magnitude: int = 1
|
| 56 |
+
params: dict[str, Any] = Field(default_factory=dict)
|
| 57 |
+
expected_effects: list[str] = Field(default_factory=list)
|
| 58 |
+
risk_tags: list[str] = Field(default_factory=list)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class MutationPlan(BaseModel):
|
| 62 |
+
"""Ordered list of mutations used to produce a child snapshot."""
|
| 63 |
+
|
| 64 |
+
parent_snapshot_id: str | None = None
|
| 65 |
+
ops: list[MutationOp] = Field(default_factory=list)
|
| 66 |
+
predicted_complexity_delta: int = 0
|
| 67 |
+
predicted_chain_delta: int = 0
|
| 68 |
+
predicted_novelty: float = 0.0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class LineageMetadata(BaseModel):
|
| 72 |
+
"""Lineage and mutation provenance for a stored snapshot."""
|
| 73 |
+
|
| 74 |
+
snapshot_id: str = ""
|
| 75 |
+
parent_snapshot_id: str | None = None
|
| 76 |
+
root_snapshot_id: str = ""
|
| 77 |
+
manifest_id: str = ""
|
| 78 |
+
generation_depth: int = 0
|
| 79 |
+
mutation_ids: list[str] = Field(default_factory=list)
|
| 80 |
+
mutation_summary: list[str] = Field(default_factory=list)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
class Vulnerability(BaseModel):
|
| 84 |
"""Single planted vulnerability in the truth graph."""
|
| 85 |
|
|
|
|
| 194 |
task: TaskSpec = Field(default_factory=TaskSpec)
|
| 195 |
compose: dict[str, Any] = Field(default_factory=dict) # rendered docker-compose
|
| 196 |
files: dict[str, str] = Field(default_factory=dict) # path -> content
|
| 197 |
+
lineage: LineageMetadata = Field(default_factory=LineageMetadata)
|
| 198 |
+
mutation_plan: MutationPlan | None = None
|
| 199 |
|
| 200 |
|
| 201 |
class Stimulus(BaseModel):
|
src/open_range/server/runtime.py
CHANGED
|
@@ -32,6 +32,8 @@ from open_range.protocols import (
|
|
| 32 |
SnapshotSpec,
|
| 33 |
)
|
| 34 |
from open_range.server.models import RangeState
|
|
|
|
|
|
|
| 35 |
from open_range.validator.task_feasibility import TaskFeasibilityCheck
|
| 36 |
from open_range.validator.validator import ValidationResult, ValidatorGate
|
| 37 |
|
|
@@ -242,11 +244,13 @@ def _default_builder() -> SnapshotBuilder:
|
|
| 242 |
)
|
| 243 |
|
| 244 |
|
| 245 |
-
def _default_validator() -> ValidatorGate:
|
| 246 |
# These checks work directly against the compiled snapshot spec and do not
|
| 247 |
# require booted containers. They are the safe default for shipped mode.
|
| 248 |
return ValidatorGate(
|
| 249 |
[
|
|
|
|
|
|
|
| 250 |
StructuralSnapshotCheck(),
|
| 251 |
TaskFeasibilityCheck(),
|
| 252 |
]
|
|
@@ -280,7 +284,7 @@ class ManagedSnapshotRuntime:
|
|
| 280 |
self.store = SnapshotStore(str(self.store_dir))
|
| 281 |
self.builder = builder or _default_builder()
|
| 282 |
self.mutator = Mutator(self.builder)
|
| 283 |
-
self.validator = validator or _default_validator()
|
| 284 |
self.renderer = SnapshotRenderer()
|
| 285 |
self.curriculum = CurriculumTracker()
|
| 286 |
self.pool_size = max(1, pool_size)
|
|
@@ -452,11 +456,14 @@ class ManagedSnapshotRuntime:
|
|
| 452 |
last_error: str | None = None
|
| 453 |
for attempt in range(1, self.generation_retries + 1):
|
| 454 |
context = self._build_context()
|
|
|
|
| 455 |
snapshot = _run_coro_sync(
|
| 456 |
self.mutator.mutate(
|
| 457 |
self.manifest,
|
| 458 |
context=context,
|
| 459 |
error={"message": last_error} if last_error else None,
|
|
|
|
|
|
|
| 460 |
)
|
| 461 |
)
|
| 462 |
validation = self._validate_snapshot(snapshot)
|
|
@@ -516,6 +523,11 @@ class ManagedSnapshotRuntime:
|
|
| 516 |
prefix = "snap_" + "_".join(vuln_types[:3]) if vuln_types else "snap_generated"
|
| 517 |
return f"{prefix}_{int(time.time() * 1000)}"
|
| 518 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
def _snapshot_dir(self, snapshot_id: str) -> Path:
|
| 520 |
return self.store_dir / snapshot_id
|
| 521 |
|
|
@@ -532,6 +544,9 @@ class ManagedSnapshotRuntime:
|
|
| 532 |
topology = dict(rendered.topology)
|
| 533 |
topology["snapshot_id"] = snapshot_id
|
| 534 |
rendered.topology = topology
|
|
|
|
|
|
|
|
|
|
| 535 |
|
| 536 |
snapshot_dir = self._snapshot_dir(snapshot_id)
|
| 537 |
artifacts_dir = self._artifacts_dir(snapshot_id)
|
|
|
|
| 32 |
SnapshotSpec,
|
| 33 |
)
|
| 34 |
from open_range.server.models import RangeState
|
| 35 |
+
from open_range.validator.graph_consistency import GraphConsistencyCheck
|
| 36 |
+
from open_range.validator.manifest_compliance import ManifestComplianceCheck
|
| 37 |
from open_range.validator.task_feasibility import TaskFeasibilityCheck
|
| 38 |
from open_range.validator.validator import ValidationResult, ValidatorGate
|
| 39 |
|
|
|
|
| 244 |
)
|
| 245 |
|
| 246 |
|
| 247 |
+
def _default_validator(manifest: dict[str, Any]) -> ValidatorGate:
|
| 248 |
# These checks work directly against the compiled snapshot spec and do not
|
| 249 |
# require booted containers. They are the safe default for shipped mode.
|
| 250 |
return ValidatorGate(
|
| 251 |
[
|
| 252 |
+
ManifestComplianceCheck(manifest),
|
| 253 |
+
GraphConsistencyCheck(),
|
| 254 |
StructuralSnapshotCheck(),
|
| 255 |
TaskFeasibilityCheck(),
|
| 256 |
]
|
|
|
|
| 284 |
self.store = SnapshotStore(str(self.store_dir))
|
| 285 |
self.builder = builder or _default_builder()
|
| 286 |
self.mutator = Mutator(self.builder)
|
| 287 |
+
self.validator = validator or _default_validator(self.manifest)
|
| 288 |
self.renderer = SnapshotRenderer()
|
| 289 |
self.curriculum = CurriculumTracker()
|
| 290 |
self.pool_size = max(1, pool_size)
|
|
|
|
| 456 |
last_error: str | None = None
|
| 457 |
for attempt in range(1, self.generation_retries + 1):
|
| 458 |
context = self._build_context()
|
| 459 |
+
parent_entry = self._select_parent_entry()
|
| 460 |
snapshot = _run_coro_sync(
|
| 461 |
self.mutator.mutate(
|
| 462 |
self.manifest,
|
| 463 |
context=context,
|
| 464 |
error={"message": last_error} if last_error else None,
|
| 465 |
+
parent_snapshot=parent_entry.snapshot if parent_entry else None,
|
| 466 |
+
parent_snapshot_id=parent_entry.snapshot_id if parent_entry else None,
|
| 467 |
)
|
| 468 |
)
|
| 469 |
validation = self._validate_snapshot(snapshot)
|
|
|
|
| 523 |
prefix = "snap_" + "_".join(vuln_types[:3]) if vuln_types else "snap_generated"
|
| 524 |
return f"{prefix}_{int(time.time() * 1000)}"
|
| 525 |
|
| 526 |
+
def _select_parent_entry(self):
|
| 527 |
+
if self.snapshot_count() == 0:
|
| 528 |
+
return None
|
| 529 |
+
return _run_coro_sync(self.store.select_entry(strategy=self.selection_strategy))
|
| 530 |
+
|
| 531 |
def _snapshot_dir(self, snapshot_id: str) -> Path:
|
| 532 |
return self.store_dir / snapshot_id
|
| 533 |
|
|
|
|
| 544 |
topology = dict(rendered.topology)
|
| 545 |
topology["snapshot_id"] = snapshot_id
|
| 546 |
rendered.topology = topology
|
| 547 |
+
rendered.lineage.snapshot_id = snapshot_id
|
| 548 |
+
if not rendered.lineage.root_snapshot_id:
|
| 549 |
+
rendered.lineage.root_snapshot_id = snapshot_id
|
| 550 |
|
| 551 |
snapshot_dir = self._snapshot_dir(snapshot_id)
|
| 552 |
artifacts_dir = self._artifacts_dir(snapshot_id)
|
src/open_range/validator/graph_consistency.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph-level consistency checks for compiled snapshot state."""
|
| 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 GraphConsistencyCheck:
|
| 10 |
+
"""Verify internal consistency of the canonical graph views."""
|
| 11 |
+
|
| 12 |
+
async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
|
| 13 |
+
compiled = compile_snapshot_graphs(snapshot)
|
| 14 |
+
issues: list[str] = []
|
| 15 |
+
|
| 16 |
+
for source, target in compiled.dependency_edges:
|
| 17 |
+
if source not in compiled.hosts or target not in compiled.hosts:
|
| 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.users or target not in compiled.users:
|
| 22 |
+
issues.append(f"trust edge '{source}->{target}' references unknown user")
|
| 23 |
+
|
| 24 |
+
lineage = snapshot.lineage
|
| 25 |
+
if lineage.generation_depth == 0 and lineage.parent_snapshot_id:
|
| 26 |
+
issues.append("root snapshot must not have parent_snapshot_id")
|
| 27 |
+
if lineage.generation_depth > 0 and not lineage.parent_snapshot_id:
|
| 28 |
+
issues.append("child snapshot missing parent_snapshot_id")
|
| 29 |
+
if snapshot.mutation_plan is not None:
|
| 30 |
+
if snapshot.mutation_plan.parent_snapshot_id != lineage.parent_snapshot_id:
|
| 31 |
+
issues.append("mutation plan parent does not match lineage parent")
|
| 32 |
+
for op in snapshot.mutation_plan.ops:
|
| 33 |
+
if op.op_type in {"add_service", "seed_vuln"}:
|
| 34 |
+
host = op.target_selector.get("host", "")
|
| 35 |
+
if host and host not in compiled.hosts:
|
| 36 |
+
issues.append(
|
| 37 |
+
f"mutation '{op.mutation_id}' targets unknown host '{host}'"
|
| 38 |
+
)
|
| 39 |
+
if op.op_type == "add_dependency_edge":
|
| 40 |
+
source = op.target_selector.get("source", "")
|
| 41 |
+
target = op.target_selector.get("target", "")
|
| 42 |
+
if source and source not in compiled.hosts:
|
| 43 |
+
issues.append(
|
| 44 |
+
f"mutation '{op.mutation_id}' source host '{source}' missing"
|
| 45 |
+
)
|
| 46 |
+
if target and target not in compiled.hosts:
|
| 47 |
+
issues.append(
|
| 48 |
+
f"mutation '{op.mutation_id}' target host '{target}' missing"
|
| 49 |
+
)
|
| 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.users:
|
| 54 |
+
issues.append(
|
| 55 |
+
f"mutation '{op.mutation_id}' source user '{source}' missing"
|
| 56 |
+
)
|
| 57 |
+
if target and target not in compiled.users:
|
| 58 |
+
issues.append(
|
| 59 |
+
f"mutation '{op.mutation_id}' target user '{target}' missing"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
passed = len(issues) == 0
|
| 63 |
+
return CheckResult(
|
| 64 |
+
name="graph_consistency",
|
| 65 |
+
passed=passed,
|
| 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 |
+
},
|
| 72 |
+
error="" if passed else "; ".join(issues),
|
| 73 |
+
)
|
src/open_range/validator/graphs.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compile SnapshotSpec into lightweight canonical graph views.
|
| 2 |
+
|
| 3 |
+
These helpers intentionally stay small and dependency-free. The validator uses
|
| 4 |
+
them to reason about host membership, dependency edges, trust edges, evidence
|
| 5 |
+
locations, and mutation targets before any live container checks run.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
|
| 12 |
+
from open_range.protocols import SnapshotSpec
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True, slots=True)
|
| 16 |
+
class CompiledGraphs:
|
| 17 |
+
"""Canonical graph-like views derived from a snapshot."""
|
| 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]]
|
| 24 |
+
vuln_ids: frozenset[str]
|
| 25 |
+
evidence_locations: frozenset[str]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def compile_snapshot_graphs(snapshot: SnapshotSpec) -> CompiledGraphs:
|
| 29 |
+
"""Compile a snapshot into canonical graph views."""
|
| 30 |
+
|
| 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)
|
| 37 |
+
vuln_ids = frozenset(v.id for v in snapshot.truth_graph.vulns if v.id)
|
| 38 |
+
evidence_locations = frozenset(item.location for item in snapshot.evidence_spec if item.location)
|
| 39 |
+
|
| 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,
|
| 46 |
+
vuln_ids=vuln_ids,
|
| 47 |
+
evidence_locations=evidence_locations,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _compile_hosts(topology: dict[str, object]) -> frozenset[str]:
|
| 52 |
+
raw_hosts = topology.get("hosts", [])
|
| 53 |
+
hosts: set[str] = set()
|
| 54 |
+
for raw in raw_hosts if isinstance(raw_hosts, list) else []:
|
| 55 |
+
if isinstance(raw, dict):
|
| 56 |
+
name = str(raw.get("name", "")).strip()
|
| 57 |
+
if name:
|
| 58 |
+
hosts.add(name)
|
| 59 |
+
else:
|
| 60 |
+
name = str(raw).strip()
|
| 61 |
+
if name:
|
| 62 |
+
hosts.add(name)
|
| 63 |
+
return frozenset(hosts)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _compile_users(topology: dict[str, object]) -> frozenset[str]:
|
| 67 |
+
raw_users = topology.get("users", [])
|
| 68 |
+
users: set[str] = set()
|
| 69 |
+
for raw in raw_users if isinstance(raw_users, list) else []:
|
| 70 |
+
if not isinstance(raw, dict):
|
| 71 |
+
continue
|
| 72 |
+
username = str(raw.get("username", "")).strip()
|
| 73 |
+
if username:
|
| 74 |
+
users.add(username)
|
| 75 |
+
return frozenset(users)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _compile_services(
|
| 79 |
+
topology: dict[str, object],
|
| 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 = {}
|
| 86 |
+
if isinstance(host_details, dict):
|
| 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 = []
|
| 93 |
+
compiled[host] = frozenset(str(service) for service in services if service)
|
| 94 |
+
return compiled
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _compile_dependency_edges(topology: dict[str, object]) -> frozenset[tuple[str, str]]:
|
| 98 |
+
raw_edges = topology.get("dependency_edges", [])
|
| 99 |
+
edges: set[tuple[str, str]] = set()
|
| 100 |
+
for raw in raw_edges if isinstance(raw_edges, list) else []:
|
| 101 |
+
if not isinstance(raw, dict):
|
| 102 |
+
continue
|
| 103 |
+
source = str(raw.get("source", "")).strip()
|
| 104 |
+
target = str(raw.get("target", "")).strip()
|
| 105 |
+
if source and target:
|
| 106 |
+
edges.add((source, target))
|
| 107 |
+
return frozenset(edges)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _compile_trust_edges(topology: dict[str, object]) -> frozenset[tuple[str, str, str]]:
|
| 111 |
+
raw_edges = topology.get("trust_edges", [])
|
| 112 |
+
edges: set[tuple[str, str, str]] = set()
|
| 113 |
+
for raw in raw_edges if isinstance(raw_edges, list) else []:
|
| 114 |
+
if not isinstance(raw, dict):
|
| 115 |
+
continue
|
| 116 |
+
source = str(raw.get("source", "")).strip()
|
| 117 |
+
target = str(raw.get("target", "")).strip()
|
| 118 |
+
edge_type = str(raw.get("type", "")).strip()
|
| 119 |
+
if source and target:
|
| 120 |
+
edges.add((source, target, edge_type))
|
| 121 |
+
return frozenset(edges)
|
src/open_range/validator/manifest_compliance.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manifest-bounded legality checks for candidate snapshots."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
|
| 8 |
+
from open_range.validator.graphs import compile_snapshot_graphs
|
| 9 |
+
|
| 10 |
+
_SUPPORTED_MUTATION_OPS = {
|
| 11 |
+
"add_service",
|
| 12 |
+
"add_user",
|
| 13 |
+
"add_dependency_edge",
|
| 14 |
+
"add_trust_edge",
|
| 15 |
+
"seed_vuln",
|
| 16 |
+
"add_benign_noise",
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
_SYSTEM_USERS = {"admin", "testuser"}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ManifestComplianceCheck:
|
| 23 |
+
"""Ensure a candidate child stays inside the manifest-defined family."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, manifest: dict[str, Any]) -> None:
|
| 26 |
+
self.manifest = manifest
|
| 27 |
+
|
| 28 |
+
async def check(self, snapshot: SnapshotSpec, containers: ContainerSet) -> CheckResult:
|
| 29 |
+
compiled = compile_snapshot_graphs(snapshot)
|
| 30 |
+
issues: list[str] = []
|
| 31 |
+
|
| 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)
|
| 38 |
+
|
| 39 |
+
unknown_hosts = compiled.hosts - manifest_hosts
|
| 40 |
+
if unknown_hosts:
|
| 41 |
+
issues.append(f"hosts outside manifest family: {sorted(unknown_hosts)}")
|
| 42 |
+
|
| 43 |
+
illegal_users = {
|
| 44 |
+
user
|
| 45 |
+
for user in compiled.users
|
| 46 |
+
if user not in allowed_users and user not in _SYSTEM_USERS and not user.startswith("svc_")
|
| 47 |
+
}
|
| 48 |
+
if illegal_users:
|
| 49 |
+
issues.append(f"users outside manifest family: {sorted(illegal_users)}")
|
| 50 |
+
|
| 51 |
+
for host, services in compiled.services_by_host.items():
|
| 52 |
+
illegal = services - allowed_services.get(host, frozenset())
|
| 53 |
+
if illegal:
|
| 54 |
+
issues.append(
|
| 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}'")
|
| 61 |
+
if vuln.host and vuln.host not in manifest_hosts:
|
| 62 |
+
issues.append(f"vuln '{vuln.id}' references host outside manifest '{vuln.host}'")
|
| 63 |
+
|
| 64 |
+
plan = snapshot.mutation_plan
|
| 65 |
+
if plan is not None:
|
| 66 |
+
for op in plan.ops:
|
| 67 |
+
if op.op_type not in _SUPPORTED_MUTATION_OPS:
|
| 68 |
+
issues.append(f"unsupported mutation op '{op.op_type}'")
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
if op.op_type == "add_service":
|
| 72 |
+
host = op.target_selector.get("host", "")
|
| 73 |
+
service = str(op.params.get("service", "")).strip()
|
| 74 |
+
if host not in manifest_hosts:
|
| 75 |
+
issues.append(f"add_service targets unknown host '{host}'")
|
| 76 |
+
elif service and service not in allowed_services.get(host, frozenset()):
|
| 77 |
+
issues.append(f"add_service introduces illegal service '{service}' on '{host}'")
|
| 78 |
+
|
| 79 |
+
if op.op_type == "add_user":
|
| 80 |
+
username = str(op.params.get("username", "")).strip()
|
| 81 |
+
if username and username not in allowed_users:
|
| 82 |
+
issues.append(f"add_user introduces unknown manifest user '{username}'")
|
| 83 |
+
|
| 84 |
+
if op.op_type == "add_dependency_edge":
|
| 85 |
+
source = op.target_selector.get("source", "")
|
| 86 |
+
target = op.target_selector.get("target", "")
|
| 87 |
+
if (source, target) not in allowed_dependency_edges:
|
| 88 |
+
issues.append(
|
| 89 |
+
f"add_dependency_edge introduces illegal edge '{source}->{target}'"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
if op.op_type == "add_trust_edge":
|
| 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 "
|
| 99 |
+
f"'{source}->{target}' ({edge_type})"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
if op.op_type == "seed_vuln":
|
| 103 |
+
host = op.target_selector.get("host", "")
|
| 104 |
+
vuln_type = str(op.params.get("vuln_type", "")).strip()
|
| 105 |
+
if host not in manifest_hosts:
|
| 106 |
+
issues.append(f"seed_vuln targets unknown host '{host}'")
|
| 107 |
+
if vuln_type and vuln_type not in allowed_bug_families:
|
| 108 |
+
issues.append(f"seed_vuln uses illegal family '{vuln_type}'")
|
| 109 |
+
|
| 110 |
+
passed = len(issues) == 0
|
| 111 |
+
return CheckResult(
|
| 112 |
+
name="manifest_compliance",
|
| 113 |
+
passed=passed,
|
| 114 |
+
details={
|
| 115 |
+
"issue_count": len(issues),
|
| 116 |
+
"manifest": self.manifest.get("name", ""),
|
| 117 |
+
},
|
| 118 |
+
error="" if passed else "; ".join(issues),
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _manifest_hosts(manifest: dict[str, Any]) -> set[str]:
|
| 123 |
+
hosts: set[str] = set()
|
| 124 |
+
for raw in manifest.get("topology", {}).get("hosts", []):
|
| 125 |
+
if isinstance(raw, dict):
|
| 126 |
+
name = str(raw.get("name", "")).strip()
|
| 127 |
+
if name:
|
| 128 |
+
hosts.add(name)
|
| 129 |
+
return hosts
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _manifest_users(manifest: dict[str, Any]) -> set[str]:
|
| 133 |
+
users: set[str] = set()
|
| 134 |
+
for raw in manifest.get("users", []):
|
| 135 |
+
if isinstance(raw, dict):
|
| 136 |
+
username = str(raw.get("username", "")).strip()
|
| 137 |
+
if username:
|
| 138 |
+
users.add(username)
|
| 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", []):
|
| 145 |
+
if not isinstance(raw, dict):
|
| 146 |
+
continue
|
| 147 |
+
name = str(raw.get("name", "")).strip()
|
| 148 |
+
if not name:
|
| 149 |
+
continue
|
| 150 |
+
raw_services = raw.get("services", [])
|
| 151 |
+
if not isinstance(raw_services, list):
|
| 152 |
+
raw_services = []
|
| 153 |
+
services[name] = frozenset(str(service) for service in raw_services if service)
|
| 154 |
+
return services
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _manifest_dependency_edges(manifest: dict[str, Any]) -> set[tuple[str, str]]:
|
| 158 |
+
edges: set[tuple[str, str]] = set()
|
| 159 |
+
for raw in manifest.get("topology", {}).get("hosts", []):
|
| 160 |
+
if not isinstance(raw, dict):
|
| 161 |
+
continue
|
| 162 |
+
source = str(raw.get("name", "")).strip()
|
| 163 |
+
raw_targets = raw.get("connects_to", [])
|
| 164 |
+
if not source or not isinstance(raw_targets, list):
|
| 165 |
+
continue
|
| 166 |
+
for raw_target in raw_targets:
|
| 167 |
+
target = str(raw_target).strip()
|
| 168 |
+
if target:
|
| 169 |
+
edges.add((source, target))
|
| 170 |
+
return edges
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _manifest_trust_edges(manifest: dict[str, Any]) -> set[tuple[str, str, str]]:
|
| 174 |
+
edges: set[tuple[str, str, str]] = set()
|
| 175 |
+
for raw in manifest.get("trust_relationships", []):
|
| 176 |
+
if not isinstance(raw, dict):
|
| 177 |
+
continue
|
| 178 |
+
source = str(raw.get("source") or raw.get("from") or "").strip()
|
| 179 |
+
target = str(raw.get("target") or raw.get("to") or "").strip()
|
| 180 |
+
edge_type = str(raw.get("type", "")).strip()
|
| 181 |
+
if source and target:
|
| 182 |
+
edges.add((source, target, edge_type))
|
| 183 |
+
return edges
|
tests/test_builder.py
CHANGED
|
@@ -103,6 +103,28 @@ async def test_template_builder_has_task_briefings(tier1_manifest):
|
|
| 103 |
assert spec.task.blue_briefing != ""
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
# ---------------------------------------------------------------------------
|
| 107 |
# FileBuilder
|
| 108 |
# ---------------------------------------------------------------------------
|
|
|
|
| 103 |
assert spec.task.blue_briefing != ""
|
| 104 |
|
| 105 |
|
| 106 |
+
@pytest.mark.asyncio
|
| 107 |
+
async def test_mutator_builds_child_snapshot_with_lineage(tier1_manifest):
|
| 108 |
+
from open_range.builder.builder import TemplateOnlyBuilder
|
| 109 |
+
from open_range.builder.mutator import Mutator
|
| 110 |
+
|
| 111 |
+
mutator = Mutator(TemplateOnlyBuilder())
|
| 112 |
+
root = await mutator.mutate(tier1_manifest, context=BuildContext(seed=1, tier=1))
|
| 113 |
+
child = await mutator.mutate(
|
| 114 |
+
tier1_manifest,
|
| 115 |
+
context=BuildContext(seed=2, tier=1),
|
| 116 |
+
parent_snapshot=root,
|
| 117 |
+
parent_snapshot_id="root_snap",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
assert child.lineage.parent_snapshot_id == "root_snap"
|
| 121 |
+
assert child.lineage.generation_depth == 1
|
| 122 |
+
assert child.mutation_plan is not None
|
| 123 |
+
assert child.mutation_plan.parent_snapshot_id == "root_snap"
|
| 124 |
+
assert child.mutation_plan.ops
|
| 125 |
+
assert child.lineage.mutation_summary
|
| 126 |
+
|
| 127 |
+
|
| 128 |
# ---------------------------------------------------------------------------
|
| 129 |
# FileBuilder
|
| 130 |
# ---------------------------------------------------------------------------
|
tests/test_runtime.py
CHANGED
|
@@ -80,6 +80,42 @@ class TestManagedSnapshotRuntime:
|
|
| 80 |
finally:
|
| 81 |
runtime.stop()
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
class TestEnvironmentRuntimeIntegration:
|
| 85 |
def test_reset_uses_managed_runtime_snapshot(self, tier1_manifest, tmp_path):
|
|
|
|
| 80 |
finally:
|
| 81 |
runtime.stop()
|
| 82 |
|
| 83 |
+
def test_start_records_root_and_child_lineage(self, tier1_manifest, tmp_path):
|
| 84 |
+
runtime = ManagedSnapshotRuntime(
|
| 85 |
+
manifest=tier1_manifest,
|
| 86 |
+
store_dir=tmp_path / "snapshots",
|
| 87 |
+
pool_size=2,
|
| 88 |
+
selection_strategy="latest",
|
| 89 |
+
refill_enabled=False,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
runtime.start()
|
| 93 |
+
try:
|
| 94 |
+
listing = runtime.list_snapshots()
|
| 95 |
+
assert len(listing) == 2
|
| 96 |
+
depths = {item["generation_depth"] for item in listing}
|
| 97 |
+
assert 0 in depths
|
| 98 |
+
assert 1 in depths
|
| 99 |
+
assert any(item["parent_snapshot_id"] for item in listing)
|
| 100 |
+
finally:
|
| 101 |
+
runtime.stop()
|
| 102 |
+
|
| 103 |
+
def test_acquire_snapshot_exposes_lineage_metadata(self, tier1_manifest, tmp_path):
|
| 104 |
+
runtime = ManagedSnapshotRuntime(
|
| 105 |
+
manifest=tier1_manifest,
|
| 106 |
+
store_dir=tmp_path / "snapshots",
|
| 107 |
+
pool_size=2,
|
| 108 |
+
refill_enabled=False,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
runtime.start()
|
| 112 |
+
try:
|
| 113 |
+
admitted = runtime.acquire_snapshot()
|
| 114 |
+
assert admitted.snapshot.lineage.snapshot_id == admitted.snapshot_id
|
| 115 |
+
assert admitted.snapshot.lineage.root_snapshot_id
|
| 116 |
+
finally:
|
| 117 |
+
runtime.stop()
|
| 118 |
+
|
| 119 |
|
| 120 |
class TestEnvironmentRuntimeIntegration:
|
| 121 |
def test_reset_uses_managed_runtime_snapshot(self, tier1_manifest, tmp_path):
|
tests/test_validator.py
CHANGED
|
@@ -10,6 +10,8 @@ from open_range.protocols import (
|
|
| 10 |
EvidenceItem,
|
| 11 |
FlagSpec,
|
| 12 |
GoldenPathStep,
|
|
|
|
|
|
|
| 13 |
NPCPersona,
|
| 14 |
SnapshotSpec,
|
| 15 |
TaskSpec,
|
|
@@ -19,6 +21,58 @@ from open_range.protocols import (
|
|
| 19 |
from open_range.validator.validator import ValidatorGate, ValidationResult
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
# ---------------------------------------------------------------------------
|
| 23 |
# Check 1: BuildBoot
|
| 24 |
# ---------------------------------------------------------------------------
|
|
|
|
| 10 |
EvidenceItem,
|
| 11 |
FlagSpec,
|
| 12 |
GoldenPathStep,
|
| 13 |
+
MutationOp,
|
| 14 |
+
MutationPlan,
|
| 15 |
NPCPersona,
|
| 16 |
SnapshotSpec,
|
| 17 |
TaskSpec,
|
|
|
|
| 21 |
from open_range.validator.validator import ValidatorGate, ValidationResult
|
| 22 |
|
| 23 |
|
| 24 |
+
@pytest.mark.asyncio
|
| 25 |
+
async def test_manifest_compliance_rejects_illegal_mutation_plan(
|
| 26 |
+
tier1_manifest,
|
| 27 |
+
sample_snapshot_spec,
|
| 28 |
+
mock_containers,
|
| 29 |
+
):
|
| 30 |
+
from open_range.validator.manifest_compliance import ManifestComplianceCheck
|
| 31 |
+
|
| 32 |
+
spec = sample_snapshot_spec.model_copy(deep=True)
|
| 33 |
+
spec.mutation_plan = MutationPlan(
|
| 34 |
+
parent_snapshot_id="root_snap",
|
| 35 |
+
ops=[
|
| 36 |
+
MutationOp(
|
| 37 |
+
mutation_id="illegal1",
|
| 38 |
+
op_type="seed_vuln",
|
| 39 |
+
target_selector={"host": "web"},
|
| 40 |
+
params={"vuln_type": "totally_fake_bug"},
|
| 41 |
+
)
|
| 42 |
+
],
|
| 43 |
+
)
|
| 44 |
+
spec.lineage.parent_snapshot_id = "root_snap"
|
| 45 |
+
spec.lineage.generation_depth = 1
|
| 46 |
+
|
| 47 |
+
result = await ManifestComplianceCheck(tier1_manifest).check(spec, mock_containers)
|
| 48 |
+
assert result.passed is False
|
| 49 |
+
assert "illegal family" in result.error
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@pytest.mark.asyncio
|
| 53 |
+
async def test_graph_consistency_rejects_missing_parent_lineage(sample_snapshot_spec, mock_containers):
|
| 54 |
+
from open_range.validator.graph_consistency import GraphConsistencyCheck
|
| 55 |
+
|
| 56 |
+
spec = sample_snapshot_spec.model_copy(deep=True)
|
| 57 |
+
spec.mutation_plan = MutationPlan(
|
| 58 |
+
parent_snapshot_id="root_snap",
|
| 59 |
+
ops=[
|
| 60 |
+
MutationOp(
|
| 61 |
+
mutation_id="mut1",
|
| 62 |
+
op_type="add_benign_noise",
|
| 63 |
+
target_selector={"location": "siem:noise.log"},
|
| 64 |
+
params={"location": "siem:noise.log"},
|
| 65 |
+
)
|
| 66 |
+
],
|
| 67 |
+
)
|
| 68 |
+
spec.lineage.generation_depth = 1
|
| 69 |
+
spec.lineage.parent_snapshot_id = None
|
| 70 |
+
|
| 71 |
+
result = await GraphConsistencyCheck().check(spec, mock_containers)
|
| 72 |
+
assert result.passed is False
|
| 73 |
+
assert "missing parent_snapshot_id" in result.error
|
| 74 |
+
|
| 75 |
+
|
| 76 |
# ---------------------------------------------------------------------------
|
| 77 |
# Check 1: BuildBoot
|
| 78 |
# ---------------------------------------------------------------------------
|