diff --git a/.claude/skills/gh-workflow-monitor/SKILL.md b/.claude/skills/gh-workflow-monitor/SKILL.md new file mode 100644 index 000000000..bc375c896 --- /dev/null +++ b/.claude/skills/gh-workflow-monitor/SKILL.md @@ -0,0 +1,19 @@ +--- +name: gh-workflow-monitor +description: Monitor GitHub workflows, propose fixes for failures, and report status +disable-model-invocation: true +--- + +# GitHub Workflow Monitor + +Use the `gh` for GitHub operations. + +## Step 1: Check current branch. + +## Step 2: If the branch is not green, find the GitHub Actions workflow run associated with the current branch. Use the gh cli. + +## Step 3: Monitor the workflow run for failures. + +## Step 4: If the workflow run fails, propose a fix. + +## Step 5: If the workflow run succeeds, exit plan mode and report the status. diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md new file mode 100644 index 000000000..4c2ec12ec --- /dev/null +++ b/.claude/skills/implement/SKILL.md @@ -0,0 +1,52 @@ +--- +name: implement +description: Assess and implement a Ground Control requirement, ensuring GitHub issue and traceability links exist +argument-hint: +disable-model-invocation: true +--- + +# Implement Requirement: $ARGUMENTS + +## Step 1: Fetch Requirement and Ensure GitHub Issue Exists + +1. Enter plan mode. + +2. Use the `gc_get_requirement` MCP tool with uid `GC-$ARGUMENTS` to fetch the requirement details. Note the requirement's UUID, title, statement, status, and wave. + +3. Use the `gc_get_traceability` MCP tool with the requirement's UUID to check for existing traceability links. Look for a link with artifact_type `GITHUB_ISSUE`. + +4. If NO GitHub issue link exists: + - Use the `gc_create_github_issue` MCP tool with uid `$ARGUMENTS` to create a GitHub issue and auto-link it. + +5. If a GitHub issue link DOES exist, note the issue number from the artifact_identifier. + +6. Run `gh issue develop --checkout --base dev` to switch to the issue branch. + +## Step 2: Read the GitHub Issue + +Run `gh issue view ` to read the full issue details including description, labels, and comments. + +## Step 3: Assess Codebase Coverage + +Explore the codebase to determine whether the requirement described in the issue is already satisfied by existing code: +- Search for relevant classes, methods, tests, and configurations +- Check if the described behavior already exists +- Review any existing traceability links (IMPLEMENTS, TESTS) from Step 1 + +## Step 4: Plan or Report + +- **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 the requirement IS already met**: Report that the requirement is satisfied and identify which code satisfies it. + +## Step 5: Ensure Traceability Links + +After implementation is complete (or if already implemented): +- use the `gc_create_traceability_link` MCP tool to create any missing links: + - `IMPLEMENTS` links from the requirement to the code files that implement it + - `TESTS` links from the requirement to the test files that verify it + - Only create links that don't already exist (check the traceability data from Step 1). +- 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. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e4bf3930d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.github +.venv +.pytest_cache +.mypy_cache +__pycache__ +*.pyc +node_modules +containers/ +docs/ +notes/ +coverage.xml +.coverage +*.egg-info +dist/ +build/ +.svelte-kit +web/node_modules +web/.svelte-kit +web/tests diff --git a/.env.example b/.env.example index 48b19c751..6c7c8cfa6 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,7 @@ MISP_ADMIN_PASSWORD=MispAdmin2024! THEHIVE_SECRET=aptl-thehive-lab-secret-key-2024-purple SHUFFLE_ADMIN_PASSWORD=ShuffleAdmin2024! SHUFFLE_API_KEY=31a211c4-ea5c-4a49-b022-5e2434e758a7 + +# Grafana (OTel observability stack) +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=CHANGE_ME_grafana_password diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..c9a2d6ad2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,139 @@ +name: Deploy Lab + +on: + push: + branches: [dev] + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + type: environment + required: true + +jobs: + deploy: + # On push to dev: use DEFAULT_DEPLOY_ENVIRONMENT repo variable. + # On manual dispatch: use the selected environment. + # If neither exists, the job is skipped entirely — users who + # only run locally are unaffected. + environment: ${{ inputs.environment || vars.DEFAULT_DEPLOY_ENVIRONMENT }} + if: ${{ inputs.environment || vars.DEFAULT_DEPLOY_ENVIRONMENT != '' }} + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Connect to Tailscale + if: vars.DEPLOY_VIA_TAILSCALE == 'true' + uses: tailscale/github-action@v4 + with: + oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} + tags: tag:ci + + - name: Set up SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + + - name: Deploy + env: + DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} + DEPLOY_USER: ${{ vars.DEPLOY_USER }} + DEPLOY_DIR: ${{ vars.DEPLOY_DIR || '/opt/aptl' }} + DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT || '22' }} + run: | + SSH="ssh -i ~/.ssh/deploy_key -p ${DEPLOY_SSH_PORT} -o StrictHostKeyChecking=accept-new ${DEPLOY_USER}@${DEPLOY_HOST}" + + # Stop any running lab before syncing code + $SSH "cd ${DEPLOY_DIR} && docker stop \$(docker ps -q) 2>/dev/null; docker rm \$(docker ps -aq) 2>/dev/null; docker network prune -f 2>/dev/null; true" + + # Sync repo from runner to remote (runner has repo access, remote doesn't) + $SSH "mkdir -p ${DEPLOY_DIR}" + rsync -az --delete \ + -e "ssh -i ~/.ssh/deploy_key -p ${DEPLOY_SSH_PORT} -o StrictHostKeyChecking=accept-new" \ + --exclude='.git' \ + --exclude='.env' \ + --exclude='config/wazuh_indexer_ssl_certs' \ + --exclude='tools/misp-mcp-server' \ + --exclude='.venv' \ + --exclude='keys' \ + --exclude='runs' \ + --exclude='.aptl' \ + --exclude='node_modules' \ + ./ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_DIR}/" + + # Seed .env from example if first deploy + $SSH "cd ${DEPLOY_DIR} && [ -f .env ] || cp .env.example .env" + + # Install Python CLI in venv and start lab + $SSH "cd ${DEPLOY_DIR} && ([ -d .venv ] || python3 -m venv .venv) && .venv/bin/pip install -e ." + $SSH "cd ${DEPLOY_DIR} && .venv/bin/aptl lab start" + + - name: Verify + env: + DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} + DEPLOY_USER: ${{ vars.DEPLOY_USER }} + DEPLOY_DIR: ${{ vars.DEPLOY_DIR || '/opt/aptl' }} + DEPLOY_SSH_PORT: ${{ vars.DEPLOY_SSH_PORT || '22' }} + run: | + SSH="ssh -i ~/.ssh/deploy_key -p ${DEPLOY_SSH_PORT} -o StrictHostKeyChecking=accept-new ${DEPLOY_USER}@${DEPLOY_HOST}" + + # Poll until all containers are running (12 attempts x 10s = 120s max) + for attempt in $(seq 1 12); do + echo "Verify attempt ${attempt}/12..." + RESULT=$($SSH "cd ${DEPLOY_DIR} && docker compose ps --format json" 2>/dev/null || echo "") + + if [ -n "$RESULT" ]; then + STATUS=$(echo "$RESULT" | python3 -c " + import json, sys + containers = [] + for line in sys.stdin: + line = line.strip() + if not line: continue + try: + if line.startswith('['): + containers.extend(json.loads(line)) + else: + containers.append(json.loads(line)) + except json.JSONDecodeError: + continue + if not containers: + print('WAITING') + sys.exit(0) + for c in containers: + name = c.get('Name', 'unknown') + state = c.get('State', 'unknown') + health = c.get('Health', '') + info = f' {name}: {state}' + if health: info += f' ({health})' + print(info, file=sys.stderr) + running = sum(1 for c in containers if c.get('State') == 'running') + total = len(containers) + print(f'{running}/{total} containers running', file=sys.stderr) + if running >= total: + print('PASS') + else: + print('WAITING') + ") + + if [ "$STATUS" = "PASS" ]; then + echo "All containers running." + exit 0 + fi + fi + + echo "Not ready yet, waiting 10s..." + sleep 10 + done + + echo "ERROR: Containers did not all reach running state within 120s" + exit 1 + + - name: Cleanup SSH key + if: always() + run: rm -f ~/.ssh/deploy_key diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 9f558ddc4..cbb214c69 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -8,22 +8,41 @@ jobs: sonarcloud: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e ".[dev]" + pip install -e ".[dev,web]" - name: Run tests with coverage run: | coverage run -m pytest tests/ -q -k "not integration" coverage xml -o coverage.xml + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Run MCP TypeScript tests with coverage + run: | + cd mcp/aptl-mcp-common + npm install + npx vitest run --coverage + - name: Run web frontend tests with coverage + run: | + cd web + npm install + npx svelte-kit sync + npx vitest run --coverage + sed -i 's|^SF:|SF:web/|' coverage/lcov.info - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@v5 + uses: SonarSource/sonarqube-scan-action@v7 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.qualitygate.wait=true diff --git a/.gitignore b/.gitignore index 9e6d96178..15ad78b58 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ downloads/ eggs/ .eggs/ lib/ +!web/src/lib/ +!web/tests/lib/ lib64/ parts/ sdist/ @@ -247,6 +249,9 @@ bower_components # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release +# SvelteKit generated +.svelte-kit/ + # Dependency directories node_modules/ jspm_packages/ @@ -396,4 +401,6 @@ config/wazuh_indexer_ssl_certs/* notes/ +research/ + data/ diff --git a/.mcp.json.example b/.mcp.json.example index dce59eaec..552f21fce 100644 --- a/.mcp.json.example +++ b/.mcp.json.example @@ -4,21 +4,21 @@ "command": "node", "args": ["./mcp/mcp-red/build/index.js"], "env": { - "APTL_TRACE_DIR": "./.aptl/traces" + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318" } }, "reverse-sandbox-ssh": { "command": "node", "args": ["./mcp/mcp-reverse/build/index.js"], "env": { - "APTL_TRACE_DIR": "./.aptl/traces" + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318" } }, "shuffle": { "command": "node", "args": ["./mcp/mcp-soar/build/index.js"], "env": { - "APTL_TRACE_DIR": "./.aptl/traces", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318", "SHUFFLE_API_KEY": "" } }, @@ -26,7 +26,7 @@ "command": "node", "args": ["./mcp/mcp-indexer/build/index.js"], "env": { - "APTL_TRACE_DIR": "./.aptl/traces", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318", "INDEXER_USERNAME": "admin", "INDEXER_PASSWORD": "SecretPassword", "API_USERNAME": "wazuh-wui", diff --git a/.serena/project.yml b/.serena/project.yml index 1220db90b..bfcd1e6e8 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -126,3 +126,22 @@ language_backend: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/CHANGELOG.md b/CHANGELOG.md index 484315d79..58db2b53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,276 @@ 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.16.1] - 2026-03-25 + +### Fixed + +- Test helper `_find_node()` returns absolute path via `shutil.which()` instead of bare `"node"` string, fixing `_server_available()` check that caused MCP protocol tests to skip on systems without NVM +- Deploy workflow: use `aptl lab start` instead of raw `docker compose up`, fixing missing SSL certs and credential sync on fresh deploys +- Deploy workflow: exclude root-owned and runtime directories from rsync --delete (SSL certs, venv, keys, runs, node_modules, tools/misp-mcp-server) +- SSL cert check now verifies root-ca.pem exists, not just the directory — fixes regeneration after empty directory is left behind +- SSH key generation target changed from `containers/keys/` to `keys/` to match docker-compose.yml bind mounts +- Deploy workflow: force-stop containers and prune networks before rsync to prevent "Address already in use" on redeploy + +### Changed + +- SonarCloud scan now waits for Quality Gate result (`-Dsonar.qualitygate.wait=true`), failing the CI job if the gate is red +- Branch protection on `main` and `dev`: "SonarCloud Code Analysis" is now a required status check + +## [4.16.0] - 2026-03-24 + +### Added + +- `APTL_ALLOWED_ORIGINS` environment variable for configurable CORS origins (defaults unchanged) +- SSH deployment parameter validation (host, user, port, key path) in `SSHComposeBackend` +- Integration tests exercising full API-to-core request paths (`test_api_integration.py`) +- PID re-verification via `/proc/{pid}/cmdline` before SIGKILL in kill switch (TOCTOU mitigation) +- File locking (`fcntl.flock`) for `session.json` concurrent access safety + +### Fixed + +- Credential sync: YAML password double-quote escaping and XML cluster key entity escaping in `credentials.py` +- WebSocket terminal: empty Origin header now correctly rejected (was bypassing origin check) +- Scenario CLI: `shutdown_tracing()` wrapped in try/finally to prevent span loss on exception +- Deploy workflow: replaced fixed 30s sleep with 120s polling loop for container health verification +- Kill switch test: SIGTERM/SIGKILL ordering now asserted by index, not just membership + +### Changed + +- TheHive Elasticsearch image updated from 7.17.24 to 7.17.28 (security patches) + +## [4.15.1] - 2026-03-23 + +### Fixed + +- Deploy workflow: replace remote `git clone` with `rsync` from runner to fix clone failures on hosts without GitHub credentials (curl 56 connection reset) + +## [4.15.0] - 2026-03-22 + +### Added + +- Deployment backend abstraction layer (DEP-001, #233): + - `DeploymentBackend` Protocol in `src/aptl/core/deployment/backend.py` defining the lifecycle interface (start, stop, status, kill, pull_images) + - `DockerComposeBackend` wrapping existing Docker Compose subprocess logic as the default backend + - `SSHComposeBackend` enabling remote deployment over SSH via `DOCKER_HOST=ssh://user@host` + - `DeploymentConfig` Pydantic model with provider selection and SSH configuration fields + - Factory function `get_backend()` for config-driven backend instantiation + - `deployment` section in `aptl.json` for backend configuration (backward-compatible default) + - ADR-013 documenting the deployment abstraction decision + - Existing `start_lab()`, `stop_lab()`, `lab_status()`, and `kill_lab_containers()` functions accept optional `backend` parameter while maintaining full backward compatibility + +## [4.14.0] - 2026-03-22 + +### Added + +- Network egress firewall on Docker networks (SAF-002, #231): + - `aptl-dmz`, `aptl-internal`, and `aptl-redteam` networks now use `internal: true` to block internet egress + - Prevents autonomous agents from scanning or attacking external targets + - `aptl-security` remains non-internal so SOC tools can reach threat feeds and rule updates + - Multi-homed management containers (dns, wazuh, suricata) retain internet via security network + - Host port mappings (SSH to victim/kali) unaffected by internal flag + - Wazuh agent and Falco pre-installed in victim, workstation, and kali container images at build time (runtime install scripts skip downloads when packages are already present) + - Static consistency tests enforce egress controls in `test_consistency.py` + - Networking architecture docs updated with egress control and upgrade details + +## [4.13.0] - 2026-03-22 + +### Added + +- Emergency kill switch for all agent and MCP operations (SAF-001, #229): + - `aptl kill` CLI command terminates all MCP server processes (SIGTERM + SIGKILL fallback) + - `--containers` flag force-stops all lab Docker containers via `docker compose kill` + - POST `/api/lab/kill` endpoint for web UI emergency button + - Automatic cleanup of active scenario sessions and trace context files + - Core `kill.py` module with resilient process discovery via `/proc/*/cmdline` + +## [4.12.0] - 2026-03-21 + +### Added + +- OpenTelemetry integration replacing custom JSONL tracing systems (OBS-001, #225): + - Python `telemetry.py` module: OTel TracerProvider with OTLP HTTP exporter, trace context generation/propagation, span creation for scenario lifecycle events + - TypeScript `telemetry.ts` module: OTel NodeTracerProvider with OTLP proto exporter, cross-process trace context via `.aptl/trace-context.json`, `traceToolCall()` wrapper with GenAI SIG attributes + - Docker infrastructure: OTel Collector (`otel` profile), Grafana Tempo (72h retention), Grafana UI at port 3100 + - OTel Collector config, Tempo config, and Grafana datasource provisioning in `config/otel/` + - Run archive `traces/spans.json` containing all OTel spans fetched from Tempo + - `trace_id` field in session state and run manifest for distributed tracing correlation + - `collect_traces()` collector querying Tempo HTTP API by trace ID + - ADR-012 documenting the OpenTelemetry integration decision + +### Changed + +- `aptl lab start` now always includes `--profile otel` for the observability stack +- Run manifest includes `trace_id` field +- `.mcp.json.example` uses `OTEL_EXPORTER_OTLP_ENDPOINT` instead of `APTL_TRACE_DIR` +- MCP server startup initializes OTel tracing; shutdown flushes spans +- `assemble_run()` no longer takes `events` parameter; collects traces from Tempo instead + +### Removed + +- `src/aptl/core/events.py` (EventLog, EventType, Event, make_event) — replaced by OTel spans +- `mcp/aptl-mcp-common/src/tracing.ts` (ToolTracer, ToolTrace) — replaced by OTel spans +- `tests/test_events.py` — replaced by `tests/test_telemetry.py` +- `APTL_TRACE_DIR` environment variable and `.aptl/traces/` directory +- `collect_mcp_traces()` from collectors (replaced by `collect_traces()`) +- `scenario/events.jsonl` and `agents/traces.jsonl` from run archive format + +## [4.11.1] - 2026-03-21 + +### Fixed + +- SonarCloud quality gate failure: new code coverage below 80% threshold + - Added tests for `container-state.ts`, route load functions, `getScenario()` API, `subscribeLabEvents`, and SSE reconnect logic in lab store + - Scoped vitest coverage to `src/` only, eliminating `build/`, `.svelte-kit/`, and `node_modules/` noise from lcov report + - All new TypeScript files now at 100% coverage (56 tests across 7 test files) + +## [4.11.0] - 2026-03-21 + +### Added + +- Interactive scenario workbench view at `/scenarios/[id]` (UI-001, #223): + - Block-composition architecture: `buildBlockSequence()` pure function maps scenario data to typed `WorkbenchBlock[]` discriminated union, separating data logic from rendering + - 9 workbench block components: NarrativeBlock (markdown), TerminalBlock (xterm.js wrapper), SiemQueryBlock (stub), ContainerStatusBlock (SSE-driven), HintToggle (progressive disclosure), ObjectiveBlock, AttackStepBlock, WorkbenchStatusBar (sticky), SectionDivider + - Full `ScenarioDefinition` TypeScript type hierarchy mirroring Python Pydantic models (metadata, steps, objectives, scoring, attack chain, MITRE references) + - `renderMarkdown()` utility using `marked` + DOMPurify for safe runtime markdown rendering + - Scenario card links from Lab Home page to workbench view + - `prose-aptl` CSS class for dark-themed markdown prose styling + - Progressive hint disclosure with escalating point penalties shown in amber + - SIEM query blocks display query JSON with disabled "Run Query" button (ready for OpenSearch integration) + - Copyable attack step commands with clipboard feedback + - Lazy-mounted terminals in attack step blocks to avoid mass WebSocket connections + - Stable block keys for Svelte each-block diffing + - Shared `stateColor()` utility for container state badge colors + - Unit tests for block sequence builder (11 tests), markdown renderer (9 tests), and HintToggle component (7 tests) + - `marked` and `dompurify` dependencies added + - Vitest `resolve.conditions: ['browser']` fix for Svelte 5 component testing with jsdom + - Route load function uses SvelteKit `error()` for proper HTTP status propagation + +### Security + +- WebSocket terminal endpoint now validates the `Origin` header before accepting connections, blocking cross-site WebSocket hijacking (CSWSH) — previously any website visited while the lab was running could open a shell on lab containers because CORS middleware does not protect WebSocket upgrades +- `ALLOWED_ORIGINS` constant shared between CORS middleware and WebSocket origin check in `aptl.api.deps` to prevent drift + +## [4.10.0] - 2026-03-21 + +### Added + +- In-browser terminal for container SSH access via xterm.js and WebSocket (#221): + - WebSocket endpoint `ws /api/terminal/ws/{container}` with asyncssh PTY relay for all SSH-capable containers + - `Terminal.svelte` component: xterm.js wrapper with FitAddon, WebLinksAddon, APTL dark theme, auto-resize + - Full-page terminal route at `/terminal/{container}` with back navigation + - "Terminal" link on ContainerCard when container is running and SSH-capable + - Vite dev server WebSocket proxy (`ws: true`) for `/api` endpoint + - `asyncssh>=2.17.0` added to `web` optional dependencies + - WebSocket endpoint tests covering validation, stdin/stdout relay, resize, disconnect cleanup, and error handling + +## [4.9.2] - 2026-03-20 + +### Fixed + +- API response models use `Optional[str] = None` instead of `str = ""` for error fields +- SSE generator implements exponential backoff and circuit breaker (terminates after 10 consecutive errors) +- CORS tightened to `GET`/`POST` methods and `Content-Type`/`Accept` headers only +- Lab start endpoint has 30-minute timeout via `asyncio.wait_for` +- All API routers use FastAPI `Depends()` for `get_project_dir` injection (testable, validates directory exists) +- `get_project_dir()` returns HTTP 503 when project directory does not exist +- Hardcoded container list in config router replaced with dynamic `ContainerSettings.model_fields` +- `ContainerSettings.enabled_profiles()` uses `model_fields` instead of hardcoded list +- Docker CLI installed via official APT repo with GPG verification instead of `curl | sh` +- Production web Docker image uses `npm ci --omit=dev` instead of copying full `node_modules` +- Uvicorn production config: `--workers`, `--log-level info`, `--timeout-keep-alive 65`, `--access-log` +- Frontend SSE reconnects with generation counter to prevent race conditions +- Frontend error text truncated to 500 characters +- `+page.svelte` checks `error != null` instead of truthy for nullable error field + +### Added + +- Structured logging (`aptl.utils.logging`) in all API routers and `create_app()` +- `+error.svelte` dark-themed error page with status code and back link +- Accessibility: `role="status"`/`role="img"`, `aria-label` on status dots, badges, spinner, buttons; `sr-only` loading text +- Expert difficulty gets distinct violet badge (was same red as advanced) +- HTML meta tags: `description`, `theme-color` (#1a1d23), `apple-mobile-web-app-capable` +- `.dockerignore` files for project root and `web/` +- Docker socket security documentation in README +- SonarCloud config includes `web/src` sources and `web/coverage/lcov.info` +- CI workflow runs web frontend tests with coverage +- Vitest coverage config (v8 provider, lcov reporter) + +### Removed + +- Unused `web/src/lib/stores/scenarios.ts` +- Unused `getScenarios` import from `+page.ts` +- `httpx` from `web` optional deps (already in `dev`; CI installs both) + +## [4.9.1] - 2026-03-20 + +### Fixed + +- CI SonarCloud workflow installs `.[dev,web]` so API tests find `fastapi` (#219) +- API test files gracefully skip via `pytest.importorskip` when web deps are absent + +## [4.9.0] - 2026-03-20 + +### Added + +- Notebook-style web UI Phase 1 MVP (SYS-010, ADR-011) (#219): + - FastAPI backend (`src/aptl/api/`) wrapping existing `aptl.core` modules — no domain logic duplication + - REST endpoints: `GET /api/lab/status`, `POST /api/lab/start`, `POST /api/lab/stop`, `GET /api/scenarios`, `GET /api/scenarios/{id}`, `GET /api/config`, `GET /api/health` + - SSE endpoint `GET /api/lab/events` for real-time container status updates + - SvelteKit frontend (`web/`) with Tailwind CSS v4, dark theme (indigo/violet/teal palette) + - Lab Home page with container status grid, start/stop controls, scenario listing with difficulty/mode badges + - `aptl web serve` CLI command to start the API server on port 8400 + - `web` optional dependency group: FastAPI, uvicorn, sse-starlette, httpx + - Docker Compose `web` profile with `aptl-web-api` (172.20.0.40:8400) and `aptl-web-ui` (172.20.0.41:3000) services + - Backend tests (`test_api_lab.py`, `test_api_scenarios.py`, `test_api_config.py`) and frontend API tests + - ADR-011 status: proposed -> accepted + +## [4.8.0] - 2026-03-20 + +### Added + +- Architecture Decision Records (ADRs) section in docs with MADR-format template and 11 ADRs documenting all significant architectural decisions from v2.0.0 through v4.7.0 (#217) + +## [4.7.0] - 2026-03-20 + +### Changed + +- Upgraded CI actions to Node.js 24-compatible versions: `actions/checkout@v6`, `actions/setup-python@v6`, `actions/setup-node@v6` (#211) +- Migrated from deprecated `SonarSource/sonarcloud-github-action` to `SonarSource/sonarqube-scan-action@v7` (#211) + +## [4.6.8] - 2026-03-20 + +### Fixed + +- SonarCloud quality gate failing with 0% TypeScript coverage — CI workflow now runs vitest with coverage in `mcp/aptl-mcp-common/` before the SonarCloud scan, generating `lcov.info` that the existing sonar config already expects (#211) +- Added `mcp/aptl-mcp-common/tests` to `sonar.tests` so SonarCloud recognizes TS test files as test sources +- Fixed SonarCloud "can't be indexed twice" error — `mcp/**/tests/**` added to `sonar.exclusions` so test files under `mcp/` are excluded from source analysis while still indexed via `sonar.tests` (#211) + +## [4.6.7] - 2026-03-08 + +### Fixed + +- Persistent SSH sessions stranded callers on close/timeout — `cleanup()` now rejects all pending command promises and clears per-command timeouts (#189) + +## [4.6.6] - 2026-03-08 + +### Fixed + +- `aptl scenario stop` did not load project `.env` into `os.environ` before calling `assemble_run()` — collectors for Wazuh Indexer, TheHive, MISP, and Shuffle got empty-string fallbacks for API keys and silently skipped data collection, losing SOC telemetry (#184) +- Collector HTTP timeout was 20s — MISP and TheHive regularly exceed this on cold queries, causing silent data loss; raised to 120s to match test helper timeouts (#184) + +## [4.6.5] - 2026-03-08 + +### Fixed + +- `sync_manager_config()` cluster key regex vulnerable to polynomial backtracking (ReDoS) — replaced `re.DOTALL` regex with linear-time string search for `` blocks (#183) + +## [4.6.4] - 2026-03-08 + +### Fixed + +- `sync_manager_config()` corrupted TLS config by replacing all `` elements — regex now scoped to only match `` inside `` blocks, leaving `` untouched (#183) + ## [4.6.3] - 2026-03-08 ### Fixed diff --git a/README.md b/README.md index accc9e129..767088240 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=Brad-Edwards_aptl&token=4dd88be3421d6d030a4615b86ac8ab0e3c9eb4d3)](https://sonarcloud.io/summary/new_code?id=Brad-Edwards_aptl) +[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=Brad-Edwards_aptl&token=4dd88be3421d6d030a4615b86ac8ab0e3c9eb4d3)](https://sonarcloud.io/summary/new_code?id=Brad-Edwards_aptl) # APTL (Advanced Purple Team Lab) @@ -112,6 +112,8 @@ Manage the lab: aptl lab status # Show running containers aptl lab stop # Stop the lab aptl lab stop -v # DESTROYS ALL DATA (Wazuh indexes, MISP, TheHive, configs) +aptl kill # Emergency: kill all MCP server processes immediately +aptl kill -c # Emergency: kill MCP processes AND all lab containers ``` **Access:** @@ -129,6 +131,37 @@ aptl lab stop -v # DESTROYS ALL DATA (Wazuh indexes, MISP, TheHive, configs) - Linux/macOS/WSL2 - Ports available: 443, 2022, 2023, 2027, 9200, 55000 +## Web UI + +An optional web interface provides a notebook-style workbench for lab management and scenario execution. Phase 1 covers Lab Home with container status and scenario listing. + +```bash +# Install web dependencies +pip install -e ".[web]" + +# Start the API server +aptl web serve + +# In another terminal, start the frontend dev server +cd web && npm install && npm run dev +``` + +**Access:** (dev) or (production) + +**Docker Compose:** +```bash +docker compose --profile web up --build +``` + +### Security Considerations + +The API container (`aptl-web-api`) requires access to the Docker socket (`/var/run/docker.sock`) to manage lab containers. This grants the container full Docker API access on the host. Mitigations: + +- The web UI is designed for **localhost-only** use — do not expose it to untrusted networks. +- CORS is restricted to `localhost:3000` and `localhost:5173`. +- No authentication is implemented; this is appropriate for a local lab tool, not internet-facing services. +- Consider running with `--userns-remap` if your threat model requires reduced container privilege. + ## AI Integration (MCP) Build all MCP servers: diff --git a/aptl.json b/aptl.json index fc4240d6e..acb64bc80 100644 --- a/aptl.json +++ b/aptl.json @@ -1,4 +1,7 @@ { + "deployment": { + "provider": "docker-compose" + }, "containers": { "wazuh": true, "victim": true, diff --git a/config/otel/grafana-datasources.yaml b/config/otel/grafana-datasources.yaml new file mode 100644 index 000000000..324a15060 --- /dev/null +++ b/config/otel/grafana-datasources.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Tempo + type: tempo + access: proxy + url: http://aptl-tempo:3200 + isDefault: true + editable: false diff --git a/config/otel/otel-collector-config.yaml b/config/otel/otel-collector-config.yaml new file mode 100644 index 000000000..f8645c837 --- /dev/null +++ b/config/otel/otel-collector-config.yaml @@ -0,0 +1,32 @@ +extensions: + health_check: + endpoint: 0.0.0.0:13133 + +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + timeout: 5s + send_batch_size: 1024 + +exporters: + otlp/tempo: + endpoint: aptl-tempo:4317 + tls: + insecure: true + debug: + verbosity: basic + +service: + extensions: [health_check] + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/tempo, debug] diff --git a/config/otel/tempo-config.yaml b/config/otel/tempo-config.yaml new file mode 100644 index 000000000..aebcac17b --- /dev/null +++ b/config/otel/tempo-config.yaml @@ -0,0 +1,23 @@ +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + +ingester: + max_block_duration: 5m + +compactor: + compaction: + block_retention: 72h + +storage: + trace: + backend: local + wal: + path: /var/tempo/wal + local: + path: /var/tempo/blocks diff --git a/containers/kali/Dockerfile b/containers/kali/Dockerfile index e897cab34..3a08efab9 100644 --- a/containers/kali/Dockerfile +++ b/containers/kali/Dockerfile @@ -63,6 +63,22 @@ RUN apt-get update && \ RUN sudo -u kali ssh-keygen -t rsa -b 2048 -f /home/kali/.ssh/id_rsa -N "" && \ chown -R kali:kali /home/kali/.ssh/ +# Pre-install Wazuh agent at build time so the container does not need internet +# at runtime (SAF-002: internal: true on attack networks). +# Runtime install script detects pre-installed package and skips download. +RUN apt-get update && \ + apt-get install -y curl gnupg lsb-release && \ + curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH | \ + gpg --no-default-keyring --keyring gnupg-ring:/usr/share/keyrings/wazuh.gpg --import && \ + chmod 644 /usr/share/keyrings/wazuh.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/wazuh.gpg] https://packages.wazuh.com/4.x/apt/ stable main" \ + > /etc/apt/sources.list.d/wazuh.list && \ + apt-get update && \ + apt-get install -y wazuh-agent=4.12.0-1 && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + killall wazuh-execd wazuh-agentd wazuh-syscheckd wazuh-logcollector wazuh-modulesd 2>/dev/null || true && \ + rm -f /var/ossec/var/run/*.pid + # Copy installation scripts to proper location RUN mkdir -p /opt/kali-redteam/scripts COPY install-scripts/ /opt/kali-redteam/scripts/ @@ -88,4 +104,4 @@ RUN echo 'export PATH="$PATH:/usr/local/bin"' >> ~/.bashrc USER root # Entrypoint handles sshd, Wazuh install, and keepalive directly -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] diff --git a/containers/kali/install-scripts/install-wazuh.sh b/containers/kali/install-scripts/install-wazuh.sh index 67fc9f491..eaafcc992 100644 --- a/containers/kali/install-scripts/install-wazuh.sh +++ b/containers/kali/install-scripts/install-wazuh.sh @@ -36,18 +36,19 @@ if [ "$manager_ready" = false ]; then echo " The agent will retry connection when the service starts" fi -# Install prerequisites for Debian/Kali -apt-get update -apt-get install -y curl gnupg lsb-release - -# Add Wazuh repository -curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH | gpg --no-default-keyring --keyring gnupg-ring:/usr/share/keyrings/wazuh.gpg --import -echo "deb [signed-by=/usr/share/keyrings/wazuh.gpg] https://packages.wazuh.com/4.x/apt/ stable main" | tee /etc/apt/sources.list.d/wazuh.list -chmod 644 /usr/share/keyrings/wazuh.gpg - -# Install Wazuh agent -apt-get update -WAZUH_MANAGER="$WAZUH_MANAGER" apt-get install -y wazuh-agent=4.12.0-1 +# Install Wazuh agent (skip if pre-installed at build time per SAF-002) +if dpkg -l wazuh-agent 2>/dev/null | grep -q '^ii'; then + echo "Wazuh agent already installed (pre-baked in image, SAF-002)" +else + echo "Wazuh agent not pre-installed, downloading from internet..." + apt-get update + apt-get install -y curl gnupg lsb-release + curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH | gpg --no-default-keyring --keyring gnupg-ring:/usr/share/keyrings/wazuh.gpg --import + echo "deb [signed-by=/usr/share/keyrings/wazuh.gpg] https://packages.wazuh.com/4.x/apt/ stable main" | tee /etc/apt/sources.list.d/wazuh.list + chmod 644 /usr/share/keyrings/wazuh.gpg + apt-get update + WAZUH_MANAGER="$WAZUH_MANAGER" apt-get install -y wazuh-agent=4.12.0-1 +fi echo "Wazuh agent installed successfully" @@ -97,4 +98,4 @@ else echo "Warning: Wazuh agent may not be fully started yet" fi -echo "=== Kali Wazuh Agent Installation Complete ===" \ No newline at end of file +echo "=== Kali Wazuh Agent Installation Complete ===" diff --git a/containers/victim/Dockerfile b/containers/victim/Dockerfile index f9364ffc5..a7c2261bd 100644 --- a/containers/victim/Dockerfile +++ b/containers/victim/Dockerfile @@ -76,6 +76,23 @@ COPY base/scripts/ossec.conf.template /opt/purple-team/scripts/ RUN mkdir -p /etc/falco/config.d COPY base/falco_custom.yaml /etc/falco/config.d/ +# Pre-install Wazuh agent and Falco at build time so containers on internal +# Docker networks (SAF-002: internal: true) do not need internet at runtime. +# Runtime install scripts detect pre-installed packages and skip downloads. +RUN rpm --import https://packages.wazuh.com/key/GPG-KEY-WAZUH && \ + printf '[wazuh]\ngpgcheck=1\ngpgkey=https://packages.wazuh.com/key/GPG-KEY-WAZUH\nenabled=1\nname=EL-$releasever - Wazuh\nbaseurl=https://packages.wazuh.com/4.x/yum/\npriority=1\n' \ + > /etc/yum.repos.d/wazuh.repo && \ + dnf install -y wazuh-agent-4.12.0 && \ + dnf clean all && \ + killall wazuh-execd wazuh-agentd wazuh-syscheckd wazuh-logcollector wazuh-modulesd 2>/dev/null || true && \ + rm -f /var/ossec/var/run/*.pid + +RUN rpm --import https://falco.org/repo/falcosecurity-packages.asc && \ + printf '[falco]\nname=Falco repository\nbaseurl=https://download.falco.org/packages/rpm\ngpgcheck=1\ngpgkey=https://falco.org/repo/falcosecurity-packages.asc\nenabled=1\n' \ + > /etc/yum.repos.d/falco.repo && \ + dnf install -y falco && \ + dnf clean all + # ---- Victim-specific layer ---- COPY victim/entrypoint.sh /usr/local/bin/ diff --git a/containers/victim/install-falco.sh b/containers/victim/install-falco.sh index 95713dad5..8b1042b61 100644 --- a/containers/victim/install-falco.sh +++ b/containers/victim/install-falco.sh @@ -10,9 +10,13 @@ if [ -f /var/ossec/.falco_installed ]; then exit 0 fi -echo "Setting up Falco repository..." -rpm --import https://falco.org/repo/falcosecurity-packages.asc -cat > /etc/yum.repos.d/falco.repo << 'EOF' +# Install Falco (skip if pre-installed at build time per SAF-002) +if rpm -q falco &>/dev/null; then + echo "Falco already installed (pre-baked in image, SAF-002)" +else + echo "Falco not pre-installed, downloading from internet..." + rpm --import https://falco.org/repo/falcosecurity-packages.asc + cat > /etc/yum.repos.d/falco.repo << 'EOF' [falco] name=Falco repository baseurl=https://download.falco.org/packages/rpm @@ -20,9 +24,8 @@ gpgcheck=1 gpgkey=https://falco.org/repo/falcosecurity-packages.asc enabled=1 EOF - -echo "Installing Falco..." -dnf install -y falco + dnf install -y falco +fi echo "Enabling and starting Falco service..." systemctl enable falco-modern-bpf.service diff --git a/containers/victim/install-wazuh.sh b/containers/victim/install-wazuh.sh index c51bc0eb9..faffa577a 100644 --- a/containers/victim/install-wazuh.sh +++ b/containers/victim/install-wazuh.sh @@ -39,9 +39,13 @@ if [ "$manager_ready" = false ]; then echo " The agent will retry connection when the service starts" fi -# Set up Wazuh repository -rpm --import https://packages.wazuh.com/key/GPG-KEY-WAZUH -cat > /etc/yum.repos.d/wazuh.repo << EOF +# Install Wazuh agent (skip if pre-installed at build time per SAF-002) +if rpm -q wazuh-agent &>/dev/null; then + echo "Wazuh agent already installed (pre-baked in image, SAF-002)" +else + echo "Wazuh agent not pre-installed, downloading from internet..." + rpm --import https://packages.wazuh.com/key/GPG-KEY-WAZUH + cat > /etc/yum.repos.d/wazuh.repo << EOF [wazuh] gpgcheck=1 gpgkey=https://packages.wazuh.com/key/GPG-KEY-WAZUH @@ -50,9 +54,8 @@ name=EL-\$releasever - Wazuh baseurl=https://packages.wazuh.com/4.x/yum/ priority=1 EOF - -# Install Wazuh agent -WAZUH_MANAGER="$WAZUH_MANAGER" dnf install -y wazuh-agent-4.12.0 + WAZUH_MANAGER="$WAZUH_MANAGER" dnf install -y wazuh-agent-4.12.0 +fi echo "Wazuh agent installed successfully" diff --git a/containers/workstation/Dockerfile b/containers/workstation/Dockerfile index a0b9135f2..ceb28651d 100644 --- a/containers/workstation/Dockerfile +++ b/containers/workstation/Dockerfile @@ -80,6 +80,23 @@ COPY base/scripts/ossec.conf.template /opt/purple-team/scripts/ RUN mkdir -p /etc/falco/config.d COPY base/falco_custom.yaml /etc/falco/config.d/ +# Pre-install Wazuh agent and Falco at build time so containers on internal +# Docker networks (SAF-002: internal: true) do not need internet at runtime. +# Runtime install scripts detect pre-installed packages and skip downloads. +RUN rpm --import https://packages.wazuh.com/key/GPG-KEY-WAZUH && \ + printf '[wazuh]\ngpgcheck=1\ngpgkey=https://packages.wazuh.com/key/GPG-KEY-WAZUH\nenabled=1\nname=EL-$releasever - Wazuh\nbaseurl=https://packages.wazuh.com/4.x/yum/\npriority=1\n' \ + > /etc/yum.repos.d/wazuh.repo && \ + dnf install -y wazuh-agent-4.12.0 && \ + dnf clean all && \ + killall wazuh-execd wazuh-agentd wazuh-syscheckd wazuh-logcollector wazuh-modulesd 2>/dev/null || true && \ + rm -f /var/ossec/var/run/*.pid + +RUN rpm --import https://falco.org/repo/falcosecurity-packages.asc && \ + printf '[falco]\nname=Falco repository\nbaseurl=https://download.falco.org/packages/rpm\ngpgcheck=1\ngpgkey=https://falco.org/repo/falcosecurity-packages.asc\nenabled=1\n' \ + > /etc/yum.repos.d/falco.repo && \ + dnf install -y falco && \ + dnf clean all + # ---- Workstation-specific layer ---- # Setup planted credential artifacts diff --git a/containers/workstation/install-falco.sh b/containers/workstation/install-falco.sh index 95713dad5..8b1042b61 100644 --- a/containers/workstation/install-falco.sh +++ b/containers/workstation/install-falco.sh @@ -10,9 +10,13 @@ if [ -f /var/ossec/.falco_installed ]; then exit 0 fi -echo "Setting up Falco repository..." -rpm --import https://falco.org/repo/falcosecurity-packages.asc -cat > /etc/yum.repos.d/falco.repo << 'EOF' +# Install Falco (skip if pre-installed at build time per SAF-002) +if rpm -q falco &>/dev/null; then + echo "Falco already installed (pre-baked in image, SAF-002)" +else + echo "Falco not pre-installed, downloading from internet..." + rpm --import https://falco.org/repo/falcosecurity-packages.asc + cat > /etc/yum.repos.d/falco.repo << 'EOF' [falco] name=Falco repository baseurl=https://download.falco.org/packages/rpm @@ -20,9 +24,8 @@ gpgcheck=1 gpgkey=https://falco.org/repo/falcosecurity-packages.asc enabled=1 EOF - -echo "Installing Falco..." -dnf install -y falco + dnf install -y falco +fi echo "Enabling and starting Falco service..." systemctl enable falco-modern-bpf.service diff --git a/containers/workstation/install-wazuh.sh b/containers/workstation/install-wazuh.sh index ce9f491af..8621b7a0b 100644 --- a/containers/workstation/install-wazuh.sh +++ b/containers/workstation/install-wazuh.sh @@ -38,9 +38,13 @@ if [ "$manager_ready" = false ]; then echo " The agent will retry connection when the service starts" fi -# Set up Wazuh repository -rpm --import https://packages.wazuh.com/key/GPG-KEY-WAZUH -cat > /etc/yum.repos.d/wazuh.repo << EOF +# Install Wazuh agent (skip if pre-installed at build time per SAF-002) +if rpm -q wazuh-agent &>/dev/null; then + echo "Wazuh agent already installed (pre-baked in image, SAF-002)" +else + echo "Wazuh agent not pre-installed, downloading from internet..." + rpm --import https://packages.wazuh.com/key/GPG-KEY-WAZUH + cat > /etc/yum.repos.d/wazuh.repo << EOF [wazuh] gpgcheck=1 gpgkey=https://packages.wazuh.com/key/GPG-KEY-WAZUH @@ -49,9 +53,8 @@ name=EL-\$releasever - Wazuh baseurl=https://packages.wazuh.com/4.x/yum/ priority=1 EOF - -# Install Wazuh agent -WAZUH_MANAGER="$WAZUH_MANAGER" dnf install -y wazuh-agent-4.12.0 + WAZUH_MANAGER="$WAZUH_MANAGER" dnf install -y wazuh-agent-4.12.0 +fi echo "Wazuh agent installed successfully" diff --git a/docker-compose.yml b/docker-compose.yml index 2b5e9cb7b..15a866de3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -348,7 +348,7 @@ services: thehive-es: profiles: ["soc"] - image: docker.elastic.co/elasticsearch/elasticsearch:7.17.24 + image: docker.elastic.co/elasticsearch/elasticsearch:7.17.28 container_name: aptl-thehive-es restart: unless-stopped environment: @@ -903,6 +903,122 @@ services: limits: memory: 512m + # ========================================================================== + # WEB UI (web profile) + # ========================================================================== + + aptl-web-api: + profiles: ["web"] + container_name: aptl-web-api + build: + context: . + dockerfile: web/Dockerfile.api + restart: unless-stopped + ports: + - "8400:8400" + environment: + - APTL_PROJECT_DIR=/project + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - .:/project:ro + networks: + aptl-security: + ipv4_address: 172.20.0.40 + deploy: + resources: + limits: + memory: 512m + + aptl-web-ui: + profiles: ["web"] + container_name: aptl-web-ui + build: + context: web + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "3000:3000" + environment: + - ORIGIN=http://localhost:3000 + depends_on: + - aptl-web-api + networks: + aptl-security: + ipv4_address: 172.20.0.41 + deploy: + resources: + limits: + memory: 256m + + # ========================================================================== + # OBSERVABILITY STACK (OpenTelemetry) + # ========================================================================== + + aptl-otel-collector: + profiles: ["otel"] + container_name: aptl-otel-collector + image: otel/opentelemetry-collector-contrib:0.123.0 + restart: unless-stopped + ports: + - "4317:4317" + - "4318:4318" + volumes: + - ./config/otel/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:13133/"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + networks: + aptl-security: + ipv4_address: 172.20.0.53 + deploy: + resources: + limits: + memory: 256m + + aptl-tempo: + profiles: ["otel"] + container_name: aptl-tempo + image: grafana/tempo:2.7.2 + restart: unless-stopped + command: ["-config.file=/etc/tempo/tempo.yaml"] + ports: + - "3200:3200" + volumes: + - ./config/otel/tempo-config.yaml:/etc/tempo/tempo.yaml:ro + - tempo_data:/var/tempo + networks: + aptl-security: + ipv4_address: 172.20.0.51 + deploy: + resources: + limits: + memory: 512m + + aptl-grafana-otel: + profiles: ["otel"] + container_name: aptl-grafana-otel + image: grafana/grafana:11.6.0 + restart: unless-stopped + ports: + - "127.0.0.1:3100:3000" + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - ./config/otel/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/tempo.yaml:ro + - grafana_otel_data:/var/lib/grafana + networks: + aptl-security: + ipv4_address: 172.20.0.52 + deploy: + resources: + limits: + memory: 256m + # ========================================================================== # NETWORKS # ========================================================================== @@ -917,28 +1033,34 @@ networks: gateway: 172.20.0.1 # DMZ - Public-facing services (web, mail, DNS) + # SAF-002: internal: true blocks internet egress from containers aptl-dmz: driver: bridge + internal: true ipam: config: - subnet: 172.20.1.0/24 - gateway: 172.20.1.1 + gateway: 172.20.1.1 # bridge interface IP; no external routing (internal network) # Internal - AD, database, file server, app server + # SAF-002: internal: true blocks internet egress from containers aptl-internal: driver: bridge + internal: true ipam: config: - subnet: 172.20.2.0/24 - gateway: 172.20.2.1 + gateway: 172.20.2.1 # bridge interface IP; no external routing (internal network) # Red team - Kali attack platform + # SAF-002: internal: true blocks internet egress from containers aptl-redteam: driver: bridge + internal: true ipam: config: - subnet: 172.20.4.0/24 - gateway: 172.20.4.1 + gateway: 172.20.4.1 # bridge interface IP; no external routing (internal network) # ========================================================================== # VOLUMES @@ -1001,3 +1123,7 @@ volumes: kali_operations: kali_logs: reverse_logs: + + # OpenTelemetry + tempo_data: + grafana_otel_data: diff --git a/docs/adrs/README.md b/docs/adrs/README.md new file mode 100644 index 000000000..9f2f4d4df --- /dev/null +++ b/docs/adrs/README.md @@ -0,0 +1,35 @@ +# Architecture Decision Records + +This directory contains Architecture Decision Records (ADRs) for APTL. ADRs capture significant architectural decisions along with their context, rationale, and consequences. + +## Format + +We use [MADR](https://adr.github.io/madr/) (Markdown Any Decision Records). Each ADR includes: + +- **Status**: `proposed`, `accepted`, `deprecated`, or `superseded by ADR-XXX` +- **Context**: The problem or situation driving the decision +- **Decision**: What we chose and why +- **Consequences**: Trade-offs (positive, negative, risks) + +## Principles + +- ADRs are **immutable** once accepted. To reverse a decision, create a new ADR that supersedes it. +- ADRs are **numbered sequentially** and never reused. +- ADRs are **versioned with code** — they live in the repo, not a wiki. + +## Index + +| ADR | Title | Status | Date | +|-----|-------|--------|------| +| [000](adr-000-use-adrs.md) | Use Architecture Decision Records | accepted | 2026-03-20 | +| [001](adr-001-docker-compose-deployment.md) | Migrate from AWS/Terraform to Local Docker Compose | accepted | 2025-08-06 | +| [002](adr-002-wazuh-siem.md) | Wazuh SIEM over Splunk and qRadar | accepted | 2025-08-06 | +| [003](adr-003-mcp-common-library.md) | MCP Common Library with Config-Driven Server Generation | accepted | 2025-08-30 | +| [004](adr-004-persistent-ssh-sessions.md) | Persistent SSH Session Architecture with Command Queuing | accepted | 2025-08-30 | +| [005](adr-005-docker-compose-profiles.md) | Docker Compose Profiles for Selective Deployment | accepted | 2025-09-01 | +| [006](adr-006-four-network-segmentation.md) | Four-Network Segmentation Architecture | accepted | 2026-02-07 | +| [007](adr-007-python-cli-control-plane.md) | Python CLI as Primary Control Plane | accepted | 2026-02-07 | +| [008](adr-008-soc-stack-integration.md) | Integrated SOC Stack (MISP, TheHive, Cortex, Shuffle, Suricata) | accepted | 2026-02-22 | +| [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 | diff --git a/docs/adrs/adr-000-use-adrs.md b/docs/adrs/adr-000-use-adrs.md new file mode 100644 index 000000000..acc65f5ca --- /dev/null +++ b/docs/adrs/adr-000-use-adrs.md @@ -0,0 +1,84 @@ +# ADR-000: Use Architecture Decision Records + +## Status + +accepted + +## Date + +2026-03-20 + +## Context + +APTL has evolved through four major versions — from an AWS/Terraform lab (v1.x) to a Docker Compose deployment (v2.0), through a TypeScript MCP server layer with a common library (v3.x), and into a full enterprise lab with Python CLI, SOC stack, and scenario engine (v4.x). Along the way, significant architectural decisions were made and their rationale was captured only in commit messages, CHANGELOG entries, and a synthesis document (`notes/team-review-synthesis.md`). + +As the project grows, newcomers (and future-us) need to understand *why* the system is shaped the way it is — not just *what* it does. Without structured decision records, context is lost and the same debates resurface. + +## Decision + +Adopt Architecture Decision Records (ADRs) using a lightweight MADR (Markdown Any Decision Records) format. All ADRs live in `docs/adrs/` and are versioned with the codebase. + +Each ADR follows this template: + +```markdown +# ADR-NNN: [Title] + +## Status + +[proposed | accepted | deprecated | superseded by ADR-XXX] + +## Date + +YYYY-MM-DD + +## Context + +What is the issue that we're seeing that is motivating this decision or change? + +## Decision + +What is the change that we're proposing and/or doing? + +## Consequences + +What becomes easier or more difficult to do because of this change? + +### Positive + +- + +### Negative + +- + +### Risks + +- +``` + +### Governance + +- ADRs are **immutable** once accepted. To reverse a decision, create a new ADR that supersedes it. +- ADRs are **numbered sequentially** (000, 001, 002, ...) and numbers are never reused. +- ADRs are **versioned with code** — they live in `docs/adrs/`, not a wiki or external tool. +- Status transitions: `proposed` → `accepted` or `rejected`. Accepted ADRs can later be `deprecated` or `superseded by ADR-XXX`. +- The `docs/adrs/README.md` index must be updated when an ADR is added or its status changes. + +## Consequences + +### Positive + +- New contributors can understand *why* the system is built this way without archaeology through git history +- Decisions are discoverable and searchable within the documentation site +- The immutability principle prevents silent decision drift +- Retroactive ADRs capture institutional knowledge before it's lost + +### Negative + +- Overhead of writing and maintaining ADRs for each significant decision +- Retroactive ADRs may not perfectly capture the original reasoning + +### Risks + +- ADRs become stale if the team forgets to update statuses when decisions are reversed +- Disagreement about what constitutes a "significant" decision worth an ADR diff --git a/docs/adrs/adr-001-docker-compose-deployment.md b/docs/adrs/adr-001-docker-compose-deployment.md new file mode 100644 index 000000000..00c76db4d --- /dev/null +++ b/docs/adrs/adr-001-docker-compose-deployment.md @@ -0,0 +1,84 @@ +# ADR-001: Migrate from AWS/Terraform to Local Docker Compose + +## Status + +accepted + +## Date + +2025-08-06 + +## Context + +APTL v1.x deployed entirely to AWS using Terraform: + +- **EC2 instances** for the SIEM (qRadar on a c5.4xlarge), Kali Linux (t3.micro), and a victim RHEL 9.6 machine +- **VPC** with public subnet, security groups, and bastion host SSH access +- **S3 + DynamoDB** for Terraform state management with UUID-based bucket names (to prevent enumeration) +- **Cost**: ~$280/month if left running continuously — the c5.4xlarge for qRadar alone was ~$250/month +- **qRadar Community Edition** required a 5GB ISO manual transfer, 1-2 hour installation, 5,000 EPS limit, and a 30-day trial license +- **Setup time**: Even with full Terraform automation, deploying from scratch took 30-60 minutes for infrastructure + 1-2 hours for qRadar installation +- **Network complexity**: VPC networking, security group rules, bastion host, inter-instance connectivity debugging + +The barrier to entry was too high for a personal training lab. Every session meant either leaving expensive infrastructure running or spending an hour redeploying. The target audience — security practitioners wanting to practice purple team operations — would often abandon setup before reaching the first exercise. + +Additionally, the AWS dependency created a hard coupling to a specific cloud provider and required AWS credentials, IAM permissions, and billing oversight that complicated sharing the project. + +### Alternatives Considered + +1. **Multi-cloud Terraform** (AWS, GCP, Azure modules): Would lower per-provider cost but not eliminate it. Increased maintenance burden across providers. +2. **Vagrant + VirtualBox**: Local deployment but heavyweight VMs. Slow provisioning, large disk footprint, no native container orchestration. +3. **Kubernetes (kind/minikube)**: Over-engineered for this scale. The lab needs ~5-15 containers, not a cluster orchestrator. Would add significant operational complexity. +4. **Docker Compose**: Native container orchestration, declarative configuration, instant startup, zero cloud costs, works offline. + +## Decision + +Replace the entire AWS/Terraform deployment model with Docker Compose running on the developer's local machine. + +- All lab components run as Docker containers defined in a single `docker-compose.yml` +- No cloud infrastructure required — zero ongoing costs +- Single-command startup: `docker compose up` (later `aptl lab start`) +- Profiles enable selective deployment of container subsets (see [ADR-005](adr-005-docker-compose-profiles.md)) +- Wazuh replaces qRadar as the SIEM (see [ADR-002](adr-002-wazuh-siem.md)) +- Docker bridge networks replace VPC networking (see [ADR-006](adr-006-four-network-segmentation.md)) + +The initial v2.0.0 deployment included 5 containers: Wazuh Manager, Wazuh Indexer, Wazuh Dashboard, a Rocky Linux victim, and Kali Linux. This has since grown to 19 containers across 4 Docker networks. + +### What Was Removed + +- All Terraform infrastructure code (`infra/`, `vms/`) +- AWS-specific configuration (VPC, security groups, S3 state) +- qRadar Community Edition integration +- Bastion host SSH access pattern +- Cloud cost management overhead + +### What Was Preserved + +- The core purple team lab concept: SIEM + attacker + victim(s) +- MCP server integration for AI agent control +- SSH-based access to lab containers +- Documentation structure (migrated from VitePress to MkDocs) + +## Consequences + +### Positive + +- **Zero cost**: No cloud billing. Lab can stay running indefinitely. +- **Instant startup**: Containers start in seconds vs. 30-60 minutes for Terraform + EC2 +- **Offline capable**: Works without internet after initial image pulls +- **Reproducible**: `docker-compose.yml` is the complete deployment spec. No Terraform state, no cloud drift. +- **Lower barrier**: `git clone && aptl lab start` vs. AWS credentials + Terraform init + multi-step deployment +- **Portable**: Works on any machine with Docker — Linux, macOS, WSL2 + +### Negative + +- **Single machine**: All containers compete for host resources. No horizontal scaling. ~20GB RAM needed for full stack. +- **No real network latency**: Docker bridge networking is near-zero latency, unlike real enterprise networks +- **No Windows containers**: Docker on Linux can't run Windows containers natively. Windows endpoints require a separate VM (see enterprise infrastructure docs). +- **Container escape risk**: Lab containers running on the developer's machine share the kernel. A container escape in a deliberately vulnerable container could affect the host. Mitigated by resource limits and non-root execution where possible. + +### Risks + +- Resource contention on machines with <16GB RAM. Mitigated by Docker Compose profiles allowing partial deployment. +- Docker Desktop licensing changes could affect commercial users (mitigated by supporting Docker Engine directly on Linux) +- Docker Compose v1 to v2 migration required updating all `docker-compose` commands to `docker compose` (space instead of hyphen) diff --git a/docs/adrs/adr-002-wazuh-siem.md b/docs/adrs/adr-002-wazuh-siem.md new file mode 100644 index 000000000..101c027ba --- /dev/null +++ b/docs/adrs/adr-002-wazuh-siem.md @@ -0,0 +1,86 @@ +# ADR-002: Wazuh SIEM over Splunk and qRadar + +## Status + +accepted + +## Date + +2025-08-06 + +## Context + +The migration from AWS to Docker ([ADR-001](adr-001-docker-compose-deployment.md)) required replacing the SIEM. APTL v1.x used IBM qRadar Community Edition, and Splunk Enterprise was briefly supported as an alternative (v1.1.0). + +### qRadar Community Edition + +- 5,000 EPS (events per second) hard limit +- 30-day trial license requiring renewal +- 5GB ISO requiring manual transfer to the instance +- 1-2 hour installation process +- Not available as a Docker image — requires a full VM +- Excellent detection capabilities but operationally heavy for a personal lab + +### Splunk Enterprise + +- Supported via Terraform in v1.1.0 on a c5.4xlarge instance +- Commercial licensing: free tier limited to 500MB/day ingestion +- Excellent UI and search capabilities (SPL) +- Docker images available but licensing is complex for lab use +- SIEM selection was via `siem_type` variable in `terraform.tfvars` + +### Requirements for v2.0 + +- Must run as Docker containers (not a VM) +- Open-source with no licensing limits for lab use +- Agent-based monitoring (for host-level visibility on victim containers) +- Syslog collection (for network-level log forwarding from all containers) +- OpenSearch/Elasticsearch-based indexing (for rich query capabilities) +- Web dashboard for investigation +- Active community and rule ecosystem +- Reasonable resource footprint (target: <4GB RAM for full SIEM stack) + +## Decision + +Adopt **Wazuh** (Manager + Indexer + Dashboard) as the SIEM platform for APTL. + +### Wazuh Architecture + +- **Wazuh Manager** (`aptl-wazuh-manager`, 172.20.0.10): Log processing, rule evaluation, alert generation, API server (port 55000). Multi-homed across security, DMZ, and internal networks to collect logs from all zones. +- **Wazuh Indexer** (`aptl-wazuh-indexer`, 172.20.0.12): OpenSearch-based data storage. Stores alerts, archives, and FIM data. Exposed on port 9200 for direct queries. +- **Wazuh Dashboard** (`aptl-wazuh-dashboard`, 172.20.0.11): OpenSearch Dashboards with Wazuh plugin. Web UI on port 443 (mapped from 5601). + +### Dual Log Collection + +Wazuh supports both collection methods APTL needs: + +1. **Agent-based** (port 1514/tcp): Wazuh agents installed on victim and reverse engineering containers provide host-level visibility — file integrity monitoring, rootkit detection, system call auditing, process monitoring. +2. **Syslog forwarding** (port 514/udp): All containers forward application logs via rsyslog. This captures web server logs, database queries, authentication events, and SOC tool outputs without requiring an agent on every container. + +### Version Pinning + +All Wazuh components are pinned to version 4.12.0 to prevent agent/manager version mismatches that caused failures in earlier versions (see CHANGELOG v3.0.13). + +## Consequences + +### Positive + +- **Zero licensing cost**: Fully open-source (GPLv2). No EPS limits, no trial periods. +- **Docker-native**: Official images (`wazuh/wazuh-manager:4.12.0`, etc.) with Docker Compose support +- **Dual collection**: Agent + syslog covers both host and network telemetry +- **OpenSearch backend**: Rich query language, compatible with MCP indexer server for AI agent queries +- **Active rules ecosystem**: Community rules, Sigma rule conversion, custom rule support. APTL adds custom rules for Falco, Kali red team activity, AD, web app, Suricata, and database events. +- **API server**: REST API on port 55000 enables programmatic rule creation and management via MCP + +### Negative + +- **Three containers for SIEM alone**: Manager + Indexer + Dashboard consume ~4GB RAM combined. The Indexer alone needs 2GB for its OpenSearch JVM heap. +- **Complex SSL certificate management**: All inter-component communication is TLS-encrypted, requiring certificate generation and distribution at startup +- **Wazuh-specific rule syntax**: Not the industry standard. Sigma rules must be converted. Detection engineers familiar with Splunk SPL or qRadar AQL face a learning curve. +- **Dashboard limitations**: The Wazuh Dashboard (OpenSearch Dashboards fork) is less polished than Splunk's UI or qRadar's offense management + +### Risks + +- Wazuh version upgrades can break agent compatibility — strict version pinning mitigates this but requires manual upgrades +- OpenSearch/Elasticsearch fork divergence may cause compatibility issues with third-party tools expecting standard Elasticsearch APIs +- The SOC stack integration ([ADR-008](adr-008-soc-stack-integration.md)) adds significant load to the Wazuh Manager for alert forwarding to MISP, TheHive, and Shuffle diff --git a/docs/adrs/adr-003-mcp-common-library.md b/docs/adrs/adr-003-mcp-common-library.md new file mode 100644 index 000000000..bf07afa31 --- /dev/null +++ b/docs/adrs/adr-003-mcp-common-library.md @@ -0,0 +1,88 @@ +# ADR-003: MCP Common Library with Config-Driven Server Generation + +## Status + +accepted + +## Date + +2025-08-30 + +## Context + +By v2.0.5, APTL had two MCP (Model Context Protocol) servers — `mcp-red` (Kali) and `mcp-blue` (Wazuh) — that allowed AI agents to control lab containers and query the SIEM. Both servers were standalone TypeScript projects with significant code duplication: + +### Problems + +1. **Duplicated SSH handling**: Each server implemented its own SSH connection logic — creating connections, managing sessions, handling timeouts, parsing output. The connection management code was nearly identical. +2. **Identical entry points**: Every `src/index.ts` was the same boilerplate: import config, create server, register tools, start listening. Copy-paste across servers led to wrong doc comments (e.g., `mcp-reverse` said "APTL Kali Red Team MCP Server"). +3. **`any`-typed tool arguments**: All MCP tool handlers received `args: any` and cast internally with `as`. No compile-time safety. The MCP SDK validates args against JSON Schema at runtime, but TypeScript couldn't catch type errors during development. +4. **No shared HTTP client**: API-based servers (Wazuh, indexer) each implemented their own HTTP request handling with inconsistent error handling and a process-global `NODE_TLS_REJECT_UNAUTHORIZED = '0'` that disabled SSL verification for all outbound requests. + +Adding a third MCP server meant copying one of the existing servers and modifying it — propagating all these problems. The reverse engineering MCP server (`mcp-reverse`) was created this way and inherited the SSH prefix bug and wrong doc comment. + +### Scale + +When this decision was made, there were 2-3 MCP servers. The architecture needed to support 8+ servers (Kali, Wazuh, indexer, reverse, MISP, TheHive, Shuffle, Suricata) as the SOC stack grew. + +## Decision + +Create `mcp/aptl-mcp-common/` as a shared TypeScript library that all MCP servers depend on. The library provides: + +### Core Components + +1. **`PersistentSession`** (ssh.ts): Long-lived SSH session management with command queuing, delimiter-based output parsing, buffer overflow protection, keepalive, and per-command timeouts. See [ADR-004](adr-004-persistent-ssh-sessions.md) for the full session architecture. + +2. **`SSHConnectionManager`** (ssh.ts): Connection pool managing multiple `PersistentSession` instances. Handles connection lifecycle, session creation/destruction, and metadata tracking. + +3. **`HTTPClient`** (http.ts): Shared HTTP client for API-based MCP servers. Uses per-request `https.Agent` with `rejectUnauthorized: false` (instead of mutating `process.env`) for self-signed certificates in the lab environment. + +4. **`createMCPServer(config)`** (server.ts): Factory function that generates a fully configured MCP server from a `docker-lab-config.json` file. Registers SSH tool definitions, API tool definitions, and their handlers based on the config. + +5. **`startServer(callerMetaUrl)`** (index.ts): Standard entry point for all APTL MCP servers. Loads config relative to the calling module's directory, creates the server, and starts it. A new MCP server's `src/index.ts` is now ~4 lines: + + ```typescript + import { startServer } from 'aptl-mcp-common'; + startServer(import.meta.url); + ``` + +6. **Typed argument interfaces** (tools/handlers.ts): Named interfaces (`RunCommandArgs`, `SessionCommandArgs`, `ListSessionsArgs`, etc.) for all 10 SSH tool handlers and 3 API tool handlers. Replaces `args: any` with compile-time type checking. + +7. **Shell formatters** (shells.ts): Strategy pattern for shell-specific command formatting — bash, sh, PowerShell, cmd. Each formatter knows how to set environment variables, change directories, and format commands for its shell. + +### Config-Driven Architecture + +Each MCP server is defined by a `docker-lab-config.json` file containing: + +- Server name and description +- Target container connection details (host, port, username, key path) +- SSH tool definitions (if applicable) +- API tool definitions with endpoint templates (if applicable) +- Shell type (bash, sh, powershell, cmd) + +The `createMCPServer()` factory reads this config and generates the full server — tool definitions, handlers, connection management — without any server-specific code. Adding a new container to the MCP layer requires only a new config file and a 4-line `index.ts`. + +### Build System + +All MCP servers are built via `mcp/build-all-mcps.sh`, which iterates through every `mcp/mcp-*` directory and runs `npm run build`. The common library is built first as a dependency. + +## Consequences + +### Positive + +- **DRY**: SSH handling, HTTP clients, entry points, tool registration — all written once +- **Type safety**: Named argument interfaces catch type errors at compile time. The `queryConfig.endpoint` → `queryConfig.url` bug was found during the typed args migration. +- **Consistency**: All 8 MCP servers behave identically for connection management, error handling, and response formatting +- **Rapid server creation**: New MCP server = `docker-lab-config.json` + 4-line `index.ts`. The SOC stack MCP servers (MISP, TheHive, Shuffle, Suricata) were created using this pattern. +- **Centralized fixes**: Bug fixes to SSH session handling or HTTP requests propagate to all servers automatically + +### Negative + +- **Coupling**: All servers depend on the common library. A breaking change requires updating and rebuilding all servers. +- **Abstraction overhead**: The config-driven approach is powerful but less transparent than explicit code. Debugging requires understanding the factory pattern and config schema. +- **Monorepo structure**: The `mcp/` directory contains 9 npm packages (1 library + 8 servers) without a workspace manager like Turborepo or Nx. Build ordering depends on `build-all-mcps.sh`. + +### Risks + +- Config schema evolution: Adding new tool types or connection methods requires updating the common library's config parser, factory, and handler generators +- The common library is a single point of failure — a bug in `PersistentSession` affects all SSH-based MCP servers simultaneously diff --git a/docs/adrs/adr-004-persistent-ssh-sessions.md b/docs/adrs/adr-004-persistent-ssh-sessions.md new file mode 100644 index 000000000..05553b1d9 --- /dev/null +++ b/docs/adrs/adr-004-persistent-ssh-sessions.md @@ -0,0 +1,110 @@ +# ADR-004: Persistent SSH Session Architecture with Command Queuing + +## Status + +accepted + +## Date + +2025-08-30 + +## Context + +AI agents interact with lab containers by sending commands through MCP tool calls. Each tool call translates to an SSH command on the target container. The naive approach — open SSH connection, run command, close connection — has serious problems when an AI agent is the client: + +### Problems with Per-Command SSH + +1. **Latency**: SSH handshake + authentication + channel setup takes 200-500ms per command. AI agents issue rapid sequences of commands (nmap, then cat a result file, then grep, then curl). The overhead dominates execution time. + +2. **State loss**: Each new SSH connection starts a fresh shell. Environment variables, working directory, shell history, and background processes are lost between commands. An agent that `cd /tmp && wget exploit.sh` in one command finds itself back in `~` on the next. + +3. **Resource churn**: Rapid connect/disconnect cycles stress the SSH daemon on containers and exhaust file descriptors on the MCP server host. + +4. **MCP is stateless, SSH is stateful**: The MCP protocol is request-response with no session concept. But SSH sessions are inherently stateful — they maintain a shell process, environment, and working directory. Bridging these paradigms requires an explicit session layer. + +### AI Agent Behavior Patterns + +Observations from early agent testing revealed: + +- Agents issue 5-20 commands in rapid succession during reconnaissance phases +- Agents frequently set up working environments (`export`, `cd`, `alias`) and expect them to persist +- Agents run long-running commands (nmap full-port scans, compilation) alongside quick checks +- Agents sometimes abandon sessions mid-task when switching strategies + +## Decision + +Implement a **`PersistentSession`** class in `mcp/aptl-mcp-common/src/ssh.ts` that provides long-lived, stateful SSH sessions with command queuing. + +### Architecture + +``` +AI Agent → MCP Tool Call → SSHConnectionManager → PersistentSession → SSH Channel → Container Shell + ↓ + Session Pool (by connection key) + - session-1: kali interactive + - session-2: kali background + - session-3: victim interactive +``` + +### Key Design Elements + +**Long-lived shell channels**: A `PersistentSession` opens a single SSH shell channel on construction and keeps it open for the session lifetime (default: 10 minutes, configurable). All commands execute in the same shell process, preserving state. + +**Delimiter-based output parsing**: Since multiple commands share one shell stream, output boundaries are ambiguous. Each command is wrapped with unique delimiters: + +```bash +echo '<<>>' + +echo '<<>>' +``` + +The session parser watches the output stream for these delimiters and extracts the command's stdout, stderr, and exit code. The delimiter includes a unique ID to prevent collisions with command output. + +**FIFO command queue**: Commands are queued and executed sequentially within a session. When command A is running, command B waits in the queue. This prevents output interleaving while allowing the agent to pipeline commands. + +**Per-command timeouts**: Each command has an individual timeout (default: 30 seconds, configurable per call). Long-running commands like nmap scans can specify longer timeouts. The session itself has a separate inactivity timeout. + +**Buffer overflow protection**: Output buffers are capped at 10,000 lines. When exceeded, the buffer is trimmed to the most recent 5,000 lines. This prevents memory exhaustion from verbose commands (e.g., `find /` or chatty compilation output). + +**Keepalive**: SSH keepalive packets every 30 seconds prevent connection drops from inactive sessions. Max 3 missed keepalives before declaring the connection dead. + +**Shell formatter strategy pattern**: The `ShellFormatter` interface abstracts shell-specific command formatting. Implementations exist for bash, sh, PowerShell, and cmd. Each formatter knows how to: +- Set environment variables (`export FOO=bar` vs `$env:FOO='bar'` vs `set FOO=bar`) +- Change working directory +- Format the delimiter-wrapped command envelope + +This supports the reverse engineering container (Ubuntu/bash) and future Windows containers (PowerShell/cmd) with the same session infrastructure. + +**Session types**: `interactive` (default) for command-response workflows, `background` for long-running tasks. Background sessions don't block the command queue. + +**Immutable metadata**: Session metadata (ID, target, creation time, history) is exposed as a copy to prevent callers from mutating internal state. + +### Connection Manager + +`SSHConnectionManager` maintains a pool of `PersistentSession` instances keyed by `{target}-{sessionId}`. It handles: + +- Session creation with SSH key authentication +- Session lookup and listing +- Graceful cleanup: pending command promises are rejected on close (fixed in v4.6.7 after a bug where cleanup left callers stranded) +- Connection error recovery + +## Consequences + +### Positive + +- **State preservation**: Agent workflows that span multiple commands (reconnaissance, exploitation, post-exploitation) work naturally +- **Performance**: Single SSH handshake amortized across dozens of commands. Order of magnitude latency reduction. +- **Concurrent sessions**: An agent can maintain separate sessions for different tasks on the same container +- **Shell-agnostic**: Strategy pattern supports multiple shell types without changing the session infrastructure + +### Negative + +- **Complexity**: Delimiter-based parsing is inherently fragile. Commands that output text matching the delimiter pattern could confuse the parser (mitigated by unique IDs). +- **Resource retention**: Long-lived sessions hold SSH connections and shell processes open. Must rely on timeouts and explicit cleanup. +- **Queue head-of-line blocking**: A slow command (long nmap scan) blocks subsequent commands in the same session. Agents must create separate sessions for parallel work. + +### Risks + +- Session timeout tuning: Too short drops sessions mid-task; too long wastes resources. The 10-minute default was chosen based on observed agent interaction patterns but may need adjustment. +- Buffer trimming can lose important early output from long-running commands. The 10,000/5,000 line limits were set based on typical command output sizes. +- The v4.6.7 stranded-callers bug showed that cleanup paths must be exhaustively tested — any code path that closes a session must reject all pending promises. diff --git a/docs/adrs/adr-005-docker-compose-profiles.md b/docs/adrs/adr-005-docker-compose-profiles.md new file mode 100644 index 000000000..2be038cac --- /dev/null +++ b/docs/adrs/adr-005-docker-compose-profiles.md @@ -0,0 +1,100 @@ +# ADR-005: Docker Compose Profiles for Selective Deployment + +## Status + +accepted + +## Date + +2025-09-01 + +## Context + +As APTL grew from 5 containers (v2.0) to 13+ containers (v3.x and beyond), the lab's resource requirements became prohibitive for many development machines: + +- **Full stack**: 19 containers requiring ~20GB RAM +- **Individual containers**: Wazuh Indexer alone needs 2GB (OpenSearch JVM heap); TheHive needs 1GB; MISP needs 1-2GB +- **Not all containers are needed for every task**: A user practicing Kali reconnaissance doesn't need the SOC stack. A user testing Wazuh rules doesn't need the enterprise infrastructure. + +Additionally, different use cases required different lab configurations: + +- **Red team practice**: Kali + victim + basic SIEM monitoring +- **Blue team practice**: Full Wazuh stack + SOC tools + victim generating events +- **Full purple team**: Everything running +- **Development/testing**: Minimal set for MCP server development + +Without selective deployment, users had to either run everything (slow startup, resource pressure) or manually comment out services in `docker-compose.yml` (error-prone, hard to share configurations). + +## Decision + +Use **Docker Compose profiles** to group containers into deployable subsets, controlled by `aptl.json` configuration. + +### Profile Definitions + +Each service in `docker-compose.yml` declares which profile(s) it belongs to: + +| Profile | Containers | Purpose | +|---------|-----------|---------| +| `wazuh` | Manager, Indexer, Dashboard | Core SIEM stack | +| `victim` | Victim (Rocky Linux) | Primary attack target | +| `kali` | Kali Linux | Attack platform | +| `reverse` | Reverse engineering | Binary analysis | +| `soc` | Suricata, MISP, TheHive, Cortex, Shuffle (frontend + backend), MISP DB, Elasticsearch | SOC tool stack | +| `enterprise` | Samba AD, PostgreSQL, Web app, File server, Mail server, DNS, Workstation | Enterprise infrastructure | + +### Configuration + +`aptl.json` defines which containers are enabled: + +```json +{ + "containers": { + "wazuh": { "enabled": true }, + "kali": { "enabled": true }, + "victim": { "enabled": true }, + "reverse": { "enabled": false }, + "soc": { "enabled": true }, + "enterprise": { "enabled": true } + } +} +``` + +The Python CLI (`aptl lab start`) reads this config and passes corresponding `--profile` flags to `docker compose up`. + +### Health Checks and Resource Limits + +As part of this standardization, all services received: + +**Health checks** with generous timeouts for cold starts: +- `start_period: 120s-300s` — SOC tools (MISP, TheHive, Shuffle) need up to 5 minutes for first-time initialization +- `retries: 5-15` — enough attempts to survive slow disk I/O +- `interval: 30s` — balanced between responsiveness and overhead + +**Memory limits** per container: +- Wazuh Indexer: 2GB (OpenSearch JVM) +- Wazuh Manager, Dashboard: 1GB each +- TheHive + Elasticsearch: 1GB each (TheHive-ES JVM heap set to 512MB within 1GB container) +- Suricata: 1GB (rule loading and packet processing) +- Most other containers: 256MB-512MB + +These limits prevent any single container from starving the others and provide predictable resource planning. + +## Consequences + +### Positive + +- **Flexible deployment**: Users run only what they need. A red team practice session uses ~6GB instead of ~20GB. +- **Faster startup**: Fewer containers = faster `docker compose up`. Core lab (Wazuh + Kali + victim) starts in ~2 minutes vs. ~8 minutes for full stack. +- **Shared configurations**: `aptl.json` can be checked in or shared to reproduce specific lab setups +- **Predictable resources**: Memory limits prevent OOM kills and resource starvation. Health check standardization ensures services are ready before dependent operations begin. + +### Negative + +- **Profile complexity**: Users must understand which profiles provide which capabilities. Enabling `soc` without `wazuh` is meaningless (SOC tools need SIEM data). +- **Inter-profile dependencies**: Some containers in one profile depend on containers in another. Docker Compose handles this via `depends_on` but only within the active profile set. +- **Start order constraints**: The Python CLI enforces a 12-step orchestration sequence because `depends_on` alone doesn't handle cross-profile readiness (see [ADR-007](adr-007-python-cli-control-plane.md)). + +### Risks + +- Health check `start_period` values were set based on observed cold-start times on a development machine. Slower machines or disk-constrained environments may need longer periods. +- Memory limits may need tuning as data volumes grow — the Wazuh Indexer's 2GB limit is tight for large alert volumes during extended scenarios diff --git a/docs/adrs/adr-006-four-network-segmentation.md b/docs/adrs/adr-006-four-network-segmentation.md new file mode 100644 index 000000000..597851d40 --- /dev/null +++ b/docs/adrs/adr-006-four-network-segmentation.md @@ -0,0 +1,96 @@ +# ADR-006: Four-Network Segmentation Architecture + +## Status + +accepted + +## Date + +2026-02-07 + +## Context + +APTL v2.0 through v3.x used a flat Docker network where all containers could reach each other directly. This was simple to configure but had a fundamental problem for a purple team training lab: **it didn't model enterprise network segmentation**. + +### Problems with Flat Networking + +1. **No pivot requirement**: An attacker (Kali) could reach every container directly. Real enterprise attacks require discovering and exploiting paths between network zones — DMZ to internal, internal to management, etc. +2. **No zone-based detection**: Network IDS (Suricata) couldn't differentiate between expected traffic (web app → database on the internal network) and suspicious traffic (attacker → database from the DMZ). Without zones, all traffic is "internal." +3. **No realistic firewall rules**: Enterprise networks restrict traffic between zones. A flat network has no concept of "the database is only reachable from the internal network." +4. **No multi-homed containers**: Containers that bridge network zones (like a Wazuh Manager collecting logs from all zones, or Kali pivoting through the DMZ) had no architectural representation. + +### Design Constraints + +- Must work with Docker bridge networking (no external routers or VPNs) +- Static IPs required for predictable container addressing (MCP servers, documentation, Wazuh agent configs all reference specific IPs) +- Multi-homed containers must have a unique IP on each network they join +- Must support 19+ containers without IP conflicts + +## Decision + +Create **four Docker bridge networks** that model enterprise network zones: + +### Network Layout + +| Network | Name | Subnet | Purpose | +|---------|------|--------|---------| +| Security | `aptl-security` | 172.20.0.0/24 | SOC stack: Wazuh, MISP, TheHive, Cortex, Shuffle, Suricata management, reverse engineering | +| DMZ | `aptl-dmz` | 172.20.1.0/24 | Externally-reachable services: web app, mail server, DNS | +| Internal | `aptl-internal` | 172.20.2.0/24 | Enterprise services: AD, database, file server, victim, workstation | +| Red Team | `aptl-redteam` | 172.20.4.0/24 | Attack platform: Kali (isolated by default) | + +Note: 172.20.3.0/24 is reserved for a future Endpoints subnet (Windows VM) but is not yet implemented. + +### Multi-Homed Containers + +Several containers bridge multiple networks to perform their function: + +| Container | Networks | Rationale | +|-----------|----------|-----------| +| Wazuh Manager | security, dmz, internal | Collects logs from all zones | +| Kali | redteam, dmz, internal | Can reach DMZ and internal (simulates attacker with pivot access) | +| Suricata | security, dmz, internal | Taps network traffic on all zones | +| DNS | security, dmz, internal | Resolves names across all zones | +| Web App | dmz, internal | Serves DMZ, accesses internal database | +| Mail Server | dmz, internal | Receives external mail, uses internal AD | + +### Static IP Allocation + +Every container has a fixed IP on each network it joins. IPs are assigned in `docker-compose.yml` using `ipv4_address` under each service's `networks` block. The allocation scheme: + +- `.10-.19`: Infrastructure services (Wazuh, AD, DB, file server) +- `.20-.29`: Application services (web app, mail, DNS, victim, workstation, Shuffle, Cortex, reverse) +- `.30-.39`: Access points (Kali, Wazuh Manager internal interface) +- `.50`: Suricata (consistent across all networks for easy identification) + +### Why /24 Subnets + +Each network uses a /24 (254 usable addresses), which is more than needed for the current ~19 containers. A /28 would suffice per zone. However, /24 was chosen for: + +- Simplicity: Standard enterprise subnet size, familiar to practitioners +- Headroom: Adding containers doesn't require subnet resizing +- Readability: `172.20.2.20` (victim on internal) is easier to remember than cramped subnets + +The /16 supernet (172.20.0.0/16) was noted as cosmetically oversized (review item #27) but declared "won't fix" — it works correctly, and changing it risks breaking existing deployments. + +## Consequences + +### Positive + +- **Realistic attack paths**: Kali must traverse network boundaries, mirroring real enterprise attacks +- **Zone-based detection**: Suricata and Wazuh can correlate events by network zone, enabling detection rules like "internal host connecting to external C2" +- **Multi-homed visibility**: Wazuh Manager on all three collection networks can receive logs from every container without a separate log aggregation layer +- **Static addressing**: MCP servers, documentation, and Wazuh configs all reference stable IPs. No DNS dependency for core infrastructure. +- **Teachable**: The 4-zone model maps directly to enterprise network concepts students are learning + +### Negative + +- **Complexity**: 19 containers × multiple networks = many IP assignments to maintain. IP allocation must be manually coordinated in `docker-compose.yml`. +- **Docker DNS limitations**: Containers on different Docker networks can't resolve each other by hostname without explicit `extra_hosts` entries or a shared DNS server (hence the DNS container). +- **No dynamic routing**: Docker bridge networks don't support routing protocols. Inter-zone routing depends entirely on multi-homed containers. There's no firewall appliance enforcing zone boundaries — isolation relies on Docker network membership. +- **Port mapping complexity**: Host port mappings must be unique across all containers regardless of network. Multiple web servers can't both use host port 80. + +### Risks + +- Docker bridge networking doesn't provide true network isolation at the kernel level — containers on different bridge networks can't communicate by default, but a compromised multi-homed container (e.g., the web app on both DMZ and internal) provides a real lateral movement path +- Static IPs create a coupling between `docker-compose.yml` and every config file, script, and documentation page that references container addresses. The v4.4.0 documentation review found wrong IPs throughout the docs after the network migration. diff --git a/docs/adrs/adr-007-python-cli-control-plane.md b/docs/adrs/adr-007-python-cli-control-plane.md new file mode 100644 index 000000000..5d93704a6 --- /dev/null +++ b/docs/adrs/adr-007-python-cli-control-plane.md @@ -0,0 +1,128 @@ +# ADR-007: Python CLI as Primary Control Plane + +## Status + +accepted + +## Date + +2026-02-07 + +## Context + +From v2.0 through v3.x, the lab lifecycle was managed by `start-lab.sh` — a 273-line bash script that handled SSH key generation, environment setup, certificate management, Docker Compose orchestration, credential synchronization, and health checking. While functional, it had accumulated serious limitations: + +### Problems with start-lab.sh + +1. **No error handling**: Commands that failed silently left the lab in an inconsistent state. A failed certificate generation didn't stop the startup sequence — containers would start without valid certs and fail with cryptic TLS errors. + +2. **No parallelism**: Every operation ran sequentially. Docker image pulling, health checking, and SSH testing all blocked the main thread. Startup took longer than necessary. + +3. **No state management**: The script had no concept of lab state. Running it twice could produce duplicate certificate generation, conflicting Docker Compose processes, or orphaned containers. There was no `stop` or `status` command. + +4. **Only 4 of 9 profiles**: The script hardcoded `--profile` flags for only 4 Docker Compose profiles (wazuh, victim, kali, reverse). As the SOC stack and enterprise infrastructure were added, the script couldn't deploy them. Users had to manually add `--profile soc --profile enterprise` flags. + +5. **No configuration validation**: The script read `aptl.json` for container enable/disable state but didn't validate the configuration. Invalid JSON, missing fields, or impossible combinations (SOC without Wazuh) passed silently. + +6. **Bash limitations**: Complex operations like NDJSON parsing (`docker compose ps --format json` outputs one JSON object per line, not a JSON array), credential file manipulation with regex, and structured logging were awkward or fragile in bash. + +### Requirements + +- Start, stop, and status commands with proper lifecycle management +- Configuration validation before deployment +- Structured error handling — fail early, report clearly +- All Docker Compose profiles supported via `aptl.json` +- Health check orchestration that waits for dependencies +- SSH connectivity verification +- Credential synchronization across Wazuh components +- SSL certificate generation/management +- Extensible for future commands (`aptl scenario`, `aptl config`) + +## Decision + +Implement a Python CLI (`aptl`) as the primary control plane, replacing `start-lab.sh`. + +### Technology Choices + +| Component | Choice | Rationale | +|-----------|--------|-----------| +| CLI framework | **Typer** | Declarative command definitions, auto-generated help, type annotations for argument validation | +| Configuration | **Pydantic** | Schema validation, type coercion, clear error messages for invalid configs | +| Output | **Rich** | Colored terminal output, progress indicators, tables — without manual ANSI escape codes | +| Structure | **src layout** | `src/aptl/` package with `pyproject.toml`. Standard Python packaging. | + +### Architecture + +``` +src/aptl/ +├── cli/ # Thin CLI layer (typer commands) +│ ├── main.py # aptl entry point +│ ├── lab.py # aptl lab start|stop|status +│ ├── scenario.py # aptl scenario start|stop|list +│ ├── container.py # aptl container list|shell|logs +│ └── config.py # aptl config show|validate +├── core/ # Domain logic (no CLI dependencies) +│ ├── lab.py # Lab lifecycle: start, stop, status, orchestration +│ ├── config.py # Pydantic models: AptlConfig, ContainerConfig +│ ├── env.py # .env file management, EnvVars +│ ├── ssh.py # SSH key generation (Ed25519) +│ ├── certs.py # SSL certificate generation via Docker +│ ├── credentials.py # Wazuh credential synchronization +│ ├── services.py # Health checks, readiness probes +│ ├── sysreqs.py # System requirements (vm.max_map_count) +│ ├── snapshot.py # Range snapshot capture +│ └── scenario.py # Scenario engine +└── utils/ + └── logging.py # Structured logging +``` + +The separation between `cli/` and `core/` is intentional — the core domain logic has no dependency on Typer, Rich, or terminal I/O. This enables: + +- **Testing**: Core functions are testable without mocking CLI frameworks +- **Reuse**: The future web UI backend ([ADR-011](adr-011-web-ui.md)) shares the same `core/` modules +- **Scripting**: Core functions can be imported directly for custom automation + +### 12-Step Lab Orchestration + +`aptl lab start` executes a deterministic sequence: + +1. Load and validate configuration (`aptl.json`) +2. Load environment variables (`.env`) +3. Check system requirements (`vm.max_map_count >= 262144`) +4. Generate SSH keys (Ed25519, if not present) +5. Generate SSL certificates (via Docker container, if not present) +6. Synchronize credentials to Wazuh config files +7. Pre-pull Docker images (with progress visibility) +8. Start containers via `docker compose up --build -d` with profile flags +9. Wait for Wazuh Indexer to be healthy +10. Wait for Wazuh Manager API to be ready +11. Test SSH connectivity to all SSH-enabled containers (with retry: 60s timeout, 5s interval) +12. Capture range snapshot + +Each step reports its status and fails the entire sequence on error, with a clear message about what went wrong and how to fix it. + +### Test Coverage + +587+ tests across 12 test files covering all core modules. Tests use mocking for Docker and subprocess calls to run without a live lab environment. + +## Consequences + +### Positive + +- **Reliable startup**: Every step validates its preconditions and reports failures clearly +- **All profiles supported**: `aptl.json` configuration drives which profiles are activated — all 6 profile groups work +- **Extensible**: Adding `aptl scenario start/stop` was straightforward because the core domain logic was already separated +- **Testable**: 587+ tests provide confidence in orchestration logic, credential handling, certificate management +- **Shared core**: Web UI backend can import `src/aptl/core/` directly instead of shelling out to the CLI + +### Negative + +- **Python dependency**: Users must have Python 3.11+ and install the package (`pip install -e .`). The bash script had no dependencies beyond Docker. +- **Two systems**: `start-lab.sh` was retained as an alternative through v4.2.1, creating confusion about which was authoritative. It was removed in v4.2.2. +- **Startup overhead**: Python interpreter startup adds ~1 second vs. bash. Negligible compared to Docker operations but noticeable for `aptl --help`. + +### Risks + +- The `subprocess.run()` calls to `docker compose` are a fragile interface — changes to Docker Compose's CLI, output format, or exit codes can break the orchestration. The NDJSON parsing bug (v4.5.0) was an example. +- Credential synchronization uses regex to modify Wazuh configuration XML files. A polynomial backtracking (ReDoS) vulnerability was found and fixed in v4.6.5. XML manipulation via regex remains fragile. +- The 12-step sequence is serial. Steps 7-12 could potentially be parallelized for faster startup, but the dependency ordering makes this non-trivial. diff --git a/docs/adrs/adr-008-soc-stack-integration.md b/docs/adrs/adr-008-soc-stack-integration.md new file mode 100644 index 000000000..57b8d08f0 --- /dev/null +++ b/docs/adrs/adr-008-soc-stack-integration.md @@ -0,0 +1,119 @@ +# ADR-008: Integrated SOC Stack (MISP, TheHive, Cortex, Shuffle, Suricata) + +## Status + +accepted + +## Date + +2026-02-22 + +## Context + +APTL's original blue team capability was Wazuh alone ([ADR-002](adr-002-wazuh-siem.md)): log collection, rule-based alerting, and a web dashboard. While sufficient for basic alert triage, this left critical gaps in the SOC workflow: + +### Gaps in Wazuh-Only SOC + +1. **No threat intelligence**: Wazuh alerts contain raw event data but no context about whether an IP, domain, or file hash is a known threat. Analysts (human or AI agent) must manually research each indicator. + +2. **No case management**: Alerts fire and scroll. There's no way to group related alerts into an investigation, track progress, assign ownership, document findings, or close a case. For an agentic SOC where AI agents investigate incidents, case management provides the structured workflow they need. + +3. **No automated response**: Wazuh has active response (block IPs, run scripts) but no orchestration layer. Complex response workflows — "if critical alert AND known IOC AND business hours, then isolate host AND create case AND notify Slack" — require a SOAR platform. + +4. **No network detection**: Wazuh sees host-level logs but is blind to network traffic. Command-and-control channels, lateral movement over the wire, data exfiltration, and DNS tunneling are invisible without network IDS. + +The design question from `notes/team-review-synthesis.md` captures the gap: Wazuh alone provides alerting, but an agentic SOC needs structured workflows, enrichment, and automated response to give AI agents real investigative work. + +### Tool Selection Criteria + +- Must run in Docker containers (per [ADR-001](adr-001-docker-compose-deployment.md)) +- Open-source with active community +- REST API for MCP server integration ([ADR-003](adr-003-mcp-common-library.md)) +- Reasonable resource footprint (entire SOC stack must fit within ~8GB alongside Wazuh) +- Established integration patterns between tools + +## Decision + +Add five SOC tools under the `soc` Docker Compose profile ([ADR-005](adr-005-docker-compose-profiles.md)), each with a dedicated MCP server for AI agent integration. + +### Tool Selection and Rationale + +#### 1. Suricata IDS (`aptl-suricata`, 172.20.0.50) + +**Chosen over Zeek**. Suricata provides signature-based network IDS with the ET Open ruleset, JSON output (Eve JSON) that integrates cleanly with Wazuh, and simpler deployment. Zeek offers richer metadata extraction and scripting but has a steeper learning curve and less direct Wazuh integration. For a training lab focused on detection rule writing and alert investigation, Suricata's approach is more accessible. + +- Deployed in IDS mode with pcap capture on Docker networks (dmz, internal, security) +- Initially configured in `network_mode: host` but switched to Docker networks with pcap capture after the host interface (`eth0`) wasn't available in container mode +- Custom local rules for lab-specific detection (e.g., detecting attack patterns in the TechVault scenario) +- Requires `NET_ADMIN`, `NET_RAW`, `SYS_NICE` capabilities for packet capture +- MCP server: `mcp-network` — query alerts, manage rules, search flow data + +#### 2. MISP Threat Intel (`aptl-misp`, 172.20.0.16) + +**Chosen over OpenCTI**. MISP is the more mature threat intelligence platform with better Wazuh integration (CDB lists, IOC matching), a simpler deployment model (single container + MySQL), and a well-documented REST API via PyMISP. OpenCTI offers a modern GraphQL API and STIX-native data model but requires more infrastructure (Elasticsearch, Redis, MinIO, multiple workers) and ~4GB RAM minimum. + +- Pre-loaded with IOCs relevant to lab scenarios via `scripts/seed-misp.sh` +- Feed integration for abuse.ch, OTX, CIRCL threat data +- PyMISP client for API access (installed in a virtualenv within the MCP server container) +- MCP server: `mcp-threatintel` — query IOCs by type, submit indicators, correlate with SIEM alerts, pull ATT&CK technique mappings +- Known issue: MISP healthcheck passes on 503 "loading" response — fixed with `-f` flag and nginx reload (v4.6.1) + +#### 3. TheHive Case Management (`aptl-thehive`, 172.20.0.18) + +TheHive 5 provides case management with alert escalation, observable tracking, and timeline documentation. Selected as the only mature open-source security case management platform with a usable API. + +- Uses Elasticsearch backend (separate `aptl-thehive-es` container) +- Case templates for common incident types +- Alert escalation from Wazuh → TheHive via Shuffle workflows +- API key provisioning via `scripts/thehive-apikey.sh` (takes ~24s on first run; timeout was initially set to 10s, causing failures) +- MCP server: `mcp-casemgmt` — create cases, add observables, run Cortex analyzers, update case status, generate reports +- Known issue: TheHive-ES JVM memory starvation — 256MB heap in 512MB container caused Cassandra query timeouts. Fixed by doubling heap to 512MB and container limit to 1GB (v4.1.0). + +#### 4. Cortex Enrichment (`aptl-cortex`, 172.20.0.22) + +Cortex provides automated observable enrichment — analyzing IPs, domains, hashes, and emails against external sources (VirusTotal, abuse.ch, WHOIS). Tightly integrated with TheHive; analyzers are triggered from case observables. + +- Shares TheHive's Elasticsearch backend +- Accessible via TheHive's MCP server (no separate MCP server) + +#### 5. Shuffle SOAR (`aptl-shuffle-backend` 172.20.0.20, `aptl-shuffle-frontend` 172.20.0.21) + +**Chosen over n8n**. Shuffle is purpose-built for security orchestration with pre-built security app integrations (Wazuh, TheHive, MISP, email, HTTP). n8n is a more general workflow automation tool with a better UI but lacks security-specific integrations. For a SOC stack, Shuffle's domain focus outweighs n8n's polish. + +- Pre-built playbooks seeded via `scripts/seed-shuffle.sh`: + - Alert-to-Case: Wazuh alert → TheHive case creation + - IOC enrichment: Extract observables → MISP lookup → annotate case +- Webhook triggers from Wazuh alerts +- MCP server: `mcp-shuffle` (initially `mcp-soar`) — trigger playbooks, check execution status, manage response actions +- Known issue: Initial MCP implementation built against a non-existent single-execution API endpoint — rewritten in v4.0.1 + +### Network Placement + +All SOC tools are on the `aptl-security` network (172.20.0.0/24) per [ADR-006](adr-006-four-network-segmentation.md). Suricata is additionally multi-homed on `aptl-dmz` and `aptl-internal` to tap traffic on all zones. + +### MCP Integration Pattern + +Each SOC tool gets a dedicated MCP server using the config-driven architecture from [ADR-003](adr-003-mcp-common-library.md). The API-based MCP servers (MISP, TheHive, Shuffle, Suricata) use the common library's `HTTPClient` and `generateAPIToolHandlers()` for consistent error handling and response formatting. + +## Consequences + +### Positive + +- **Full SOC workflow**: Alert → Triage → Enrich → Investigate → Contain → Report — all steps have tooling and MCP integration +- **Agentic SOC capability**: AI agents can now run complete investigation workflows through MCP tool calls, not just query logs +- **Network visibility**: Suricata fills the critical gap — C2 detection, lateral movement, exfiltration are now detectable +- **Structured threat intel**: MISP provides context for every indicator, turning raw alerts into enriched investigations +- **Automated response**: Shuffle playbooks enable programmatic containment actions without manual intervention + +### Negative + +- **Resource cost**: The full SOC stack adds ~6-8GB RAM (MISP ~2GB, TheHive+ES ~2GB, Shuffle ~1GB, Suricata ~1GB, Cortex ~0.5GB). Combined with Wazuh (~4GB), the security stack alone needs ~10-12GB. +- **Complexity**: 5 additional tools means 5 more configuration surfaces, version pinning concerns, inter-tool integration debugging, and health check tuning +- **Cold start time**: SOC tools have slow first-start initialization (MISP database migrations, TheHive schema creation, Shuffle app loading). Full SOC profile adds 3-5 minutes to lab startup. +- **Fragile integrations**: Each tool-to-tool integration (Wazuh → Shuffle → TheHive, TheHive → Cortex, MISP ↔ Wazuh CDB) is a potential failure point with version-specific API contracts + +### Risks + +- SOC tool versions are not pinned as strictly as Wazuh (which pins to 4.12.0 exactly). Major version upgrades to TheHive, MISP, or Shuffle could break API contracts with MCP servers. +- The MISP `restSearch` API only supports a lower-bound `timestamp` parameter, requiring client-side post-filtering for upper bounds (fixed in v4.6.3). Similar API limitations in other tools may surface. +- Shuffle playbook execution is eventually consistent — workflow completion status may not reflect in TheHive immediately, causing race conditions in automated investigation workflows. diff --git a/docs/adrs/adr-009-scenario-engine.md b/docs/adrs/adr-009-scenario-engine.md new file mode 100644 index 000000000..f85b5b58a --- /dev/null +++ b/docs/adrs/adr-009-scenario-engine.md @@ -0,0 +1,144 @@ +# ADR-009: Scenario Engine with YAML Specs and Run Archive Collectors + +## Status + +accepted + +## Date + +2026-03-02 + +## Context + +Before the scenario engine, APTL exercises were ad-hoc: a user read documentation, manually started the lab, ran commands, and inspected results. There was no structured way to: + +1. **Define exercises**: What containers are needed? What's the attack chain? What MITRE ATT&CK techniques are involved? What does success look like? +2. **Reproduce runs**: Two runs of the "same" exercise could produce completely different data depending on which commands the operator ran, in what order, and what state the lab was in. +3. **Collect data**: After an exercise, SOC telemetry (Wazuh alerts, Suricata events, TheHive cases, MISP correlations, Shuffle execution logs) existed only in the running containers. Stopping the lab could lose ephemeral data. + +The research goal — comparing AI agent performance across different models, prompts, and configurations on identical scenarios — requires **repeatable runs** with **comparable data collection**. Without structured scenarios, every run is a unique snowflake. + +### Requirements + +- Declarative scenario definitions (not code) +- MITRE ATT&CK technique mapping for each scenario +- Prerequisite validation (required containers, profiles) +- Run lifecycle management (start, stop, with timing) +- Automated data collection from all SOC tools after a run +- Range state snapshots for reproducibility +- Archive storage for historical comparison + +## Decision + +Implement a scenario engine with YAML-based scenario specifications, run lifecycle management via the Python CLI ([ADR-007](adr-007-python-cli-control-plane.md)), and automated data collectors for each SOC tool ([ADR-008](adr-008-soc-stack-integration.md)). + +### Scenario Specification Format + +Scenarios are defined in `scenarios/*.yaml` using Pydantic-validated YAML: + +```yaml +metadata: + id: prime-enterprise + name: "TechVault Enterprise Compromise" + description: > + Multi-host enterprise environment designed for repeatable research. + Three independent attack paths traverse a realistic enterprise topology. + version: "1.0.0" + author: "APTL Research" + difficulty: expert + estimated_minutes: 60 + tags: [research, enterprise, multi-path, sqli, lateral-movement] + mitre_attack: + tactics: [Reconnaissance, Initial Access, Credential Access, ...] + techniques: [T1595.002, T1190, T1059.004, T1552.001, ...] + +mode: purple # red, blue, or purple + +requirements: + profiles: [wazuh, victim, kali, soc, enterprise] + +objectives: + - id: recon-nmap + name: "Network Reconnaissance" + description: "Discover services on the DMZ" + techniques: [T1595.002] + # ... + +steps: + - id: step-1 + name: "External Reconnaissance" + # ... +``` + +Six scenarios are currently defined: reconnaissance (nmap scan), detection (brute force), lateral movement, web app compromise, AD domain compromise, and the full prime enterprise scenario. + +### Run Lifecycle + +``` +aptl scenario start + → Validate prerequisites (required profiles enabled and running) + → Record start timestamp + → Create run directory + → Begin scenario session + +aptl scenario stop + → Record end timestamp + → Execute data collectors + → Capture range snapshot + → Assemble run archive +``` + +### Data Collectors + +Each SOC tool has a collector module that queries its API for data generated during the run's time window (`start_iso` to `end_iso`): + +| Collector | Source | Data Collected | +|-----------|--------|---------------| +| Wazuh | Indexer API (9200) | Alerts matching time window | +| Suricata | Eve JSON via Wazuh | Network IDS alerts | +| TheHive | TheHive API | Cases created during run (with `_gte` and `_lte` filters on `_createdAt`) | +| MISP | MISP API via PyMISP | Events matching time window (with client-side post-filter for upper bound) | +| Shuffle | Shuffle API | Workflow executions during run | + +Collector outputs are JSON files stored in the run directory alongside a range snapshot (`snapshot.json`) containing software versions, container state, Wazuh rules inventory, network configuration, and config file hashes. + +### Run Archives + +Completed runs can be exported: + +``` +aptl runs export --s3-bucket + → Package run directory as tar.gz + → Generate SHA-256 checksum + → Optional S3 upload with metadata tags +``` + +### Prerequisite Validation + +`aptl scenario start` validates that all required lab profiles are enabled and their containers are running before starting a session. This prevents starting scenarios with disabled containers that would lead to impossible objectives (added in v4.6.2 after a bug where scenarios could start without required containers). + +### Environment and Credentials + +Collectors load API credentials from the project `.env` file into `os.environ` before executing. This was a critical fix in v4.6.6 — without it, collectors got empty-string fallbacks for API keys and silently skipped data collection. + +## Consequences + +### Positive + +- **Reproducible research**: Identical scenario definitions + automated data collection = comparable runs across different AI agents, models, and configurations +- **MITRE ATT&CK mapping**: Every scenario documents which techniques are exercised, enabling ATT&CK coverage analysis +- **Automated data collection**: No manual post-exercise data gathering. Collectors run automatically on scenario stop. +- **Range snapshots**: Complete lab state captured with each run — versions, configs, network topology — enabling root cause analysis of behavioral differences between runs +- **Extensible**: Adding a new collector (for a new SOC tool) is a new module implementing the collector interface + +### Negative + +- **API dependency**: Collectors depend on each SOC tool's API being available and responsive at scenario stop time. If TheHive is overloaded, its collector may timeout. +- **Time-window filtering limitations**: Each SOC tool's API has different time filtering capabilities. MISP's `restSearch` only supports a lower bound, requiring client-side post-filtering (v4.6.3). TheHive required adding both `_gte` and `_lte` filters (v4.6.3). +- **Collector timeout sensitivity**: The initial 20-second HTTP timeout was too short for MISP and TheHive cold queries, causing silent data loss. Raised to 120 seconds in v4.6.6. + +### Risks + +- Scenario YAML schema evolution: Adding new fields (scoring criteria, automated validation, red team instructions) requires updating the Pydantic models and potentially migrating existing scenario files +- S3 export creates a dependency on AWS credentials and boto3 — isolated behind the optional `aptl[s3]` dependency group to avoid requiring it for local-only use +- Run data volumes can grow large over hundreds of research runs. No automatic cleanup or retention policy is currently implemented. diff --git a/docs/adrs/adr-010-sonarcloud-quality.md b/docs/adrs/adr-010-sonarcloud-quality.md new file mode 100644 index 000000000..a8e8edd84 --- /dev/null +++ b/docs/adrs/adr-010-sonarcloud-quality.md @@ -0,0 +1,56 @@ +# ADR-010: SonarCloud for Continuous Code Quality + +## Status + +accepted + +## Date + +2026-03-07 + +## Context + +APTL had no automated code quality analysis. Code quality was assessed through manual review only — first during development, then formally in the team review documented in `notes/team-review-synthesis.md`. That review found 30 issues (17 major, 13 minor), including `any`-typed MCP args, duplicated code, dead config fields, and a process-global SSL disable. + +As the codebase grew to include both Python (`src/aptl/`, 587+ tests) and TypeScript (`mcp/`, vitest tests), a continuous quality gate was needed to catch regressions before they accumulate. + +## Decision + +Integrate SonarCloud via GitHub Actions for continuous code quality analysis across both language stacks. + +### Configuration + +**`sonar-project.properties`**: +- Sources: `src` (Python) and `mcp` (TypeScript) +- Tests: `tests` (Python) and `mcp/aptl-mcp-common/tests` (TypeScript) +- Exclusions: `node_modules`, `__pycache__`, `dist`, `.venv`, `build`, `containers`, and `mcp/**/tests/**` (to prevent test files from being double-indexed as both source and test) + +**Coverage pipeline** (`.github/workflows/sonarcloud.yml`): +1. Python: `pytest --cov` generates `coverage.xml` +2. TypeScript: `vitest --coverage` in `mcp/aptl-mcp-common/` generates `lcov.info` +3. SonarCloud scan reads both coverage reports + +### Key Implementation Details + +- **Test file separation**: MCP test files under `mcp/**/tests/**` are added to `sonar.exclusions` (excluded from source analysis) while simultaneously listed in `sonar.tests` (recognized as test sources). Without this, SonarCloud reported "can't be indexed twice" errors (v4.6.8). +- **Coverage bootstrapping**: The initial CI workflow ran SonarCloud without running tests first, resulting in 0% coverage. Fixed by adding pytest and vitest steps before the scan (v4.5.0, v4.6.8). +- **Scanner migration**: Migrated from deprecated `SonarSource/sonarcloud-github-action` to `SonarSource/sonarqube-scan-action@v7` (v4.7.0). +- **CI action versions**: Upgraded to Node.js 24-compatible versions (`actions/checkout@v6`, `actions/setup-python@v6`, `actions/setup-node@v6`) in v4.7.0. + +## Consequences + +### Positive + +- **Continuous quality gate**: Every PR and push to main gets automated quality analysis +- **Dual-language coverage**: Both Python and TypeScript codebases have measured test coverage +- **Historical tracking**: SonarCloud maintains quality metrics over time, showing trends + +### Negative + +- **External dependency**: SonarCloud is a third-party service. Outages block quality gate checks. +- **CI pipeline complexity**: The workflow must install Python, Node.js, run two test suites, and then scan — adding ~3-5 minutes to every CI run. + +### Risks + +- SonarCloud free tier limits may be reached as the codebase grows (currently well within limits for open-source projects) +- Coverage thresholds are not yet enforced as quality gates — currently informational only diff --git a/docs/adrs/adr-011-web-ui.md b/docs/adrs/adr-011-web-ui.md new file mode 100644 index 000000000..a5312806a --- /dev/null +++ b/docs/adrs/adr-011-web-ui.md @@ -0,0 +1,123 @@ +# ADR-011: Notebook-Style Web UI (SvelteKit + FastAPI) + +## Status + +accepted + +## Date + +2026-03-20 + +## Context + +APTL's primary interface is a Python CLI ([ADR-007](adr-007-python-cli-control-plane.md)). While effective for lab lifecycle management and scenario execution, the CLI has limitations for: + +1. **Scenario execution**: Following multi-step attack/defense scenarios requires switching between terminal windows (Kali SSH, victim SSH, SIEM queries) and mentally correlating events across them. +2. **SIEM data visualization**: Wazuh Dashboard provides some visualization, but correlating SIEM alerts with scenario steps, network IDS events, and case management data requires multiple browser tabs. +3. **Data collection review**: Run archives from the scenario engine ([ADR-009](adr-009-scenario-engine.md)) are JSON files on disk. Comparing runs requires manual inspection. + +### Design Constraint + +The UI must be **visually distinct from enterprise SOC/XDR-style mission control dashboards** — the dark tile grids with icon sidebars, status dot matrices, and pill buttons used by products like Cortex XDR, Splunk SOAR, or SecurityOnion. Those suit enterprise demo platforms but are the wrong paradigm for a personal training lab. APTL needs something that feels like a different product category entirely. + +This constraint comes from the user's concurrent work on an enterprise security product (`../shifter`) — APTL's UI must not look or feel like a second version of the same thing. + +## Decision (Proposed) + +Adopt an **interactive workbench** paradigm — closer to a Jupyter notebook or VS Code than a SOC console. The user works through a linear, scrollable document that interleaves narrative context, live system state, interactive controls, and terminal output. + +### Why This Paradigm + +- A training lab is inherently **sequential and educational** — the user follows scenarios step by step. A notebook captures this flow naturally. +- Unlike an enterprise dashboard where operators monitor passively, APTL users **actively do things** — run commands, read output, correlate results. The workbench puts actions and results in a single scrollable flow. +- It avoids the visual language of enterprise security products entirely — no tile grids, no icon-only sidebars, no status dot matrices. +- It naturally supports **mixed content**: markdown instructions, live terminal embeds, SIEM query results, container status widgets, all inline. + +### Tech Stack + +| Layer | Technology | Rationale | +|-------|-----------|-----------| +| Frontend | **SvelteKit** | Lighter than Next.js, excellent for interactive document-style UIs, SSR, distinct ecosystem choice | +| Language | TypeScript | Shared types with MCP servers | +| Components | **Skeleton UI** + Tailwind CSS | Clean Svelte component library with good theming | +| Real-time | **SSE** + WebSocket | SSE for one-way streams (container status, log tailing, alerts). WebSocket only for bidirectional terminal I/O via xterm.js. | +| Terminal | **xterm.js** | In-browser terminal for SSH sessions | +| Markdown | mdsvex or unified/remark | Render scenario content as rich markdown with embedded interactive components | +| Charts | LayerChart or Pancake | Svelte-native charting, no React dependency | +| Backend | **FastAPI** | Python backend sharing `src/aptl/core/` domain logic. Same core modules used by CLI and web API. | + +### Key Views + +**Lab Home**: Minimal landing page. Lab state (running/stopped), active containers, scenario list as cards with difficulty badges. Large Start/Stop button. + +**Scenario Workbench** (core experience): Scrollable document with interleaved content blocks: +- Narrative blocks (markdown scenario instructions) +- Terminal blocks (xterm.js connected to specific containers) +- SIEM query blocks (inline OpenSearch queries with results tables) +- Container status blocks (live health indicators) +- Hint toggles (progressive disclosure of guidance) + +**Container Inspector**: Slide-over panel (not a separate page) with container details, live log stream, quick terminal access, restart/rebuild actions. + +**SIEM Explorer**: Dedicated page for open-ended SIEM exploration — query builder, alert timeline, results with expandable rows. Closer to Kibana's Discover tab than a SOC dashboard. + +### Design Language + +- **Dark theme primary**: Dark surfaces with warm neutrals (slate/zinc tones, not pure black `#000`). Light theme available as toggle. +- **Color accent: indigo/violet**: Purple team identity. Indigo for primary actions, violet for red team markers, teal for blue team/defensive markers. +- **Typography**: Inter or IBM Plex Sans for UI text; JetBrains Mono for code/terminal/data +- **Generous whitespace**: Learning environment — readability over information density +- **Rounded corners, soft shadows**: Approachable, not industrial. Think Notion or Linear, not Splunk. +- **Top navigation bar**: Simple text navigation, not a collapsible icon sidebar + +### Real-Time Architecture + +``` +Browser (SvelteKit) + ├── REST API (initial load, mutations) + │ └── FastAPI backend + │ ├── Docker SDK (container management) + │ ├── OpenSearch client (Wazuh alerts/logs) + │ └── Scenario runner (subprocess for setup/cleanup) + ├── SSE (one-way server push) + │ ├── Container status events + │ ├── Log streams + │ ├── SIEM alert feed + │ └── Scenario step progress + └── WebSocket (bidirectional, terminal only) + └── xterm.js ↔ SSH via asyncssh +``` + +### How This Differs from Enterprise Dashboards + +| Aspect | Enterprise SOC / Mission Control | APTL Workbench | +|--------|----------------------------------|----------------| +| Layout | Fixed grid of tiles/cards | Scrollable document with inline blocks | +| Navigation | Icon sidebar with submenu panels | Top bar or simple text sidebar | +| Theme | Cold industrial dark (#000/#151515) | Warm dark (slate/zinc tones) | +| Typography | Dense system sans-serif | Inter/Plex, generous spacing | +| Primary metaphor | Control room / monitoring wall | Lab notebook / interactive textbook | +| Information density | High (many widgets visible) | Progressive (expand on demand) | +| Color language | Green/red/amber status dots | Indigo/violet/teal palette | +| Interaction model | Click tiles, monitor passively | Scroll, type, read, learn | + +## Consequences + +### Positive + +- **Unified experience**: Scenario instructions, terminal access, SIEM queries, and container management in one scrollable view instead of multiple windows/tabs +- **Shared backend**: FastAPI imports `src/aptl/core/` directly — no duplication of lab management logic between CLI and web +- **Distinct identity**: The notebook paradigm and indigo/violet palette are visually unrelated to enterprise SOC dashboards +- **Educational fit**: The document-first approach naturally supports the scenario-driven, step-by-step learning model + +### Negative + +- **Development effort**: A full web UI is a significant new surface area — frontend framework, backend API, real-time infrastructure, terminal integration, authentication +- **Maintenance burden**: Two interfaces (CLI + web) must stay in sync as core functionality evolves +- **SvelteKit ecosystem**: Smaller ecosystem than React/Next.js. Fewer pre-built components, fewer tutorials, smaller hiring pool. + +### Risks + +- Scope creep: The web UI could absorb unlimited development time. Must be scoped to a focused MVP (Lab Home + Scenario Workbench) before expanding to SIEM Explorer and Container Inspector. +- xterm.js + WebSocket SSH is a complex integration with security implications (SSH sessions from browser to containers). Requires careful authentication and session management. +- The SSE + WebSocket dual approach adds complexity compared to WebSocket-only, but SSE is simpler for the common one-way streaming case and gracefully handles reconnection. diff --git a/docs/adrs/adr-012-opentelemetry-integration.md b/docs/adrs/adr-012-opentelemetry-integration.md new file mode 100644 index 000000000..402e9fa7c --- /dev/null +++ b/docs/adrs/adr-012-opentelemetry-integration.md @@ -0,0 +1,115 @@ +# ADR-012: OpenTelemetry Integration + +**Status:** Accepted +**Date:** 2026-03-21 +**Deciders:** Brad Edwards + +## Context + +APTL had two custom JSONL tracing systems: + +1. **Python `EventLog`** — per-scenario JSONL files recording lifecycle events + (start, stop, preconditions, objectives, hints, evaluations). +2. **TypeScript `ToolTracer`** — per-MCP-server JSONL files recording every + tool invocation with arguments, responses, timing, and errors. + +At run assembly, `collect_mcp_traces()` read JSONL files from disk and merged +them into the run archive. Events were serialized as a list of dicts. + +Neither system was queryable in real-time, neither followed a standard format, +and neither supported distributed tracing across the Python CLI and TypeScript +MCP server processes. + +## Decision + +Replace both custom systems with [OpenTelemetry](https://opentelemetry.io/). +OTel becomes the single tracing path with no JSONL fallback or dual code paths. + +### Span Hierarchy + +``` +[Scenario Run] aptl.scenario.run (root span, backdated at stop) + +-- [Precondition] aptl.precondition (child span from CLI) + +-- [Objective] aptl.objective (child span from CLI) + +-- [Alert Match] (span event) + +-- [Hint Request] (span event) + +-- [Evaluation] aptl.evaluation (child span from CLI) + +-- [Tool Call] execute_tool (child span from MCP server) +``` + +### Cross-Process Propagation + +MCP servers are started by the AI agent host (Claude Desktop), not the CLI. +They are already running when `scenario start` is called. Propagation uses a +shared file: + +1. `scenario start` generates `trace_id` + `span_id`, writes `.aptl/trace-context.json` +2. MCP servers read this file on each tool call; if present, tool spans use + that `trace_id` as parent +3. `scenario stop` creates a synthetic root span with `start_time=session.started_at` +4. After flushing, `scenario stop` queries Tempo for all spans, writes to run archive + +### Transport + +Both Python and TypeScript use **HTTP/protobuf on port 4318** to avoid native +gRPC binding issues in Node.js. The standard `OTEL_EXPORTER_OTLP_ENDPOINT` +env var controls the endpoint. + +### Infrastructure + +The OTel stack is always-on lab infrastructure (not optional): + +- **OTel Collector** (`otel/opentelemetry-collector-contrib`) — receives OTLP, + batches, forwards to Tempo +- **Grafana Tempo** — trace storage with 72h retention +- **Grafana** — trace visualization at `http://localhost:3100` (bound to + localhost only; default credentials `admin`/`aptl-otel`) + +All services run under the `otel` Docker Compose profile, automatically +included by `aptl lab start`. + +### GenAI SIG Conventions + +MCP tool spans follow the [OpenTelemetry GenAI SIG](https://github.com/open-telemetry/semantic-conventions/tree/main/docs/gen-ai) +attribute conventions: `gen_ai.operation.name`, `gen_ai.tool.name`, +`gen_ai.agent.name`. + +## Consequences + +### Positive + +- Industry-standard tracing format; queryable via Tempo API and Grafana UI +- Distributed tracing links Python CLI and TypeScript MCP server spans +- Real-time visibility into running scenarios (not just post-hoc) +- Run archives contain complete trace data in `traces/spans.json` +- OTel SDK gracefully degrades — if Collector is unreachable, spans are silently dropped + +### Negative + +- **Breaking change**: Old run archives have `scenario/events.jsonl` and + `agents/traces.jsonl`; new archives have `traces/spans.json`. No migration + tool provided (old archives remain readable by hand). +- Three additional Docker containers (~1 GB combined memory) +- OTel SDK adds dependencies to both Python and TypeScript packages + +### Files Removed + +- `src/aptl/core/events.py` (EventLog, EventType, Event, make_event) +- `mcp/aptl-mcp-common/src/tracing.ts` (ToolTracer, ToolTrace) +- `tests/test_events.py` + +### Files Added + +- `src/aptl/core/telemetry.py` — Python OTel module +- `mcp/aptl-mcp-common/src/telemetry.ts` — TypeScript OTel module +- `config/otel/*.yaml` — Collector, Tempo, Grafana configs +- `tests/test_telemetry.py` — Python telemetry tests + +### Run Archive Format Change + +| Old | New | +|---|---| +| `scenario/events.jsonl` | *(removed)* | +| `agents/traces.jsonl` | *(removed)* | +| *(none)* | `traces/spans.json` | +| manifest lacks `trace_id` | manifest includes `trace_id` | diff --git a/docs/adrs/adr-013-deployment-abstraction.md b/docs/adrs/adr-013-deployment-abstraction.md new file mode 100644 index 000000000..d73e60088 --- /dev/null +++ b/docs/adrs/adr-013-deployment-abstraction.md @@ -0,0 +1,110 @@ +# ADR-013: Deployment Backend Abstraction Layer + +## Status + +accepted + +## Date + +2026-03-22 + +## Context + +From v2.0 onward, all lab lifecycle operations (start, stop, status, kill) were implemented as direct subprocess calls to `docker compose` in `lab.py` and `kill.py`. This coupling was appropriate when APTL ran exclusively on a single developer machine ([ADR-001](adr-001-docker-compose-deployment.md)), but it prevents deploying the lab to: + +- A dedicated lab server (classroom or team deployments) +- Cloud VMs (EC2, GCE, Azure VM) +- Container orchestration platforms (Kubernetes, Nomad) +- Hybrid environments (some services local, some remote) + +The coupling was isolated to the **deployment lifecycle** — scenario definitions already reference containers by name (not Docker-specific concepts), and MCP servers use config-driven `docker-lab-config.json` files. This meant a deployment abstraction could be introduced without touching scenario or MCP code. + +### Alternatives Considered + +1. **Kubernetes-first**: Replace Docker Compose entirely with Kubernetes manifests. Over-engineered for the current scale (5-19 containers). Adds operational complexity without clear benefit for single-user labs. + +2. **Terraform provider**: Use Terraform to manage Docker Compose and cloud deployments. Heavy dependency, slow feedback loop, and the lab doesn't need infrastructure-as-code semantics. + +3. **Ansible playbooks**: SSH-based deployment automation. Good for multi-host but introduces a new tool dependency and doesn't integrate with the Python CLI. + +4. **Protocol-based abstraction with multiple backends**: Define a Python Protocol for deployment operations and implement concrete backends for each target. Follows the existing `RunStorageBackend` pattern in `runstore.py`. + +## Decision + +Introduce a `DeploymentBackend` Protocol in `src/aptl/core/deployment/` with two initial implementations: + +### Protocol + +```python +class DeploymentBackend(Protocol): + def start(self, profiles: list[str], *, build: bool = True) -> LabResult: ... + def stop(self, profiles: list[str], *, remove_volumes: bool = False) -> LabResult: ... + def status(self) -> LabStatus: ... + def kill(self, profiles: list[str]) -> tuple[bool, str]: ... + def pull_images(self, images: list[str]) -> list[str]: ... +``` + +### Backends + +| Backend | Provider Key | Description | +|---------|-------------|-------------| +| `DockerComposeBackend` | `docker-compose` | Local Docker Compose (default). Wraps the existing subprocess logic from `lab.py` and `kill.py`. | +| `SSHComposeBackend` | `ssh-compose` | Remote Docker Compose over SSH. Sets `DOCKER_HOST=ssh://user@host` so all Docker CLI commands execute against a remote daemon. | + +### Configuration + +Backend selection is driven by `aptl.json`: + +```json +{ + "deployment": { + "provider": "docker-compose" + } +} +``` + +For SSH remote deployment: + +```json +{ + "deployment": { + "provider": "ssh-compose", + "ssh_host": "lab-server.example.com", + "ssh_user": "labadmin", + "ssh_key": "~/.ssh/lab_key", + "ssh_port": 22, + "remote_dir": "/opt/aptl" + } +} +``` + +A factory function `get_backend(config, project_dir)` instantiates the correct backend. + +### Scope + +This abstraction covers **deployment lifecycle** only: start, stop, status, kill, and image pulling. Container interaction (exec, logs, inspect) used by `snapshot.py`, `flags.py`, and `collectors.py` remains Docker-specific — those operations are a separate concern that can be abstracted independently when needed. + +### Backward Compatibility + +The public functions `start_lab()`, `stop_lab()`, `lab_status()`, and `kill_lab_containers()` retain their existing signatures with an additional optional `backend` parameter. When no backend is provided, they default to `DockerComposeBackend`, preserving identical behavior for all existing callers (CLI, API, tests). + +## Consequences + +### Positive + +- **Multi-host deployment**: Labs can run on remote servers via SSH without changing scenario definitions or MCP configurations +- **Extensible**: New backends (Kubernetes, Podman, cloud-specific) can be added by implementing the Protocol without modifying core orchestration +- **Testable**: The Protocol enables mock backends for unit testing without Docker +- **Follows existing patterns**: Same Protocol + concrete implementation + factory approach as `RunStorageBackend` +- **No breaking changes**: All existing code continues to work via default backend + +### Negative + +- **Additional indirection**: Deployment operations now go through a Protocol interface instead of direct subprocess calls. The indirection is one level deep and adds negligible overhead. +- **SSH backend limitations**: The `DOCKER_HOST=ssh://` approach requires the local Docker CLI version to be compatible with the remote Docker daemon. Mismatched versions can cause subtle failures. +- **Partial abstraction**: Container interaction (exec, logs) is not yet abstracted. Remote deployments that need flag collection or snapshot capture still require direct SSH access to containers. + +### Risks + +- The SSH backend has not been tested against all Docker versions. The `DOCKER_HOST=ssh://` feature was introduced in Docker 18.09 and has edge cases around context handling and compose file paths. +- Future backends (Kubernetes) may need a richer Protocol interface — e.g., namespace management, resource quotas, or pod scheduling constraints that don't map cleanly to the current `profiles` concept. diff --git a/docs/architecture/networking.md b/docs/architecture/networking.md index 36a6da6f2..1ba08d069 100644 --- a/docs/architecture/networking.md +++ b/docs/architecture/networking.md @@ -105,5 +105,38 @@ Several containers connect to multiple networks: - Containers isolated from host network via Docker bridge - Only mapped ports accessible from host - Internal traffic unencrypted (lab environment) -- External internet access available (standard Docker behavior) - Kali can reach DMZ and internal networks (simulates attacker with pivot access) + +## Egress Controls (SAF-002) + +Three of the four networks use Docker's `internal: true` flag to prevent containers from reaching the internet. This is a safety constraint: autonomous agents controlling Kali must not be able to scan or attack external targets. + +| Network | `internal: true` | Rationale | +|---------|-------------------|-----------| +| aptl-security | No | SOC tools (MISP, Wazuh, Shuffle) need internet for threat feeds and rule updates | +| aptl-dmz | **Yes** | Contains attack targets and Kali entry point | +| aptl-internal | **Yes** | Contains AD, database, victim, workstation — all attack targets | +| aptl-redteam | **Yes** | Kali command center; must not reach the real internet | + +### Multi-homed container egress + +Containers connected to both an internal network and `aptl-security` (dns, wazuh.manager, suricata) retain internet access via the security network interface. Attack containers (kali, victim, webapp, ad, db, fileshare, workstation, mailserver) are only on internal networks and have no internet egress. + +### Host port mappings + +Docker `internal: true` blocks outbound container traffic (no MASQUERADE rules), but inbound host port mappings (docker-proxy/DNAT) continue to work. SSH access to victim (port 2022) and kali (port 2023) from the host is unaffected. + +### Package pre-installation + +Wazuh agent and Falco are pre-installed in container images at build time so that containers on internal networks do not need internet access at runtime. The runtime install scripts (`install-wazuh.sh`, `install-falco.sh`) detect pre-installed packages and skip downloads. If the packages are not pre-installed (e.g., using an older image), the scripts fall back to downloading from the internet, which will fail on internal networks. + +### Upgrading from pre-SAF-002 deployments + +Existing labs must be fully torn down before restarting with the new network configuration: + +```bash +docker compose down # remove old networks +docker compose up -d # recreate with internal: true +``` + +Docker cannot change a network's `internal` flag in place. Rebuilding container images (`docker compose build`) is also required to pre-install Wazuh and Falco packages. diff --git a/docs/deployment/ci-cd-deploy.md b/docs/deployment/ci-cd-deploy.md new file mode 100644 index 000000000..107b67edc --- /dev/null +++ b/docs/deployment/ci-cd-deploy.md @@ -0,0 +1,117 @@ +# CI/CD Remote Deployment + +APTL can auto-deploy to a remote host via GitHub Actions. This is optional — the default is local Docker Compose (`aptl lab start`). + +## How It Works + +The `deploy.yml` workflow: + +1. Connects to a remote host via SSH (optionally through Tailscale) +2. Syncs the project files with `rsync` +3. Installs the Python CLI +4. Starts the lab with `docker compose up` +5. Verifies containers are running + +Deployment targets are configured as **GitHub Environments** — each contributor can set up their own target without modifying any committed files. + +## Setting Up a Deployment Target + +### 1. Create a GitHub Environment + +Go to **Settings > Environments > New environment** in the APTL repo. + +Name it something descriptive: `brad-dragondev`, `team-lab-aws`, `ci-staging`. + +### 2. Set Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `DEPLOY_HOST` | Yes | IP address or hostname of the target machine | +| `DEPLOY_USER` | Yes | SSH username on the target | +| `DEPLOY_DIR` | No | Remote project directory (default: `/opt/aptl`) | +| `DEPLOY_VIA_TAILSCALE` | No | Set to `true` if the target is on a Tailscale network | + +### 3. Set Secrets + +| Secret | Required | Description | +|--------|----------|-------------| +| `DEPLOY_SSH_KEY` | Yes | Private SSH key with access to the target host | +| `TS_OAUTH_CLIENT_ID` | Only if Tailscale | Tailscale OAuth client ID ([create one here](https://login.tailscale.com/admin/settings/oauth)) | +| `TS_OAUTH_SECRET` | Only if Tailscale | Tailscale OAuth client secret | + +### 4. Prepare the Target Host + +The target host needs: + +- Docker Engine + Docker Compose v2 +- Python 3.11+ with pip +- SSH access for the configured user +- Enough resources (8GB+ RAM for core lab, 20GB+ for full stack) + +```bash +# Create the project directory +sudo mkdir -p /opt/aptl +sudo chown $USER:$USER /opt/aptl +``` + +## Triggering Deploys + +### Manual Dispatch + +Go to **Actions > Deploy Lab > Run workflow** and select your environment from the dropdown. + +Or via CLI: + +```bash +gh workflow run deploy.yml -f environment=brad-dragondev +``` + +### Auto-Deploy on Push to Dev + +Set a **repository variable** (not environment variable) to auto-deploy on every push to `dev`: + +```bash +gh variable set DEFAULT_DEPLOY_ENVIRONMENT --body "brad-dragondev" --repo Brad-Edwards/aptl +``` + +To disable auto-deploy: + +```bash +gh variable delete DEFAULT_DEPLOY_ENVIRONMENT --repo Brad-Edwards/aptl +``` + +If `DEFAULT_DEPLOY_ENVIRONMENT` is not set, the deploy job is skipped entirely — only the existing SonarCloud analysis runs. + +## Tailscale Setup + +If your target host is on a Tailscale network (not publicly accessible): + +1. Set `DEPLOY_VIA_TAILSCALE=true` in your environment variables +2. Create a Tailscale OAuth client at [admin console](https://login.tailscale.com/admin/settings/oauth) with `Devices: Write` scope +3. Add `TS_OAUTH_CLIENT_ID` and `TS_OAUTH_SECRET` to your environment secrets +4. Add an ACL tag `tag:ci` in your Tailscale policy and grant it access to your target host +5. Use the Tailscale IP (e.g., `100.x.y.z`) as `DEPLOY_HOST` + +## What Gets Deployed + +The workflow syncs the full project via rsync, excluding: + +- `.git/` (history) +- `node_modules/` (rebuilt on remote) +- `__pycache__/`, `.venv/` (Python artifacts) +- `runs/` (experiment data) +- `research/` (local research files) +- `.env` (secrets — seeded from `.env.example` on first deploy) +- `.aptl/` (runtime state) + +The lab is started with profiles matching `aptl.json` in the repo, plus the `otel` profile for observability. + +## Multiple Environments + +Each contributor can create their own environment. The workflow supports any number: + +- `brad-dragondev` — Tailscale box at home +- `alice-ec2` — AWS EC2 instance +- `ci-staging` — shared team lab server + +The same workflow YAML serves all targets. Only the GitHub Environment secrets and variables differ. diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 2a4cae140..42b1a8fed 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -16,6 +16,8 @@ aptl lab start aptl lab status # Show running containers and health aptl lab stop # Stop the lab aptl lab stop -v # Stop and remove all volumes +aptl kill # Emergency: kill all MCP server processes immediately +aptl kill -c # Emergency: kill MCP processes AND all lab containers ``` ## Access diff --git a/docs/index.md b/docs/index.md index f41feb44a..a7e424560 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,9 @@ aptl lab start - [MCP Integration](components/mcp-integration.md) - [Reverse Engineering](components/reverse-engineering-container.md) +### Architecture Decision Records +- [ADR Index](adrs/README.md) -- Why we built it this way + ### Scenarios & Runs - [SOC Architecture Spec](specs/soc-feature-spec.md) — Scenario engine, run archives, collectors diff --git a/docs/specs/soc-feature-spec.md b/docs/specs/soc-feature-spec.md index 170ba79c4..e1396a7c7 100644 --- a/docs/specs/soc-feature-spec.md +++ b/docs/specs/soc-feature-spec.md @@ -77,8 +77,8 @@ aptl scenario start | ScenarioSession | -> .aptl/session.json | +------------------+------------------+ | | | - Kali MCP Wazuh SIEM Container logs - (traces.jsonl) (alerts) (docker logs) + MCP Servers Wazuh SIEM Container logs + (OTel spans) (alerts) (docker logs) | | | +------------------+------------------+ | @@ -97,7 +97,8 @@ runs// flags.json # Captured CTF flags from containers scenario/ definition.yaml # Copy of the scenario YAML used - events.jsonl # Copy of the session event log + traces/ + spans.json # All OTel spans (scenario + MCP tool calls) from Tempo wazuh/ alerts.jsonl # All Wazuh alerts from run time window suricata/ @@ -108,8 +109,6 @@ runs// shuffle-executions.json # Shuffle workflow executions containers/ .log # Per-container docker logs (stdout+stderr) - agents/ - traces.jsonl # Merged MCP tool call traces (all servers) ``` --- @@ -230,8 +229,8 @@ interface ToolTrace { } ``` -Traces are written to `/.jsonl` and merged at run -assembly time. +MCP tool calls are instrumented as OpenTelemetry spans and exported to the OTel +Collector via OTLP HTTP. At run assembly, spans are fetched from Tempo by trace ID. ### 5.3 CTF Flags @@ -314,7 +313,8 @@ class RunManifest(TypedDict): | Variable | Used By | Purpose | |----------|---------|---------| -| `APTL_TRACE_DIR` | MCP servers | Directory for trace JSONL output | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | MCP servers, CLI | OTLP endpoint for OTel Collector | +| `TEMPO_URL` | CLI (collectors) | Grafana Tempo HTTP API for trace queries | | `THEHIVE_API_KEY` | collectors.py | TheHive authentication | | `MISP_API_KEY` | collectors.py | MISP authentication | | `SHUFFLE_API_KEY` | collectors.py | Shuffle authentication | diff --git a/mcp/aptl-mcp-common/package-lock.json b/mcp/aptl-mcp-common/package-lock.json index 05ecf8e72..9bffbb8e8 100644 --- a/mcp/aptl-mcp-common/package-lock.json +++ b/mcp/aptl-mcp-common/package-lock.json @@ -15,10 +15,71 @@ }, "devDependencies": { "@types/node": "^25.2.1", + "@vitest/coverage-v8": "^4.0.18", "typescript": "^5.3.3", "vitest": "^4.0.18" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -473,6 +534,16 @@ "hono": "^4" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -480,6 +551,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -936,6 +1018,37 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -1112,6 +1225,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -1690,6 +1815,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1723,6 +1858,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1795,6 +1937,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", @@ -1804,6 +1985,13 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1826,6 +2014,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2196,6 +2412,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2397,6 +2626,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/mcp/aptl-mcp-common/package.json b/mcp/aptl-mcp-common/package.json index c732f5e04..a7f0993e7 100644 --- a/mcp/aptl-mcp-common/package.json +++ b/mcp/aptl-mcp-common/package.json @@ -16,12 +16,18 @@ ], "dependencies": { "@modelcontextprotocol/sdk": "^1.17.2", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.57.0", + "@opentelemetry/resources": "^1.30.0", + "@opentelemetry/sdk-trace-node": "^1.30.0", + "@opentelemetry/semantic-conventions": "^1.30.0", "@types/ssh2": "^1.15.5", "ssh2": "^1.16.0", "zod": "^3.25.49" }, "devDependencies": { "@types/node": "^25.2.1", + "@vitest/coverage-v8": "^4.0.18", "typescript": "^5.3.3", "vitest": "^4.0.18" }, diff --git a/mcp/aptl-mcp-common/src/index.ts b/mcp/aptl-mcp-common/src/index.ts index 2d84e45f4..a540ed9a1 100644 --- a/mcp/aptl-mcp-common/src/index.ts +++ b/mcp/aptl-mcp-common/src/index.ts @@ -15,7 +15,7 @@ export { expandTilde } from './utils.js'; // Export MCP server creation and types export { createMCPServer } from './server.js'; -export { ToolTracer, type ToolTrace } from './tracing.js'; +export { initTracing, shutdownTracing, getTracer, traceToolCall } from './telemetry.js'; export type { LabConfig } from './config.js'; export { loadLabConfig, substituteEnvVars, parseDotEnv } from './config.js'; export type { ToolContext } from './tools/handlers.js'; @@ -41,4 +41,4 @@ export async function startServer(callerMetaUrl: string): Promise { const config = await loadLabConfig(configPath); const server = createMCPServer(config); await server.start(); -} \ No newline at end of file +} diff --git a/mcp/aptl-mcp-common/src/server.ts b/mcp/aptl-mcp-common/src/server.ts index 812ba701d..8df6a9796 100644 --- a/mcp/aptl-mcp-common/src/server.ts +++ b/mcp/aptl-mcp-common/src/server.ts @@ -17,7 +17,7 @@ import { import { type LabConfig } from './config.js'; import { SSHConnectionManager } from './ssh.js'; import { HTTPClient } from './http.js'; -import { ToolTracer } from './tracing.js'; +import { initTracing, shutdownTracing, traceToolCall } from './telemetry.js'; import { generateToolDefinitions } from './tools/definitions.js'; import { generateToolHandlers, type ToolHandler, type ToolContext } from './tools/handlers.js'; import { generateAPIToolDefinitions } from './tools/api-definitions.js'; @@ -27,10 +27,10 @@ import { generateAPIToolHandlers, type APIToolHandler, type APIToolContext } fro * Create and configure an MCP server with the provided lab configuration */ export function createMCPServer(labConfig: LabConfig) { - // Initialize clients and tracer based on config + // Initialize clients and OTel tracing based on config const sshManager = labConfig.containers ? new SSHConnectionManager() : null; const httpClient = labConfig.api ? new HTTPClient(labConfig.api) : null; - const tracer = new ToolTracer(labConfig.server.name); + initTracing(labConfig.server.name); // Pre-generate tools and handlers based on available capabilities let cachedTools: Tool[] = []; @@ -103,7 +103,7 @@ export function createMCPServer(labConfig: LabConfig) { } // Context is narrowed to the correct type above based on tool name - return tracer.trace(name, args ?? {}, () => (handler as any)(args ?? {}, context)); + return traceToolCall(name, labConfig.server.name, args ?? {}, () => (handler as any)(args ?? {}, context)); }); // Setup graceful shutdown handlers (once per process) @@ -120,6 +120,7 @@ export function createMCPServer(labConfig: LabConfig) { if (!handlersSetup) { process.on('SIGINT', async () => { console.error('[MCP] Shutting down gracefully...'); + await shutdownTracing(); if (sshManager) { await sshManager.disconnectAll(); } @@ -128,6 +129,7 @@ export function createMCPServer(labConfig: LabConfig) { process.on('SIGTERM', async () => { console.error('[MCP] Shutting down gracefully...'); + await shutdownTracing(); if (sshManager) { await sshManager.disconnectAll(); } diff --git a/mcp/aptl-mcp-common/src/ssh.ts b/mcp/aptl-mcp-common/src/ssh.ts index 584adad19..7793d1aa8 100644 --- a/mcp/aptl-mcp-common/src/ssh.ts +++ b/mcp/aptl-mcp-common/src/ssh.ts @@ -82,12 +82,20 @@ export class PersistentSession extends EventEmitter { private commandDelimiter: string; private keepAliveInterval: NodeJS.Timeout | null = null; private sessionTimeout: NodeJS.Timeout | null = null; + private commandTimeout: NodeJS.Timeout | null = null; private isInitialized = false; private outputData = ''; private shellFormatter: ShellFormatter; private sessionTimeoutMs: number; + private clearCommandTimeout(): void { + if (this.commandTimeout) { + clearTimeout(this.commandTimeout); + this.commandTimeout = null; + } + } + constructor( sessionId: string, target: string, @@ -226,6 +234,7 @@ export class PersistentSession extends EventEmitter { this.currentCommand = this.commandQueue.shift()!; this.outputData = ''; + this.clearCommandTimeout(); if (this.currentCommand.raw) { // Raw mode: send command directly without wrapping @@ -235,10 +244,11 @@ export class PersistentSession extends EventEmitter { if (this.currentCommand.timeout) { const commandId = this.currentCommand.id; const timeoutDuration = this.currentCommand.timeout; - setTimeout(() => { + this.commandTimeout = setTimeout(() => { if (this.currentCommand?.id === commandId) { // In raw mode, resolve with whatever output we've collected const output = this.outputData; + this.commandTimeout = null; this.currentCommand!.resolve({ stdout: output, stderr: '', @@ -265,8 +275,9 @@ export class PersistentSession extends EventEmitter { if (this.currentCommand.timeout) { const commandId = this.currentCommand.id; - setTimeout(() => { + this.commandTimeout = setTimeout(() => { if (this.currentCommand?.id === commandId) { + this.commandTimeout = null; this.currentCommand!.reject(new SSHError(`Command timeout: ${this.currentCommand!.command}`)); this.currentCommand = null; this.processNextCommand(); @@ -327,6 +338,7 @@ export class PersistentSession extends EventEmitter { const cleanOutput = filteredLines.join('\n'); + this.clearCommandTimeout(); this.currentCommand.resolve({ stdout: cleanOutput, stderr: '', @@ -394,8 +406,20 @@ export class PersistentSession extends EventEmitter { } this.sessionInfo.isActive = false; - this.currentCommand = null; + + this.clearCommandTimeout(); + + if (this.currentCommand) { + const current = this.currentCommand; + this.currentCommand = null; + current.reject(new SSHError('Session closed while command was in progress')); + } + + const queued = this.commandQueue; this.commandQueue = []; + for (const request of queued) { + request.reject(new SSHError('Session closed while command was queued')); + } } } diff --git a/mcp/aptl-mcp-common/src/telemetry.ts b/mcp/aptl-mcp-common/src/telemetry.ts new file mode 100644 index 000000000..94a5c4646 --- /dev/null +++ b/mcp/aptl-mcp-common/src/telemetry.ts @@ -0,0 +1,221 @@ +/** + * OpenTelemetry tracing for APTL MCP servers. + * + * Replaces the former ToolTracer JSONL system with OTel spans exported + * via OTLP HTTP/protobuf to the OTel Collector. + * + * Cross-process propagation: reads `.aptl/trace-context.json` written + * by `aptl scenario start` so that MCP tool spans share the same + * trace_id as scenario lifecycle spans from the Python CLI. + */ + +import { readFileSync, existsSync, statSync } from 'fs'; +import { join, resolve } from 'path'; + +import { context, trace, Tracer, Context, SpanStatusCode, SpanKind } from '@opentelemetry/api'; +import { Resource } from '@opentelemetry/resources'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; + +let provider: NodeTracerProvider | null = null; + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/** + * Configure OTel TracerProvider with OTLP HTTP exporter. + * Safe to call multiple times — subsequent calls are no-ops. + */ +export function initTracing(serverName: string): void { + if (provider !== null) return; + + const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318'; + + const resource = new Resource({ + [ATTR_SERVICE_NAME]: serverName, + }); + + provider = new NodeTracerProvider({ resource }); + + const exporter = new OTLPTraceExporter({ + url: `${endpoint}/v1/traces`, + }); + + provider.addSpanProcessor(new BatchSpanProcessor(exporter)); + provider.register(); + + console.error(`[OTel] Tracing initialized: service=${serverName} endpoint=${endpoint}`); +} + +/** + * Force-flush and shutdown the TracerProvider. + * + * Flush/shutdown errors are caught and logged — tracing teardown + * must never crash the host process (e.g. when the Collector is + * unreachable). + */ +export async function shutdownTracing(): Promise { + if (provider !== null) { + try { + await provider.forceFlush(); + } catch { + // Collector may be unreachable — not fatal + } + try { + await provider.shutdown(); + } catch { + // Best-effort shutdown + } + provider = null; + console.error('[OTel] Tracing shut down'); + } +} + +/** + * Return a Tracer from the current provider. + */ +export function getTracer(name: string = 'aptl-mcp'): Tracer { + return trace.getTracer(name); +} + +// --------------------------------------------------------------------------- +// Cross-process context +// --------------------------------------------------------------------------- + +interface TraceContextFile { + trace_id: string; + span_id: string; + trace_flags: string; +} + +// Cache to avoid reading trace-context.json on every tool call +let cachedContext: Context | undefined; +let cachedMtimeMs: number = 0; +let cachedCtxPath: string = ''; + +/** + * Read the trace context written by `aptl scenario start`. + * Returns an OTel Context with the remote parent, or undefined if + * no active scenario context exists. + * + * Results are cached by file mtime to avoid repeated disk reads + * on every tool call. + */ +export function loadParentContext(): Context | undefined { + const traceDir = process.env.APTL_STATE_DIR || '.aptl'; + const ctxPath = resolve(traceDir, 'trace-context.json'); + + if (!existsSync(ctxPath)) { + cachedContext = undefined; + cachedMtimeMs = 0; + return undefined; + } + + // Check mtime — return cached result if file hasn't changed + try { + const mtimeMs = statSync(ctxPath).mtimeMs; + if (ctxPath === cachedCtxPath && mtimeMs === cachedMtimeMs) { + return cachedContext; + } + cachedCtxPath = ctxPath; + cachedMtimeMs = mtimeMs; + } catch { + return cachedContext; + } + + try { + const raw = readFileSync(ctxPath, 'utf-8'); + const data: TraceContextFile = JSON.parse(raw); + + const spanContext = { + traceId: data.trace_id, + spanId: data.span_id, + traceFlags: parseInt(data.trace_flags, 16), + isRemote: true, + }; + + // Validate hex lengths + if (spanContext.traceId.length !== 32 || spanContext.spanId.length !== 16) { + console.error('[OTel] Invalid trace context dimensions, ignoring'); + cachedContext = undefined; + return undefined; + } + + const parentSpan = trace.wrapSpanContext(spanContext); + cachedContext = trace.setSpan(context.active(), parentSpan); + return cachedContext; + } catch (err) { + console.error(`[OTel] Could not load trace context from ${ctxPath}: ${err}`); + cachedContext = undefined; + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Tool call tracing (replaces ToolTracer.trace()) +// --------------------------------------------------------------------------- + +const MAX_ATTR_SIZE = 50_000; + +/** + * Truncate a value to a string suitable for a span attribute. + */ +function truncateAttr(value: unknown): string { + const serialized = JSON.stringify(value); + if (serialized && serialized.length > MAX_ATTR_SIZE) { + return serialized.slice(0, 2000) + `... [truncated from ${serialized.length} bytes]`; + } + return serialized; +} + +/** + * Wrap a tool handler call with OTel span instrumentation. + * + * Creates a span with GenAI SIG conventions, records timing, errors, + * and truncated arguments/responses as attributes. + */ +export async function traceToolCall( + toolName: string, + serverName: string, + args: Record, + handler: () => Promise, +): Promise { + const tracer = getTracer(); + const parentContext = loadParentContext() || context.active(); + + return tracer.startActiveSpan( + 'execute_tool', + { + kind: SpanKind.INTERNAL, + attributes: { + 'gen_ai.operation.name': 'execute_tool', + 'gen_ai.tool.name': toolName, + 'gen_ai.agent.name': serverName, + 'aptl.tool.arguments': truncateAttr(args), + }, + }, + parentContext, + async (span) => { + try { + const result = await handler(); + span.setAttribute('aptl.tool.response', truncateAttr(result)); + span.setStatus({ code: SpanStatusCode.OK }); + return result; + } catch (err) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err instanceof Error ? err.message : String(err), + }); + if (err instanceof Error) { + span.recordException(err); + } + throw err; + } finally { + span.end(); + } + }, + ); +} diff --git a/mcp/aptl-mcp-common/src/tracing.ts b/mcp/aptl-mcp-common/src/tracing.ts deleted file mode 100644 index d0bfbd67a..000000000 --- a/mcp/aptl-mcp-common/src/tracing.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * MCP tool call tracing for experiment instrumentation. - * - * Records every tool invocation with full arguments, responses, - * timing, and error information as JSONL files for post-run analysis. - */ - -import { appendFileSync, mkdirSync, existsSync } from 'fs'; -import { join } from 'path'; - -export interface ToolTrace { - timestamp: string; - server_name: string; - tool_name: string; - arguments: Record; - response: unknown; - duration_ms: number; - success: boolean; - error?: string; -} - -export class ToolTracer { - private readonly serverName: string; - private readonly traceDir: string; - private readonly tracePath: string; - - constructor(serverName: string, traceDir?: string) { - this.serverName = serverName; - this.traceDir = traceDir || process.env.APTL_TRACE_DIR || '.aptl/traces'; - this.tracePath = join(this.traceDir, `${serverName}.jsonl`); - - // Ensure trace directory exists - if (!existsSync(this.traceDir)) { - try { - mkdirSync(this.traceDir, { recursive: true }); - } catch { - console.error(`[Tracer] Could not create trace dir: ${this.traceDir}`); - } - } - } - - /** - * Wrap a tool handler call with tracing instrumentation. - * - * Records the call arguments, response, timing, and error status - * to a per-server JSONL file. - */ - async trace( - toolName: string, - args: Record, - handler: () => Promise, - ): Promise { - const startTime = Date.now(); - let response: unknown = null; - let success = true; - let errorMsg: string | undefined; - - try { - const result = await handler(); - response = result; - return result; - } catch (err) { - success = false; - errorMsg = err instanceof Error ? err.message : String(err); - throw err; - } finally { - const trace: ToolTrace = { - timestamp: new Date().toISOString(), - server_name: this.serverName, - tool_name: toolName, - arguments: args, - response: this.truncateResponse(response), - duration_ms: Date.now() - startTime, - success, - ...(errorMsg !== undefined && { error: errorMsg }), - }; - - this.writeTrace(trace); - } - } - - /** - * Truncate large responses to avoid bloating trace files. - */ - private truncateResponse(response: unknown): unknown { - const serialized = JSON.stringify(response); - if (serialized && serialized.length > 50000) { - return { _truncated: true, _length: serialized.length, _preview: serialized.slice(0, 2000) }; - } - return response; - } - - private writeTrace(trace: ToolTrace): void { - try { - const line = JSON.stringify(trace) + '\n'; - appendFileSync(this.tracePath, line, 'utf-8'); - } catch (err) { - console.error(`[Tracer] Failed to write trace: ${err}`); - } - } - - /** - * No-op flush — writes are synchronous via appendFileSync. - */ - flush(): void { - // Writes are already synchronous - } -} diff --git a/mcp/aptl-mcp-common/tests/ssh.test.ts b/mcp/aptl-mcp-common/tests/ssh.test.ts index 9462d57ab..9dbdcfbfb 100644 --- a/mcp/aptl-mcp-common/tests/ssh.test.ts +++ b/mcp/aptl-mcp-common/tests/ssh.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { PersistentSession, SSHConnectionManager } from '../src/ssh.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { PersistentSession, SSHConnectionManager, SSHError } from '../src/ssh.js'; import { ShellType } from '../src/shells.js'; +import { EventEmitter } from 'events'; // Test our business logic, not the ssh2 library describe('Session State Management', () => { @@ -234,3 +235,116 @@ describe('Shell Type Support', () => { expect(info.username).toBe('Administrator'); }); }); + +describe('Session Cleanup and Timeout Handling', () => { + let mockStream: EventEmitter & { write: ReturnType; end: ReturnType; stderr: EventEmitter }; + let mockClient: any; + let session: PersistentSession; + + beforeEach(async () => { + vi.useFakeTimers(); + + mockStream = Object.assign(new EventEmitter(), { + write: vi.fn(), + end: vi.fn(), + stderr: new EventEmitter(), + }); + + mockClient = { + shell: vi.fn((cb: any) => cb(null, mockStream)), + }; + + session = new PersistentSession( + 'cleanup-test', 'host', 'user', 'interactive', mockClient, 22, 'normal', 600000 + ); + + // initialize() has a setTimeout(resolve, 1000) that won't fire + // under fake timers unless we advance concurrently + const initPromise = session.initialize(); + await vi.advanceTimersByTimeAsync(1000); + await initPromise; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('cleanup rejects in-flight command', async () => { + const commandPromise = session.executeCommand('sleep 999', 60000); + + // Command is now in-flight; close the session + session.close(); + + await expect(commandPromise).rejects.toThrow('Session closed while command was in progress'); + await expect(commandPromise).rejects.toBeInstanceOf(SSHError); + }); + + it('cleanup rejects queued commands', async () => { + // Queue three commands — first becomes current, rest are queued + const p1 = session.executeCommand('cmd1', 60000); + const p2 = session.executeCommand('cmd2', 60000); + const p3 = session.executeCommand('cmd3', 60000); + + session.close(); + + await expect(p1).rejects.toThrow('Session closed while command was in progress'); + await expect(p2).rejects.toThrow('Session closed while command was queued'); + await expect(p3).rejects.toThrow('Session closed while command was queued'); + }); + + it('per-command timeout cleared on success', async () => { + const commandPromise = session.executeCommand('echo hello', 5000); + + // Simulate the shell producing delimiter-wrapped output + const delimiter = (session as any).commandDelimiter; + const cmdId = (session as any).currentCommand.id; + const startMarker = `${delimiter}_START_${cmdId}`; + const endMarker = `${delimiter}_END_${cmdId}`; + + mockStream.emit('data', Buffer.from(`${startMarker}\nhello\n${endMarker}:0\n`)); + + const result = await commandPromise; + expect(result.code).toBe(0); + expect(result.stdout).toBe('hello'); + + // Advance past the 5000ms command timeout — should cause no side effects + await vi.advanceTimersByTimeAsync(6000); + + // Session is still active and functional + expect(session.getSessionInfo().isActive).toBe(true); + }); + + it('per-command timeout cleared on cleanup', async () => { + const commandPromise = session.executeCommand('slow-cmd', 5000); + + // Close before timeout fires + session.close(); + + // Should reject with session-closed, not timeout + await expect(commandPromise).rejects.toThrow('Session closed while command was in progress'); + + // Advance past the timeout — should be a no-op + await vi.advanceTimersByTimeAsync(6000); + }); + + it('cleanup is idempotent', async () => { + const commandPromise = session.executeCommand('cmd', 60000); + + // First cleanup via close() + session.close(); + + await expect(commandPromise).rejects.toThrow('Session closed while command was in progress'); + + // Second cleanup via the shell 'close' event (simulates shell.end() triggering close) + mockStream.emit('close'); + + // No additional errors or throws — the second cleanup is a no-op + expect(session.getSessionInfo().isActive).toBe(false); + }); + + it('cleanup with nothing pending is a no-op', () => { + // No commands queued or in flight + expect(() => session.close()).not.toThrow(); + expect(session.getSessionInfo().isActive).toBe(false); + }); +}); diff --git a/mcp/aptl-mcp-common/tests/telemetry.test.ts b/mcp/aptl-mcp-common/tests/telemetry.test.ts new file mode 100644 index 000000000..62eb681a6 --- /dev/null +++ b/mcp/aptl-mcp-common/tests/telemetry.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for the OpenTelemetry telemetry module. + * + * Verifies lifecycle, trace context loading, and tool call tracing + * using mock/spy patterns since we can't easily intercept OTel spans + * without a full in-memory exporter setup in the test harness. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; + +// We need to test the module under controlled conditions +describe('telemetry', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'aptl-telemetry-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('loadParentContext', () => { + it('returns undefined when trace-context.json does not exist', async () => { + vi.stubEnv('APTL_STATE_DIR', join(tmpDir, 'nonexistent')); + + const { loadParentContext } = await import('../src/telemetry.js'); + const ctx = loadParentContext(); + expect(ctx).toBeUndefined(); + + vi.unstubAllEnvs(); + }); + + it('returns a context when valid trace-context.json exists', async () => { + const traceCtx = { + trace_id: 'a'.repeat(32), + span_id: 'b'.repeat(16), + trace_flags: '01', + }; + writeFileSync(join(tmpDir, 'trace-context.json'), JSON.stringify(traceCtx)); + vi.stubEnv('APTL_STATE_DIR', tmpDir); + + // Dynamic import to pick up env change + const mod = await import('../src/telemetry.js'); + const ctx = mod.loadParentContext(); + expect(ctx).toBeDefined(); + + vi.unstubAllEnvs(); + }); + + it('returns undefined for invalid JSON', async () => { + writeFileSync(join(tmpDir, 'trace-context.json'), 'not json'); + vi.stubEnv('APTL_STATE_DIR', tmpDir); + + const { loadParentContext } = await import('../src/telemetry.js'); + const ctx = loadParentContext(); + expect(ctx).toBeUndefined(); + + vi.unstubAllEnvs(); + }); + + it('returns undefined for invalid trace ID dimensions', async () => { + const traceCtx = { + trace_id: 'abc', // too short + span_id: 'def', // too short + trace_flags: '01', + }; + writeFileSync(join(tmpDir, 'trace-context.json'), JSON.stringify(traceCtx)); + vi.stubEnv('APTL_STATE_DIR', tmpDir); + + const { loadParentContext } = await import('../src/telemetry.js'); + const ctx = loadParentContext(); + expect(ctx).toBeUndefined(); + + vi.unstubAllEnvs(); + }); + }); + + describe('traceToolCall', () => { + it('returns the handler result on success', async () => { + const { initTracing, traceToolCall, shutdownTracing } = await import('../src/telemetry.js'); + + initTracing('test-server'); + + const result = await traceToolCall( + 'test_tool', + 'test-server', + { arg1: 'value1' }, + async () => ({ text: 'Hello' }), + ); + + expect(result).toEqual({ text: 'Hello' }); + + await shutdownTracing(); + }); + + it('re-throws handler errors', async () => { + const { initTracing, traceToolCall, shutdownTracing } = await import('../src/telemetry.js'); + + initTracing('test-server'); + + await expect( + traceToolCall( + 'failing_tool', + 'test-server', + {}, + async () => { throw new Error('boom'); }, + ), + ).rejects.toThrow('boom'); + + await shutdownTracing(); + }); + }); + + describe('initTracing / shutdownTracing', () => { + it('initializes and shuts down without error', async () => { + const { initTracing, shutdownTracing } = await import('../src/telemetry.js'); + + // Should not throw + initTracing('test-service'); + await shutdownTracing(); + }); + + it('shutdown without init is safe', async () => { + const { shutdownTracing } = await import('../src/telemetry.js'); + await shutdownTracing(); // should not throw + }); + }); +}); diff --git a/mcp/aptl-mcp-common/vitest.config.ts b/mcp/aptl-mcp-common/vitest.config.ts index 7054f48a4..687247c4c 100644 --- a/mcp/aptl-mcp-common/vitest.config.ts +++ b/mcp/aptl-mcp-common/vitest.config.ts @@ -3,5 +3,12 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', + coverage: { + provider: 'v8', + reporter: ['lcov', 'text'], + reportsDirectory: 'coverage', + include: ['src/**/*.ts'], + exclude: ['src/**/*.d.ts'], + }, }, -}); \ No newline at end of file +}); diff --git a/mkdocs.yml b/mkdocs.yml index a0999ad63..056eb28f0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,6 +89,20 @@ nav: - Overview: troubleshooting/index.md - Common Issues: troubleshooting/common-issues.md - Debugging: troubleshooting/debugging.md + - Decisions: + - Index: adrs/README.md + - ADR-000 Use ADRs: adrs/adr-000-use-adrs.md + - ADR-001 Docker Deployment: adrs/adr-001-docker-compose-deployment.md + - ADR-002 Wazuh SIEM: adrs/adr-002-wazuh-siem.md + - ADR-003 MCP Common Library: adrs/adr-003-mcp-common-library.md + - ADR-004 Persistent SSH: adrs/adr-004-persistent-ssh-sessions.md + - ADR-005 Docker Profiles: adrs/adr-005-docker-compose-profiles.md + - ADR-006 Network Segmentation: adrs/adr-006-four-network-segmentation.md + - ADR-007 Python CLI: adrs/adr-007-python-cli-control-plane.md + - ADR-008 SOC Stack: adrs/adr-008-soc-stack-integration.md + - ADR-009 Scenario Engine: adrs/adr-009-scenario-engine.md + - ADR-010 SonarCloud: adrs/adr-010-sonarcloud-quality.md + - ADR-011 Web UI: adrs/adr-011-web-ui.md - Reference: - Commands: reference/commands.md - API: reference/api.md @@ -99,4 +113,4 @@ extra: - icon: fontawesome/brands/github link: https://github.com/Brad-Edwards/aptl version: - provider: mike \ No newline at end of file + provider: mike diff --git a/pyproject.toml b/pyproject.toml index 3fc39d51f..7e23d1853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,10 @@ dependencies = [ "pydantic>=2.0.0", "rich>=13.0.0", "PyYAML>=6.0", + "opentelemetry-api>=1.33.0", + "opentelemetry-sdk>=1.33.0", + "opentelemetry-exporter-otlp-proto-http>=1.33.0", + "opentelemetry-semantic-conventions>=0.54b0", ] [project.scripts] @@ -21,10 +25,17 @@ aptl = "aptl.cli.main:app" s3 = [ "boto3>=1.28.0", ] +web = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "sse-starlette>=2.0.0", + "asyncssh>=2.17.0", +] dev = [ "pytest>=8.0.0", "pytest-mock>=3.12.0", "coverage>=7.0.0", + "httpx>=0.27.0", ] [tool.hatch.build.targets.wheel] diff --git a/sonar-project.properties b/sonar-project.properties index e394b5bce..2c45e166f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,11 +1,12 @@ sonar.projectKey=Brad-Edwards_aptl sonar.organization=brad-edwards -sonar.sources=src,mcp -sonar.tests=tests -sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/dist/**,**/.venv/**,**/build/**,**/*.pyc,**/containers/** +sonar.sources=src,mcp,web/src +sonar.tests=tests,mcp/aptl-mcp-common/tests,web/tests +sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/dist/**,**/.venv/**,**/build/**,**/*.pyc,**/containers/**,mcp/**/tests/**,web/**/tests/** +sonar.coverage.exclusions=**/*.svelte sonar.python.version=3.12 sonar.python.coverage.reportPaths=coverage.xml -sonar.typescript.lcov.reportPaths=mcp/**/coverage/lcov.info +sonar.typescript.lcov.reportPaths=mcp/**/coverage/lcov.info,web/coverage/lcov.info diff --git a/src/aptl/api/__init__.py b/src/aptl/api/__init__.py new file mode 100644 index 000000000..e3a2acca9 --- /dev/null +++ b/src/aptl/api/__init__.py @@ -0,0 +1 @@ +"""APTL Web API package.""" diff --git a/src/aptl/api/deps.py b/src/aptl/api/deps.py new file mode 100644 index 000000000..88b469092 --- /dev/null +++ b/src/aptl/api/deps.py @@ -0,0 +1,57 @@ +"""Dependency injection and shared constants for the APTL API.""" + +import os +from pathlib import Path +from typing import Optional + +from fastapi import HTTPException + +from aptl.core.config import AptlConfig, find_config, load_config + +# Trusted origins for CORS and WebSocket origin validation. +# CORS middleware does not protect WebSocket upgrade requests, so +# the terminal endpoint checks this set independently. +# Override with APTL_ALLOWED_ORIGINS env var (comma-separated). +_DEFAULT_ORIGINS = {"http://localhost:3000", "http://localhost:5173"} +_env_origins = os.environ.get("APTL_ALLOWED_ORIGINS", "") +ALLOWED_ORIGINS: set[str] = ( + {o.strip() for o in _env_origins.split(",") if o.strip()} or _DEFAULT_ORIGINS +) + + +def get_project_dir() -> Path: + """Return the project root directory. + + Checks APTL_PROJECT_DIR env var first, then falls back to cwd. + Raises HTTP 503 if the resolved directory does not exist. + """ + import os + + env_dir = os.environ.get("APTL_PROJECT_DIR") + if env_dir: + p = Path(env_dir) + else: + p = Path.cwd() + if not p.is_dir(): + raise HTTPException( + status_code=503, + detail=f"Project directory does not exist: {p}", + ) + return p + + +def get_config(project_dir: Optional[Path] = None) -> AptlConfig: + """Load the APTL configuration. + + Args: + project_dir: Project directory to search for aptl.json. + Defaults to get_project_dir(). + + Returns: + Validated AptlConfig, or a default config if no file is found. + """ + search_dir = project_dir or get_project_dir() + config_path = find_config(search_dir) + if config_path is None: + return AptlConfig() + return load_config(config_path) diff --git a/src/aptl/api/main.py b/src/aptl/api/main.py new file mode 100644 index 000000000..72b8b646e --- /dev/null +++ b/src/aptl/api/main.py @@ -0,0 +1,45 @@ +"""FastAPI application factory for the APTL web API.""" + +from fastapi import FastAPI +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.utils.logging import get_logger, setup_logging + +log = get_logger("api") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + setup_logging() + log.info("Creating APTL web API application") + + app = FastAPI( + title="APTL Web API", + description="Advanced Purple Team Lab — Web Interface API", + version="0.1.0", + ) + + app.add_middleware( # NOSONAR — localhost-only origins; this is a local lab tool, not internet-facing + CORSMiddleware, + allow_origins=list(ALLOWED_ORIGINS), + allow_credentials=False, + allow_methods=["GET", "POST"], + allow_headers=["Content-Type", "Accept"], + ) + + 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") + + @app.get("/api/health") + async def health() -> dict: + return {"status": "ok"} + + return app + + +app = create_app() diff --git a/src/aptl/api/routers/__init__.py b/src/aptl/api/routers/__init__.py new file mode 100644 index 000000000..d48f357b8 --- /dev/null +++ b/src/aptl/api/routers/__init__.py @@ -0,0 +1 @@ +"""APTL API routers.""" diff --git a/src/aptl/api/routers/config.py b/src/aptl/api/routers/config.py new file mode 100644 index 000000000..cea6a8092 --- /dev/null +++ b/src/aptl/api/routers/config.py @@ -0,0 +1,38 @@ +"""Configuration API endpoints.""" + +import asyncio +from pathlib import Path + +from fastapi import APIRouter, Depends + +from aptl.api.deps import get_config, get_project_dir +from aptl.api.schemas import ConfigResponse +from aptl.core.config import ContainerSettings +from aptl.utils.logging import get_logger + +log = get_logger("api.config") + +router = APIRouter(tags=["config"]) + + +def _load_config_response(project_dir: Path) -> ConfigResponse: + """Build a ConfigResponse from the current configuration.""" + config = get_config(project_dir) + return ConfigResponse( + lab_name=config.lab.name, + network_subnet=config.lab.network_subnet, + containers={ + name: getattr(config.containers, name) + for name in ContainerSettings.model_fields + }, + run_storage_backend=config.run_storage.backend, + ) + + +@router.get("/config") +async def get_config_endpoint( + project_dir: Path = Depends(get_project_dir), +) -> ConfigResponse: + """Get the current APTL configuration.""" + log.info("GET /config") + return await asyncio.to_thread(_load_config_response, project_dir) diff --git a/src/aptl/api/routers/kill.py b/src/aptl/api/routers/kill.py new file mode 100644 index 000000000..e0f8ec20f --- /dev/null +++ b/src/aptl/api/routers/kill.py @@ -0,0 +1,50 @@ +"""Emergency kill switch API endpoint.""" + +import asyncio +from pathlib import Path + +from fastapi import APIRouter, Depends, Query + +from aptl.api.deps import get_project_dir +from aptl.api.schemas import KillActionResponse +from aptl.core.kill import execute_kill +from aptl.utils.logging import get_logger + +log = get_logger("api.kill") + +router = APIRouter(tags=["kill"]) + + +@router.post("/lab/kill") +async def lab_kill( + containers: bool = Query(False, description="Also force-stop all lab containers"), + project_dir: Path = Depends(get_project_dir), +) -> KillActionResponse: + """Emergency kill switch: terminate all MCP processes and agent activity. + + Immediately sends SIGTERM (then SIGKILL) to all running MCP server + processes. Optionally force-stops all lab Docker containers. + Clears scenario session state and trace context files. + """ + log.info("POST /lab/kill (containers=%s)", containers) + + result = await asyncio.to_thread( + execute_kill, + containers=containers, + project_dir=project_dir, + ) + + log.info( + "POST /lab/kill -> success=%s mcp=%d containers=%s", + result.success, + result.mcp_processes_killed, + result.containers_stopped, + ) + + return KillActionResponse( + success=result.success, + mcp_processes_killed=result.mcp_processes_killed, + containers_stopped=result.containers_stopped, + session_cleared=result.session_cleared, + errors=result.errors, + ) diff --git a/src/aptl/api/routers/lab.py b/src/aptl/api/routers/lab.py new file mode 100644 index 000000000..f9864cad3 --- /dev/null +++ b/src/aptl/api/routers/lab.py @@ -0,0 +1,171 @@ +"""Lab lifecycle API endpoints.""" + +import asyncio +from pathlib import Path +from typing import AsyncGenerator + +from fastapi import APIRouter, Depends +from sse_starlette.sse import EventSourceResponse + +from aptl.api.deps import get_project_dir +from aptl.api.schemas import ( + ContainerInfo, + LabActionResponse, + LabStatusResponse, +) +from aptl.core.lab import ( + LabResult, + lab_status as core_lab_status, + orchestrate_lab_start, + stop_lab as core_stop_lab, +) +from aptl.utils.logging import get_logger + +log = get_logger("api.lab") + +router = APIRouter(tags=["lab"]) + +# SSE circuit breaker settings +MAX_CONSECUTIVE_ERRORS = 10 + + +def _build_status_response(project_dir: Path) -> LabStatusResponse: + """Build a LabStatusResponse from the core lab_status function.""" + status = core_lab_status(project_dir=project_dir) + containers = [ContainerInfo.from_compose_dict(c) for c in status.containers] + return LabStatusResponse( + running=status.running, + containers=containers, + error=status.error or None, + ) + + +@router.get("/lab/status") +async def lab_status( + project_dir: Path = Depends(get_project_dir), +) -> LabStatusResponse: + """Get current lab status including container information.""" + log.info("GET /lab/status") + response = await asyncio.to_thread(_build_status_response, project_dir) + log.info("GET /lab/status -> running=%s containers=%d", response.running, len(response.containers)) + return response + + +@router.post("/lab/start") +async def lab_start( + project_dir: Path = Depends(get_project_dir), +) -> LabActionResponse: + """Start the lab environment. + + Blocks until orchestration completes or times out (30 min). + """ + log.info("POST /lab/start") + try: + result: LabResult = await asyncio.wait_for( + asyncio.to_thread(orchestrate_lab_start, project_dir), + timeout=1800, + ) + except asyncio.TimeoutError: + log.exception("Lab start timed out after 1800s") + return LabActionResponse( + success=False, + error="Lab start timed out after 1800s", + ) + log.info("POST /lab/start -> success=%s", result.success) + return LabActionResponse( + success=result.success, + message=result.message, + error=result.error or None, + ) + + +@router.post("/lab/stop") +async def lab_stop( + project_dir: Path = Depends(get_project_dir), +) -> LabActionResponse: + """Stop the lab environment.""" + log.info("POST /lab/stop") + result: LabResult = await asyncio.to_thread( + core_stop_lab, project_dir=project_dir + ) + log.info("POST /lab/stop -> success=%s", result.success) + return LabActionResponse( + success=result.success, + message=result.message, + error=result.error or None, + ) + + +async def _lab_event_generator( + project_dir: Path, +) -> AsyncGenerator[dict, None]: + """Poll lab status and yield SSE events on state changes. + + Implements exponential backoff on errors and terminates after + MAX_CONSECUTIVE_ERRORS consecutive failures (circuit breaker). + """ + log.info("SSE connection opened") + previous_running: bool | None = None + previous_containers: list[str] = [] + consecutive_errors = 0 + + while True: + try: + response = await asyncio.to_thread( + _build_status_response, project_dir + ) + consecutive_errors = 0 + current_containers = [c.name for c in response.containers] + + # Emit event if state changed or on first poll + if ( + previous_running is None + or response.running != previous_running + or current_containers != previous_containers + ): + yield { + "event": "lab_status", + "data": response.model_dump_json(), + } + previous_running = response.running + previous_containers = current_containers + + except Exception as exc: + consecutive_errors += 1 + log.warning( + "SSE poll error (%d/%d): %s", + consecutive_errors, + MAX_CONSECUTIVE_ERRORS, + exc, + ) + yield { + "event": "error", + "data": str(exc), + } + if consecutive_errors >= MAX_CONSECUTIVE_ERRORS: + log.error( + "SSE circuit breaker tripped after %d consecutive errors, closing stream", + MAX_CONSECUTIVE_ERRORS, + ) + return + # Exponential backoff: 5, 10, 20, 40, 60, 60, ... + backoff = min(5 * (2 ** (consecutive_errors - 1)), 60) + await asyncio.sleep(backoff) + continue + + await asyncio.sleep(5) + + log.info("SSE connection closed") + + +@router.get("/lab/events") +async def lab_events( + project_dir: Path = Depends(get_project_dir), +) -> EventSourceResponse: + """SSE stream of lab status changes. + + Polls lab status every 5 seconds and emits events when the + running state or container list changes. + """ + log.info("GET /lab/events — starting SSE stream") + return EventSourceResponse(_lab_event_generator(project_dir)) diff --git a/src/aptl/api/routers/scenarios.py b/src/aptl/api/routers/scenarios.py new file mode 100644 index 000000000..45bc28430 --- /dev/null +++ b/src/aptl/api/routers/scenarios.py @@ -0,0 +1,94 @@ +"""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/routers/terminal.py b/src/aptl/api/routers/terminal.py new file mode 100644 index 000000000..53b806953 --- /dev/null +++ b/src/aptl/api/routers/terminal.py @@ -0,0 +1,146 @@ +"""WebSocket terminal endpoint for container SSH access.""" + +import asyncio +import json +from pathlib import Path + +import asyncssh +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect + +from aptl.api.deps import ALLOWED_ORIGINS, get_project_dir +from aptl.core.lab import lab_status +from aptl.core.ssh import _KEY_NAME +from aptl.utils.logging import get_logger + +log = get_logger("api.terminal") + +router = APIRouter(tags=["terminal"]) + +# SSH endpoint map: container -> (port, username) +SSH_ENDPOINTS: dict[str, tuple[int, str]] = { + "victim": (2022, "labadmin"), + "kali": (2023, "kali"), + "reverse": (2027, "labadmin"), + "workstation": (2028, "labadmin"), +} + + +def _get_key_path() -> Path: + """Resolve the SSH private key path.""" + return Path.home() / ".ssh" / _KEY_NAME + + +async def _relay_ssh_to_ws( + process: asyncssh.SSHClientProcess, + websocket: WebSocket, +) -> None: + """Read SSH stdout and forward to WebSocket.""" + try: + while True: + data = await process.stdout.read(4096) + if not data: + break + await websocket.send_json({"type": "stdout", "data": data}) + except (asyncio.CancelledError, WebSocketDisconnect): + pass + + +async def _relay_ws_to_ssh( + websocket: WebSocket, + process: asyncssh.SSHClientProcess, +) -> None: + """Read WebSocket messages and forward to SSH stdin or handle resize.""" + try: + while True: + raw = await websocket.receive_text() + msg = json.loads(raw) + msg_type = msg.get("type") + if msg_type == "stdin": + process.stdin.write(msg.get("data", "")) + elif msg_type == "resize": + cols = max(1, min(msg.get("cols", 80), 500)) + rows = max(1, min(msg.get("rows", 24), 200)) + process.change_terminal_size(cols, rows) + except (asyncio.CancelledError, WebSocketDisconnect): + pass + except json.JSONDecodeError: + log.debug("Malformed WebSocket message, ignoring") + + +@router.websocket("/terminal/ws/{container}") +async def terminal_ws( + websocket: WebSocket, + container: str, + project_dir: Path = Depends(get_project_dir), +) -> None: + """WebSocket endpoint for interactive terminal sessions. + + Opens an SSH PTY connection to the specified container and relays + stdin/stdout between the WebSocket and the SSH process. + """ + # Reject cross-origin WebSocket connections. + # CORS middleware does NOT protect WebSocket upgrades — browsers send + # them cross-origin without preflight. Without this check, any website + # the user visits could open a shell on lab containers. + origin = websocket.headers.get("origin", "") + if not origin or origin not in ALLOWED_ORIGINS: + await websocket.close(code=1008, reason="Origin not allowed") + log.warning("Rejected WebSocket from disallowed origin: %s", origin) + return + + # Validate container name + if container not in SSH_ENDPOINTS: + await websocket.accept() + await websocket.close(code=1008, reason="Unknown container") + log.warning("Rejected terminal connection for unknown container") + return + + # Check lab is running + status = await asyncio.to_thread(lab_status, project_dir=project_dir) + if not status.running: + await websocket.accept() + await websocket.send_json( + {"type": "error", "message": "Lab is not running"} + ) + await websocket.close(code=1008, reason="Lab not running") + log.warning("Rejected terminal connection: lab not running") + return + + port, username = SSH_ENDPOINTS[container] + key_path = _get_key_path() + + await websocket.accept() + log.info("Terminal WebSocket accepted on port %d", port) + + conn = None + try: + conn = await asyncssh.connect( + host="localhost", + port=port, + username=username, + client_keys=[str(key_path)], + known_hosts=None, # localhost-only; containers regenerate host keys on rebuild + ) + process = await conn.create_process( + term_type="xterm-256color", + term_size=(80, 24), + ) + + await asyncio.gather( + _relay_ssh_to_ws(process, websocket), + _relay_ws_to_ssh(websocket, process), + ) + except asyncssh.Error as exc: + log.exception("SSH connection error on port %d: %s", port, exc) + try: + await websocket.send_json( + {"type": "error", "message": "SSH connection failed"} + ) + except Exception: + pass + except WebSocketDisconnect: + log.info("Terminal WebSocket disconnected from port %d", port) + finally: + if conn is not None: + conn.close() + log.info("Terminal session cleaned up for port %d", port) diff --git a/src/aptl/api/schemas.py b/src/aptl/api/schemas.py new file mode 100644 index 000000000..9fe736d65 --- /dev/null +++ b/src/aptl/api/schemas.py @@ -0,0 +1,110 @@ +"""API response models for the APTL web interface.""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str = "ok" + + +class ContainerInfo(BaseModel): + """Container status information.""" + + name: str = "" + state: str = "" + status: str = "" + health: str = "" + image: str = "" + ports: list[str] = [] + + @classmethod + def from_compose_dict(cls, data: dict) -> "ContainerInfo": + """Create from docker compose ps JSON output.""" + # docker compose ps --format json uses varying key names + # across versions; handle common variants. + name = data.get("Name", data.get("name", "")) + state = data.get("State", data.get("state", "")) + status = data.get("Status", data.get("status", "")) + health = data.get("Health", data.get("health", "")) + image = data.get("Image", data.get("image", "")) + + # Ports can be a string like "0.0.0.0:443->443/tcp" or a list + raw_ports = data.get("Ports", data.get("ports", data.get("Publishers", []))) + if isinstance(raw_ports, str): + ports = [p.strip() for p in raw_ports.split(",") if p.strip()] + elif isinstance(raw_ports, list): + # Could be list of dicts (Publishers format) or list of strings + ports = [] + for p in raw_ports: + if isinstance(p, dict): + url = p.get("URL", "") + target = p.get("TargetPort", "") + published = p.get("PublishedPort", 0) + protocol = p.get("Protocol", "tcp") + if published: + ports.append(f"{url}:{published}->{target}/{protocol}") + else: + ports.append(str(p)) + else: + ports = [] + + return cls( + name=name, + state=state, + status=status, + health=health, + image=image, + ports=ports, + ) + + +class LabStatusResponse(BaseModel): + """Response for GET /api/lab/status.""" + + running: bool + containers: list[ContainerInfo] = [] + error: Optional[str] = None + + +class LabActionResponse(BaseModel): + """Response for POST /api/lab/start and POST /api/lab/stop.""" + + success: bool + message: str = "" + error: Optional[str] = None + + +class KillActionResponse(BaseModel): + """Response for POST /api/lab/kill.""" + + success: bool + mcp_processes_killed: int = 0 + containers_stopped: bool = False + session_cleared: bool = False + 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.""" + + lab_name: str = "" + network_subnet: str = "" + containers: dict[str, bool] = {} + run_storage_backend: str = "local" diff --git a/src/aptl/cli/kill.py b/src/aptl/cli/kill.py new file mode 100644 index 000000000..4bb070520 --- /dev/null +++ b/src/aptl/cli/kill.py @@ -0,0 +1,66 @@ +"""Emergency kill switch CLI command.""" + +from pathlib import Path + +import typer + +from aptl.core.kill import execute_kill +from aptl.utils.logging import get_logger + +log = get_logger("cli.kill") + +app = typer.Typer(help="Emergency kill switch.", invoke_without_command=True) + + +@app.callback(invoke_without_command=True) +def kill_command( + containers: bool = typer.Option( + False, + "--containers", + "-c", + help="Also force-stop all lab Docker containers.", + ), + project_dir: Path = typer.Option( + Path("."), + "--project-dir", + "-d", + help="Path to the APTL project directory.", + ), +) -> None: + """Immediately terminate all MCP server processes and agent activity. + + Sends SIGTERM to all running MCP server processes, waits briefly for + graceful shutdown, then sends SIGKILL to any survivors. Clears any + active scenario session and removes trace context files. + + Use --containers to also force-stop all lab Docker containers. + """ + log.info("Kill switch activated (containers=%s)", containers) + + result = execute_kill(containers=containers, project_dir=project_dir) + + # Print summary + if result.mcp_processes_killed > 0: + typer.echo(f"Killed {result.mcp_processes_killed} MCP server process(es).") + else: + typer.echo("No MCP server processes found.") + + if containers: + if result.containers_stopped: + typer.echo("Lab containers stopped.") + else: + typer.echo("Failed to stop lab containers.") + + if result.session_cleared: + typer.echo("Active scenario session cleared.") + + if result.trace_context_cleaned: + typer.echo("Trace context file removed.") + + if result.errors: + typer.echo("") + for error in result.errors: + typer.echo(f" Warning: {error}") + + if not result.success: + raise typer.Exit(code=1) diff --git a/src/aptl/cli/main.py b/src/aptl/cli/main.py index b9950f348..2b019ff9d 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 +from aptl.cli import lab, config, container, scenario, runs, web, kill app = typer.Typer( name="aptl", @@ -18,6 +18,8 @@ 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") def _version_callback(value: bool) -> None: diff --git a/src/aptl/cli/scenario.py b/src/aptl/cli/scenario.py index 2bdc0f4e4..f2667e8ab 100644 --- a/src/aptl/cli/scenario.py +++ b/src/aptl/cli/scenario.py @@ -1,7 +1,7 @@ """CLI commands for scenario management.""" import os -import shutil +import time from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 @@ -13,7 +13,7 @@ from rich.table import Table from aptl.core.config import AptlConfig, find_config, load_config -from aptl.core.events import EventLog, EventType, make_event +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 @@ -27,6 +27,15 @@ validate_scenario_containers, ) 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") @@ -380,13 +389,8 @@ def start( state = _state_dir(project_dir) session_mgr = ScenarioSession(state) - # Create events directory and log - ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S") - events_dir = state / "events" - events_file = events_dir / f"{scenario.metadata.id}_{ts}.jsonl" - try: - session = session_mgr.start(scenario, events_file) + session = session_mgr.start(scenario) except ScenarioStateError as e: log.error("Cannot start scenario: %s", e) typer.echo(f"Error: {e}") @@ -397,12 +401,8 @@ def start( session.run_id = run_id session_mgr._write(session) - # Set trace directory for MCP servers and clean stale traces - trace_dir = state / "traces" - if trace_dir.exists(): - shutil.rmtree(trace_dir) - trace_dir.mkdir(parents=True, exist_ok=True) - os.environ["APTL_TRACE_DIR"] = str(trace_dir) + # 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() @@ -414,16 +414,20 @@ def start( else: log.warning("No CTF flags collected (containers may not be running)") - event_log = EventLog(state / session.events_file) - event_log.append(make_event( - EventType.SCENARIO_STARTED, - scenario.metadata.id, - { - "mode": scenario.mode.value, - "flags_collected": len(flags), - "run_id": run_id, - }, - )) + # 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}") @@ -431,8 +435,7 @@ def start( 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" Events: {session.events_file}") - typer.echo(f" Traces: {trace_dir}") + typer.echo(f" Trace ID: {session.trace_id}") @app.command() @@ -491,24 +494,46 @@ def stop( scenario = _load_active_scenario(session, project_dir, scenarios_dir) - # Log stop event - event_log = EventLog(state / session.events_file) - event_log.append(make_event( - EventType.SCENARIO_STOPPED, - session.scenario_id, - )) - # Calculate duration started = datetime.fromisoformat(session.started_at) - elapsed = (datetime.now(timezone.utc) - started).total_seconds() + 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() - # Read events - events = event_log.read_all() + # 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 @@ -534,7 +559,6 @@ def stop( session=finished_session, scenario=scenario, scenario_path=scenario_path, - events=events, config=config, ) except Exception as e: @@ -544,6 +568,11 @@ def stop( # 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") diff --git a/src/aptl/cli/web.py b/src/aptl/cli/web.py new file mode 100644 index 000000000..575215cd1 --- /dev/null +++ b/src/aptl/cli/web.py @@ -0,0 +1,47 @@ +"""Web UI server command.""" + +from typing import Optional + +import typer + +app = typer.Typer(help="Web UI server management.") + + +@app.command() +def serve( + host: str = typer.Option("127.0.0.1", help="Bind address."), + port: int = typer.Option(8400, help="Bind port."), + reload: bool = typer.Option(False, help="Enable auto-reload for development."), + workers: int = typer.Option(1, help="Number of uvicorn workers."), + project_dir: Optional[str] = typer.Option( + None, + help="Project directory (default: current directory).", + ), +) -> None: + """Start the APTL web API server.""" + try: + import uvicorn + except ImportError: + typer.echo( + "Web dependencies not installed. " + 'Install with: pip install -e ".[web]"', + err=True, + ) + raise typer.Exit(1) + + import os + + if project_dir: + os.environ["APTL_PROJECT_DIR"] = project_dir + + typer.echo(f"Starting APTL web API on {host}:{port}") + uvicorn.run( + "aptl.api.main:app", + host=host, + port=port, + reload=reload, + workers=workers, + log_level="info", + timeout_keep_alive=65, + access_log=True, + ) diff --git a/src/aptl/core/certs.py b/src/aptl/core/certs.py index 14a54e9cb..9434e3e04 100644 --- a/src/aptl/core/certs.py +++ b/src/aptl/core/certs.py @@ -43,8 +43,9 @@ def ensure_ssl_certs(project_dir: Path) -> CertResult: and the path to the certs directory. """ certs_dir = project_dir / _CERTS_SUBDIR + root_ca = certs_dir / "root-ca.pem" - if certs_dir.exists(): + if certs_dir.exists() and root_ca.exists(): log.info("SSL certificates already exist at %s", certs_dir) return CertResult( success=True, diff --git a/src/aptl/core/collectors.py b/src/aptl/core/collectors.py index 8d0c88f8f..1b049386d 100644 --- a/src/aptl/core/collectors.py +++ b/src/aptl/core/collectors.py @@ -7,6 +7,7 @@ """ import json +import os import subprocess from datetime import datetime, timezone from pathlib import Path @@ -36,7 +37,7 @@ def _curl_json( auth_header: str | None = None, body: dict | None = None, insecure: bool = False, - timeout: int = 20, + timeout: int = 120, ) -> dict | list | None: """Make an HTTP request via curl and return parsed JSON, or None.""" cmd = ["curl", "-sf", url] @@ -324,47 +325,46 @@ def collect_container_logs( return logs -def collect_mcp_traces( - trace_dir: Path, - start_iso: str, - end_iso: str, +def collect_traces( + trace_id: str, + tempo_url: str | None = None, ) -> list[dict]: - """Merge all MCP trace JSONL files from the trace directory. + """Fetch all spans for a trace from Grafana Tempo. + + Queries the Tempo HTTP API by trace ID and returns the spans + in OTLP JSON format. - Reads ``*.jsonl`` files, filters by timestamp within the window, - and returns a chronologically sorted merged list. + Args: + trace_id: The hex trace ID to query. + tempo_url: Base URL for Tempo. Defaults to ``TEMPO_URL`` env + var or ``http://localhost:3200``. + + Returns: + List of span dicts, or empty list on error. """ - if not trace_dir.exists(): - log.info("Trace directory %s does not exist, skipping", trace_dir) + if not trace_id: + log.info("No trace_id provided, skipping trace collection") return [] - start_dt = datetime.fromisoformat(start_iso) - end_dt = datetime.fromisoformat(end_iso) - all_traces: list[dict] = [] + url = tempo_url or os.getenv("TEMPO_URL", "http://localhost:3200") + api_url = f"{url}/api/traces/{trace_id}" - for jsonl_file in trace_dir.glob("*.jsonl"): - for line in jsonl_file.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue - try: - entry = json.loads(line) - except json.JSONDecodeError: - continue + result = _curl_json(api_url, timeout=30) + if result is None: + log.warning("Failed to fetch traces from Tempo at %s", api_url) + return [] - ts = entry.get("timestamp", "") - if ts: - try: - entry_dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) - if start_dt <= entry_dt <= end_dt: - all_traces.append(entry) - except ValueError: - all_traces.append(entry) - else: - all_traces.append(entry) - - # Sort chronologically - all_traces.sort(key=lambda x: x.get("timestamp", "")) - - log.info("Collected %d MCP trace entries", len(all_traces)) - return all_traces + # Tempo returns { batches: [ { resource: {...}, scopeSpans: [...] } ] } + # or { resourceSpans: [...] } depending on version. Normalize to span list. + spans: list[dict] = [] + if isinstance(result, dict): + # Tempo v2 format: batches -> scopeSpans -> spans + for batch in result.get("batches", result.get("resourceSpans", [])): + resource = batch.get("resource", {}) + for scope_span in batch.get("scopeSpans", []): + for span in scope_span.get("spans", []): + enriched = {**span, "resource": resource} + spans.append(enriched) + + log.info("Collected %d spans from Tempo for trace %s", len(spans), trace_id[:16]) + return spans diff --git a/src/aptl/core/config.py b/src/aptl/core/config.py index fc477ca1d..8f396f589 100644 --- a/src/aptl/core/config.py +++ b/src/aptl/core/config.py @@ -57,14 +57,10 @@ class ContainerSettings(BaseModel): def enabled_profiles(self) -> list[str]: """Return docker compose profile names for enabled containers.""" - profiles = [] - for field_name in [ - "wazuh", "victim", "kali", "reverse", - "enterprise", "soc", "mail", "fileshare", "dns", - ]: - if getattr(self, field_name): - profiles.append(field_name) - return profiles + return [ + name for name in type(self).model_fields + if getattr(self, name) + ] class RunStorageConfig(BaseModel): @@ -78,6 +74,38 @@ class RunStorageConfig(BaseModel): s3_prefix: str = "runs/" # Future +class DeploymentConfig(BaseModel): + """Configuration for deployment backend selection. + + Controls which deployment backend is used for lab lifecycle + operations (start, stop, status, kill). Defaults to local + Docker Compose. + """ + + model_config = ConfigDict(extra="forbid") + + provider: str = "docker-compose" # "docker-compose" or "ssh-compose" + project_name: str = "aptl" + + # SSH-specific fields (only used when provider == "ssh-compose") + ssh_host: str | None = None + ssh_user: str | None = None + ssh_key: str | None = None + ssh_port: int = 22 + remote_dir: str | None = None + + @field_validator("provider") + @classmethod + def validate_provider(cls, v: str) -> str: + allowed = {"docker-compose", "ssh-compose"} + if v not in allowed: + raise ValueError( + f"Unknown deployment provider '{v}'. " + f"Supported: {', '.join(sorted(allowed))}" + ) + return v + + class AptlConfig(BaseModel): """Top-level APTL configuration.""" @@ -85,6 +113,7 @@ class AptlConfig(BaseModel): lab: LabSettings = LabSettings(name="aptl") containers: ContainerSettings = ContainerSettings() + deployment: DeploymentConfig = DeploymentConfig() run_storage: RunStorageConfig = RunStorageConfig() diff --git a/src/aptl/core/credentials.py b/src/aptl/core/credentials.py index b382ab52f..fad1fd6ca 100644 --- a/src/aptl/core/credentials.py +++ b/src/aptl/core/credentials.py @@ -6,6 +6,7 @@ import re from pathlib import Path +from xml.sax.saxutils import escape as xml_escape from aptl.utils.logging import get_logger @@ -14,7 +15,7 @@ # Matches: password: "anything" (with optional surrounding whitespace) _PASSWORD_PATTERN = re.compile(r'(password:\s*)"[^"]*"') -# Matches: anything +# Matches: anything (used only within pre-extracted blocks) _KEY_PATTERN = re.compile(r"[^<]*") @@ -36,8 +37,10 @@ def sync_dashboard_config(config_path: Path, api_password: str) -> None: content = config_path.read_text() + # Escape characters that would break YAML double-quoted strings. + safe_pw = api_password.replace("\\", "\\\\").replace('"', '\\"') new_content, count = _PASSWORD_PATTERN.subn( - lambda m: f'{m.group(1)}"{api_password}"', content + lambda m: f'{m.group(1)}"{safe_pw}"', content ) if count == 0: @@ -68,15 +71,43 @@ def sync_manager_config(config_path: Path, cluster_key: str) -> None: content = config_path.read_text() - new_content, count = _KEY_PATTERN.subn( - lambda _: f"{cluster_key}", content - ) + # Find blocks by string search (O(n), no backtracking), + # then replace only within each block. + count = 0 + result: list[str] = [] + pos = 0 + while True: + block_start = content.find("", pos) + if block_start == -1: + result.append(content[pos:]) + break + block_end = content.find("", block_start) + if block_end == -1: + result.append(content[pos:]) + break + block_end += len("") + # Append everything before this block unchanged + result.append(content[pos:block_start]) + # Replace only within this block + block = content[block_start:block_end] + safe_key = xml_escape(cluster_key) + new_block, n = _KEY_PATTERN.subn( + lambda _: f"{safe_key}", block + ) + count += n + result.append(new_block) + pos = block_end + + new_content = "".join(result) if count == 0: log.warning( - "No pattern found in %s; file left unchanged", config_path + "No pattern found in %s; file left unchanged", + config_path, ) else: - log.info("Replaced %d occurrence(s) in %s", count, config_path) + log.info( + "Replaced %d cluster occurrence(s) in %s", count, config_path + ) config_path.write_text(new_content) diff --git a/src/aptl/core/deployment/__init__.py b/src/aptl/core/deployment/__init__.py new file mode 100644 index 000000000..d219ef86c --- /dev/null +++ b/src/aptl/core/deployment/__init__.py @@ -0,0 +1,84 @@ +"""Deployment backend abstraction layer. + +Provides a pluggable deployment backend for lab lifecycle management. +The default backend is Docker Compose; additional backends (SSH remote, +Kubernetes, etc.) can be selected via the ``deployment.provider`` field +in aptl.json. + +Usage:: + + from aptl.core.config import load_config + from aptl.core.deployment import get_backend + + config = load_config(Path("aptl.json")) + backend = get_backend(config, project_dir=Path(".")) + result = backend.start(profiles=["wazuh", "victim", "kali"]) +""" + +from pathlib import Path + +from aptl.core.deployment.backend import DeploymentBackend +from aptl.core.deployment.docker_compose import DockerComposeBackend +from aptl.core.deployment.ssh_compose import SSHComposeBackend + +__all__ = [ + "DeploymentBackend", + "DockerComposeBackend", + "SSHComposeBackend", + "get_backend", +] + + +def get_backend(config: "AptlConfig", project_dir: Path) -> DeploymentBackend: # noqa: F821 + """Create a deployment backend from configuration. + + Reads ``config.deployment.provider`` to select the backend: + - ``"docker-compose"`` (default): Local Docker Compose + - ``"ssh-compose"``: Remote Docker Compose over SSH + + Args: + config: Validated APTL configuration. + project_dir: Working directory for the deployment. + + Returns: + A DeploymentBackend instance. + + Raises: + ValueError: If the provider is not recognized or required + fields are missing. + """ + from aptl.core.config import AptlConfig # avoid circular import + + provider = config.deployment.provider + project_name = config.deployment.project_name + + if provider == "docker-compose": + return DockerComposeBackend( + project_dir=project_dir, + project_name=project_name, + ) + + if provider == "ssh-compose": + dep = config.deployment + if not dep.ssh_host: + raise ValueError( + "deployment.ssh_host is required for ssh-compose provider" + ) + if not dep.ssh_user: + raise ValueError( + "deployment.ssh_user is required for ssh-compose provider" + ) + return SSHComposeBackend( + project_dir=project_dir, + host=dep.ssh_host, + user=dep.ssh_user, + ssh_key=dep.ssh_key, + ssh_port=dep.ssh_port, + remote_dir=dep.remote_dir, + project_name=project_name, + ) + + raise ValueError( + f"Unknown deployment provider: {provider!r}. " + f"Supported: 'docker-compose', 'ssh-compose'" + ) diff --git a/src/aptl/core/deployment/backend.py b/src/aptl/core/deployment/backend.py new file mode 100644 index 000000000..fc0e737b2 --- /dev/null +++ b/src/aptl/core/deployment/backend.py @@ -0,0 +1,80 @@ +"""Deployment backend protocol. + +Defines the abstract interface for lab deployment backends. Each backend +implements lifecycle operations (start, stop, status, kill, pull) for a +specific deployment target (Docker Compose, SSH remote, Kubernetes, etc.). + +Follows the same Protocol pattern as RunStorageBackend in runstore.py. +""" + +from typing import Protocol + +from aptl.core.lab import LabResult, LabStatus + + +class DeploymentBackend(Protocol): + """Protocol for lab deployment backends. + + Backends manage the deployment lifecycle: starting and stopping + containers, querying status, emergency kill, and image pulling. + Container interaction (exec, logs, inspect) is a separate concern. + """ + + def start(self, profiles: list[str], *, build: bool = True) -> LabResult: + """Start lab services for the given profiles. + + Args: + profiles: List of profile names to activate. + build: If True, rebuild images before starting. + + Returns: + LabResult indicating success or failure. + """ + ... + + def stop( + self, profiles: list[str], *, remove_volumes: bool = False + ) -> LabResult: + """Stop lab services. + + Args: + profiles: List of profile names to include in the stop. + remove_volumes: If True, also remove persistent volumes. + + Returns: + LabResult indicating success or failure. + """ + ... + + def status(self) -> LabStatus: + """Query the current deployment status. + + Returns: + LabStatus with container information. + """ + ... + + def kill(self, profiles: list[str]) -> tuple[bool, str]: + """Emergency-stop all lab containers. + + Sends immediate kill signal, then cleans up. + + Args: + profiles: List of profile names to include. + + Returns: + Tuple of (success, error_message). + """ + ... + + def pull_images(self, images: list[str]) -> list[str]: + """Pre-pull container images. + + Args: + images: List of image references to pull. + + Returns: + List of warning messages for images that failed to pull + (non-fatal). + """ + ... diff --git a/src/aptl/core/deployment/docker_compose.py b/src/aptl/core/deployment/docker_compose.py new file mode 100644 index 000000000..3a38e4f93 --- /dev/null +++ b/src/aptl/core/deployment/docker_compose.py @@ -0,0 +1,271 @@ +"""Docker Compose deployment backend. + +Implements the DeploymentBackend protocol using local Docker Compose +subprocess calls. This is the default backend and wraps the logic +previously embedded directly in lab.py and kill.py. +""" + +import json +import subprocess +from pathlib import Path + +from aptl.core.lab import LabResult, LabStatus +from aptl.utils.logging import get_logger + +log = get_logger("deployment.docker_compose") + +# Timeout for Docker Compose subprocess calls during kill operations. +# Generous enough for a large stack, short enough that a hung daemon +# won't block the kill switch indefinitely. +_DOCKER_TIMEOUT = 30 + + +class DockerComposeBackend: + """Docker Compose deployment backend. + + Manages lab lifecycle via ``docker compose`` subprocess calls. + All commands run against the docker-compose.yml in project_dir. + """ + + def __init__( + self, + project_dir: Path, + project_name: str = "aptl", + ) -> None: + self._project_dir = project_dir + self._project_name = project_name + + @property + def project_dir(self) -> Path: + return self._project_dir + + @property + def project_name(self) -> str: + return self._project_name + + def _build_command( + self, + action: str, + profiles: list[str], + ) -> list[str]: + """Build a docker compose command with profile flags. + + Does NOT add action-specific flags (--build, -d, -v); callers + are responsible for appending those after calling this method. + + Args: + action: The compose action (up, down, ps, kill, etc.). + profiles: List of docker compose profiles to activate. + + Returns: + Command as a list of strings suitable for subprocess.run(). + """ + cmd = ["docker", "compose"] + + for profile in profiles: + cmd.extend(["--profile", profile]) + + cmd.append(action) + + return cmd + + def _run( + self, + cmd: list[str], + *, + timeout: int | None = None, + ) -> subprocess.CompletedProcess: + """Run a subprocess command in the project directory. + + Args: + cmd: Command as a list of strings. + timeout: Optional timeout in seconds. + + Returns: + CompletedProcess result. + """ + kwargs: dict = { + "capture_output": True, + "text": True, + "cwd": self._project_dir, + } + if timeout is not None: + kwargs["timeout"] = timeout + return subprocess.run(cmd, **kwargs) + + def start(self, profiles: list[str], *, build: bool = True) -> LabResult: + """Start lab services via docker compose up. + + Args: + profiles: List of profile names to activate. + build: If True, rebuild images before starting. + + Returns: + LabResult indicating success or failure. + """ + cmd = self._build_command("up", profiles) + if build: + cmd.append("--build") + cmd.append("-d") + + log.info("Starting lab with profiles: %s", profiles) + log.debug("Command: %s", " ".join(cmd)) + + result = self._run(cmd) + + if result.returncode != 0: + log.error("Lab start failed: %s", result.stderr) + return LabResult(success=False, error=result.stderr) + + log.info("Lab started successfully") + return LabResult(success=True, message="Lab started") + + def stop( + self, profiles: list[str], *, remove_volumes: bool = False + ) -> LabResult: + """Stop lab services via docker compose down. + + Args: + profiles: List of profile names to include in the stop. + remove_volumes: If True, also remove Docker volumes (-v flag). + + Returns: + LabResult indicating success or failure. + """ + cmd = self._build_command("down", profiles=profiles) + if remove_volumes: + cmd.append("-v") + + log.info("Stopping lab (remove_volumes=%s)", remove_volumes) + + result = self._run(cmd) + + if result.returncode != 0: + log.error("Lab stop failed: %s", result.stderr) + return LabResult(success=False, error=result.stderr) + + log.info("Lab stopped successfully") + return LabResult(success=True, message="Lab stopped") + + def status(self) -> LabStatus: + """Query current lab status via docker compose ps. + + Returns: + LabStatus with container information. + """ + cmd = ["docker", "compose", "ps", "--format", "json"] + + result = self._run(cmd) + + if result.returncode != 0: + log.warning("Could not get lab status: %s", result.stderr) + return LabStatus(running=False, error=result.stderr) + + try: + # docker compose ps --format json outputs one JSON object per + # line (NDJSON), not a JSON array. Try array first, fall back + # to NDJSON. + stripped = result.stdout.strip() + if not stripped: + containers: list[dict] = [] + elif stripped.startswith("["): + containers = json.loads(stripped) + else: + containers = [ + json.loads(line) + for line in stripped.splitlines() + if line.strip() + ] + except json.JSONDecodeError: + log.warning("Could not parse compose ps output") + return LabStatus( + running=False, error="Failed to parse container status" + ) + + running = len(containers) > 0 + return LabStatus(running=running, containers=containers) + + def kill(self, profiles: list[str]) -> tuple[bool, str]: + """Emergency-stop all lab containers. + + Uses ``docker compose kill`` for immediate SIGKILL, followed by + ``docker compose down`` to clean up stopped containers. + + Args: + profiles: List of profile names to include. + + Returns: + Tuple of (success, error_message). + """ + # Phase 1: docker compose kill (immediate SIGKILL) + kill_cmd = ["docker", "compose"] + for profile in profiles: + kill_cmd.extend(["--profile", profile]) + kill_cmd.append("kill") + + log.info("Running: %s", " ".join(kill_cmd)) + kill_ok = False + try: + result = self._run(kill_cmd, timeout=_DOCKER_TIMEOUT) + kill_ok = result.returncode == 0 + if not kill_ok: + log.warning( + "docker compose kill stderr: %s", result.stderr.strip() + ) + except subprocess.TimeoutExpired: + log.warning( + "docker compose kill timed out after %ds", _DOCKER_TIMEOUT + ) + except (FileNotFoundError, OSError) as exc: + msg = f"docker compose kill failed: {exc}" + log.error(msg) + return False, msg + + # Phase 2: docker compose down (cleanup). Treat non-zero exit as + # a warning -- the important work (SIGKILL) already happened above. + down_cmd = self._build_command("down", profiles=profiles) + log.info("Running: %s", " ".join(down_cmd)) + try: + result = self._run(down_cmd, timeout=_DOCKER_TIMEOUT) + if result.returncode != 0: + log.warning( + "docker compose down stderr: %s", result.stderr.strip() + ) + except subprocess.TimeoutExpired: + log.warning( + "docker compose down timed out after %ds", _DOCKER_TIMEOUT + ) + except (FileNotFoundError, OSError) as exc: + log.warning("docker compose down failed: %s", exc) + + if not kill_ok: + return False, "docker compose kill returned non-zero" + + log.info("All lab containers stopped") + return True, "" + + def pull_images(self, images: list[str]) -> list[str]: + """Pre-pull container images via docker pull. + + Args: + images: List of image references to pull. + + Returns: + List of warning messages for images that failed to pull + (non-fatal). + """ + warnings: list[str] = [] + for image in images: + try: + result = self._run(["docker", "pull", image]) + if result.returncode != 0: + msg = f"Failed to pull {image}: {result.stderr.strip()}" + log.warning(msg) + warnings.append(msg) + else: + log.info("Pulled %s", image) + except (FileNotFoundError, OSError) as exc: + msg = f"Failed to pull {image}: {exc}" + log.warning(msg) + warnings.append(msg) + return warnings diff --git a/src/aptl/core/deployment/ssh_compose.py b/src/aptl/core/deployment/ssh_compose.py new file mode 100644 index 000000000..c12db07cc --- /dev/null +++ b/src/aptl/core/deployment/ssh_compose.py @@ -0,0 +1,161 @@ +"""SSH Remote Docker Compose deployment backend. + +Runs Docker Compose commands on a remote host over SSH by setting the +DOCKER_HOST environment variable to ``ssh://user@host``. This enables +deploying the lab to a dedicated server, classroom environment, or +cloud VM without changing scenario definitions or MCP configs. +""" + +import os +import re +import subprocess +from pathlib import Path + +from aptl.core.deployment.docker_compose import DockerComposeBackend +from aptl.utils.logging import get_logger + +log = get_logger("deployment.ssh_compose") + +# Validation patterns for SSH parameters. +_HOST_RE = re.compile(r"^[\w.\-]+$|^\[[\w:]+\]$") +_USER_RE = re.compile(r"^[\w\-]+$") + + +class SSHComposeBackend(DockerComposeBackend): + """SSH Remote Docker Compose deployment backend. + + Extends DockerComposeBackend to run all Docker commands against a + remote Docker daemon over SSH. Uses ``DOCKER_HOST=ssh://user@host`` + so that the local ``docker compose`` CLI transparently forwards + commands to the remote host. + + The remote host must have: + - Docker Engine installed and running + - SSH access for the configured user + - The project files available at ``remote_dir`` + """ + + def __init__( + self, + project_dir: Path, + host: str, + user: str, + *, + ssh_key: str | None = None, + ssh_port: int = 22, + remote_dir: str | None = None, + project_name: str = "aptl", + ) -> None: + # Validate parameters before constructing SSH URI. + if not isinstance(ssh_port, int) or not (1 <= ssh_port <= 65535): + raise ValueError(f"ssh_port must be int in 1-65535, got {ssh_port!r}") + if not _USER_RE.match(user): + raise ValueError(f"Invalid SSH user: {user!r}") + if not _HOST_RE.match(host): + raise ValueError(f"Invalid SSH host: {host!r}") + if ssh_key is not None: + key_path = Path(ssh_key) + if not key_path.is_absolute(): + raise ValueError( + f"ssh_key must be an absolute path, got {ssh_key!r}" + ) + if ".." in key_path.parts: + raise ValueError( + f"ssh_key must not contain '..', got {ssh_key!r}" + ) + + super().__init__(project_dir=project_dir, project_name=project_name) + self._host = host + self._user = user + self._ssh_key = ssh_key + self._ssh_port = ssh_port + self._remote_dir = remote_dir or str(project_dir) + self._docker_host = f"ssh://{user}@{host}" + if ssh_port != 22: + self._docker_host = f"ssh://{user}@{host}:{ssh_port}" + + @property + def host(self) -> str: + return self._host + + @property + def user(self) -> str: + return self._user + + @property + def docker_host(self) -> str: + return self._docker_host + + def _run( + self, + cmd: list[str], + *, + timeout: int | None = None, + ) -> subprocess.CompletedProcess: + """Run a command with DOCKER_HOST pointing to the remote daemon. + + Overrides the parent _run to inject the SSH-based DOCKER_HOST + environment variable, causing all Docker CLI commands to execute + against the remote host. + + Args: + cmd: Command as a list of strings. + timeout: Optional timeout in seconds. + + Returns: + CompletedProcess result. + """ + env = os.environ.copy() + env["DOCKER_HOST"] = self._docker_host + + if self._ssh_key: + # SSH_AUTH_SOCK won't help with a specific key file; + # configure via ssh config or GIT_SSH_COMMAND-style env. + # Docker's SSH transport respects the standard SSH config, + # so users should add a Host entry. We also set + # DOCKER_SSH_IDENTITY for Docker's built-in SSH support. + env["DOCKER_SSH_IDENTITY"] = self._ssh_key + + kwargs: dict = { + "capture_output": True, + "text": True, + "env": env, + # Use remote_dir as cwd context for compose file discovery. + # Docker Compose with DOCKER_HOST=ssh:// sends the project + # context; the local cwd determines which compose file is read. + "cwd": self._project_dir, + } + if timeout is not None: + kwargs["timeout"] = timeout + + log.debug( + "Running via DOCKER_HOST=%s: %s", + self._docker_host, + " ".join(cmd), + ) + return subprocess.run(cmd, **kwargs) + + def validate_connection(self) -> tuple[bool, str]: + """Test SSH connectivity to the remote Docker daemon. + + Returns: + Tuple of (success, error_message). + """ + try: + result = self._run( + ["docker", "info", "--format", "{{.ServerVersion}}"], + timeout=30, + ) + if result.returncode == 0: + version = result.stdout.strip() + log.info( + "Connected to remote Docker %s at %s", + version, + self._docker_host, + ) + return True, "" + return False, result.stderr.strip() + except subprocess.TimeoutExpired: + return False, f"SSH connection to {self._docker_host} timed out" + except (FileNotFoundError, OSError) as exc: + return False, f"Failed to connect to {self._docker_host}: {exc}" diff --git a/src/aptl/core/events.py b/src/aptl/core/events.py deleted file mode 100644 index d145bc36e..000000000 --- a/src/aptl/core/events.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Event timeline for scenario execution. - -Provides an append-only event log backed by JSONL (one JSON object per -line). Events are flushed immediately on write for crash safety. The log -supports chronological reads and filtering by event type. -""" - -import json -from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone -from enum import Enum -from pathlib import Path -from typing import Any, Optional - -from aptl.utils.logging import get_logger - -log = get_logger("events") - - -class EventType(str, Enum): - """Types of events that can occur during a scenario.""" - - SCENARIO_STARTED = "scenario_started" - SCENARIO_STOPPED = "scenario_stopped" - PRECONDITION_APPLIED = "precondition_applied" - PRECONDITION_FAILED = "precondition_failed" - OBJECTIVE_COMPLETED = "objective_completed" - OBJECTIVE_FAILED = "objective_failed" - ALERT_MATCHED = "alert_matched" - HINT_REQUESTED = "hint_requested" - EVALUATION_RUN = "evaluation_run" - - -@dataclass -class Event: - """A single event in the scenario timeline. - - Attributes: - event_type: The kind of event. - scenario_id: ID of the scenario this event belongs to. - timestamp: ISO 8601 UTC timestamp. - data: Arbitrary event-specific payload. - """ - - event_type: EventType - scenario_id: str - timestamp: str - data: dict[str, Any] = field(default_factory=dict) - - -def make_event( - event_type: EventType, - scenario_id: str, - data: Optional[dict[str, Any]] = None, -) -> Event: - """Create an event with the current UTC timestamp. - - Args: - event_type: The kind of event. - scenario_id: ID of the scenario this event belongs to. - data: Optional event-specific payload. - - Returns: - A new Event with a UTC ISO 8601 timestamp. - """ - return Event( - event_type=event_type, - scenario_id=scenario_id, - timestamp=datetime.now(timezone.utc).isoformat(), - data=data or {}, - ) - - -def _serialize_event(event: Event) -> str: - """Serialize an event to a JSON string. - - Args: - event: The event to serialize. - - Returns: - A compact JSON string (no trailing newline). - """ - d = asdict(event) - # EventType serializes as its value via asdict, but ensure it's a string - d["event_type"] = event.event_type.value - return json.dumps(d, separators=(",", ":")) - - -def _deserialize_event(line: str) -> Event: - """Deserialize a JSON string into an Event. - - Args: - line: A JSON string representing an event. - - Returns: - The deserialized Event. - - Raises: - ValueError: If the line is not valid JSON or missing required fields. - """ - try: - d = json.loads(line) - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JSON in event log: {e}") from e - - try: - return Event( - event_type=EventType(d["event_type"]), - scenario_id=d["scenario_id"], - timestamp=d["timestamp"], - data=d.get("data", {}), - ) - except (KeyError, ValueError) as e: - raise ValueError(f"Malformed event record: {e}") from e - - -class EventLog: - """Append-only event log backed by a JSONL file. - - Each event is written as a single JSON line and flushed immediately. - Reads are always from disk, ensuring consistency across CLI invocations. - """ - - def __init__(self, path: Path) -> None: - """Initialize the event log. - - Creates parent directories if needed. The file is created on - first write, not on initialization. - - Args: - path: Path to the .jsonl file. - """ - self._path = path - log.debug("EventLog initialized at %s", path) - - @property - def path(self) -> Path: - """The path to the JSONL file.""" - return self._path - - def append(self, event: Event) -> None: - """Append an event to the log. - - Writes a single JSON line and flushes immediately for crash - safety. Creates the file and parent directories on first write. - - Args: - event: The event to record. - """ - self._path.parent.mkdir(parents=True, exist_ok=True) - line = _serialize_event(event) - with open(self._path, "a", encoding="utf-8") as f: - f.write(line + "\n") - f.flush() - log.debug( - "Appended %s event for scenario '%s'", - event.event_type.value, - event.scenario_id, - ) - - def read_all(self) -> list[Event]: - """Read all events from the log file. - - Returns: - List of Events in chronological order (insertion order). - - Raises: - FileNotFoundError: If the log file does not exist. - """ - if not self._path.exists(): - raise FileNotFoundError(f"Event log not found: {self._path}") - - events: list[Event] = [] - for line_num, line in enumerate( - self._path.read_text(encoding="utf-8").splitlines(), start=1 - ): - stripped = line.strip() - if not stripped: - continue - try: - events.append(_deserialize_event(stripped)) - except ValueError as e: - log.warning( - "Skipping malformed event at line %d in %s: %s", - line_num, - self._path, - e, - ) - return events - - def query_by_type(self, event_type: EventType) -> list[Event]: - """Filter events by type. - - Args: - event_type: The type to filter for. - - Returns: - Matching events in chronological order. - - Raises: - FileNotFoundError: If the log file does not exist. - """ - return [e for e in self.read_all() if e.event_type == event_type] - - def query_by_scenario(self, scenario_id: str) -> list[Event]: - """Filter events by scenario ID. - - Args: - scenario_id: The scenario ID to filter for. - - Returns: - Matching events in chronological order. - - Raises: - FileNotFoundError: If the log file does not exist. - """ - return [e for e in self.read_all() if e.scenario_id == scenario_id] - - def is_empty(self) -> bool: - """Check whether the log file exists and has content. - - Returns: - True if the file does not exist or is empty. - """ - if not self._path.exists(): - return True - return self._path.stat().st_size == 0 diff --git a/src/aptl/core/kill.py b/src/aptl/core/kill.py new file mode 100644 index 000000000..747551281 --- /dev/null +++ b/src/aptl/core/kill.py @@ -0,0 +1,411 @@ +"""Emergency kill switch for all agent and MCP operations. + +Discovers and terminates MCP server processes, optionally stops lab +containers, clears scenario session state, and cleans up trace context +files. Designed for resilience: each step runs independently so that a +failure in one does not prevent the others. +""" + +import os +import signal +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +from aptl.core.deployment.docker_compose import ( + _DOCKER_TIMEOUT, +) +from aptl.core.lab import ALL_KNOWN_PROFILES, build_compose_command +from aptl.core.session import ScenarioSession +from aptl.utils.logging import get_logger + +if TYPE_CHECKING: + from aptl.core.deployment.backend import DeploymentBackend + +log = get_logger("kill") + +MCP_SERVER_NAMES = [ + "mcp-wazuh", + "mcp-red", + "mcp-reverse", + "mcp-soar", + "mcp-indexer", + "mcp-casemgmt", + "mcp-network", + "mcp-threatintel", +] + +_TRACE_CONTEXT_FILENAME = "trace-context.json" + + +@dataclass +class KillResult: + """Result of the kill switch operation.""" + + success: bool + mcp_processes_killed: int = 0 + containers_stopped: bool = False + session_cleared: bool = False + trace_context_cleaned: bool = False + errors: list[str] = field(default_factory=list) + + +def find_mcp_processes() -> list[dict]: + """Discover running MCP server Node.js processes. + + Scans ``/proc/*/cmdline`` on Linux for processes whose command line + contains a known MCP server build path (e.g. ``mcp-wazuh/build/index.js``). + Falls back to ``pgrep -f`` on non-Linux platforms. + + Returns: + List of dicts with keys ``pid`` (int), ``cmdline`` (str), and + ``name`` (str, the matched server name). + """ + if sys.platform == "linux": + return _find_via_proc() + return _find_via_pgrep() + + +def _find_via_proc() -> list[dict]: + """Scan /proc for MCP server processes. + + Skips the current process to avoid false-positive matches when the + kill switch's own command line happens to contain an MCP server name + (e.g. in log messages or grep patterns). + """ + found: list[dict] = [] + own_pid = os.getpid() + try: + entries = os.listdir("/proc") + except OSError: + log.warning("Cannot read /proc, falling back to pgrep") + return _find_via_pgrep() + + for entry in entries: + if not entry.isdigit(): + continue + pid = int(entry) + if pid == own_pid: + continue + cmdline_path = f"/proc/{pid}/cmdline" + try: + with open(cmdline_path, "rb") as f: + raw = f.read() + except (OSError, PermissionError): + continue + + cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip() + for name in MCP_SERVER_NAMES: + if f"{name}/build/index.js" in cmdline: + found.append({"pid": pid, "cmdline": cmdline, "name": name}) + break + + return found + + +def _find_via_pgrep() -> list[dict]: + """Discover MCP processes using pgrep (non-Linux fallback).""" + found: list[dict] = [] + for name in MCP_SERVER_NAMES: + pattern = f"{name}/build/index.js" + try: + result = subprocess.run( + ["pgrep", "-f", pattern], + capture_output=True, + text=True, + ) + if result.returncode == 0: + for line in result.stdout.strip().splitlines(): + pid = int(line.strip()) + found.append({"pid": pid, "cmdline": pattern, "name": name}) + except (FileNotFoundError, OSError, ValueError): + continue + return found + + +def _process_exited(pid: int) -> bool: + """Check whether a process has exited. + + Uses ``os.kill(pid, 0)`` which sends no signal but checks existence. + Returns True if the process is gone (``ProcessLookupError``) or if + we lack permission to signal it (``PermissionError``). In the + permission case the process may still be alive, but since we also + cannot SIGKILL it there is no point waiting further. + """ + try: + os.kill(pid, 0) + return False + except (ProcessLookupError, PermissionError): + return True + + +def _verify_mcp_process(pid: int) -> bool: + """Verify that *pid* still belongs to an MCP server process. + + Re-reads ``/proc/{pid}/cmdline`` and checks for known MCP server + patterns. Returns False if the process is gone, unreadable, or no + longer matches an MCP server — preventing SIGKILL to a recycled PID. + """ + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + raw = f.read() + except (OSError, PermissionError): + return False + cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace") + return any(f"{name}/build/index.js" in cmdline for name in MCP_SERVER_NAMES) + + +def kill_mcp_processes(timeout: float = 5.0) -> tuple[int, list[str]]: + """Terminate all running MCP server processes. + + Sends SIGTERM first, waits up to *timeout* seconds for graceful + shutdown (allowing OTel span flush), then sends SIGKILL to any + survivors. + + Args: + timeout: Seconds to wait after SIGTERM before sending SIGKILL. + + Returns: + Tuple of (number of processes killed, list of error messages). + """ + processes = find_mcp_processes() + if not processes: + log.info("No MCP server processes found") + return 0, [] + + log.info("Found %d MCP server process(es): %s", len(processes), + ", ".join(f"{p['name']}(pid={p['pid']})" for p in processes)) + + errors: list[str] = [] + pids_to_track: list[int] = [] + + # Phase 1: SIGTERM + for proc in processes: + pid = proc["pid"] + try: + os.kill(pid, signal.SIGTERM) + pids_to_track.append(pid) + log.info("Sent SIGTERM to %s (pid=%d)", proc["name"], pid) + except ProcessLookupError: + log.debug("Process %d already dead", pid) + except PermissionError: + errors.append(f"Permission denied killing {proc['name']} (pid={pid})") + log.warning("Permission denied sending SIGTERM to pid=%d", pid) + + # Phase 2: Wait for graceful shutdown + deadline = time.monotonic() + timeout + while pids_to_track and time.monotonic() < deadline: + pids_to_track = [pid for pid in pids_to_track if not _process_exited(pid)] + if pids_to_track: + time.sleep(0.25) + + # Phase 3: SIGKILL survivors (re-verify PID still belongs to MCP) + for pid in pids_to_track: + if sys.platform == "linux" and not _verify_mcp_process(pid): + log.warning( + "PID %d is no longer an MCP process, skipping SIGKILL", pid + ) + continue + try: + os.kill(pid, signal.SIGKILL) + log.warning("Sent SIGKILL to pid=%d (did not exit after SIGTERM)", pid) + except ProcessLookupError: + pass + except PermissionError: + errors.append(f"Permission denied force-killing pid={pid}") + + killed = len(processes) - len(errors) + log.info("Killed %d MCP server process(es)", killed) + return killed, errors + + +def kill_lab_containers( + project_dir: Optional[Path] = None, + backend: Optional["DeploymentBackend"] = None, +) -> tuple[bool, str]: + """Emergency-stop all lab containers. + + Uses the deployment backend's kill method for immediate shutdown. + Falls back to direct Docker Compose subprocess calls if no backend + is provided. + + Args: + project_dir: Working directory for Docker Compose. + backend: Optional pre-created deployment backend. + + Returns: + Tuple of (success, error_message). + """ + profiles = list(ALL_KNOWN_PROFILES) + + if backend is not None: + return backend.kill(profiles) + + # Fallback: direct Docker Compose calls (for backward compat and + # cases where config is unavailable) + kwargs: dict = {"capture_output": True, "text": True, "timeout": _DOCKER_TIMEOUT} + if project_dir is not None: + kwargs["cwd"] = project_dir + + # Phase 1: docker compose kill (immediate SIGKILL) + kill_cmd = ["docker", "compose"] + for profile in profiles: + kill_cmd.extend(["--profile", profile]) + kill_cmd.append("kill") + + log.info("Running: %s", " ".join(kill_cmd)) + kill_ok = False + try: + result = subprocess.run(kill_cmd, **kwargs) + kill_ok = result.returncode == 0 + if not kill_ok: + log.warning("docker compose kill stderr: %s", result.stderr.strip()) + except subprocess.TimeoutExpired: + log.warning("docker compose kill timed out after %ds", _DOCKER_TIMEOUT) + except (FileNotFoundError, OSError) as exc: + msg = f"docker compose kill failed: {exc}" + log.error(msg) + return False, msg + + # Phase 2: docker compose down (cleanup). Treat non-zero exit as + # a warning — the important work (SIGKILL) already happened above. + down_cmd = build_compose_command("down", profiles=profiles) + log.info("Running: %s", " ".join(down_cmd)) + try: + result = subprocess.run(down_cmd, **kwargs) + if result.returncode != 0: + log.warning("docker compose down stderr: %s", result.stderr.strip()) + except subprocess.TimeoutExpired: + log.warning("docker compose down timed out after %ds", _DOCKER_TIMEOUT) + except (FileNotFoundError, OSError) as exc: + log.warning("docker compose down failed: %s", exc) + + if not kill_ok: + return False, "docker compose kill returned non-zero" + + log.info("All lab containers stopped") + return True, "" + + +def clear_session(state_dir: Path) -> bool: + """Clear any active scenario session. + + Args: + state_dir: Path to the ``.aptl/`` state directory. + + Returns: + True if a session was cleared, False if none existed. + """ + session_mgr = ScenarioSession(state_dir) + active = session_mgr.get_active() + if active is None: + log.debug("No active session to clear") + return False + + log.info("Clearing active session for scenario '%s'", active.scenario_id) + session_mgr.clear() + return True + + +def clean_trace_context(state_dir: Path) -> bool: + """Remove the trace context file if it exists. + + Args: + state_dir: Path to the ``.aptl/`` state directory. + + Returns: + True if the file was removed, False if it did not exist. + """ + trace_file = state_dir / _TRACE_CONTEXT_FILENAME + if trace_file.exists(): + trace_file.unlink() + log.info("Removed trace context file: %s", trace_file) + return True + log.debug("No trace context file at %s", trace_file) + return False + + +def execute_kill( + containers: bool = False, + project_dir: Optional[Path] = None, +) -> KillResult: + """Execute the emergency kill switch. + + Runs each step independently so that a failure in one does not + prevent the others from executing. + + Args: + containers: If True, also force-stop all lab containers. + project_dir: APTL project directory (defaults to cwd). + + Returns: + KillResult with details of what was done. + """ + resolved_dir = Path(project_dir) if project_dir else Path(".") + state_dir = resolved_dir / ".aptl" + errors: list[str] = [] + + # Step 1: Kill MCP processes (highest priority) + mcp_killed = 0 + try: + mcp_killed, mcp_errors = kill_mcp_processes() + errors.extend(mcp_errors) + except Exception as exc: + msg = f"MCP process kill failed: {exc}" + log.error(msg) + errors.append(msg) + + # Step 2: Kill lab containers (if requested) + containers_stopped = False + if containers: + try: + containers_stopped, container_error = kill_lab_containers( + project_dir=resolved_dir, + ) + if container_error: + errors.append(container_error) + except Exception as exc: + msg = f"Container kill failed: {exc}" + log.error(msg) + errors.append(msg) + + # Step 3: Clear session state + session_cleared = False + try: + session_cleared = clear_session(state_dir) + except Exception as exc: + msg = f"Session clear failed: {exc}" + log.error(msg) + errors.append(msg) + + # Step 4: Clean up trace context + trace_cleaned = False + try: + trace_cleaned = clean_trace_context(state_dir) + except Exception as exc: + msg = f"Trace context cleanup failed: {exc}" + log.error(msg) + errors.append(msg) + + # Success if any step accomplished something, or if there was simply + # nothing to kill (no errors). + any_action = mcp_killed > 0 or containers_stopped or session_cleared or trace_cleaned + success = any_action or len(errors) == 0 + + result = KillResult( + success=success, + mcp_processes_killed=mcp_killed, + containers_stopped=containers_stopped, + session_cleared=session_cleared, + trace_context_cleaned=trace_cleaned, + errors=errors, + ) + + log.info( + "Kill switch complete: mcp=%d containers=%s session=%s trace=%s errors=%d", + mcp_killed, containers_stopped, session_cleared, trace_cleaned, len(errors), + ) + return result diff --git a/src/aptl/core/lab.py b/src/aptl/core/lab.py index 051e6e368..efbb1e4a0 100644 --- a/src/aptl/core/lab.py +++ b/src/aptl/core/lab.py @@ -1,16 +1,16 @@ """Lab lifecycle management. -Wraps docker compose commands for starting, stopping, and checking lab status. -All Docker interactions go through subprocess calls to docker compose. -Includes the full orchestration of lab startup. +Wraps deployment backends for starting, stopping, and checking lab status. +Docker interactions go through the DeploymentBackend protocol, with Docker +Compose as the default backend. Includes the full orchestration of lab +startup. """ -import json import subprocess from dataclasses import dataclass, field from functools import partial from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Optional import yaml @@ -29,10 +29,22 @@ from aptl.core.sysreqs import check_max_map_count from aptl.utils.logging import get_logger +if TYPE_CHECKING: + from aptl.core.deployment.backend import DeploymentBackend + log = get_logger("lab") WAZUH_IMAGE_VERSION = "4.12.0" +# All known Docker Compose profiles. Used as fallback when config is +# unavailable (e.g. stop_lab, kill switch). Keep in sync with +# docker-compose.yml profile definitions. +ALL_KNOWN_PROFILES = [ + "wazuh", "victim", "kali", "reverse", + "enterprise", "soc", "mail", "fileshare", "dns", + "otel", +] + @dataclass class LabResult: @@ -58,6 +70,27 @@ def docker_client(): return docker.from_env() +def _get_backend( + project_dir: Path, + config: AptlConfig | None = None, +) -> "DeploymentBackend": + """Create a deployment backend from config or defaults. + + Args: + project_dir: Working directory for the deployment. + config: Optional config; if None, uses default Docker Compose. + + Returns: + A DeploymentBackend instance. + """ + from aptl.core.deployment import get_backend + from aptl.core.deployment.docker_compose import DockerComposeBackend + + if config is not None: + return get_backend(config, project_dir) + return DockerComposeBackend(project_dir=project_dir) + + def build_compose_command( action: str, profiles: list[str], @@ -87,39 +120,37 @@ def build_compose_command( def start_lab( config: AptlConfig, project_dir: Optional[Path] = None, + backend: Optional["DeploymentBackend"] = None, ) -> LabResult: - """Start the lab environment using docker compose. + """Start the lab environment. + + Delegates to the deployment backend. If no backend is provided, + one is created from the config. Args: config: Validated APTL configuration. - project_dir: Working directory for docker compose (where docker-compose.yml lives). + project_dir: Working directory (where docker-compose.yml lives). + backend: Optional pre-created deployment backend. Returns: LabResult indicating success or failure. """ profiles = config.containers.enabled_profiles() - cmd = build_compose_command("up", profiles) - - log.info("Starting lab with profiles: %s", profiles) - log.debug("Command: %s", " ".join(cmd)) + # OTel stack (Collector + Tempo + Grafana) is core infrastructure + if "otel" not in profiles: + profiles = [*profiles, "otel"] - kwargs: dict = {"capture_output": True, "text": True} - if project_dir is not None: - kwargs["cwd"] = project_dir + if backend is None: + resolved_dir = project_dir or Path(".") + backend = _get_backend(resolved_dir, config) - result = subprocess.run(cmd, **kwargs) - - if result.returncode != 0: - log.error("Lab start failed: %s", result.stderr) - return LabResult(success=False, error=result.stderr) - - log.info("Lab started successfully") - return LabResult(success=True, message="Lab started") + return backend.start(profiles) def stop_lab( remove_volumes: bool = False, project_dir: Optional[Path] = None, + backend: Optional["DeploymentBackend"] = None, ) -> LabResult: """Stop the lab environment. @@ -129,7 +160,8 @@ def stop_lab( Args: remove_volumes: If True, also remove Docker volumes (-v flag). - project_dir: Working directory for docker compose. + project_dir: Working directory for the deployment. + backend: Optional pre-created deployment backend. Returns: LabResult indicating success or failure. @@ -138,6 +170,7 @@ def stop_lab( profiles: list[str] = [] search_dir = project_dir or Path(".") config_path = find_config(search_dir) + config: AptlConfig | None = None if config_path is not None: try: config = load_config(config_path) @@ -145,70 +178,35 @@ def stop_lab( except (FileNotFoundError, ValueError) as exc: log.warning("Could not load config for profiles: %s", exc) if not profiles: - profiles = [ - "wazuh", "victim", "kali", "reverse", - "enterprise", "soc", "mail", "fileshare", "dns", - ] - - cmd = build_compose_command("down", profiles=profiles) - if remove_volumes: - cmd.append("-v") - - log.info("Stopping lab (remove_volumes=%s)", remove_volumes) + profiles = list(ALL_KNOWN_PROFILES) - kwargs: dict = {"capture_output": True, "text": True} - if project_dir is not None: - kwargs["cwd"] = project_dir + if backend is None: + backend = _get_backend(search_dir, config) - result = subprocess.run(cmd, **kwargs) - - if result.returncode != 0: - log.error("Lab stop failed: %s", result.stderr) - return LabResult(success=False, error=result.stderr) - - log.info("Lab stopped successfully") - return LabResult(success=True, message="Lab stopped") + return backend.stop(profiles, remove_volumes=remove_volumes) def lab_status( project_dir: Optional[Path] = None, + backend: Optional["DeploymentBackend"] = None, ) -> LabStatus: - """Get the current lab status by querying docker compose. + """Get the current lab status. + + Delegates to the deployment backend. Args: - project_dir: Working directory for docker compose. + project_dir: Working directory for the deployment. + backend: Optional pre-created deployment backend. Returns: LabStatus with container information. """ - cmd = ["docker", "compose", "ps", "--format", "json"] - - kwargs: dict = {"capture_output": True, "text": True} - if project_dir is not None: - kwargs["cwd"] = project_dir + resolved_dir = project_dir or Path(".") - result = subprocess.run(cmd, **kwargs) - - if result.returncode != 0: - log.warning("Could not get lab status: %s", result.stderr) - return LabStatus(running=False, error=result.stderr) - - try: - # docker compose ps --format json outputs one JSON object per line - # (NDJSON), not a JSON array. Try array first, fall back to NDJSON. - stripped = result.stdout.strip() - if not stripped: - containers = [] - elif stripped.startswith("["): - containers = json.loads(stripped) - else: - containers = [json.loads(line) for line in stripped.splitlines() if line.strip()] - except json.JSONDecodeError: - log.warning("Could not parse compose ps output") - return LabStatus(running=False, error="Failed to parse container status") + if backend is None: + backend = _get_backend(resolved_dir) - running = len(containers) > 0 - return LabStatus(running=running, containers=containers) + return backend.status() def _check_bind_mounts(project_dir: Path) -> list[str]: @@ -261,7 +259,7 @@ def orchestrate_lab_start( 5. Sync config file credentials 6. Generate SSL certificates 7. Pre-pull container images - 8. Start containers via docker compose + 8. Start containers via deployment backend 9. Wait for services to become ready 10. Test SSH connectivity 11. Generate connection info @@ -302,9 +300,12 @@ def orchestrate_lab_start( log.error("Failed to load config: %s", exc) return LabResult(success=False, error=f"Failed to load config: {exc}") + # Create deployment backend from config + backend = _get_backend(project_dir, config) + # Step 3: Generate SSH keys log.info("Step 3: Generating SSH keys...") - keys_dir = project_dir / "containers" / "keys" + keys_dir = project_dir / "keys" host_ssh_dir = Path.home() / ".ssh" ssh_result = ensure_ssh_keys(keys_dir=keys_dir, host_ssh_dir=host_ssh_dir) if not ssh_result.success: @@ -373,31 +374,20 @@ def orchestrate_lab_start( error="Bind-mount pre-flight failed:\n" + "\n".join(mount_errors), ) - # Step 7: Pre-pull container images (non-critical) + # Step 7: Pre-pull container images via backend (non-critical) log.info("Step 7: Pre-pulling container images...") images = [ f"wazuh/wazuh-manager:{WAZUH_IMAGE_VERSION}", f"wazuh/wazuh-indexer:{WAZUH_IMAGE_VERSION}", f"wazuh/wazuh-dashboard:{WAZUH_IMAGE_VERSION}", ] - for image in images: - try: - pull_result = subprocess.run( - ["docker", "pull", image], - capture_output=True, - text=True, - cwd=project_dir, - ) - if pull_result.returncode != 0: - log.warning("Failed to pull %s: %s", image, pull_result.stderr.strip()) - else: - log.info("Pulled %s", image) - except (FileNotFoundError, OSError) as exc: - log.warning("Failed to pull %s: %s", image, exc) + pull_warnings = backend.pull_images(images) + for warning in pull_warnings: + log.warning(warning) - # Step 8: Start containers (retry once if SOC dependencies need more time) + # Step 8: Start containers via backend (retry once if SOC needs time) log.info("Step 8: Starting containers...") - start_result = start_lab(config, project_dir=project_dir) + start_result = start_lab(config, project_dir=project_dir, backend=backend) if not start_result.success and config.containers.soc: log.warning( "Initial compose up failed (SOC dependencies may still be " @@ -405,7 +395,7 @@ def orchestrate_lab_start( ) import time time.sleep(60) - start_result = start_lab(config, project_dir=project_dir) + start_result = start_lab(config, project_dir=project_dir, backend=backend) if not start_result.success: log.error("Lab start failed: %s", start_result.error) return LabResult( diff --git a/src/aptl/core/run_assembler.py b/src/aptl/core/run_assembler.py index 5d7fe3aa5..983f6143f 100644 --- a/src/aptl/core/run_assembler.py +++ b/src/aptl/core/run_assembler.py @@ -5,21 +5,19 @@ """ import os -from dataclasses import asdict from datetime import datetime, timezone from pathlib import Path from aptl.core.collectors import ( collect_container_logs, - collect_mcp_traces, 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.events import Event from aptl.core.runstore import LocalRunStore, RunManifest from aptl.core.scenarios import ScenarioDefinition from aptl.core.session import ActiveSession @@ -29,13 +27,6 @@ log = get_logger("run_assembler") -def _serialize_event(event: Event) -> dict: - """Convert an Event to a JSON-serializable dict.""" - d = asdict(event) - d["event_type"] = event.event_type.value - return d - - def _active_containers() -> list[str]: """List running Docker containers with the aptl- prefix.""" import subprocess @@ -60,7 +51,6 @@ def assemble_run( session: ActiveSession, scenario: ScenarioDefinition, scenario_path: Path, - events: list[Event], config: AptlConfig, ) -> Path: """Assemble a complete run directory after scenario stop. @@ -93,11 +83,7 @@ def assemble_run( if scenario_path.exists(): store.copy_file(run_id, "scenario/definition.yaml", scenario_path) - # 4. Write events - event_dicts = [_serialize_event(e) for e in events] - store.write_jsonl(run_id, "scenario/events.jsonl", event_dicts) - - # 5. Collect Wazuh alerts + # 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") @@ -108,12 +94,12 @@ def assemble_run( ) store.write_jsonl(run_id, "wazuh/alerts.jsonl", alerts) - # 7. Collect Suricata EVE + # 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) - # 8. Collect TheHive cases + # 6. Collect TheHive cases thehive_url = os.getenv("THEHIVE_URL", "http://localhost:9000") thehive_key = os.getenv("THEHIVE_API_KEY", "") cases = collect_thehive_cases( @@ -122,7 +108,7 @@ def assemble_run( if cases: store.write_json(run_id, "soc/thehive-cases.json", cases) - # 9. Collect MISP correlations + # 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( @@ -131,7 +117,7 @@ def assemble_run( if misp_events: store.write_json(run_id, "soc/misp-correlations.json", misp_events) - # 10. Collect Shuffle executions + # 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( @@ -140,7 +126,7 @@ def assemble_run( if shuffle_execs: store.write_json(run_id, "soc/shuffle-executions.json", shuffle_execs) - # 11. Collect container logs + # 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(): @@ -148,13 +134,14 @@ def assemble_run( run_id, f"containers/{name}.log", log_text.encode("utf-8") ) - # 12. Merge MCP traces - trace_dir = Path(os.getenv("APTL_TRACE_DIR", ".aptl/traces")) - traces = collect_mcp_traces(trace_dir, started_at, finished_at) - if traces: - store.write_jsonl(run_id, "agents/traces.jsonl", traces) + # 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) - # 13. Write manifest + # 11. Write manifest started_dt = datetime.fromisoformat(started_at) finished_dt = datetime.fromisoformat(finished_at) duration = (finished_dt - started_dt).total_seconds() @@ -166,6 +153,7 @@ def assemble_run( "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(), @@ -176,10 +164,10 @@ def assemble_run( store.write_json(run_id, "manifest.json", manifest) log.info( - "Run %s assembled: %d alerts, %d traces, %d container logs", + "Run %s assembled: %d alerts, %d spans, %d container logs", run_id, len(alerts), - len(traces), + len(spans), len(container_logs), ) return run_dir diff --git a/src/aptl/core/runstore.py b/src/aptl/core/runstore.py index be37f97ee..6603b73a6 100644 --- a/src/aptl/core/runstore.py +++ b/src/aptl/core/runstore.py @@ -24,6 +24,7 @@ class RunManifest(TypedDict): started_at: str finished_at: str duration_seconds: float + trace_id: str config_snapshot: dict containers: list[str] flags_captured: int diff --git a/src/aptl/core/session.py b/src/aptl/core/session.py index b6f30f3ce..1ec3b7ea2 100644 --- a/src/aptl/core/session.py +++ b/src/aptl/core/session.py @@ -6,7 +6,9 @@ session lifecycle events. """ +import fcntl import json +import os from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from enum import Enum @@ -38,7 +40,8 @@ class ActiveSession: scenario_id: ID of the active scenario. state: Current lifecycle state. started_at: ISO 8601 UTC timestamp of when the session started. - events_file: Relative path to the events JSONL file within .aptl/. + trace_id: 32-char hex trace ID for OpenTelemetry distributed tracing. + span_id: 16-char hex span ID for the scenario root span context. hints_used: Map of objective_id to highest hint level revealed. completed_objectives: List of objective IDs that have been completed. flags: CTF flags captured at scenario start, keyed by container name. @@ -47,7 +50,8 @@ class ActiveSession: scenario_id: str state: SessionState started_at: str - events_file: str + trace_id: str = "" + span_id: str = "" hints_used: dict[str, int] = field(default_factory=dict) completed_objectives: list[str] = field(default_factory=list) flags: dict[str, dict[str, dict]] = field(default_factory=dict) @@ -85,7 +89,8 @@ def _deserialize_session(data: dict) -> ActiveSession: scenario_id=data["scenario_id"], state=SessionState(data["state"]), started_at=data["started_at"], - events_file=data["events_file"], + trace_id=data.get("trace_id", ""), + span_id=data.get("span_id", ""), hints_used=data.get("hints_used", {}), completed_objectives=data.get("completed_objectives", []), flags=data.get("flags", {}), @@ -147,7 +152,9 @@ def get_active(self) -> Optional[ActiveSession]: if not self._session_path.exists(): return None - raw = self._session_path.read_text().strip() + with open(self._session_path, "r", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_SH) + raw = f.read().strip() if not raw: return None @@ -173,15 +180,14 @@ def get_active(self) -> Optional[ActiveSession]: def start( self, scenario: ScenarioDefinition, - events_file: Path, ) -> ActiveSession: """Start a new scenario session. Creates the session file and returns the new session. + Generates a trace_id for OpenTelemetry distributed tracing. Args: scenario: The scenario being started. - events_file: Path to the events JSONL file. Returns: The newly created ActiveSession. @@ -197,17 +203,16 @@ def start( "Stop it first with 'aptl scenario stop'." ) - # Use relative path from state_dir for portability - try: - relative_events = events_file.relative_to(self._state_dir) - except ValueError: - relative_events = events_file + from aptl.core.telemetry import generate_trace_context + + ctx = generate_trace_context() session = ActiveSession( scenario_id=scenario.metadata.id, state=SessionState.ACTIVE, started_at=datetime.now(timezone.utc).isoformat(), - events_file=str(relative_events), + trace_id=ctx["trace_id"], + span_id=ctx["span_id"], ) self._write(session) @@ -316,14 +321,20 @@ def _write(self, session: ActiveSession) -> None: """Persist session state to disk. Creates the state directory and parent directories if needed. + Uses an exclusive file lock to prevent concurrent write corruption. Args: session: The session to persist. """ self._state_dir.mkdir(parents=True, exist_ok=True) data = _serialize_session(session) - self._session_path.write_text( - json.dumps(data, indent=2) + "\n", - encoding="utf-8", + payload = json.dumps(data, indent=2) + "\n" + fd = os.open( + str(self._session_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC ) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + os.write(fd, payload.encode("utf-8")) + finally: + os.close(fd) # releases lock log.debug("Wrote session to %s", self._session_path) diff --git a/src/aptl/core/telemetry.py b/src/aptl/core/telemetry.py new file mode 100644 index 000000000..ba47b3b07 --- /dev/null +++ b/src/aptl/core/telemetry.py @@ -0,0 +1,273 @@ +"""OpenTelemetry tracing for APTL. + +Provides the single tracing path for both scenario lifecycle events +(from the Python CLI) and MCP tool calls (from TypeScript servers, +via shared trace context). Replaces the former EventLog and ToolTracer +JSONL systems. + +Span hierarchy: + [Scenario Run] aptl.scenario.run (root, backdated at stop) + +-- [Precondition] aptl.precondition (child) + +-- [Objective] aptl.objective (child) + +-- [Alert Match] (span event on root) + +-- [Hint Request] (span event on root) + +-- [Evaluation] aptl.evaluation (child) + +-- [Tool Call] execute_tool (child from MCP server) +""" + +import json +import os +import secrets +from pathlib import Path + +from opentelemetry import trace +from opentelemetry.context import Context +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + SpanKind, + StatusCode, + TraceFlags, +) + +from aptl.utils.logging import get_logger + +log = get_logger("telemetry") + +# --------------------------------------------------------------------------- +# Span name constants (replacing EventType enum) +# --------------------------------------------------------------------------- + +SPAN_SCENARIO_RUN = "aptl.scenario.run" +SPAN_PRECONDITION = "aptl.precondition" +SPAN_OBJECTIVE = "aptl.objective" +SPAN_EVALUATION = "aptl.evaluation" +EVENT_ALERT_MATCHED = "alert_matched" +EVENT_HINT_REQUESTED = "hint_requested" + +# Module-level provider reference for shutdown +_provider: TracerProvider | None = None + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +def init_tracing(service_name: str = "aptl-cli") -> None: + """Configure OTel TracerProvider with OTLP HTTP exporter. + + Safe to call multiple times — subsequent calls are no-ops if + already initialized. If the Collector is unreachable, + BatchSpanProcessor silently drops spans (not fatal). + """ + global _provider + if _provider is not None: + return + + endpoint = os.getenv( + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318" + ) + + resource = Resource.create({"service.name": service_name}) + _provider = TracerProvider(resource=resource) + + exporter = OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces") + _provider.add_span_processor(BatchSpanProcessor(exporter)) + trace.set_tracer_provider(_provider) + + log.debug("OTel tracing initialized: service=%s endpoint=%s", service_name, endpoint) + + +def shutdown_tracing() -> None: + """Force-flush and shutdown the TracerProvider.""" + global _provider + if _provider is not None: + _provider.force_flush() + _provider.shutdown() + _provider = None + log.debug("OTel tracing shut down") + + +def get_tracer(name: str = "aptl") -> trace.Tracer: + """Return a Tracer from the current provider.""" + return trace.get_tracer(name) + + +# --------------------------------------------------------------------------- +# Cross-process trace context +# --------------------------------------------------------------------------- + +_TRACE_CONTEXT_FILENAME = "trace-context.json" + + +def generate_trace_context() -> dict: + """Generate a fresh trace_id and span_id for a new scenario run. + + Returns: + Dict with ``trace_id``, ``span_id``, and ``trace_flags`` as + zero-padded hex strings. + """ + trace_id = secrets.token_hex(16) # 32 hex chars = 128-bit + span_id = secrets.token_hex(8) # 16 hex chars = 64-bit + return { + "trace_id": trace_id, + "span_id": span_id, + "trace_flags": "01", # sampled + } + + +def write_trace_context(path: Path, trace_id: str, span_id: str) -> None: + """Write trace context to a JSON file for MCP servers to read. + + Args: + path: Directory to write ``trace-context.json`` into. + trace_id: 32-char hex trace ID. + span_id: 16-char hex span ID. + """ + path.mkdir(parents=True, exist_ok=True) + ctx_file = path / _TRACE_CONTEXT_FILENAME + ctx_file.write_text( + json.dumps( + {"trace_id": trace_id, "span_id": span_id, "trace_flags": "01"}, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + log.debug("Wrote trace context to %s", ctx_file) + + +def load_trace_context(path: Path) -> SpanContext | None: + """Load trace context from the JSON file written by ``scenario start``. + + Args: + path: Directory containing ``trace-context.json``. + + Returns: + An OTel SpanContext, or None if the file doesn't exist or is invalid. + """ + ctx_file = path / _TRACE_CONTEXT_FILENAME + if not ctx_file.exists(): + return None + + try: + data = json.loads(ctx_file.read_text(encoding="utf-8")) + return SpanContext( + trace_id=int(data["trace_id"], 16), + span_id=int(data["span_id"], 16), + is_remote=True, + trace_flags=TraceFlags(int(data.get("trace_flags", "01"), 16)), + ) + except (json.JSONDecodeError, KeyError, ValueError) as exc: + log.warning("Could not load trace context from %s: %s", ctx_file, exc) + return None + + +def make_parent_context(trace_id: str, span_id: str) -> Context: + """Build an OTel Context with a remote parent span. + + Useful for CLI commands that need to create child spans under + the scenario root (which lives as trace context, not as an + active span in this process). + """ + parent_span_context = SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + is_remote=True, + trace_flags=TraceFlags(0x01), + ) + parent_span = NonRecordingSpan(parent_span_context) + return trace.set_span_in_context(parent_span) + + +# --------------------------------------------------------------------------- +# Scenario spans +# --------------------------------------------------------------------------- + + +def create_root_span( + tracer: trace.Tracer, + scenario_id: str, + run_id: str, + start_time: int, + end_time: int, + trace_id: str, + span_id: str, +) -> None: + """Create and immediately end the synthetic root span for a scenario run. + + Called at ``scenario stop`` with backdated timestamps covering the + full scenario duration. Uses the trace context generated at start. + + Args: + tracer: OTel Tracer instance. + scenario_id: Scenario identifier. + run_id: Unique run identifier. + start_time: Nanosecond epoch for span start. + end_time: Nanosecond epoch for span end. + trace_id: 32-char hex trace ID from session. + span_id: 16-char hex span ID from session. + """ + parent_ctx = make_parent_context(trace_id, span_id) + span = tracer.start_span( + name=SPAN_SCENARIO_RUN, + context=parent_ctx, + kind=SpanKind.INTERNAL, + attributes={ + "aptl.scenario.id": scenario_id, + "aptl.run.id": run_id, + }, + start_time=start_time, + ) + span.set_status(StatusCode.OK) + span.end(end_time=end_time) + log.debug("Created root span for scenario %s run %s", scenario_id, run_id) + + +def record_event( + tracer: trace.Tracer, + parent_ctx: Context, + event_name: str, + attributes: dict | None = None, +) -> None: + """Record a span event on a lightweight child span. + + Used for alert matches, hint requests, and other point-in-time + events that don't have meaningful duration. + """ + span = tracer.start_span( + name=event_name, + context=parent_ctx, + kind=SpanKind.INTERNAL, + ) + if attributes: + span.set_attributes(attributes) + span.set_status(StatusCode.OK) + span.end() + + +def create_child_span( + tracer: trace.Tracer, + parent_ctx: Context, + name: str, + attributes: dict | None = None, +) -> trace.Span: + """Start a child span under the given parent context. + + The caller is responsible for ending the span. + + Returns: + The started Span (still open). + """ + span = tracer.start_span( + name=name, + context=parent_ctx, + kind=SpanKind.INTERNAL, + attributes=attributes or {}, + ) + return span diff --git a/tests/helpers.py b/tests/helpers.py index 7f5ded5db..cb3c1da52 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -82,7 +82,8 @@ def _find_node() -> str: candidate = os.path.join(nvm_bin, v, "bin", "node") if os.path.isfile(candidate): return candidate - return "node" + import shutil + return shutil.which("node") or "node" # Custom Node.js MCP servers (built from source in mcp/) diff --git a/tests/test_api_config.py b/tests/test_api_config.py new file mode 100644 index 000000000..7ae515aa0 --- /dev/null +++ b/tests/test_api_config.py @@ -0,0 +1,95 @@ +"""Tests for config API endpoints.""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +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 + 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() + + +class TestConfigEndpoint: + def test_returns_config(self, api_client, tmp_path): + config = { + "lab": {"name": "test-lab", "network_subnet": "172.20.0.0/16"}, + "containers": { + "wazuh": True, + "victim": True, + "kali": True, + "reverse": False, + }, + } + (tmp_path / "aptl.json").write_text(json.dumps(config)) + + response = api_client.get("/api/config") + + assert response.status_code == 200 + data = response.json() + assert data["lab_name"] == "test-lab" + assert data["network_subnet"] == "172.20.0.0/16" + assert data["containers"]["wazuh"] is True + assert data["containers"]["victim"] is True + + def test_returns_defaults_when_no_config(self, api_client): + response = api_client.get("/api/config") + + assert response.status_code == 200 + data = response.json() + assert data["lab_name"] == "aptl" + assert data["run_storage_backend"] == "local" + + def test_dynamic_container_list(self, api_client): + """Config endpoint returns all ContainerSettings fields dynamically.""" + from aptl.core.config import ContainerSettings + + response = api_client.get("/api/config") + + assert response.status_code == 200 + data = response.json() + # Every field in ContainerSettings should appear in the response + for field_name in ContainerSettings.model_fields: + assert field_name in data["containers"], f"Missing container: {field_name}" + + +class TestGetProjectDir: + """Test the get_project_dir dependency directly.""" + + def test_returns_env_var_when_set(self, tmp_path): + from aptl.api.deps import get_project_dir + + with patch.dict(os.environ, {"APTL_PROJECT_DIR": str(tmp_path)}): + result = get_project_dir() + assert result == tmp_path + + def test_returns_cwd_when_env_not_set(self): + from aptl.api.deps import get_project_dir + + with patch.dict(os.environ, {}, clear=True): + # Remove APTL_PROJECT_DIR if present + os.environ.pop("APTL_PROJECT_DIR", None) + result = get_project_dir() + assert result == Path.cwd() + + def test_raises_503_for_nonexistent_dir(self): + from aptl.api.deps import get_project_dir + from fastapi import HTTPException + + with patch.dict(os.environ, {"APTL_PROJECT_DIR": "/no/such/path"}): + with pytest.raises(HTTPException) as exc_info: + get_project_dir() + assert exc_info.value.status_code == 503 diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py new file mode 100644 index 000000000..d896890b0 --- /dev/null +++ b/tests/test_api_integration.py @@ -0,0 +1,210 @@ +"""Integration tests for the APTL API. + +Unlike test_api_*.py which mock core business logic entirely, these +tests exercise the full request path through FastAPI -> routers -> core +logic, mocking only external I/O (subprocess calls to Docker, SSH). +""" + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("fastapi", reason="Web dependencies not installed") + + +@pytest.fixture +def integration_client(tmp_path): + """TestClient wired to real core logic with DI override for project_dir.""" + from aptl.api.deps import get_project_dir + from aptl.api.main import app + from starlette.testclient import TestClient + + # Write minimal aptl.json so config loader succeeds. + (tmp_path / "aptl.json").write_text( + json.dumps( + { + "lab": {"name": "test-lab"}, + "containers": { + "wazuh": True, + "victim": True, + "kali": False, + "reverse": False, + }, + } + ) + ) + + app.dependency_overrides[get_project_dir] = lambda: tmp_path + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Lab Status Integration (API -> routers/lab.py -> core/lab.py -> docker_compose.py -> subprocess) +# --------------------------------------------------------------------------- + + +class TestLabStatusIntegration: + """Lab status endpoint with real core logic, mocked subprocess.""" + + @patch("aptl.core.deployment.docker_compose.subprocess.run") + def test_parses_json_array(self, mock_run, integration_client): + """Status endpoint correctly parses JSON array from docker compose ps.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps( + [ + { + "Name": "aptl-victim", + "State": "running", + "Health": "healthy", + "Image": "victim:latest", + } + ] + ), + stderr="", + ) + + resp = integration_client.get("/api/lab/status") + + assert resp.status_code == 200 + data = resp.json() + assert data["running"] is True + assert len(data["containers"]) == 1 + assert data["containers"][0]["name"] == "aptl-victim" + assert data["containers"][0]["state"] == "running" + + @patch("aptl.core.deployment.docker_compose.subprocess.run") + def test_parses_ndjson(self, mock_run, integration_client): + """Status endpoint handles NDJSON (one JSON object per line).""" + ndjson = ( + '{"Name":"aptl-victim","State":"running","Health":"healthy"}\n' + '{"Name":"aptl-kali","State":"running","Health":""}\n' + ) + mock_run.return_value = MagicMock( + returncode=0, stdout=ndjson, stderr="" + ) + + resp = integration_client.get("/api/lab/status") + + assert resp.status_code == 200 + data = resp.json() + assert data["running"] is True + assert len(data["containers"]) == 2 + + @patch("aptl.core.deployment.docker_compose.subprocess.run") + def test_docker_failure_returns_not_running(self, mock_run, integration_client): + """Status returns running=False when docker compose fails.""" + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="docker not found" + ) + + resp = integration_client.get("/api/lab/status") + + data = resp.json() + assert data["running"] is False + assert data["error"] is not None + + +# --------------------------------------------------------------------------- +# Kill Integration (API -> routers/kill.py -> core/kill.py -> OS/subprocess) +# --------------------------------------------------------------------------- + + +class TestKillIntegration: + """Kill endpoint with real execute_kill orchestration.""" + + @patch("aptl.core.kill.find_mcp_processes") + @patch("aptl.core.kill.os.kill") + @patch("aptl.core.kill._process_exited", return_value=True) + def test_kill_exercises_real_orchestration( + self, mock_exited, mock_kill, mock_find, integration_client + ): + """Kill endpoint runs through the real execute_kill flow.""" + mock_find.return_value = [ + { + "pid": 999, + "cmdline": "node mcp-wazuh/build/index.js", + "name": "mcp-wazuh", + } + ] + + resp = integration_client.post("/api/lab/kill") + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["mcp_processes_killed"] == 1 + + @patch("aptl.core.kill.find_mcp_processes", return_value=[]) + def test_kill_no_processes(self, mock_find, integration_client): + """Kill endpoint succeeds when no MCP processes are running.""" + resp = integration_client.post("/api/lab/kill") + + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["mcp_processes_killed"] == 0 + + +# --------------------------------------------------------------------------- +# Terminal Origin Integration +# --------------------------------------------------------------------------- + + +class TestTerminalOriginIntegration: + """WebSocket origin validation with real router logic (no mock on origin check).""" + + def test_unknown_container_with_valid_origin(self, integration_client): + """Unknown container is rejected even with a valid origin.""" + with pytest.raises(Exception): + with integration_client.websocket_connect( + "/api/terminal/ws/nonexistent", + headers={"origin": "http://localhost:3000"}, + ) as ws: + ws.receive_json() + + +# --------------------------------------------------------------------------- +# CORS env var override +# --------------------------------------------------------------------------- + + +class TestCorsEnvOverride: + """Verify that APTL_ALLOWED_ORIGINS env var parsing logic works. + + Tests the parsing logic directly to avoid module reload side effects + that could break other tests in the suite. + """ + + def test_custom_origins_parsed(self): + """Comma-separated origins are parsed into a set.""" + env_val = "http://custom:9000,http://other:8080" + result = {o.strip() for o in env_val.split(",") if o.strip()} + assert result == {"http://custom:9000", "http://other:8080"} + + def test_empty_env_falls_back_to_defaults(self): + """Empty string produces empty set, triggering default fallback.""" + env_val = "" + result = {o.strip() for o in env_val.split(",") if o.strip()} + # Empty set is falsy, so `result or defaults` returns defaults. + defaults = {"http://localhost:3000", "http://localhost:5173"} + actual = result or defaults + assert actual == defaults + + def test_whitespace_trimmed(self): + """Leading/trailing whitespace in origins is stripped.""" + env_val = " http://a:1 , http://b:2 " + result = {o.strip() for o in env_val.split(",") if o.strip()} + assert result == {"http://a:1", "http://b:2"} + + def test_default_origins_are_set(self): + """Without env override, default origins are present.""" + from aptl.api.deps import ALLOWED_ORIGINS + + assert "http://localhost:3000" in ALLOWED_ORIGINS + assert "http://localhost:5173" in ALLOWED_ORIGINS diff --git a/tests/test_api_kill.py b/tests/test_api_kill.py new file mode 100644 index 000000000..b03648811 --- /dev/null +++ b/tests/test_api_kill.py @@ -0,0 +1,101 @@ +"""Tests for the kill switch API endpoint.""" + +from unittest.mock import patch + +import pytest + +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 + 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() + + +class TestKillEndpoint: + """Tests for POST /api/lab/kill.""" + + @patch("aptl.api.routers.kill.execute_kill") + def test_kill_success(self, mock_execute, api_client): + from aptl.core.kill import KillResult + + mock_execute.return_value = KillResult( + success=True, + mcp_processes_killed=2, + session_cleared=True, + ) + + response = api_client.post("/api/lab/kill") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["mcp_processes_killed"] == 2 + assert data["session_cleared"] is True + + @patch("aptl.api.routers.kill.execute_kill") + def test_kill_with_containers(self, mock_execute, api_client): + from aptl.core.kill import KillResult + + mock_execute.return_value = KillResult( + success=True, + mcp_processes_killed=1, + containers_stopped=True, + ) + + response = api_client.post("/api/lab/kill?containers=true") + + assert response.status_code == 200 + data = response.json() + assert data["containers_stopped"] is True + mock_execute.assert_called_once() + assert mock_execute.call_args.kwargs["containers"] is True + + @patch("aptl.api.routers.kill.execute_kill") + def test_kill_failure(self, mock_execute, api_client): + from aptl.core.kill import KillResult + + mock_execute.return_value = KillResult( + success=False, + errors=["Permission denied", "Docker not running"], + ) + + response = api_client.post("/api/lab/kill") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert len(data["errors"]) == 2 + + @patch("aptl.api.routers.kill.execute_kill") + def test_kill_returns_process_count(self, mock_execute, api_client): + from aptl.core.kill import KillResult + + mock_execute.return_value = KillResult( + success=True, + mcp_processes_killed=5, + ) + + response = api_client.post("/api/lab/kill") + + assert response.status_code == 200 + assert response.json()["mcp_processes_killed"] == 5 + + @patch("aptl.api.routers.kill.execute_kill") + def test_kill_default_no_containers(self, mock_execute, api_client): + from aptl.core.kill import KillResult + + mock_execute.return_value = KillResult(success=True) + + response = api_client.post("/api/lab/kill") + + assert response.status_code == 200 + assert mock_execute.call_args.kwargs["containers"] is False diff --git a/tests/test_api_lab.py b/tests/test_api_lab.py new file mode 100644 index 000000000..48333ac13 --- /dev/null +++ b/tests/test_api_lab.py @@ -0,0 +1,375 @@ +"""Tests for lab API endpoints.""" + +import asyncio +import json +from unittest.mock import MagicMock, patch + +import pytest + +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 + 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() + + +class TestHealthEndpoint: + def test_health_returns_ok(self, api_client): + response = api_client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +class TestLabStatus: + @patch("aptl.api.routers.lab.core_lab_status") + def test_returns_running_status(self, mock_status, api_client): + from aptl.core.lab import LabStatus + + mock_status.return_value = LabStatus( + running=True, + containers=[ + {"Name": "aptl-victim", "State": "running", "Health": "healthy", "Image": "victim:latest"} + ], + ) + + response = api_client.get("/api/lab/status") + + assert response.status_code == 200 + data = response.json() + assert data["running"] is True + assert len(data["containers"]) == 1 + assert data["containers"][0]["name"] == "aptl-victim" + + @patch("aptl.api.routers.lab.core_lab_status") + def test_returns_stopped_status(self, mock_status, api_client): + from aptl.core.lab import LabStatus + + mock_status.return_value = LabStatus(running=False, containers=[]) + + response = api_client.get("/api/lab/status") + + assert response.status_code == 200 + data = response.json() + assert data["running"] is False + assert data["containers"] == [] + + @patch("aptl.api.routers.lab.core_lab_status") + def test_returns_error(self, mock_status, api_client): + from aptl.core.lab import LabStatus + + mock_status.return_value = LabStatus( + running=False, error="docker not found" + ) + + response = api_client.get("/api/lab/status") + + assert response.status_code == 200 + data = response.json() + assert data["error"] == "docker not found" + + @patch("aptl.api.routers.lab.core_lab_status") + def test_error_field_is_null_when_no_error(self, mock_status, api_client): + from aptl.core.lab import LabStatus + + mock_status.return_value = LabStatus(running=True, containers=[]) + + response = api_client.get("/api/lab/status") + + data = response.json() + assert data["error"] is None + + +class TestLabStart: + @patch("aptl.api.routers.lab.orchestrate_lab_start") + def test_start_success(self, mock_start, api_client): + from aptl.core.lab import LabResult + + mock_start.return_value = LabResult(success=True, message="Lab started") + + response = api_client.post("/api/lab/start") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["message"] == "Lab started" + + @patch("aptl.api.routers.lab.orchestrate_lab_start") + def test_start_failure(self, mock_start, api_client): + from aptl.core.lab import LabResult + + mock_start.return_value = LabResult( + success=False, error="Missing .env" + ) + + response = api_client.post("/api/lab/start") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "Missing .env" in data["error"] + + @patch("aptl.api.routers.lab.orchestrate_lab_start") + def test_start_timeout(self, mock_start, api_client): + """Lab start returns error when orchestration exceeds timeout.""" + from aptl.core.lab import LabResult + + # Simulate a function that takes forever + import time + def slow_start(project_dir): + time.sleep(10) + return LabResult(success=True, message="done") + + mock_start.side_effect = slow_start + + # Patch the timeout to something small for the test + with patch("aptl.api.routers.lab.asyncio.wait_for", side_effect=asyncio.TimeoutError): + response = api_client.post("/api/lab/start") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "timed out" in data["error"] + + +class TestLabStop: + @patch("aptl.api.routers.lab.core_stop_lab") + def test_stop_success(self, mock_stop, api_client): + from aptl.core.lab import LabResult + + mock_stop.return_value = LabResult(success=True, message="Lab stopped") + + response = api_client.post("/api/lab/stop") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + @patch("aptl.api.routers.lab.core_stop_lab") + def test_stop_failure(self, mock_stop, api_client): + from aptl.core.lab import LabResult + + mock_stop.return_value = LabResult( + success=False, error="compose down failed" + ) + + response = api_client.post("/api/lab/stop") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + + +class TestProjectDirValidation: + def test_nonexistent_project_dir_returns_503(self): + """When APTL_PROJECT_DIR points to nonexistent dir, API returns 503.""" + from aptl.api.deps import get_project_dir + from aptl.api.main import app + from starlette.testclient import TestClient + from pathlib import Path + + app.dependency_overrides[get_project_dir] = lambda: (_ for _ in ()).throw( + __import__("fastapi").HTTPException( + status_code=503, + detail="Project directory does not exist: /nonexistent", + ) + ) + try: + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/api/lab/status") + assert response.status_code == 503 + finally: + app.dependency_overrides.clear() + + def test_nonexistent_project_dir_via_env(self): + """get_project_dir raises 503 for nonexistent env path.""" + from aptl.api.deps import get_project_dir + from unittest.mock import patch as _patch + import os + + with _patch.dict(os.environ, {"APTL_PROJECT_DIR": "/no/such/path"}): + with pytest.raises(Exception) as exc_info: + get_project_dir() + assert "503" in str(exc_info.value.status_code) + + +async def _noop_sleep(_seconds): + """Instant replacement for asyncio.sleep in tests.""" + + +class TestLabEventGenerator: + """Test the SSE generator directly (avoids HTTP streaming hangs).""" + + @patch("aptl.api.routers.lab.asyncio.sleep", _noop_sleep) + @patch("aptl.api.routers.lab._build_status_response") + def test_emits_initial_status(self, mock_build, tmp_path): + from aptl.api.routers.lab import _lab_event_generator + from aptl.api.schemas import LabStatusResponse + + mock_build.return_value = LabStatusResponse(running=False, containers=[]) + + async def run(): + gen = _lab_event_generator(tmp_path) + event = await gen.__anext__() + await gen.aclose() + return event + + event = asyncio.run(run()) + assert event["event"] == "lab_status" + data = json.loads(event["data"]) + assert data["running"] is False + + @patch("aptl.api.routers.lab.asyncio.sleep", _noop_sleep) + @patch("aptl.api.routers.lab._build_status_response") + def test_emits_on_state_change(self, mock_build, tmp_path): + from aptl.api.routers.lab import _lab_event_generator + from aptl.api.schemas import ContainerInfo, LabStatusResponse + + call_count = 0 + + def changing_status(project_dir): + nonlocal call_count + call_count += 1 + if call_count <= 1: + return LabStatusResponse(running=False, containers=[]) + return LabStatusResponse( + running=True, + containers=[ContainerInfo(name="aptl-victim", state="running")], + ) + + mock_build.side_effect = changing_status + + async def run(): + gen = _lab_event_generator(tmp_path) + first = await gen.__anext__() + second = await gen.__anext__() + await gen.aclose() + return first, second + + first, second = asyncio.run(run()) + assert json.loads(first["data"])["running"] is False + assert json.loads(second["data"])["running"] is True + assert json.loads(second["data"])["containers"][0]["name"] == "aptl-victim" + + @patch("aptl.api.routers.lab.asyncio.sleep", _noop_sleep) + @patch("aptl.api.routers.lab._build_status_response") + def test_emits_error_event(self, mock_build, tmp_path): + from aptl.api.routers.lab import _lab_event_generator + + mock_build.side_effect = RuntimeError("docker daemon not running") + + async def run(): + gen = _lab_event_generator(tmp_path) + event = await gen.__anext__() + await gen.aclose() + return event + + event = asyncio.run(run()) + assert event["event"] == "error" + assert "docker daemon not running" in event["data"] + + @patch("aptl.api.routers.lab.asyncio.sleep", _noop_sleep) + @patch("aptl.api.routers.lab._build_status_response") + def test_skips_duplicate_status(self, mock_build, tmp_path): + from aptl.api.routers.lab import _lab_event_generator + from aptl.api.schemas import LabStatusResponse + + # Same status every time — only first poll should emit + mock_build.return_value = LabStatusResponse(running=False, containers=[]) + + async def run(): + gen = _lab_event_generator(tmp_path) + event1 = await gen.__anext__() + # Second __anext__ should NOT yield because state didn't change. + # It will loop and sleep forever, so we time it out. + got_second = False + try: + await asyncio.wait_for(gen.__anext__(), timeout=0.05) + got_second = True + except asyncio.TimeoutError: + pass + await gen.aclose() + return got_second + + got_second = asyncio.run(run()) + assert got_second is False + + @patch("aptl.api.routers.lab.asyncio.sleep", _noop_sleep) + @patch("aptl.api.routers.lab._build_status_response") + def test_circuit_breaker_terminates_after_max_errors(self, mock_build, tmp_path): + """SSE generator terminates after MAX_CONSECUTIVE_ERRORS.""" + from aptl.api.routers.lab import _lab_event_generator, MAX_CONSECUTIVE_ERRORS + + mock_build.side_effect = RuntimeError("persistent failure") + + async def run(): + gen = _lab_event_generator(tmp_path) + events = [] + async for event in gen: + events.append(event) + return events + + events = asyncio.run(run()) + assert len(events) == MAX_CONSECUTIVE_ERRORS + assert all(e["event"] == "error" for e in events) + + def test_lab_events_endpoint_returns_sse(self, tmp_path): + """Verify the endpoint function returns an EventSourceResponse.""" + from aptl.api.routers.lab import lab_events + from aptl.api.deps import get_project_dir + from sse_starlette.sse import EventSourceResponse + + # Call with project_dir directly (simulating Depends injection) + result = asyncio.run(lab_events(project_dir=tmp_path)) + assert isinstance(result, EventSourceResponse) + + +class TestContainerInfoPortParsing: + def test_string_ports(self): + from aptl.api.schemas import ContainerInfo + + info = ContainerInfo.from_compose_dict({ + "Name": "web", + "Ports": "0.0.0.0:443->443/tcp, 0.0.0.0:80->80/tcp", + }) + assert info.ports == ["0.0.0.0:443->443/tcp", "0.0.0.0:80->80/tcp"] + + def test_publisher_dict_ports(self): + from aptl.api.schemas import ContainerInfo + + info = ContainerInfo.from_compose_dict({ + "Name": "web", + "Publishers": [ + {"URL": "0.0.0.0", "TargetPort": 443, "PublishedPort": 8443, "Protocol": "tcp"}, + {"URL": "", "TargetPort": 80, "PublishedPort": 0, "Protocol": "tcp"}, + ], + }) + assert "0.0.0.0:8443->443/tcp" in info.ports + # PublishedPort=0 means not published + assert len(info.ports) == 1 + + def test_list_of_string_ports(self): + from aptl.api.schemas import ContainerInfo + + info = ContainerInfo.from_compose_dict({ + "Name": "web", + "Ports": ["443/tcp", "80/tcp"], + }) + assert info.ports == ["443/tcp", "80/tcp"] + + def test_non_list_non_string_ports(self): + from aptl.api.schemas import ContainerInfo + + info = ContainerInfo.from_compose_dict({ + "Name": "web", + "Ports": 12345, + }) + assert info.ports == [] diff --git a/tests/test_api_scenarios.py b/tests/test_api_scenarios.py new file mode 100644 index 000000000..bcff76aeb --- /dev/null +++ b/tests/test_api_scenarios.py @@ -0,0 +1,172 @@ +"""Tests for scenario API endpoints.""" + +from pathlib import Path +from unittest.mock import patch + +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 + 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 + + +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 + + def test_returns_scenario_details(self, api_client, scenarios_dir): + 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") + + assert response.status_code == 404 diff --git a/tests/test_api_terminal.py b/tests/test_api_terminal.py new file mode 100644 index 000000000..7c362f9c4 --- /dev/null +++ b/tests/test_api_terminal.py @@ -0,0 +1,230 @@ +"""Tests for terminal WebSocket API endpoint.""" + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytest.importorskip("fastapi", reason="Web dependencies not installed") +pytest.importorskip("asyncssh", reason="asyncssh 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 + 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() + + +def _make_lab_status(running: bool): + """Create a mock LabStatus.""" + from aptl.core.lab import LabStatus + return LabStatus(running=running, containers=[]) + + +def _make_mock_process(): + """Create a mock asyncssh process with stdout/stdin.""" + process = MagicMock() + + # stdout is an async iterator that yields data then EOF + stdout = AsyncMock() + stdout.read = AsyncMock(side_effect=["hello from container", ""]) + process.stdout = stdout + + # stdin + stdin = MagicMock() + stdin.write = MagicMock() + process.stdin = stdin + + # resize + process.change_terminal_size = MagicMock() + + return process + + +def _make_mock_conn(process): + """Create a mock asyncssh connection.""" + conn = AsyncMock() + conn.create_process = AsyncMock(return_value=process) + conn.close = MagicMock() + return conn + + +_VALID_ORIGIN = {"origin": "http://localhost:3000"} + + +class TestTerminalWebSocket: + def test_cross_origin_rejected(self, api_client): + """Cross-origin WebSocket connections are rejected before accept.""" + with pytest.raises(Exception): + with api_client.websocket_connect( + "/api/terminal/ws/victim", + headers={"origin": "http://evil.com"}, + ) as ws: + ws.receive_json() + + @patch("aptl.api.routers.terminal.lab_status") + def test_allowed_origin_accepted(self, mock_status, api_client): + """Connections from allowed origins proceed normally.""" + mock_status.return_value = _make_lab_status(running=False) + + with api_client.websocket_connect( + "/api/terminal/ws/victim", + headers={"origin": "http://localhost:3000"}, + ) as ws: + msg = ws.receive_json() + assert msg["type"] == "error" + assert "not running" in msg["message"] + + def test_no_origin_header_rejected(self, api_client): + """Connections without an Origin header are rejected.""" + with pytest.raises(Exception): + with api_client.websocket_connect("/api/terminal/ws/victim") as ws: + ws.receive_json() + + @patch("aptl.api.routers.terminal.lab_status") + def test_invalid_container_rejected(self, mock_status, api_client): + """Unknown container name closes WebSocket with 1008.""" + mock_status.return_value = _make_lab_status(running=True) + + with pytest.raises(Exception): + with api_client.websocket_connect( + "/api/terminal/ws/nonexistent", headers=_VALID_ORIGIN + ) as ws: + # Should be closed by server + ws.receive_json() + + @patch("aptl.api.routers.terminal.lab_status") + def test_lab_not_running_rejected(self, mock_status, api_client): + """Lab not running sends error and closes WebSocket.""" + mock_status.return_value = _make_lab_status(running=False) + + with api_client.websocket_connect( + "/api/terminal/ws/victim", headers=_VALID_ORIGIN + ) as ws: + msg = ws.receive_json() + assert msg["type"] == "error" + assert "not running" in msg["message"] + + @patch("aptl.api.routers.terminal.asyncssh") + @patch("aptl.api.routers.terminal.lab_status") + def test_stdin_forwarded_to_ssh(self, mock_status, mock_asyncssh, api_client): + """stdin messages are forwarded to SSH process.""" + mock_status.return_value = _make_lab_status(running=True) + + process = _make_mock_process() + conn = _make_mock_conn(process) + mock_asyncssh.connect = AsyncMock(return_value=conn) + mock_asyncssh.Error = Exception + + # stdout.read returns empty immediately to end the ssh->ws relay + process.stdout.read = AsyncMock(return_value="") + + with api_client.websocket_connect( + "/api/terminal/ws/victim", headers=_VALID_ORIGIN + ) as ws: + ws.send_text(json.dumps({"type": "stdin", "data": "ls\n"})) + + # ws context exit closes the WebSocket, which unblocks the relay + # tasks and allows them to process queued messages before shutdown + process.stdin.write.assert_called_with("ls\n") + + @patch("aptl.api.routers.terminal.asyncssh") + @patch("aptl.api.routers.terminal.lab_status") + def test_stdout_forwarded_to_ws(self, mock_status, mock_asyncssh, api_client): + """SSH stdout is forwarded to WebSocket as stdout messages.""" + mock_status.return_value = _make_lab_status(running=True) + + process = _make_mock_process() + conn = _make_mock_conn(process) + mock_asyncssh.connect = AsyncMock(return_value=conn) + mock_asyncssh.Error = Exception + + # stdout returns data then empty + process.stdout.read = AsyncMock(side_effect=["$ prompt\n", ""]) + + with api_client.websocket_connect( + "/api/terminal/ws/victim", headers=_VALID_ORIGIN + ) as ws: + msg = ws.receive_json() + assert msg["type"] == "stdout" + assert "prompt" in msg["data"] + + @patch("aptl.api.routers.terminal.asyncssh") + @patch("aptl.api.routers.terminal.lab_status") + def test_resize_calls_change_terminal_size(self, mock_status, mock_asyncssh, api_client): + """resize messages call change_terminal_size on the SSH process.""" + mock_status.return_value = _make_lab_status(running=True) + + process = _make_mock_process() + conn = _make_mock_conn(process) + mock_asyncssh.connect = AsyncMock(return_value=conn) + mock_asyncssh.Error = Exception + + # stdout returns empty immediately + process.stdout.read = AsyncMock(return_value="") + + with api_client.websocket_connect( + "/api/terminal/ws/victim", headers=_VALID_ORIGIN + ) as ws: + ws.send_text(json.dumps({"type": "resize", "cols": 120, "rows": 40})) + + process.change_terminal_size.assert_called_with(120, 40) + + @patch("aptl.api.routers.terminal.asyncssh") + @patch("aptl.api.routers.terminal.lab_status") + def test_disconnect_closes_ssh(self, mock_status, mock_asyncssh, api_client): + """WebSocket disconnect triggers SSH connection cleanup.""" + mock_status.return_value = _make_lab_status(running=True) + + process = _make_mock_process() + conn = _make_mock_conn(process) + mock_asyncssh.connect = AsyncMock(return_value=conn) + mock_asyncssh.Error = Exception + + # stdout returns empty immediately to allow clean close + process.stdout.read = AsyncMock(return_value="") + + with api_client.websocket_connect( + "/api/terminal/ws/victim", headers=_VALID_ORIGIN + ) as ws: + pass # disconnect on exit + + conn.close.assert_called_once() + + @patch("aptl.api.routers.terminal.asyncssh") + @patch("aptl.api.routers.terminal.lab_status") + def test_ssh_error_sends_error_message(self, mock_status, mock_asyncssh, api_client): + """SSH connection failure sends error message to WebSocket.""" + mock_status.return_value = _make_lab_status(running=True) + + mock_asyncssh.connect = AsyncMock(side_effect=Exception("Connection refused")) + mock_asyncssh.Error = Exception + + with api_client.websocket_connect( + "/api/terminal/ws/victim", headers=_VALID_ORIGIN + ) as ws: + msg = ws.receive_json() + assert msg["type"] == "error" + assert msg["message"] == "SSH connection failed" + + @patch("aptl.api.routers.terminal.lab_status") + def test_valid_containers_accepted(self, mock_status, api_client): + """All valid container names are accepted (not rejected with 1008).""" + mock_status.return_value = _make_lab_status(running=False) + + for name in ["victim", "kali", "reverse", "workstation"]: + with api_client.websocket_connect( + f"/api/terminal/ws/{name}", headers=_VALID_ORIGIN + ) as ws: + msg = ws.receive_json() + # Should get "lab not running" error, not unknown container + assert msg["type"] == "error" + assert "not running" in msg["message"] diff --git a/tests/test_certs.py b/tests/test_certs.py index a47a38231..32d27e347 100644 --- a/tests/test_certs.py +++ b/tests/test_certs.py @@ -19,6 +19,7 @@ def test_skips_generation_when_certs_dir_exists(self, tmp_path, mocker): certs_dir = tmp_path / "config" / "wazuh_indexer_ssl_certs" certs_dir.mkdir(parents=True) + (certs_dir / "root-ca.pem").write_text("fake-cert") mock_run = mocker.patch("aptl.core.certs.subprocess.run") @@ -183,6 +184,7 @@ def test_returns_correct_cert_result_fields(self, tmp_path, mocker): certs_dir = tmp_path / "config" / "wazuh_indexer_ssl_certs" certs_dir.mkdir(parents=True) + (certs_dir / "root-ca.pem").write_text("fake-cert") result = ensure_ssl_certs(tmp_path) diff --git a/tests/test_cli_kill.py b/tests/test_cli_kill.py new file mode 100644 index 000000000..dda5a4e2f --- /dev/null +++ b/tests/test_cli_kill.py @@ -0,0 +1,178 @@ +"""Tests for the kill switch CLI command. + +Verifies command registration, argument handling, and output formatting. +Core kill logic is mocked. +""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestKillCommand: + """Tests for aptl kill.""" + + def test_kill_help_exists(self, runner): + """aptl kill --help should return 0.""" + from aptl.cli.main import app + + result = runner.invoke(app, ["kill", "--help"]) + assert result.exit_code == 0 + assert "kill" in result.stdout.lower() + + @patch("aptl.cli.kill.execute_kill") + def test_kill_calls_execute_kill(self, mock_execute, runner): + """aptl kill should call execute_kill with default args.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult(success=True, mcp_processes_killed=2) + + result = runner.invoke(app, ["kill"]) + + assert result.exit_code == 0 + mock_execute.assert_called_once() + call_kwargs = mock_execute.call_args + assert call_kwargs.kwargs.get("containers") is False or not call_kwargs[1].get("containers", True) + + @patch("aptl.cli.kill.execute_kill") + def test_kill_with_containers_flag(self, mock_execute, runner): + """aptl kill --containers should pass containers=True.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult( + success=True, mcp_processes_killed=1, containers_stopped=True, + ) + + result = runner.invoke(app, ["kill", "--containers"]) + + assert result.exit_code == 0 + mock_execute.assert_called_once() + assert mock_execute.call_args.kwargs["containers"] is True + + @patch("aptl.cli.kill.execute_kill") + def test_kill_short_flag(self, mock_execute, runner): + """aptl kill -c should work as short form for --containers.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult( + success=True, mcp_processes_killed=0, containers_stopped=True, + ) + + result = runner.invoke(app, ["kill", "-c"]) + + assert result.exit_code == 0 + assert mock_execute.call_args.kwargs["containers"] is True + + @patch("aptl.cli.kill.execute_kill") + def test_kill_no_confirmation_prompt(self, mock_execute, runner): + """Kill should not prompt for confirmation (emergency action).""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult( + success=True, mcp_processes_killed=0, containers_stopped=True, + ) + + # If there were a prompt, runner.invoke without input would fail + result = runner.invoke(app, ["kill", "--containers"]) + + assert result.exit_code == 0 + + @patch("aptl.cli.kill.execute_kill") + def test_kill_displays_process_count(self, mock_execute, runner): + """Output should report how many processes were killed.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult( + success=True, mcp_processes_killed=3, + ) + + result = runner.invoke(app, ["kill"]) + + assert "3" in result.stdout + assert "MCP" in result.stdout + + @patch("aptl.cli.kill.execute_kill") + def test_kill_displays_no_processes_found(self, mock_execute, runner): + """Output should report when no MCP processes were found.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult( + success=True, mcp_processes_killed=0, + ) + + result = runner.invoke(app, ["kill"]) + + assert "No MCP server processes found" in result.stdout + + @patch("aptl.cli.kill.execute_kill") + def test_kill_exit_0_on_success(self, mock_execute, runner): + """Exit code 0 when kill succeeds.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult(success=True) + + result = runner.invoke(app, ["kill"]) + + assert result.exit_code == 0 + + @patch("aptl.cli.kill.execute_kill") + def test_kill_exit_1_on_total_failure(self, mock_execute, runner): + """Exit code 1 when all kill steps fail.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult( + success=False, + errors=["everything failed"], + ) + + result = runner.invoke(app, ["kill"]) + + assert result.exit_code == 1 + + @patch("aptl.cli.kill.execute_kill") + def test_kill_shows_warnings(self, mock_execute, runner): + """Errors should be displayed as warnings.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult( + success=True, + mcp_processes_killed=1, + errors=["Permission denied killing mcp-red (pid=200)"], + ) + + result = runner.invoke(app, ["kill"]) + + assert result.exit_code == 0 + assert "Permission denied" in result.stdout + + @patch("aptl.cli.kill.execute_kill") + def test_kill_shows_session_cleared(self, mock_execute, runner): + """Output should report when a session was cleared.""" + from aptl.core.kill import KillResult + from aptl.cli.main import app + + mock_execute.return_value = KillResult( + success=True, + mcp_processes_killed=0, + session_cleared=True, + ) + + result = runner.invoke(app, ["kill"]) + + assert "session cleared" in result.stdout.lower() diff --git a/tests/test_cli_scenario.py b/tests/test_cli_scenario.py index 02e49d253..2b97aa41b 100644 --- a/tests/test_cli_scenario.py +++ b/tests/test_cli_scenario.py @@ -5,6 +5,8 @@ """ import json +import os +import shutil from datetime import datetime, timezone from pathlib import Path from unittest.mock import MagicMock, patch @@ -14,6 +16,7 @@ from typer.testing import CliRunner from aptl.cli.scenario import app +from tests.helpers import LIVE_LAB runner = CliRunner() @@ -259,16 +262,16 @@ def test_start_creates_session_file(self, tmp_path): assert data["scenario_id"] == "test-scenario" assert data["state"] == "active" - def test_start_creates_events_file(self, tmp_path): + 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), ]) - events_dir = project_dir / ".aptl" / "events" - assert events_dir.exists() - event_files = list(events_dir.glob("*.jsonl")) - assert len(event_files) == 1 + 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) @@ -466,6 +469,144 @@ def test_full_lifecycle(self, tmp_path): ]) 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 diff --git a/tests/test_cli_web.py b/tests/test_cli_web.py new file mode 100644 index 000000000..b22e5d8e8 --- /dev/null +++ b/tests/test_cli_web.py @@ -0,0 +1,67 @@ +"""Tests for the web serve CLI command.""" + +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +pytest.importorskip("uvicorn", reason="Web dependencies not installed") + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestWebServe: + @patch("uvicorn.run") + def test_serve_default_options(self, mock_run, runner): + from aptl.cli.web import app + + result = runner.invoke(app, ["--host", "0.0.0.0", "--port", "9000"]) + + assert result.exit_code == 0 + assert "Starting APTL web API on 0.0.0.0:9000" in result.output + mock_run.assert_called_once_with( + "aptl.api.main:app", + host="0.0.0.0", + port=9000, + reload=False, + workers=1, + log_level="info", + timeout_keep_alive=65, + access_log=True, + ) + + @patch("uvicorn.run") + def test_serve_with_project_dir(self, mock_run, runner): + import os + from aptl.cli.web import app + + result = runner.invoke(app, ["--project-dir", "/tmp/mylab"]) + + assert result.exit_code == 0 + mock_run.assert_called_once() + assert os.environ.get("APTL_PROJECT_DIR") == "/tmp/mylab" + # Cleanup + os.environ.pop("APTL_PROJECT_DIR", None) + + @patch("uvicorn.run") + def test_serve_with_workers(self, mock_run, runner): + from aptl.cli.web import app + + result = runner.invoke(app, ["--workers", "4"]) + + assert result.exit_code == 0 + call_kwargs = mock_run.call_args[1] + assert call_kwargs["workers"] == 4 + + @patch("uvicorn.run") + def test_serve_with_reload(self, mock_run, runner): + from aptl.cli.web import app + + result = runner.invoke(app, ["--reload"]) + + assert result.exit_code == 0 + call_kwargs = mock_run.call_args[1] + assert call_kwargs["reload"] is True diff --git a/tests/test_collectors.py b/tests/test_collectors.py index d349a999f..f3bfce5f7 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -9,81 +9,112 @@ from aptl.core.collectors import ( collect_container_logs, - collect_mcp_traces, collect_suricata_eve, collect_thehive_cases, collect_misp_events, collect_shuffle_executions, + collect_traces, collect_wazuh_alerts, ) -class TestCollectMCPTraces: - """Tests for MCP trace collection.""" +class TestCollectTraces: + """Tests for Tempo trace collection.""" - def test_collect_from_empty_dir(self, tmp_path): - traces = collect_mcp_traces( - tmp_path, "2020-01-01T00:00:00+00:00", "2030-01-01T00:00:00+00:00" - ) - assert traces == [] + @patch("aptl.core.collectors._curl_json") + def test_returns_spans_from_tempo(self, mock_curl): + mock_curl.return_value = { + "resourceSpans": [ + { + "resource": {"attributes": [{"key": "service.name", "value": {"stringValue": "aptl-cli"}}]}, + "scopeSpans": [ + { + "spans": [ + {"name": "aptl.scenario.run", "traceId": "abc123"}, + {"name": "execute_tool", "traceId": "abc123"}, + ] + } + ], + } + ] + } - def test_collect_nonexistent_dir(self, tmp_path): - traces = collect_mcp_traces( - tmp_path / "nope", "2020-01-01T00:00:00+00:00", "2030-01-01T00:00:00+00:00" - ) - assert traces == [] + spans = collect_traces("abc123def456" * 2 + "0" * 8) + assert len(spans) == 2 + assert spans[0]["name"] == "aptl.scenario.run" + assert spans[1]["name"] == "execute_tool" + # Resource should be attached to each span + assert "resource" in spans[0] - def test_collect_filters_by_time(self, tmp_path): - traces = [ - {"timestamp": "2025-01-01T10:00:00+00:00", "tool_name": "early"}, - {"timestamp": "2025-01-01T12:00:00+00:00", "tool_name": "in_window"}, - {"timestamp": "2025-01-01T14:00:00+00:00", "tool_name": "late"}, - ] - trace_file = tmp_path / "server.jsonl" - trace_file.write_text( - "\n".join(json.dumps(t) for t in traces) + "\n" - ) + @patch("aptl.core.collectors._curl_json") + def test_returns_empty_on_failure(self, mock_curl): + mock_curl.return_value = None + spans = collect_traces("abc123") + assert spans == [] - result = collect_mcp_traces( - tmp_path, - "2025-01-01T11:00:00+00:00", - "2025-01-01T13:00:00+00:00", - ) - assert len(result) == 1 - assert result[0]["tool_name"] == "in_window" + def test_returns_empty_without_trace_id(self): + spans = collect_traces("") + assert spans == [] - def test_collect_merges_multiple_files(self, tmp_path): - (tmp_path / "server-a.jsonl").write_text( - json.dumps({"timestamp": "2025-01-01T12:00:00+00:00", "server": "a"}) + "\n" - ) - (tmp_path / "server-b.jsonl").write_text( - json.dumps({"timestamp": "2025-01-01T11:00:00+00:00", "server": "b"}) + "\n" - ) + @patch("aptl.core.collectors._curl_json") + def test_handles_empty_response(self, mock_curl): + mock_curl.return_value = {} + spans = collect_traces("abc123") + assert spans == [] - result = collect_mcp_traces( - tmp_path, - "2025-01-01T10:00:00+00:00", - "2025-01-01T13:00:00+00:00", - ) - assert len(result) == 2 - # Should be sorted chronologically - assert result[0]["server"] == "b" - assert result[1]["server"] == "a" - - def test_collect_skips_invalid_json(self, tmp_path): - (tmp_path / "bad.jsonl").write_text( - "not json\n" - + json.dumps({"timestamp": "2025-01-01T12:00:00+00:00", "ok": True}) - + "\n" - ) + @patch("aptl.core.collectors._curl_json") + def test_handles_batches_format(self, mock_curl): + """Tempo v2 uses 'batches' key.""" + mock_curl.return_value = { + "batches": [ + { + "resource": {}, + "scopeSpans": [ + {"spans": [{"name": "test-span"}]} + ], + } + ] + } + spans = collect_traces("abc123") + assert len(spans) == 1 + assert spans[0]["name"] == "test-span" - result = collect_mcp_traces( - tmp_path, - "2025-01-01T10:00:00+00:00", - "2025-01-01T13:00:00+00:00", - ) - assert len(result) == 1 - assert result[0]["ok"] is True + @patch("aptl.core.collectors._curl_json") + def test_uses_tempo_url_env(self, mock_curl, monkeypatch): + mock_curl.return_value = {"resourceSpans": []} + monkeypatch.setenv("TEMPO_URL", "http://custom:9999") + + collect_traces("abc123") + + called_url = mock_curl.call_args[0][0] + assert called_url.startswith("http://custom:9999/api/traces/") + + @patch("aptl.core.collectors._curl_json") + def test_uses_explicit_tempo_url(self, mock_curl): + mock_curl.return_value = {"resourceSpans": []} + + collect_traces("abc123", tempo_url="http://explicit:7777") + + called_url = mock_curl.call_args[0][0] + assert called_url == "http://explicit:7777/api/traces/abc123" + + @patch("aptl.core.collectors._curl_json") + def test_does_not_mutate_input_spans(self, mock_curl): + """collect_traces should not mutate the parsed Tempo response.""" + original_span = {"name": "test", "traceId": "abc"} + mock_curl.return_value = { + "resourceSpans": [ + { + "resource": {"attrs": "val"}, + "scopeSpans": [{"spans": [original_span]}], + } + ] + } + spans = collect_traces("abc123") + assert len(spans) == 1 + assert "resource" in spans[0] + # Original span dict should not have been mutated + assert "resource" not in original_span class TestCollectContainerLogs: diff --git a/tests/test_consistency.py b/tests/test_consistency.py index b02413078..324584e7f 100644 --- a/tests/test_consistency.py +++ b/tests/test_consistency.py @@ -53,6 +53,39 @@ def test_container_names_are_unique(self, compose_config): dupes = [n for n in names if names.count(n) > 1] assert not dupes, f"Duplicate container_name values: {set(dupes)}" + def test_static_ipv4_addresses_are_unique_per_network(self, compose_config): + """Static container IPs must not collide within the same Docker network.""" + services = compose_config.get("services", {}) + seen: dict[tuple[str, str], str] = {} + dupes: list[tuple[str, str, str, str]] = [] + + for service_name, svc in services.items(): + networks = svc.get("networks", {}) + if not isinstance(networks, dict): + continue + + for network_name, network_cfg in networks.items(): + if not isinstance(network_cfg, dict): + continue + + ip = network_cfg.get("ipv4_address") + if not ip: + continue + + key = (network_name, ip) + if key in seen: + dupes.append((network_name, ip, seen[key], service_name)) + else: + seen[key] = service_name + + assert not dupes, ( + "Duplicate static ipv4_address assignments:\n" + + "\n".join( + f" {network}: {ip} used by {first} and {second}" + for network, ip, first, second in dupes + ) + ) + class TestCodeReferencesMatchCompose: """Ensure docker exec references in code match actual container_name values.""" @@ -105,6 +138,36 @@ def test_config_profiles_exist_in_compose(self, compose_config, aptl_config): ) +class TestNetworkEgressControls: + """Validate SAF-002: attack networks must block internet egress.""" + + ATTACK_NETWORKS = ["aptl-dmz", "aptl-internal", "aptl-redteam"] + + def test_attack_networks_are_internal(self, compose_config): + """Networks carrying attack traffic must use internal: true.""" + networks = compose_config.get("networks", {}) + not_internal = [] + for name in self.ATTACK_NETWORKS: + net = networks.get(name, {}) + if not net.get("internal", False): + not_internal.append(name) + assert not not_internal, ( + f"SAF-002 violation: attack networks missing internal: true: " + f"{not_internal}. Containers on these networks can reach the " + f"internet, risking autonomous agent attacks on external targets." + ) + + def test_security_network_allows_egress(self, compose_config): + """Security/management network must NOT be internal (SOC tools need internet).""" + networks = compose_config.get("networks", {}) + security = networks.get("aptl-security", {}) + assert not security.get("internal", False), ( + "aptl-security must not be internal: true. " + "SOC tools (MISP, Wazuh, Shuffle) require internet for " + "threat feeds and rule updates." + ) + + class TestBuildScriptCoverage: """Validate build-all-mcps.sh covers all MCP server directories.""" diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 19564a7ea..11adb51c4 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -161,24 +161,98 @@ def test_handles_no_key_pattern(self, tmp_path): content = config_file.read_text() assert "value" in content - def test_replaces_multiple_key_elements(self, tmp_path): - """If there are multiple elements, all should be replaced.""" + def test_replaces_cluster_key_only(self, tmp_path): + """Only elements inside blocks should be replaced.""" from aptl.core.credentials import sync_manager_config config_file = tmp_path / "wazuh_manager.conf" config_file.write_text( '\n' ' first_key\n' - ' second_key\n' '\n' ) sync_manager_config(config_file, "unified_key") content = config_file.read_text() - assert content.count('unified_key') == 2 + assert 'unified_key' in content assert 'first_key' not in content - assert 'second_key' not in content + + def test_preserves_ssl_key_element(self, tmp_path): + """ inside must NOT be touched (#183).""" + from aptl.core.credentials import sync_manager_config + + config_file = tmp_path / "wazuh_manager.conf" + config_file.write_text( + '\n' + ' \n' + ' \n' + ' /etc/filebeat/certs/filebeat-key.pem\n' + ' \n' + ' \n' + ' \n' + ' old_cluster_key\n' + ' \n' + '\n' + ) + + sync_manager_config(config_file, "new_cluster_key") + + content = config_file.read_text() + # SSL key path must be untouched + assert '/etc/filebeat/certs/filebeat-key.pem' in content + # Cluster key must be replaced + assert 'new_cluster_key' in content + assert 'old_cluster_key' not in content + + def test_real_config_structure(self, tmp_path): + """Mirror actual wazuh_manager.conf structure with both key elements.""" + from aptl.core.credentials import sync_manager_config + + config_file = tmp_path / "wazuh_manager.conf" + config_file.write_text( + '\n' + ' \n' + ' yes\n' + ' \n' + ' https://wazuh.indexer:9200\n' + ' \n' + ' \n' + ' \n' + ' /etc/filebeat/certs/root-ca.pem\n' + ' \n' + ' /etc/filebeat/certs/filebeat.pem\n' + ' /etc/filebeat/certs/filebeat-key.pem\n' + ' \n' + ' \n' + '\n' + ' \n' + ' wazuh\n' + ' master\n' + ' master\n' + ' placeholder_cluster_key\n' + ' 1516\n' + ' 0.0.0.0\n' + ' \n' + ' NODE_IP\n' + ' \n' + ' no\n' + ' yes\n' + ' \n' + '\n' + ) + + sync_manager_config(config_file, "real_secret_key_123") + + content = config_file.read_text() + # SSL key path preserved + assert '/etc/filebeat/certs/filebeat-key.pem' in content + # Cluster key replaced + assert 'real_secret_key_123' in content + assert 'placeholder_cluster_key' not in content + # Other cluster elements preserved + assert 'wazuh' in content + assert '1516' in content def test_empty_cluster_key_allowed(self, tmp_path): """Should allow setting an empty cluster key.""" @@ -206,7 +280,8 @@ def test_password_with_backslash_one(self, tmp_path): sync_dashboard_config(config_file, r"pass\1word") content = config_file.read_text() - assert r'password: "pass\1word"' in content + # Backslash is escaped for YAML double-quoted string safety. + assert r'password: "pass\\1word"' in content def test_password_with_dollar_sign(self, tmp_path): """Password containing $ should be literal.""" @@ -221,7 +296,7 @@ def test_password_with_dollar_sign(self, tmp_path): assert 'password: "pa$$word$1"' in content def test_password_with_backslashes(self, tmp_path): - r"""Password containing backslashes should be preserved literally.""" + r"""Password containing backslashes should be escaped for YAML.""" from aptl.core.credentials import sync_dashboard_config config_file = tmp_path / "wazuh.yml" @@ -230,10 +305,11 @@ def test_password_with_backslashes(self, tmp_path): sync_dashboard_config(config_file, r"C:\Users\admin") content = config_file.read_text() - assert r'password: "C:\Users\admin"' in content + # YAML double-quoted strings interpret \U as escape; must be \\U. + assert r'password: "C:\\Users\\admin"' in content def test_password_with_newline_escape(self, tmp_path): - r"""Password containing \n should not insert a newline.""" + r"""Password containing \n should be escaped, not insert a newline.""" from aptl.core.credentials import sync_dashboard_config config_file = tmp_path / "wazuh.yml" @@ -242,7 +318,8 @@ def test_password_with_newline_escape(self, tmp_path): sync_dashboard_config(config_file, r"pass\nword") content = config_file.read_text() - assert r'password: "pass\nword"' in content + # YAML double-quoted \n means newline; must be \\n for literal. + assert r'password: "pass\\nword"' in content def test_cluster_key_with_backslash_one(self, tmp_path): r"""Cluster key containing \1 should not be treated as backreference.""" @@ -279,3 +356,27 @@ def test_password_with_quotes(self, tmp_path): content = config_file.read_text() assert """password: "it's-a-password\"""" in content + + def test_password_with_double_quotes(self, tmp_path): + """Password containing double quotes should be escaped.""" + from aptl.core.credentials import sync_dashboard_config + + config_file = tmp_path / "wazuh.yml" + config_file.write_text(' password: "old"\n') + + sync_dashboard_config(config_file, 'pass"word') + + content = config_file.read_text() + assert 'password: "pass\\"word"' in content + + def test_cluster_key_with_xml_special_chars(self, tmp_path): + """Cluster key with <, >, & should be XML-escaped.""" + from aptl.core.credentials import sync_manager_config + + config_file = tmp_path / "wazuh_manager.conf" + config_file.write_text("\n old\n\n") + + sync_manager_config(config_file, "key<>&value") + + content = config_file.read_text() + assert "key<>&value" in content diff --git a/tests/test_deployment_backend.py b/tests/test_deployment_backend.py new file mode 100644 index 000000000..7d5d60ae8 --- /dev/null +++ b/tests/test_deployment_backend.py @@ -0,0 +1,705 @@ +"""Tests for the deployment backend abstraction layer. + +Tests cover the DeploymentBackend Protocol, DockerComposeBackend, +SSHComposeBackend, config model, and factory function. +""" + +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from aptl.core.config import AptlConfig, DeploymentConfig +from aptl.core.deployment import ( + DockerComposeBackend, + SSHComposeBackend, + get_backend, +) +from aptl.core.lab import LabResult, LabStatus + + +# --------------------------------------------------------------------------- +# DeploymentConfig model tests +# --------------------------------------------------------------------------- + + +class TestDeploymentConfig: + """Tests for the DeploymentConfig Pydantic model.""" + + def test_default_provider_is_docker_compose(self): + cfg = DeploymentConfig() + assert cfg.provider == "docker-compose" + assert cfg.project_name == "aptl" + + def test_accepts_docker_compose(self): + cfg = DeploymentConfig(provider="docker-compose") + assert cfg.provider == "docker-compose" + + def test_accepts_ssh_compose(self): + cfg = DeploymentConfig(provider="ssh-compose") + assert cfg.provider == "ssh-compose" + + def test_rejects_unknown_provider(self): + with pytest.raises(ValueError, match="Unknown deployment provider"): + DeploymentConfig(provider="kubernetes") + + def test_ssh_fields_default_to_none(self): + cfg = DeploymentConfig() + assert cfg.ssh_host is None + assert cfg.ssh_user is None + assert cfg.ssh_key is None + assert cfg.ssh_port == 22 + assert cfg.remote_dir is None + + def test_ssh_fields_populated(self): + cfg = DeploymentConfig( + provider="ssh-compose", + ssh_host="lab.example.com", + ssh_user="admin", + ssh_key="~/.ssh/lab_key", + ssh_port=2222, + remote_dir="/opt/aptl", + ) + assert cfg.ssh_host == "lab.example.com" + assert cfg.ssh_user == "admin" + assert cfg.ssh_key == "~/.ssh/lab_key" + assert cfg.ssh_port == 2222 + assert cfg.remote_dir == "/opt/aptl" + + +class TestAptlConfigDeployment: + """Tests for DeploymentConfig in AptlConfig.""" + + def test_default_config_has_deployment(self): + cfg = AptlConfig(lab={"name": "test"}) + assert cfg.deployment.provider == "docker-compose" + + def test_config_with_explicit_deployment(self): + cfg = AptlConfig( + lab={"name": "test"}, + deployment={"provider": "ssh-compose", "ssh_host": "server"}, + ) + assert cfg.deployment.provider == "ssh-compose" + assert cfg.deployment.ssh_host == "server" + + def test_config_without_deployment_key(self): + """Config JSON without deployment key uses defaults.""" + cfg = AptlConfig( + lab={"name": "test"}, + containers={"wazuh": True, "kali": False}, + ) + assert cfg.deployment.provider == "docker-compose" + + def test_load_config_with_deployment(self, tmp_path): + from aptl.core.config import load_config + + config_file = tmp_path / "aptl.json" + config_file.write_text(json.dumps({ + "lab": {"name": "test"}, + "deployment": {"provider": "docker-compose", "project_name": "mylab"}, + })) + + cfg = load_config(config_file) + assert cfg.deployment.project_name == "mylab" + + +# --------------------------------------------------------------------------- +# DockerComposeBackend tests +# --------------------------------------------------------------------------- + + +class TestDockerComposeBackend: + """Tests for the Docker Compose deployment backend.""" + + def _make_backend(self, tmp_path: Path) -> DockerComposeBackend: + return DockerComposeBackend(project_dir=tmp_path, project_name="test") + + def test_start_calls_compose_up(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = backend.start(["wazuh", "kali"]) + + assert result.success is True + cmd = mock_run.call_args[0][0] + assert cmd[0] == "docker" + assert cmd[1] == "compose" + assert "--profile" in cmd + assert "wazuh" in cmd + assert "kali" in cmd + assert "up" in cmd + assert "--build" in cmd + assert "-d" in cmd + assert mock_run.call_args[1]["cwd"] == tmp_path + + def test_start_without_build(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = backend.start(["wazuh"], build=False) + + assert result.success is True + cmd = mock_run.call_args[0][0] + assert "--build" not in cmd + + def test_start_returns_failure_on_error(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="compose up failed" + ) + result = backend.start(["wazuh"]) + + assert result.success is False + assert "compose up failed" in result.error + + def test_stop_calls_compose_down(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = backend.stop(["wazuh"]) + + assert result.success is True + cmd = mock_run.call_args[0][0] + assert "down" in cmd + assert "-v" not in cmd + + def test_stop_with_volumes(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = backend.stop(["wazuh"], remove_volumes=True) + + assert result.success is True + cmd = mock_run.call_args[0][0] + assert "-v" in cmd + + def test_stop_returns_failure(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="network error" + ) + result = backend.stop([]) + + assert result.success is False + + def test_status_parses_json_array(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, + stdout='[{"Name":"aptl-victim","State":"running"}]', + stderr="", + ) + status = backend.status() + + assert status.running is True + assert len(status.containers) == 1 + + def test_status_parses_ndjson(self, tmp_path): + backend = self._make_backend(tmp_path) + ndjson = ( + '{"Name":"aptl-victim","State":"running"}\n' + '{"Name":"aptl-kali","State":"running"}' + ) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout=ndjson, stderr="" + ) + status = backend.status() + + assert status.running is True + assert len(status.containers) == 2 + + def test_status_handles_empty_output(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout="", stderr="" + ) + status = backend.status() + + assert status.running is False + assert status.containers == [] + + def test_status_handles_compose_failure(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="docker not found" + ) + status = backend.status() + + assert status.running is False + assert "docker not found" in status.error + + def test_status_handles_invalid_json(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout="not json", stderr="" + ) + status = backend.status() + + assert status.running is False + assert "parse" in status.error.lower() + + def test_kill_runs_compose_kill_then_down(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr="") + success, error = backend.kill(["wazuh", "kali"]) + + assert success is True + assert error == "" + assert mock_run.call_count == 2 + + first_cmd = mock_run.call_args_list[0][0][0] + assert "kill" in first_cmd + second_cmd = mock_run.call_args_list[1][0][0] + assert "down" in second_cmd + + def test_kill_handles_timeout(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired( + cmd="docker", timeout=30 + ) + success, error = backend.kill(["wazuh"]) + + assert success is False + + def test_kill_handles_file_not_found(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError("docker not found") + success, error = backend.kill(["wazuh"]) + + assert success is False + assert "docker compose kill failed" in error + + def test_pull_images_success(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + warnings = backend.pull_images(["wazuh/wazuh-manager:4.12.0"]) + + assert warnings == [] + cmd = mock_run.call_args[0][0] + assert cmd == ["docker", "pull", "wazuh/wazuh-manager:4.12.0"] + + def test_pull_images_returns_warnings(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="not found" + ) + warnings = backend.pull_images(["bad:image"]) + + assert len(warnings) == 1 + assert "bad:image" in warnings[0] + + def test_pull_images_handles_oserror(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.side_effect = OSError("docker binary not found") + warnings = backend.pull_images(["some:image"]) + + assert len(warnings) == 1 + + def test_project_dir_property(self, tmp_path): + backend = self._make_backend(tmp_path) + assert backend.project_dir == tmp_path + + def test_project_name_property(self, tmp_path): + backend = DockerComposeBackend( + project_dir=tmp_path, project_name="mylab" + ) + assert backend.project_name == "mylab" + + +# --------------------------------------------------------------------------- +# SSHComposeBackend tests +# --------------------------------------------------------------------------- + + +class TestSSHComposeBackend: + """Tests for the SSH Remote Docker Compose backend.""" + + def _make_backend( + self, + tmp_path: Path, + host: str = "lab.example.com", + user: str = "admin", + **kwargs, + ) -> SSHComposeBackend: + return SSHComposeBackend( + project_dir=tmp_path, + host=host, + user=user, + **kwargs, + ) + + def test_docker_host_format(self, tmp_path): + backend = self._make_backend(tmp_path) + assert backend.docker_host == "ssh://admin@lab.example.com" + + def test_docker_host_with_custom_port(self, tmp_path): + backend = self._make_backend(tmp_path, ssh_port=2222) + assert backend.docker_host == "ssh://admin@lab.example.com:2222" + + def test_host_and_user_properties(self, tmp_path): + backend = self._make_backend(tmp_path) + assert backend.host == "lab.example.com" + assert backend.user == "admin" + + def test_run_sets_docker_host_env(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + backend.start(["wazuh"]) + + call_kwargs = mock_run.call_args[1] + env = call_kwargs["env"] + assert env["DOCKER_HOST"] == "ssh://admin@lab.example.com" + + def test_run_sets_ssh_identity_when_key_provided(self, tmp_path): + backend = self._make_backend( + tmp_path, ssh_key="/home/user/.ssh/lab_key" + ) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + backend.start(["wazuh"]) + + call_kwargs = mock_run.call_args[1] + env = call_kwargs["env"] + assert env["DOCKER_SSH_IDENTITY"] == "/home/user/.ssh/lab_key" + + def test_inherits_start_behavior(self, tmp_path): + """SSH backend start should produce same command structure.""" + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = backend.start(["wazuh", "kali"]) + + assert result.success is True + cmd = mock_run.call_args[0][0] + assert "docker" in cmd + assert "compose" in cmd + assert "up" in cmd + + def test_inherits_stop_behavior(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = backend.stop(["wazuh"], remove_volumes=True) + + assert result.success is True + cmd = mock_run.call_args[0][0] + assert "down" in cmd + assert "-v" in cmd + + def test_validate_connection_success(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout="24.0.7\n", stderr="" + ) + ok, err = backend.validate_connection() + + assert ok is True + assert err == "" + + def test_validate_connection_failure(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="Connection refused" + ) + ok, err = backend.validate_connection() + + assert ok is False + assert "Connection refused" in err + + def test_validate_connection_timeout(self, tmp_path): + backend = self._make_backend(tmp_path) + + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired( + cmd="docker", timeout=30 + ) + ok, err = backend.validate_connection() + + assert ok is False + assert "timed out" in err + + +# --------------------------------------------------------------------------- +# Factory function tests +# --------------------------------------------------------------------------- + + +class TestGetBackend: + """Tests for the get_backend factory function.""" + + def test_returns_docker_compose_by_default(self, tmp_path): + config = AptlConfig(lab={"name": "test"}) + backend = get_backend(config, tmp_path) + assert isinstance(backend, DockerComposeBackend) + + def test_returns_docker_compose_explicitly(self, tmp_path): + config = AptlConfig( + lab={"name": "test"}, + deployment={"provider": "docker-compose"}, + ) + backend = get_backend(config, tmp_path) + assert isinstance(backend, DockerComposeBackend) + + def test_returns_ssh_compose(self, tmp_path): + config = AptlConfig( + lab={"name": "test"}, + deployment={ + "provider": "ssh-compose", + "ssh_host": "server.example.com", + "ssh_user": "deploy", + }, + ) + backend = get_backend(config, tmp_path) + assert isinstance(backend, SSHComposeBackend) + assert backend.host == "server.example.com" + assert backend.user == "deploy" + + def test_ssh_compose_requires_host(self, tmp_path): + config = AptlConfig( + lab={"name": "test"}, + deployment={ + "provider": "ssh-compose", + "ssh_user": "deploy", + }, + ) + with pytest.raises(ValueError, match="ssh_host is required"): + get_backend(config, tmp_path) + + def test_ssh_compose_requires_user(self, tmp_path): + config = AptlConfig( + lab={"name": "test"}, + deployment={ + "provider": "ssh-compose", + "ssh_host": "server", + }, + ) + with pytest.raises(ValueError, match="ssh_user is required"): + get_backend(config, tmp_path) + + def test_passes_project_name(self, tmp_path): + config = AptlConfig( + lab={"name": "test"}, + deployment={"provider": "docker-compose", "project_name": "mylab"}, + ) + backend = get_backend(config, tmp_path) + assert isinstance(backend, DockerComposeBackend) + assert backend.project_name == "mylab" + + def test_passes_ssh_options(self, tmp_path): + config = AptlConfig( + lab={"name": "test"}, + deployment={ + "provider": "ssh-compose", + "ssh_host": "server", + "ssh_user": "admin", + "ssh_key": "/path/to/key", + "ssh_port": 2222, + "remote_dir": "/opt/aptl", + }, + ) + backend = get_backend(config, tmp_path) + assert isinstance(backend, SSHComposeBackend) + assert backend.docker_host == "ssh://admin@server:2222" + + +# --------------------------------------------------------------------------- +# Backward-compatibility: lab.py wrapper functions +# --------------------------------------------------------------------------- + + +class TestLabBackwardCompat: + """Verify that lab.py wrapper functions still work without backend arg.""" + + def test_start_lab_without_backend(self, mock_subprocess): + from aptl.core.lab import start_lab + + config = AptlConfig( + lab={"name": "test"}, + containers={"wazuh": True, "victim": True, "kali": False, "reverse": False}, + ) + mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = start_lab(config) + + assert result.success is True + + def test_stop_lab_without_backend(self, mock_subprocess, tmp_path): + from aptl.core.lab import stop_lab + + mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result = stop_lab(project_dir=tmp_path) + + assert result.success is True + + def test_lab_status_without_backend(self, mock_subprocess): + from aptl.core.lab import lab_status + + mock_subprocess.return_value = MagicMock( + returncode=0, + stdout='[{"Name":"aptl-victim","State":"running"}]', + stderr="", + ) + + status = lab_status() + + assert status.running is True + + def test_start_lab_with_explicit_backend(self, tmp_path): + """start_lab accepts an explicit backend.""" + from aptl.core.lab import start_lab + + config = AptlConfig( + lab={"name": "test"}, + containers={"wazuh": True, "kali": False, "reverse": False}, + ) + + mock_backend = MagicMock() + mock_backend.start.return_value = LabResult(success=True, message="ok") + + result = start_lab(config, backend=mock_backend) + + assert result.success is True + mock_backend.start.assert_called_once() + + +class TestKillBackwardCompat: + """Verify kill_lab_containers still works without backend arg.""" + + @patch("aptl.core.kill.subprocess.run") + def test_kill_without_backend(self, mock_run): + from aptl.core.kill import kill_lab_containers + + mock_run.return_value = MagicMock(returncode=0, stderr="") + + success, error = kill_lab_containers(project_dir=Path("/tmp/aptl")) + + assert success is True + + def test_kill_with_backend(self): + from aptl.core.kill import kill_lab_containers + + mock_backend = MagicMock() + mock_backend.kill.return_value = (True, "") + + success, error = kill_lab_containers(backend=mock_backend) + + assert success is True + mock_backend.kill.assert_called_once() + + +# --------------------------------------------------------------------------- +# SSH parameter validation tests +# --------------------------------------------------------------------------- + + +class TestSSHComposeBackendValidation: + """Validate that SSHComposeBackend rejects dangerous parameter values.""" + + def test_rejects_host_with_at_sign(self, tmp_path): + with pytest.raises(ValueError, match="Invalid SSH host"): + SSHComposeBackend(tmp_path, host="user@evil", user="deploy") + + def test_rejects_host_with_semicolon(self, tmp_path): + with pytest.raises(ValueError, match="Invalid SSH host"): + SSHComposeBackend(tmp_path, host="host;rm -rf /", user="deploy") + + def test_rejects_host_with_spaces(self, tmp_path): + with pytest.raises(ValueError, match="Invalid SSH host"): + SSHComposeBackend(tmp_path, host="host name", user="deploy") + + def test_rejects_user_with_spaces(self, tmp_path): + with pytest.raises(ValueError, match="Invalid SSH user"): + SSHComposeBackend(tmp_path, host="example.com", user="admin root") + + def test_rejects_user_with_at_sign(self, tmp_path): + with pytest.raises(ValueError, match="Invalid SSH user"): + SSHComposeBackend(tmp_path, host="example.com", user="user@host") + + def test_rejects_port_zero(self, tmp_path): + with pytest.raises(ValueError, match="ssh_port must be int in 1-65535"): + SSHComposeBackend(tmp_path, host="example.com", user="deploy", ssh_port=0) + + def test_rejects_port_negative(self, tmp_path): + with pytest.raises(ValueError, match="ssh_port must be int in 1-65535"): + SSHComposeBackend(tmp_path, host="example.com", user="deploy", ssh_port=-1) + + def test_rejects_port_over_65535(self, tmp_path): + with pytest.raises(ValueError, match="ssh_port must be int in 1-65535"): + SSHComposeBackend(tmp_path, host="example.com", user="deploy", ssh_port=70000) + + def test_rejects_relative_ssh_key(self, tmp_path): + with pytest.raises(ValueError, match="absolute path"): + SSHComposeBackend( + tmp_path, host="example.com", user="deploy", + ssh_key="relative/path/key", + ) + + def test_rejects_ssh_key_with_traversal(self, tmp_path): + with pytest.raises(ValueError, match="must not contain"): + SSHComposeBackend( + tmp_path, host="example.com", user="deploy", + ssh_key="/home/../etc/passwd", + ) + + def test_accepts_valid_ipv6_host(self, tmp_path): + backend = SSHComposeBackend(tmp_path, host="[::1]", user="deploy") + assert backend.docker_host == "ssh://deploy@[::1]" + + def test_accepts_valid_hostname(self, tmp_path): + backend = SSHComposeBackend(tmp_path, host="lab.example.com", user="deploy") + assert backend.docker_host == "ssh://deploy@lab.example.com" + + def test_accepts_valid_absolute_key(self, tmp_path): + backend = SSHComposeBackend( + tmp_path, host="example.com", user="deploy", + ssh_key="/home/user/.ssh/id_rsa", + ) + assert backend.docker_host == "ssh://deploy@example.com" + + def test_accepts_non_default_port(self, tmp_path): + backend = SSHComposeBackend( + tmp_path, host="example.com", user="deploy", ssh_port=2222, + ) + assert backend.docker_host == "ssh://deploy@example.com:2222" diff --git a/tests/test_events.py b/tests/test_events.py deleted file mode 100644 index f49a44f61..000000000 --- a/tests/test_events.py +++ /dev/null @@ -1,477 +0,0 @@ -"""Tests for the event timeline system. - -Tests exercise event creation, JSONL serialization/deserialization, -append/read operations, query filtering, and error handling for -corrupted or missing log files. -""" - -import json -from pathlib import Path - -import pytest - - -# --------------------------------------------------------------------------- -# Event and EventType -# --------------------------------------------------------------------------- - - -class TestEventType: - """Tests for the EventType enum.""" - - def test_all_event_types_exist(self): - """All expected event types should be defined.""" - from aptl.core.events import EventType - - expected = { - "scenario_started", - "scenario_stopped", - "precondition_applied", - "precondition_failed", - "objective_completed", - "objective_failed", - "alert_matched", - "hint_requested", - "evaluation_run", - } - actual = {e.value for e in EventType} - assert actual == expected - - def test_event_type_is_string(self): - """EventType values should be usable as strings.""" - from aptl.core.events import EventType - - assert EventType.SCENARIO_STARTED == "scenario_started" - - -class TestEvent: - """Tests for the Event dataclass.""" - - def test_event_creation(self): - """An event with all fields should be valid.""" - from aptl.core.events import Event, EventType - - event = Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test-scenario", - timestamp="2026-02-16T14:30:00+00:00", - data={"mode": "red"}, - ) - assert event.event_type == EventType.SCENARIO_STARTED - assert event.scenario_id == "test-scenario" - assert event.data == {"mode": "red"} - - def test_event_default_data(self): - """Event data should default to an empty dict.""" - from aptl.core.events import Event, EventType - - event = Event( - event_type=EventType.SCENARIO_STOPPED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - ) - assert event.data == {} - - -class TestMakeEvent: - """Tests for the make_event helper.""" - - def test_creates_event_with_utc_timestamp(self): - """make_event should produce an event with a UTC ISO 8601 timestamp.""" - from aptl.core.events import EventType, make_event - - event = make_event(EventType.SCENARIO_STARTED, "test-scenario") - assert event.event_type == EventType.SCENARIO_STARTED - assert event.scenario_id == "test-scenario" - assert "T" in event.timestamp - assert "+" in event.timestamp or "Z" in event.timestamp - - def test_creates_event_with_data(self): - """make_event should include the provided data dict.""" - from aptl.core.events import EventType, make_event - - event = make_event( - EventType.HINT_REQUESTED, - "test", - data={"objective_id": "obj-1", "level": 2}, - ) - assert event.data == {"objective_id": "obj-1", "level": 2} - - def test_creates_event_with_empty_data_by_default(self): - """make_event with no data arg should default to empty dict.""" - from aptl.core.events import EventType, make_event - - event = make_event(EventType.EVALUATION_RUN, "test") - assert event.data == {} - - -# --------------------------------------------------------------------------- -# Serialization -# --------------------------------------------------------------------------- - - -class TestSerialization: - """Tests for event serialization and deserialization.""" - - def test_round_trip(self): - """An event should survive serialize -> deserialize.""" - from aptl.core.events import Event, EventType, _deserialize_event, _serialize_event - - original = Event( - event_type=EventType.OBJECTIVE_COMPLETED, - scenario_id="test-scenario", - timestamp="2026-02-16T14:30:00+00:00", - data={"objective_id": "find-flag", "points": 100}, - ) - serialized = _serialize_event(original) - restored = _deserialize_event(serialized) - assert restored.event_type == original.event_type - assert restored.scenario_id == original.scenario_id - assert restored.timestamp == original.timestamp - assert restored.data == original.data - - def test_serialize_produces_valid_json(self): - """Serialization should produce parseable JSON.""" - from aptl.core.events import Event, EventType, _serialize_event - - event = Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - ) - serialized = _serialize_event(event) - parsed = json.loads(serialized) - assert parsed["event_type"] == "scenario_started" - assert parsed["scenario_id"] == "test" - - def test_serialize_is_compact(self): - """Serialized events should not contain unnecessary whitespace.""" - from aptl.core.events import Event, EventType, _serialize_event - - event = Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - ) - serialized = _serialize_event(event) - assert " " not in serialized - assert ": " not in serialized - - def test_deserialize_invalid_json_raises(self): - """Deserializing invalid JSON should raise ValueError.""" - from aptl.core.events import _deserialize_event - - with pytest.raises(ValueError, match="Invalid JSON"): - _deserialize_event("{not valid json}") - - def test_deserialize_missing_fields_raises(self): - """Deserializing JSON with missing required fields should raise.""" - from aptl.core.events import _deserialize_event - - with pytest.raises(ValueError, match="Malformed"): - _deserialize_event('{"event_type":"scenario_started"}') - - def test_deserialize_invalid_event_type_raises(self): - """Deserializing an unknown event type should raise ValueError.""" - from aptl.core.events import _deserialize_event - - line = json.dumps({ - "event_type": "unknown_type", - "scenario_id": "test", - "timestamp": "2026-02-16T14:30:00+00:00", - }) - with pytest.raises(ValueError, match="Malformed"): - _deserialize_event(line) - - -# --------------------------------------------------------------------------- -# EventLog append and read -# --------------------------------------------------------------------------- - - -class TestEventLogAppend: - """Tests for EventLog.append().""" - - def test_append_creates_file(self, tmp_path): - """Appending to a new log should create the file.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "events" / "test.jsonl" - event_log = EventLog(log_path) - event = Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - ) - event_log.append(event) - assert log_path.exists() - - def test_append_creates_parent_directories(self, tmp_path): - """Appending should create parent directories if needed.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "deep" / "nested" / "events.jsonl" - event_log = EventLog(log_path) - event = Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - ) - event_log.append(event) - assert log_path.exists() - - def test_append_writes_one_line_per_event(self, tmp_path): - """Each append should add exactly one line.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "test.jsonl" - event_log = EventLog(log_path) - - for i in range(3): - event_log.append(Event( - event_type=EventType.EVALUATION_RUN, - scenario_id="test", - timestamp=f"2026-02-16T14:3{i}:00+00:00", - )) - - lines = log_path.read_text().strip().split("\n") - assert len(lines) == 3 - - def test_append_each_line_is_valid_json(self, tmp_path): - """Each line in the log should be valid JSON.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "test.jsonl" - event_log = EventLog(log_path) - - event_log.append(Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - data={"key": "value"}, - )) - - line = log_path.read_text().strip() - parsed = json.loads(line) - assert parsed["event_type"] == "scenario_started" - assert parsed["data"] == {"key": "value"} - - -class TestEventLogRead: - """Tests for EventLog.read_all().""" - - def test_read_all_returns_events_in_order(self, tmp_path): - """read_all should return events in insertion order.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "test.jsonl" - event_log = EventLog(log_path) - - types = [ - EventType.SCENARIO_STARTED, - EventType.PRECONDITION_APPLIED, - EventType.EVALUATION_RUN, - ] - for i, et in enumerate(types): - event_log.append(Event( - event_type=et, - scenario_id="test", - timestamp=f"2026-02-16T14:3{i}:00+00:00", - )) - - events = event_log.read_all() - assert len(events) == 3 - assert events[0].event_type == EventType.SCENARIO_STARTED - assert events[1].event_type == EventType.PRECONDITION_APPLIED - assert events[2].event_type == EventType.EVALUATION_RUN - - def test_read_all_nonexistent_file_raises(self, tmp_path): - """read_all on a nonexistent file should raise FileNotFoundError.""" - from aptl.core.events import EventLog - - event_log = EventLog(tmp_path / "missing.jsonl") - with pytest.raises(FileNotFoundError): - event_log.read_all() - - def test_read_all_skips_blank_lines(self, tmp_path): - """read_all should skip blank lines without error.""" - from aptl.core.events import EventLog, EventType - - log_path = tmp_path / "test.jsonl" - log_path.write_text( - '{"event_type":"scenario_started","scenario_id":"test","timestamp":"2026-02-16T14:30:00+00:00","data":{}}\n' - "\n" - '{"event_type":"scenario_stopped","scenario_id":"test","timestamp":"2026-02-16T14:31:00+00:00","data":{}}\n' - ) - event_log = EventLog(log_path) - events = event_log.read_all() - assert len(events) == 2 - - def test_read_all_skips_malformed_lines(self, tmp_path): - """read_all should skip malformed lines and continue.""" - from aptl.core.events import EventLog, EventType - - log_path = tmp_path / "test.jsonl" - log_path.write_text( - '{"event_type":"scenario_started","scenario_id":"test","timestamp":"2026-02-16T14:30:00+00:00","data":{}}\n' - "THIS IS NOT JSON\n" - '{"event_type":"scenario_stopped","scenario_id":"test","timestamp":"2026-02-16T14:31:00+00:00","data":{}}\n' - ) - event_log = EventLog(log_path) - events = event_log.read_all() - assert len(events) == 2 - assert events[0].event_type == EventType.SCENARIO_STARTED - assert events[1].event_type == EventType.SCENARIO_STOPPED - - def test_read_all_empty_file(self, tmp_path): - """read_all on an empty file should return an empty list.""" - from aptl.core.events import EventLog - - log_path = tmp_path / "test.jsonl" - log_path.write_text("") - event_log = EventLog(log_path) - events = event_log.read_all() - assert events == [] - - -# --------------------------------------------------------------------------- -# EventLog queries -# --------------------------------------------------------------------------- - - -class TestEventLogQueries: - """Tests for EventLog query methods.""" - - def test_query_by_type(self, tmp_path): - """query_by_type should return only matching events.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "test.jsonl" - event_log = EventLog(log_path) - - event_log.append(Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - )) - event_log.append(Event( - event_type=EventType.HINT_REQUESTED, - scenario_id="test", - timestamp="2026-02-16T14:31:00+00:00", - data={"objective_id": "obj-1"}, - )) - event_log.append(Event( - event_type=EventType.HINT_REQUESTED, - scenario_id="test", - timestamp="2026-02-16T14:32:00+00:00", - data={"objective_id": "obj-2"}, - )) - - hints = event_log.query_by_type(EventType.HINT_REQUESTED) - assert len(hints) == 2 - assert all(e.event_type == EventType.HINT_REQUESTED for e in hints) - - def test_query_by_type_no_matches(self, tmp_path): - """query_by_type with no matches should return empty list.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "test.jsonl" - event_log = EventLog(log_path) - event_log.append(Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - )) - - results = event_log.query_by_type(EventType.OBJECTIVE_COMPLETED) - assert results == [] - - def test_query_by_type_nonexistent_file_raises(self, tmp_path): - """query_by_type on nonexistent log should raise FileNotFoundError.""" - from aptl.core.events import EventLog, EventType - - event_log = EventLog(tmp_path / "missing.jsonl") - with pytest.raises(FileNotFoundError): - event_log.query_by_type(EventType.SCENARIO_STARTED) - - def test_query_by_scenario(self, tmp_path): - """query_by_scenario should return only matching events.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "test.jsonl" - event_log = EventLog(log_path) - - event_log.append(Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="scenario-a", - timestamp="2026-02-16T14:30:00+00:00", - )) - event_log.append(Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="scenario-b", - timestamp="2026-02-16T14:31:00+00:00", - )) - event_log.append(Event( - event_type=EventType.SCENARIO_STOPPED, - scenario_id="scenario-a", - timestamp="2026-02-16T14:32:00+00:00", - )) - - results = event_log.query_by_scenario("scenario-a") - assert len(results) == 2 - assert all(e.scenario_id == "scenario-a" for e in results) - - -# --------------------------------------------------------------------------- -# EventLog.is_empty -# --------------------------------------------------------------------------- - - -class TestEventLogIsEmpty: - """Tests for EventLog.is_empty().""" - - def test_is_empty_when_file_missing(self, tmp_path): - """is_empty should return True when the file does not exist.""" - from aptl.core.events import EventLog - - event_log = EventLog(tmp_path / "nonexistent.jsonl") - assert event_log.is_empty() is True - - def test_is_empty_when_file_empty(self, tmp_path): - """is_empty should return True when the file has zero bytes.""" - from aptl.core.events import EventLog - - log_path = tmp_path / "empty.jsonl" - log_path.write_text("") - event_log = EventLog(log_path) - assert event_log.is_empty() is True - - def test_is_empty_when_file_has_content(self, tmp_path): - """is_empty should return False when the file has content.""" - from aptl.core.events import Event, EventLog, EventType - - log_path = tmp_path / "test.jsonl" - event_log = EventLog(log_path) - event_log.append(Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test", - timestamp="2026-02-16T14:30:00+00:00", - )) - assert event_log.is_empty() is False - - -# --------------------------------------------------------------------------- -# EventLog.path property -# --------------------------------------------------------------------------- - - -class TestEventLogPath: - """Tests for EventLog.path property.""" - - def test_path_returns_configured_path(self, tmp_path): - """path property should return the path passed to the constructor.""" - from aptl.core.events import EventLog - - log_path = tmp_path / "events" / "test.jsonl" - event_log = EventLog(log_path) - assert event_log.path == log_path diff --git a/tests/test_integration.py b/tests/test_integration.py index 1188af46d..3408d352a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,7 +13,6 @@ from typer.testing import CliRunner from aptl.cli.scenario import app -from aptl.core.events import EventLog, EventType from aptl.core.scenarios import ( ScenarioDefinition, ScenarioMode, @@ -318,8 +317,8 @@ def _setup_project(tmp_path: Path) -> Path: ) return tmp_path - def test_start_creates_session_and_events(self, tmp_path): - """AC-3: start creates session.json and initializes event log.""" + 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, [ @@ -335,13 +334,13 @@ def test_start_creates_session_and_events(self, tmp_path): 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 - # Events file - events_dir = project / ".aptl" / "events" - event_files = list(events_dir.glob("*.jsonl")) - assert len(event_files) == 1 - content = event_files[0].read_text() - assert "scenario_started" in content + # 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.""" @@ -398,28 +397,16 @@ def test_session_cleared_after_stop(self, tmp_path): ]) assert result.exit_code == 0 - def test_event_log_records_timeline(self, tmp_path): - """Events log should capture the scenario timeline.""" + 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), ]) - - # Capture the events file path before stop clears session - session_data = json.loads( - (project / ".aptl" / "session.json").read_text() - ) - events_path = project / ".aptl" / session_data["events_file"] - runner.invoke(app, [ "stop", "--project-dir", str(project), ]) - # Read events - event_log = EventLog(events_path) - events = event_log.read_all() - event_types = [e.event_type for e in events] - - assert EventType.SCENARIO_STARTED in event_types - assert EventType.SCENARIO_STOPPED in event_types + ctx_path = project / ".aptl" / "trace-context.json" + assert not ctx_path.exists() diff --git a/tests/test_kill.py b/tests/test_kill.py new file mode 100644 index 000000000..90245ce57 --- /dev/null +++ b/tests/test_kill.py @@ -0,0 +1,601 @@ +"""Tests for the emergency kill switch core logic. + +All process discovery, signal sending, and Docker Compose interactions +are mocked. No real processes are killed or containers stopped. +""" + +import json +import os +import signal +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + + +class TestFindMcpProcesses: + """Tests for MCP server process discovery.""" + + @patch("aptl.core.kill.sys") + @patch("aptl.core.kill.os.listdir") + @patch("builtins.open", create=True) + def test_finds_running_mcp_processes(self, mock_open, mock_listdir, mock_sys): + from aptl.core.kill import find_mcp_processes + + mock_sys.platform = "linux" + mock_listdir.return_value = ["1", "100", "200", "self", "cpuinfo"] + + # PID 100 is an MCP server, PID 200 is not, PID 1 is init + def open_side_effect(path, mode="r"): + m = MagicMock() + if path == "/proc/100/cmdline": + m.__enter__ = lambda s: s + m.__exit__ = MagicMock(return_value=False) + m.read.return_value = b"node\x00/home/user/aptl/mcp/mcp-wazuh/build/index.js\x00" + elif path == "/proc/200/cmdline": + m.__enter__ = lambda s: s + m.__exit__ = MagicMock(return_value=False) + m.read.return_value = b"node\x00/home/user/other-app/server.js\x00" + elif path == "/proc/1/cmdline": + m.__enter__ = lambda s: s + m.__exit__ = MagicMock(return_value=False) + m.read.return_value = b"/sbin/init\x00" + else: + raise FileNotFoundError(path) + return m + + mock_open.side_effect = open_side_effect + + result = find_mcp_processes() + + assert len(result) == 1 + assert result[0]["pid"] == 100 + assert result[0]["name"] == "mcp-wazuh" + assert "mcp-wazuh/build/index.js" in result[0]["cmdline"] + + @patch("aptl.core.kill.sys") + @patch("aptl.core.kill.os.listdir") + def test_returns_empty_when_no_mcp_processes(self, mock_listdir, mock_sys): + from aptl.core.kill import find_mcp_processes + + mock_sys.platform = "linux" + mock_listdir.return_value = ["1"] + + with patch("builtins.open", create=True) as mock_open: + m = MagicMock() + m.__enter__ = lambda s: s + m.__exit__ = MagicMock(return_value=False) + m.read.return_value = b"/sbin/init\x00" + mock_open.return_value = m + + result = find_mcp_processes() + + assert result == [] + + @patch("aptl.core.kill.sys") + @patch("aptl.core.kill.os.listdir") + @patch("builtins.open", create=True) + def test_finds_multiple_mcp_servers(self, mock_open, mock_listdir, mock_sys): + from aptl.core.kill import find_mcp_processes + + mock_sys.platform = "linux" + mock_listdir.return_value = ["100", "200"] + + def open_side_effect(path, mode="r"): + m = MagicMock() + m.__enter__ = lambda s: s + m.__exit__ = MagicMock(return_value=False) + if path == "/proc/100/cmdline": + m.read.return_value = b"node\x00mcp/mcp-wazuh/build/index.js\x00" + elif path == "/proc/200/cmdline": + m.read.return_value = b"node\x00mcp/mcp-red/build/index.js\x00" + else: + raise FileNotFoundError(path) + return m + + mock_open.side_effect = open_side_effect + + result = find_mcp_processes() + + assert len(result) == 2 + names = {p["name"] for p in result} + assert names == {"mcp-wazuh", "mcp-red"} + + @patch("aptl.core.kill.sys") + @patch("aptl.core.kill.os.listdir") + @patch("builtins.open", create=True) + def test_handles_permission_errors(self, mock_open, mock_listdir, mock_sys): + from aptl.core.kill import find_mcp_processes + + mock_sys.platform = "linux" + mock_listdir.return_value = ["100"] + + mock_open.side_effect = PermissionError("Permission denied") + + result = find_mcp_processes() + + assert result == [] + + @patch("aptl.core.kill.sys") + @patch("aptl.core.kill.os.listdir") + @patch("builtins.open", create=True) + def test_handles_vanishing_processes(self, mock_open, mock_listdir, mock_sys): + from aptl.core.kill import find_mcp_processes + + mock_sys.platform = "linux" + mock_listdir.return_value = ["100"] + + mock_open.side_effect = FileNotFoundError("No such file") + + result = find_mcp_processes() + + assert result == [] + + @patch("aptl.core.kill.sys") + @patch("aptl.core.kill.os.getpid", return_value=999) + @patch("aptl.core.kill.os.listdir") + @patch("builtins.open", create=True) + def test_skips_own_process(self, mock_open, mock_listdir, mock_getpid, mock_sys): + from aptl.core.kill import find_mcp_processes + + mock_sys.platform = "linux" + # PID 999 is our own process, PID 100 is a real MCP server + mock_listdir.return_value = ["999", "100"] + + def open_side_effect(path, mode="r"): + m = MagicMock() + m.__enter__ = lambda s: s + m.__exit__ = MagicMock(return_value=False) + if path == "/proc/100/cmdline": + m.read.return_value = b"node\x00mcp/mcp-wazuh/build/index.js\x00" + elif path == "/proc/999/cmdline": + # Own process cmdline might match if running aptl kill + m.read.return_value = b"python\x00aptl\x00kill\x00mcp-wazuh/build/index.js\x00" + else: + raise FileNotFoundError(path) + return m + + mock_open.side_effect = open_side_effect + + result = find_mcp_processes() + + assert len(result) == 1 + assert result[0]["pid"] == 100 + + @patch("aptl.core.kill.sys") + @patch("aptl.core.kill.subprocess.run") + def test_pgrep_fallback_on_non_linux(self, mock_run, mock_sys): + from aptl.core.kill import find_mcp_processes + + mock_sys.platform = "darwin" + + def run_side_effect(cmd, **kwargs): + m = MagicMock() + if "mcp-wazuh" in cmd[2]: + m.returncode = 0 + m.stdout = "12345\n" + else: + m.returncode = 1 + m.stdout = "" + return m + + mock_run.side_effect = run_side_effect + + result = find_mcp_processes() + + assert len(result) == 1 + assert result[0]["pid"] == 12345 + assert result[0]["name"] == "mcp-wazuh" + + +class TestKillMcpProcesses: + """Tests for SIGTERM/SIGKILL process termination.""" + + @patch("aptl.core.kill.find_mcp_processes") + @patch("aptl.core.kill.os.kill") + @patch("aptl.core.kill._process_exited") + def test_kills_all_found_processes(self, mock_alive, mock_kill, mock_find): + from aptl.core.kill import kill_mcp_processes + + mock_find.return_value = [ + {"pid": 100, "cmdline": "node mcp-wazuh/build/index.js", "name": "mcp-wazuh"}, + ] + # Process exits after SIGTERM + mock_alive.return_value = True + + killed, errors = kill_mcp_processes(timeout=1.0) + + mock_kill.assert_called_once_with(100, signal.SIGTERM) + assert killed == 1 + assert errors == [] + + @patch("aptl.core.kill.find_mcp_processes") + @patch("aptl.core.kill.os.kill") + @patch("aptl.core.kill._verify_mcp_process", return_value=True) + @patch("aptl.core.kill._process_exited") + @patch("aptl.core.kill.time.sleep") + @patch("aptl.core.kill.time.monotonic") + @patch("aptl.core.kill.sys") + def test_sigkill_fallback_after_timeout( + self, mock_sys, mock_monotonic, mock_sleep, mock_alive, mock_verify, mock_kill, mock_find + ): + from aptl.core.kill import kill_mcp_processes + + mock_sys.platform = "linux" + mock_find.return_value = [ + {"pid": 100, "cmdline": "node mcp-wazuh/build/index.js", "name": "mcp-wazuh"}, + ] + # Process stays alive throughout timeout (_process_exited returns False) + mock_alive.return_value = False + # Simulate time progression past deadline + mock_monotonic.side_effect = [0.0, 0.0, 6.0] + + killed, errors = kill_mcp_processes(timeout=5.0) + + # SIGTERM must precede SIGKILL + calls = mock_kill.call_args_list + term_idx = calls.index(call(100, signal.SIGTERM)) + kill_idx = calls.index(call(100, signal.SIGKILL)) + assert term_idx < kill_idx, "SIGTERM must precede SIGKILL" + assert killed == 1 + + @patch("aptl.core.kill.find_mcp_processes") + @patch("aptl.core.kill.os.kill") + @patch("aptl.core.kill._verify_mcp_process", return_value=False) + @patch("aptl.core.kill._process_exited") + @patch("aptl.core.kill.time.sleep") + @patch("aptl.core.kill.time.monotonic") + @patch("aptl.core.kill.sys") + def test_sigkill_skipped_when_pid_reassigned( + self, mock_sys, mock_monotonic, mock_sleep, mock_alive, mock_verify, mock_kill, mock_find + ): + """SIGKILL is skipped if PID no longer belongs to an MCP process.""" + from aptl.core.kill import kill_mcp_processes + + mock_sys.platform = "linux" + mock_find.return_value = [ + {"pid": 100, "cmdline": "node mcp-wazuh/build/index.js", "name": "mcp-wazuh"}, + ] + mock_alive.return_value = False + mock_monotonic.side_effect = [0.0, 0.0, 6.0] + + killed, errors = kill_mcp_processes(timeout=5.0) + + # SIGTERM should be sent, but SIGKILL should be skipped + calls = mock_kill.call_args_list + assert call(100, signal.SIGTERM) in calls + assert call(100, signal.SIGKILL) not in calls + + @patch("aptl.core.kill.find_mcp_processes") + @patch("aptl.core.kill.os.kill") + def test_handles_already_dead_process(self, mock_kill, mock_find): + from aptl.core.kill import kill_mcp_processes + + mock_find.return_value = [ + {"pid": 100, "cmdline": "node mcp-wazuh/build/index.js", "name": "mcp-wazuh"}, + ] + mock_kill.side_effect = ProcessLookupError("No such process") + + killed, errors = kill_mcp_processes(timeout=1.0) + + assert killed == 1 + assert errors == [] + + @patch("aptl.core.kill.find_mcp_processes") + @patch("aptl.core.kill.os.kill") + def test_handles_permission_error(self, mock_kill, mock_find): + from aptl.core.kill import kill_mcp_processes + + mock_find.return_value = [ + {"pid": 100, "cmdline": "node mcp-wazuh/build/index.js", "name": "mcp-wazuh"}, + ] + mock_kill.side_effect = PermissionError("Operation not permitted") + + killed, errors = kill_mcp_processes(timeout=1.0) + + assert killed == 0 + assert len(errors) == 1 + assert "Permission denied" in errors[0] + + @patch("aptl.core.kill.find_mcp_processes") + def test_returns_zero_when_no_processes(self, mock_find): + from aptl.core.kill import kill_mcp_processes + + mock_find.return_value = [] + + killed, errors = kill_mcp_processes() + + assert killed == 0 + assert errors == [] + + +class TestKillLabContainers: + """Tests for emergency container stop.""" + + @patch("aptl.core.kill.subprocess.run") + def test_runs_docker_compose_kill_and_down(self, mock_run): + from aptl.core.kill import kill_lab_containers + + mock_run.return_value = MagicMock(returncode=0, stderr="") + + success, error = kill_lab_containers(project_dir=Path("/tmp/aptl")) + + assert success is True + assert error == "" + assert mock_run.call_count == 2 + + # First call: docker compose kill + first_cmd = mock_run.call_args_list[0][0][0] + assert "kill" in first_cmd + assert "--profile" in first_cmd + + # Second call: docker compose down + second_cmd = mock_run.call_args_list[1][0][0] + assert "down" in second_cmd + + @patch("aptl.core.kill.subprocess.run") + def test_includes_all_profiles(self, mock_run): + from aptl.core.kill import kill_lab_containers + from aptl.core.lab import ALL_KNOWN_PROFILES + + mock_run.return_value = MagicMock(returncode=0, stderr="") + + kill_lab_containers() + + first_cmd = mock_run.call_args_list[0][0][0] + for profile in ALL_KNOWN_PROFILES: + assert profile in first_cmd + + @patch("aptl.core.kill.subprocess.run") + def test_handles_docker_failure(self, mock_run): + from aptl.core.kill import kill_lab_containers + + mock_run.side_effect = FileNotFoundError("docker not found") + + success, error = kill_lab_containers() + + assert success is False + assert "docker compose kill failed" in error + + @patch("aptl.core.kill.subprocess.run") + def test_uses_project_dir_as_cwd(self, mock_run): + from aptl.core.kill import kill_lab_containers + + mock_run.return_value = MagicMock(returncode=0, stderr="") + project = Path("/my/project") + + kill_lab_containers(project_dir=project) + + for c in mock_run.call_args_list: + assert c[1]["cwd"] == project + + @patch("aptl.core.kill.subprocess.run") + def test_subprocess_calls_have_timeout(self, mock_run): + from aptl.core.kill import _DOCKER_TIMEOUT, kill_lab_containers + + mock_run.return_value = MagicMock(returncode=0, stderr="") + + kill_lab_containers() + + for c in mock_run.call_args_list: + assert c[1]["timeout"] == _DOCKER_TIMEOUT + + @patch("aptl.core.kill.subprocess.run") + def test_handles_timeout_expired(self, mock_run): + from aptl.core.kill import kill_lab_containers + + mock_run.side_effect = subprocess.TimeoutExpired(cmd="docker", timeout=30) + + success, error = kill_lab_containers() + + assert success is False + + @patch("aptl.core.kill.subprocess.run") + def test_down_failure_is_non_fatal_when_kill_succeeded(self, mock_run): + from aptl.core.kill import kill_lab_containers + + def run_side_effect(cmd, **kwargs): + m = MagicMock() + if "kill" in cmd: + m.returncode = 0 + m.stderr = "" + else: + # docker compose down fails + m.returncode = 1 + m.stderr = "network not found" + return m + + mock_run.side_effect = run_side_effect + + success, error = kill_lab_containers() + + # kill succeeded, down failure is just a warning + assert success is True + assert error == "" + + +class TestClearSession: + """Tests for scenario session cleanup.""" + + def test_clears_active_session(self, tmp_path): + from aptl.core.kill import clear_session + + state_dir = tmp_path / ".aptl" + state_dir.mkdir() + session_file = state_dir / "session.json" + session_file.write_text(json.dumps({ + "scenario_id": "test-scenario", + "state": "active", + "started_at": "2026-03-22T00:00:00+00:00", + })) + + result = clear_session(state_dir) + + assert result is True + assert not session_file.exists() + + def test_safe_when_no_session(self, tmp_path): + from aptl.core.kill import clear_session + + state_dir = tmp_path / ".aptl" + state_dir.mkdir() + + result = clear_session(state_dir) + + assert result is False + + def test_safe_when_state_dir_missing(self, tmp_path): + from aptl.core.kill import clear_session + + state_dir = tmp_path / ".aptl" + # Don't create it + + result = clear_session(state_dir) + + assert result is False + + +class TestCleanTraceContext: + """Tests for trace context file cleanup.""" + + def test_removes_trace_context_file(self, tmp_path): + from aptl.core.kill import clean_trace_context + + state_dir = tmp_path / ".aptl" + state_dir.mkdir() + trace_file = state_dir / "trace-context.json" + trace_file.write_text('{"trace_id": "abc", "span_id": "def"}') + + result = clean_trace_context(state_dir) + + assert result is True + assert not trace_file.exists() + + def test_safe_when_no_file(self, tmp_path): + from aptl.core.kill import clean_trace_context + + state_dir = tmp_path / ".aptl" + state_dir.mkdir() + + result = clean_trace_context(state_dir) + + assert result is False + + +class TestExecuteKill: + """Integration tests for the kill switch orchestrator.""" + + @patch("aptl.core.kill.kill_mcp_processes") + @patch("aptl.core.kill.kill_lab_containers") + @patch("aptl.core.kill.clear_session") + @patch("aptl.core.kill.clean_trace_context") + def test_kills_mcp_only_by_default( + self, mock_trace, mock_session, mock_containers, mock_mcp + ): + from aptl.core.kill import execute_kill + + mock_mcp.return_value = (3, []) + mock_session.return_value = False + mock_trace.return_value = False + + result = execute_kill(containers=False) + + assert result.success is True + assert result.mcp_processes_killed == 3 + assert result.containers_stopped is False + mock_containers.assert_not_called() + + @patch("aptl.core.kill.kill_mcp_processes") + @patch("aptl.core.kill.kill_lab_containers") + @patch("aptl.core.kill.clear_session") + @patch("aptl.core.kill.clean_trace_context") + def test_kills_mcp_and_containers( + self, mock_trace, mock_session, mock_containers, mock_mcp + ): + from aptl.core.kill import execute_kill + + mock_mcp.return_value = (2, []) + mock_containers.return_value = (True, "") + mock_session.return_value = True + mock_trace.return_value = True + + result = execute_kill(containers=True) + + assert result.success is True + assert result.mcp_processes_killed == 2 + assert result.containers_stopped is True + assert result.session_cleared is True + assert result.trace_context_cleaned is True + mock_containers.assert_called_once() + + @patch("aptl.core.kill.kill_mcp_processes") + @patch("aptl.core.kill.kill_lab_containers") + @patch("aptl.core.kill.clear_session") + @patch("aptl.core.kill.clean_trace_context") + def test_continues_on_partial_failure( + self, mock_trace, mock_session, mock_containers, mock_mcp + ): + from aptl.core.kill import execute_kill + + mock_mcp.side_effect = RuntimeError("proc failed") + mock_containers.return_value = (True, "") + mock_session.return_value = True + mock_trace.return_value = True + + result = execute_kill(containers=True) + + # MCP failed but containers + session + trace succeeded + assert result.success is True + assert result.mcp_processes_killed == 0 + assert result.containers_stopped is True + assert len(result.errors) == 1 + assert "MCP process kill failed" in result.errors[0] + + @patch("aptl.core.kill.kill_mcp_processes") + @patch("aptl.core.kill.clear_session") + @patch("aptl.core.kill.clean_trace_context") + def test_reports_all_errors(self, mock_trace, mock_session, mock_mcp): + from aptl.core.kill import execute_kill + + mock_mcp.return_value = (0, ["perm denied pid=100"]) + mock_session.side_effect = RuntimeError("session broken") + mock_trace.return_value = False + + result = execute_kill(containers=False) + + assert result.success is False + assert len(result.errors) == 2 + + @patch("aptl.core.kill.kill_mcp_processes") + @patch("aptl.core.kill.clear_session") + @patch("aptl.core.kill.clean_trace_context") + def test_success_when_nothing_to_kill(self, mock_trace, mock_session, mock_mcp): + from aptl.core.kill import execute_kill + + mock_mcp.return_value = (0, []) + mock_session.return_value = False + mock_trace.return_value = False + + result = execute_kill(containers=False) + + # No actions taken but no errors either = success + assert result.success is True + assert result.mcp_processes_killed == 0 + + @patch("aptl.core.kill.kill_mcp_processes") + @patch("aptl.core.kill.clear_session") + @patch("aptl.core.kill.clean_trace_context") + def test_cleans_session_and_trace_context(self, mock_trace, mock_session, mock_mcp): + from aptl.core.kill import execute_kill + + mock_mcp.return_value = (0, []) + mock_session.return_value = True + mock_trace.return_value = True + + result = execute_kill(containers=False) + + assert result.session_cleared is True + assert result.trace_context_cleaned is True + mock_session.assert_called_once() + mock_trace.assert_called_once() diff --git a/tests/test_run_assembler.py b/tests/test_run_assembler.py index e8cec8a77..633896e65 100644 --- a/tests/test_run_assembler.py +++ b/tests/test_run_assembler.py @@ -8,7 +8,6 @@ import pytest from aptl.core.config import AptlConfig -from aptl.core.events import Event, EventType from aptl.core.run_assembler import assemble_run from aptl.core.runstore import LocalRunStore from aptl.core.scenarios import ScenarioDefinition, load_scenario @@ -20,7 +19,8 @@ def _make_session(run_id: str = "test-run-id") -> ActiveSession: scenario_id="test-scenario", state=SessionState.COMPLETED, started_at="2025-01-01T00:00:00+00:00", - events_file="events/test.jsonl", + trace_id="a" * 32, + span_id="b" * 16, flags={ "aptl-victim": { "user": { @@ -36,22 +36,6 @@ def _make_session(run_id: str = "test-run-id") -> ActiveSession: -def _make_events() -> list[Event]: - return [ - Event( - event_type=EventType.SCENARIO_STARTED, - scenario_id="test-scenario", - timestamp="2025-01-01T00:00:00+00:00", - data={"mode": "red"}, - ), - Event( - event_type=EventType.SCENARIO_STOPPED, - scenario_id="test-scenario", - timestamp="2025-01-01T01:00:00+00:00", - ), - ] - - def _make_scenario_yaml(path: Path) -> Path: """Create a minimal scenario YAML file for testing.""" yaml_content = """metadata: @@ -103,7 +87,7 @@ class TestAssembleRun: @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_mcp_traces") + @patch("aptl.core.run_assembler.collect_traces") def test_assembles_complete_run( self, mock_traces, @@ -129,7 +113,8 @@ def test_assembles_complete_run( "aptl-victim": "victim log output\n", } mock_traces.return_value = [ - {"timestamp": "2025-01-01T00:15:00+00:00", "tool_name": "kali_run_command"}, + {"name": "aptl.scenario.run", "traceId": "a" * 32}, + {"name": "execute_tool", "traceId": "a" * 32}, ] # Create scenario file @@ -138,7 +123,6 @@ def test_assembles_complete_run( store = LocalRunStore(tmp_path / "runs") session = _make_session() - events = _make_events() config = AptlConfig() run_dir = assemble_run( @@ -147,7 +131,6 @@ def test_assembles_complete_run( session=session, scenario=scenario, scenario_path=scenario_path, - events=events, config=config, ) @@ -156,10 +139,9 @@ def test_assembles_complete_run( assert (run_dir / "manifest.json").exists() assert (run_dir / "flags.json").exists() assert (run_dir / "scenario" / "definition.yaml").exists() - assert (run_dir / "scenario" / "events.jsonl").exists() assert (run_dir / "wazuh" / "alerts.jsonl").exists() assert (run_dir / "containers" / "aptl-victim.log").exists() - assert (run_dir / "agents" / "traces.jsonl").exists() + assert (run_dir / "traces" / "spans.json").exists() # Verify manifest content manifest = json.loads((run_dir / "manifest.json").read_text()) @@ -167,23 +149,19 @@ def test_assembles_complete_run( 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 events - event_lines = (run_dir / "scenario" / "events.jsonl").read_text().strip().splitlines() - assert len(event_lines) == 2 - # Verify container logs assert "victim log output" in (run_dir / "containers" / "aptl-victim.log").read_text() # Verify traces - trace_lines = (run_dir / "agents" / "traces.jsonl").read_text().strip().splitlines() - assert len(trace_lines) == 1 - trace = json.loads(trace_lines[0]) - assert trace["tool_name"] == "kali_run_command" + 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") @@ -192,7 +170,7 @@ def test_assembles_complete_run( @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_mcp_traces") + @patch("aptl.core.run_assembler.collect_traces") def test_handles_no_optional_data( self, mock_traces, @@ -220,7 +198,6 @@ def test_handles_no_optional_data( store = LocalRunStore(tmp_path / "runs") session = _make_session() - events = _make_events() config = AptlConfig() run_dir = assemble_run( @@ -229,17 +206,15 @@ def test_handles_no_optional_data( session=session, scenario=scenario, scenario_path=scenario_path, - events=events, config=config, ) # Core files should always exist assert (run_dir / "manifest.json").exists() assert (run_dir / "flags.json").exists() - assert (run_dir / "scenario" / "events.jsonl").exists() - # Optional directories should not have files when empty - assert not (run_dir / "agents" / "traces.jsonl").exists() + # No traces when Tempo returns empty + assert not (run_dir / "traces" / "spans.json").exists() assert not (run_dir / "soc" / "thehive-cases.json").exists() @@ -253,7 +228,7 @@ class TestRunManifest: @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_mcp_traces", 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 ): @@ -268,7 +243,6 @@ def test_manifest_has_required_fields( session=_make_session("manifest-test"), scenario=scenario, scenario_path=scenario_path, - events=_make_events(), config=AptlConfig(), ) @@ -281,6 +255,7 @@ def test_manifest_has_required_fields( "started_at", "finished_at", "duration_seconds", + "trace_id", "config_snapshot", "containers", "flags_captured", @@ -289,6 +264,7 @@ def test_manifest_has_required_fields( 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"] diff --git a/tests/test_session.py b/tests/test_session.py index eb7d91af0..9c1029585 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -74,10 +74,11 @@ def test_creation_with_defaults(self): scenario_id="test", state=SessionState.ACTIVE, started_at="2026-02-16T14:30:00+00:00", - events_file="events/test.jsonl", ) assert session.hints_used == {} assert session.completed_objectives == [] + assert session.trace_id == "" + assert session.span_id == "" def test_creation_with_all_fields(self): """ActiveSession should accept all fields.""" @@ -87,12 +88,15 @@ def test_creation_with_all_fields(self): scenario_id="test", state=SessionState.ACTIVE, started_at="2026-02-16T14:30:00+00:00", - events_file="events/test.jsonl", + trace_id="a" * 32, + span_id="b" * 16, hints_used={"obj-a": 2}, completed_objectives=["obj-a"], ) assert session.hints_used == {"obj-a": 2} assert session.completed_objectives == ["obj-a"] + assert session.trace_id == "a" * 32 + assert session.span_id == "b" * 16 # --------------------------------------------------------------------------- @@ -116,7 +120,8 @@ def test_round_trip(self): scenario_id="test-scenario", state=SessionState.ACTIVE, started_at="2026-02-16T14:30:00+00:00", - events_file="events/test.jsonl", + trace_id="a" * 32, + span_id="b" * 16, hints_used={"obj-a": 1}, completed_objectives=["obj-b"], ) @@ -126,7 +131,8 @@ def test_round_trip(self): assert restored.scenario_id == original.scenario_id assert restored.state == original.state assert restored.started_at == original.started_at - assert restored.events_file == original.events_file + assert restored.trace_id == original.trace_id + assert restored.span_id == original.span_id assert restored.hints_used == original.hints_used assert restored.completed_objectives == original.completed_objectives @@ -138,7 +144,6 @@ def test_serialize_state_as_string(self): scenario_id="test", state=SessionState.ACTIVE, started_at="2026-02-16T14:30:00+00:00", - events_file="test.jsonl", ) data = _serialize_session(session) assert data["state"] == "active" @@ -160,19 +165,19 @@ def test_deserialize_invalid_state_raises(self): "scenario_id": "test", "state": "unknown_state", "started_at": "2026-02-16T14:30:00+00:00", - "events_file": "test.jsonl", }) def test_deserialize_defaults_optional_fields(self): - """Deserialization should default hints_used and completed_objectives.""" + """Deserialization should default trace_id, span_id, hints_used and completed_objectives.""" from aptl.core.session import _deserialize_session session = _deserialize_session({ "scenario_id": "test", "state": "active", "started_at": "2026-02-16T14:30:00+00:00", - "events_file": "test.jsonl", }) + assert session.trace_id == "" + assert session.span_id == "" assert session.hints_used == {} assert session.completed_objectives == [] @@ -191,9 +196,8 @@ def test_start_creates_session_file(self, aptl_state_dir): mgr = ScenarioSession(aptl_state_dir) scenario = _make_scenario_def() - events_file = aptl_state_dir / "events" / "test.jsonl" - session = mgr.start(scenario, events_file) + session = mgr.start(scenario) assert mgr.session_path.exists() assert session.scenario_id == "test-scenario" @@ -204,7 +208,7 @@ def test_start_writes_valid_json(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) data = json.loads(mgr.session_path.read_text()) assert data["scenario_id"] == "test-scenario" @@ -216,7 +220,7 @@ def test_start_creates_state_dir_if_missing(self, tmp_path): state_dir = tmp_path / "new_aptl_dir" mgr = ScenarioSession(state_dir) - mgr.start(_make_scenario_def(), state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) assert state_dir.exists() assert mgr.session_path.exists() @@ -227,33 +231,30 @@ def test_start_rejects_when_already_active(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def("first"), aptl_state_dir / "e1.jsonl") + mgr.start(_make_scenario_def("first")) with pytest.raises(ScenarioStateError, match="already active"): - mgr.start(_make_scenario_def("second"), aptl_state_dir / "e2.jsonl") + mgr.start(_make_scenario_def("second")) def test_start_sets_utc_timestamp(self, aptl_state_dir): """Session started_at should be a UTC ISO 8601 timestamp.""" from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - session = mgr.start( - _make_scenario_def(), - aptl_state_dir / "events" / "test.jsonl", - ) + session = mgr.start(_make_scenario_def()) assert "T" in session.started_at assert "+" in session.started_at or "Z" in session.started_at - def test_start_stores_relative_events_path(self, aptl_state_dir): - """Events file path should be stored relative to state dir.""" + def test_start_generates_trace_context(self, aptl_state_dir): + """Starting a session should generate valid trace_id and span_id.""" from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - events_file = aptl_state_dir / "events" / "test.jsonl" - session = mgr.start(_make_scenario_def(), events_file) - - # Should be relative, not absolute - assert not session.events_file.startswith("/") + session = mgr.start(_make_scenario_def()) + assert len(session.trace_id) == 32 + assert len(session.span_id) == 16 + int(session.trace_id, 16) # should not raise + int(session.span_id, 16) # should not raise # --------------------------------------------------------------------------- @@ -276,7 +277,7 @@ def test_get_active_returns_session(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) session = mgr.get_active() assert session is not None @@ -287,7 +288,7 @@ def test_is_active_true_when_active(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) assert mgr.is_active() is True def test_is_active_false_when_no_session(self, aptl_state_dir): @@ -302,7 +303,7 @@ def test_is_active_false_when_completed(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.finish() assert mgr.is_active() is False @@ -330,7 +331,7 @@ def test_persistence_across_instances(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr1 = ScenarioSession(aptl_state_dir) - mgr1.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr1.start(_make_scenario_def()) mgr2 = ScenarioSession(aptl_state_dir) session = mgr2.get_active() @@ -351,7 +352,7 @@ def test_record_hint(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.record_hint("obj-a", 1) @@ -363,7 +364,7 @@ def test_record_higher_hint_level_updates(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.record_hint("obj-a", 1) mgr.record_hint("obj-a", 2) @@ -376,7 +377,7 @@ def test_record_lower_hint_level_is_noop(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.record_hint("obj-a", 2) mgr.record_hint("obj-a", 1) @@ -398,7 +399,7 @@ def test_record_hints_for_multiple_objectives(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.record_hint("obj-a", 1) mgr.record_hint("obj-b", 2) @@ -420,7 +421,7 @@ def test_record_objective_complete(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.record_objective_complete("obj-a") @@ -432,7 +433,7 @@ def test_record_same_objective_is_idempotent(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.record_objective_complete("obj-a") mgr.record_objective_complete("obj-a") @@ -445,7 +446,7 @@ def test_record_multiple_objectives(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.record_objective_complete("obj-a") mgr.record_objective_complete("obj-b") @@ -476,7 +477,7 @@ def test_finish_sets_completed_state(self, aptl_state_dir): from aptl.core.session import ScenarioSession, SessionState mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) finished = mgr.finish() assert finished.state == SessionState.COMPLETED @@ -486,7 +487,7 @@ def test_finish_persists_to_disk(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.finish() data = json.loads(mgr.session_path.read_text()) @@ -506,7 +507,7 @@ def test_finish_returns_session_with_accumulated_state(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) mgr.record_hint("obj-a", 1) mgr.record_objective_complete("obj-b") @@ -528,7 +529,7 @@ def test_clear_removes_session_file(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def(), aptl_state_dir / "events" / "test.jsonl") + mgr.start(_make_scenario_def()) assert mgr.session_path.exists() mgr.clear() @@ -546,11 +547,11 @@ def test_clear_allows_new_start(self, aptl_state_dir): from aptl.core.session import ScenarioSession mgr = ScenarioSession(aptl_state_dir) - mgr.start(_make_scenario_def("first"), aptl_state_dir / "e1.jsonl") + mgr.start(_make_scenario_def("first")) mgr.finish() mgr.clear() - session = mgr.start(_make_scenario_def("second"), aptl_state_dir / "e2.jsonl") + session = mgr.start(_make_scenario_def("second")) assert session.scenario_id == "second" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 000000000..8d13e2baf --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,346 @@ +"""Tests for the OpenTelemetry tracing module. + +Tests exercise trace context generation, file round-trip, parent context +construction, span creation, and the init/shutdown lifecycle using +InMemorySpanExporter to verify spans are correctly produced. +""" + +import json +from pathlib import Path + +import pytest + +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry import trace + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _reset_telemetry(): + """Ensure telemetry module state is clean for each test.""" + import aptl.core.telemetry as mod + mod._provider = None + yield + # Clean up after test + if mod._provider is not None: + try: + mod._provider.shutdown() + except Exception: + pass + mod._provider = None + + +@pytest.fixture +def memory_exporter(monkeypatch): + """Set up an InMemorySpanExporter for testing span output. + + Patches get_tracer to return a tracer from our test provider + so spans are captured regardless of the global provider state. + """ + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + import aptl.core.telemetry as mod + + # Patch get_tracer to use our test provider + monkeypatch.setattr(mod, "get_tracer", lambda name="aptl": provider.get_tracer(name)) + mod._provider = provider + + yield exporter + + provider.shutdown() + mod._provider = None + + +# --------------------------------------------------------------------------- +# generate_trace_context +# --------------------------------------------------------------------------- + + +class TestGenerateTraceContext: + """Tests for trace context ID generation.""" + + def test_produces_valid_hex_ids(self): + from aptl.core.telemetry import generate_trace_context + + ctx = generate_trace_context() + assert len(ctx["trace_id"]) == 32 + assert len(ctx["span_id"]) == 16 + int(ctx["trace_id"], 16) # should not raise + int(ctx["span_id"], 16) + + def test_produces_unique_ids(self): + from aptl.core.telemetry import generate_trace_context + + ctx1 = generate_trace_context() + ctx2 = generate_trace_context() + assert ctx1["trace_id"] != ctx2["trace_id"] + assert ctx1["span_id"] != ctx2["span_id"] + + def test_includes_sampled_flag(self): + from aptl.core.telemetry import generate_trace_context + + ctx = generate_trace_context() + assert ctx["trace_flags"] == "01" + + +# --------------------------------------------------------------------------- +# write / load trace context +# --------------------------------------------------------------------------- + + +class TestTraceContextRoundTrip: + """Tests for writing and loading trace context files.""" + + def test_round_trip(self, tmp_path): + from aptl.core.telemetry import ( + generate_trace_context, + load_trace_context, + write_trace_context, + ) + + ctx = generate_trace_context() + write_trace_context(tmp_path, ctx["trace_id"], ctx["span_id"]) + + loaded = load_trace_context(tmp_path) + assert loaded is not None + assert format(loaded.trace_id, "032x") == ctx["trace_id"] + assert format(loaded.span_id, "016x") == ctx["span_id"] + assert loaded.is_remote is True + + def test_creates_parent_dirs(self, tmp_path): + from aptl.core.telemetry import write_trace_context + + nested = tmp_path / "deep" / "nested" + write_trace_context(nested, "a" * 32, "b" * 16) + assert (nested / "trace-context.json").exists() + + def test_load_missing_file_returns_none(self, tmp_path): + from aptl.core.telemetry import load_trace_context + + assert load_trace_context(tmp_path / "nonexistent") is None + + def test_load_corrupt_json_returns_none(self, tmp_path): + from aptl.core.telemetry import load_trace_context + + (tmp_path / "trace-context.json").write_text("not json") + assert load_trace_context(tmp_path) is None + + def test_load_missing_fields_returns_none(self, tmp_path): + from aptl.core.telemetry import load_trace_context + + (tmp_path / "trace-context.json").write_text('{"trace_id": "abc"}') + assert load_trace_context(tmp_path) is None + + def test_write_produces_valid_json(self, tmp_path): + from aptl.core.telemetry import write_trace_context + + write_trace_context(tmp_path, "a" * 32, "b" * 16) + data = json.loads((tmp_path / "trace-context.json").read_text()) + assert data["trace_id"] == "a" * 32 + assert data["span_id"] == "b" * 16 + assert data["trace_flags"] == "01" + + +# --------------------------------------------------------------------------- +# make_parent_context +# --------------------------------------------------------------------------- + + +class TestMakeParentContext: + """Tests for constructing OTel parent contexts.""" + + def test_creates_valid_context(self): + from aptl.core.telemetry import make_parent_context + + ctx = make_parent_context("a" * 32, "b" * 16) + span = trace.get_current_span(ctx) + span_ctx = span.get_span_context() + assert span_ctx.trace_id == int("a" * 32, 16) + assert span_ctx.span_id == int("b" * 16, 16) + assert span_ctx.is_remote is True + + +# --------------------------------------------------------------------------- +# Span creation with InMemorySpanExporter +# --------------------------------------------------------------------------- + + +class TestCreateRootSpan: + """Tests for the synthetic root span.""" + + def test_creates_span_with_correct_attributes(self, memory_exporter): + from aptl.core.telemetry import create_root_span, get_tracer + + tracer = get_tracer() + trace_id = "a" * 32 + span_id = "0" * 16 + + start_ns = 1_000_000_000_000 + end_ns = 2_000_000_000_000 + + create_root_span( + tracer=tracer, + scenario_id="test-scenario", + run_id="test-run", + start_time=start_ns, + end_time=end_ns, + trace_id=trace_id, + span_id=span_id, + ) + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.name == "aptl.scenario.run" + assert span.attributes["aptl.scenario.id"] == "test-scenario" + assert span.attributes["aptl.run.id"] == "test-run" + assert span.start_time == start_ns + assert span.end_time == end_ns + + +class TestRecordEvent: + """Tests for span events.""" + + def test_creates_event_span(self, memory_exporter): + from aptl.core.telemetry import ( + get_tracer, + make_parent_context, + record_event, + ) + + tracer = get_tracer() + parent_ctx = make_parent_context("a" * 32, "b" * 16) + + record_event(tracer, parent_ctx, "alert_matched", { + "rule_id": "1234", + "severity": "high", + }) + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "alert_matched" + assert spans[0].attributes["rule_id"] == "1234" + + def test_event_without_attributes(self, memory_exporter): + from aptl.core.telemetry import ( + get_tracer, + make_parent_context, + record_event, + ) + + tracer = get_tracer() + parent_ctx = make_parent_context("a" * 32, "b" * 16) + record_event(tracer, parent_ctx, "hint_requested") + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "hint_requested" + + +class TestCreateChildSpan: + """Tests for child span creation.""" + + def test_creates_child_span(self, memory_exporter): + from aptl.core.telemetry import ( + create_child_span, + get_tracer, + make_parent_context, + ) + + tracer = get_tracer() + parent_ctx = make_parent_context("a" * 32, "b" * 16) + + span = create_child_span(tracer, parent_ctx, "aptl.precondition", { + "precondition.name": "file-check", + }) + span.end() + + spans = memory_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "aptl.precondition" + assert spans[0].attributes["precondition.name"] == "file-check" + + def test_child_span_caller_must_end(self, memory_exporter): + from aptl.core.telemetry import ( + create_child_span, + get_tracer, + make_parent_context, + ) + + tracer = get_tracer() + parent_ctx = make_parent_context("a" * 32, "b" * 16) + span = create_child_span(tracer, parent_ctx, "test-span") + + # Before ending, no finished spans + assert len(memory_exporter.get_finished_spans()) == 0 + + span.end() + assert len(memory_exporter.get_finished_spans()) == 1 + + +# --------------------------------------------------------------------------- +# init / shutdown lifecycle +# --------------------------------------------------------------------------- + + +class TestLifecycle: + """Tests for init_tracing / shutdown_tracing.""" + + def test_init_is_idempotent(self): + from aptl.core.telemetry import init_tracing, shutdown_tracing + import aptl.core.telemetry as mod + + init_tracing("test-svc") + provider1 = mod._provider + init_tracing("test-svc-2") + assert mod._provider is provider1 # not replaced + + shutdown_tracing() + + def test_shutdown_clears_provider(self): + from aptl.core.telemetry import init_tracing, shutdown_tracing + import aptl.core.telemetry as mod + + init_tracing("test-svc") + assert mod._provider is not None + shutdown_tracing() + assert mod._provider is None + + def test_shutdown_without_init_is_safe(self): + from aptl.core.telemetry import shutdown_tracing + + shutdown_tracing() # should not raise + + +# --------------------------------------------------------------------------- +# Span name constants +# --------------------------------------------------------------------------- + + +class TestConstants: + """Verify span name constants are defined.""" + + def test_span_names(self): + from aptl.core.telemetry import ( + EVENT_ALERT_MATCHED, + EVENT_HINT_REQUESTED, + SPAN_EVALUATION, + SPAN_OBJECTIVE, + SPAN_PRECONDITION, + SPAN_SCENARIO_RUN, + ) + + assert SPAN_SCENARIO_RUN == "aptl.scenario.run" + assert SPAN_PRECONDITION == "aptl.precondition" + assert SPAN_OBJECTIVE == "aptl.objective" + assert SPAN_EVALUATION == "aptl.evaluation" + assert EVENT_ALERT_MATCHED == "alert_matched" + assert EVENT_HINT_REQUESTED == "hint_requested" diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 000000000..da59c357e --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.svelte-kit +tests +coverage +.env +*.test.ts diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 000000000..7245900da --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-slim AS builder + +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:22-slim +WORKDIR /app +COPY --from=builder /app/build ./build +COPY --from=builder /app/package.json . +COPY --from=builder /app/package-lock.json* . +RUN npm ci --omit=dev + +EXPOSE 3000 +ENV NODE_ENV=production +CMD ["node", "build"] diff --git a/web/Dockerfile.api b/web/Dockerfile.api new file mode 100644 index 000000000..62edfd567 --- /dev/null +++ b/web/Dockerfile.api @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install Docker CLI via official APT repo (not curl|sh) +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl gnupg && \ + install -m 0755 -d /etc/apt/keyrings && \ + curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \ + chmod a+r /etc/apt/keyrings/docker.asc && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends docker-ce-cli && \ + apt-get purge -y gnupg && \ + apt-get autoremove -y && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install Python deps +COPY pyproject.toml . +COPY src/ src/ +RUN pip install --no-cache-dir ".[web]" + +EXPOSE 8400 + +CMD ["uvicorn", "aptl.api.main:app", "--host", "0.0.0.0", "--port", "8400", "--workers", "2", "--log-level", "info", "--timeout-keep-alive", "65"] diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 000000000..ff423024a --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4701 @@ +{ + "name": "aptl-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aptl-web", + "version": "0.1.0", + "dependencies": { + "dompurify": "^3.3.3", + "marked": "^17.0.5" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "@testing-library/svelte": "^5.2.0", + "@types/dompurify": "^3.0.5", + "@vitest/coverage-v8": "^3.2.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "jsdom": "^25.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.5.tgz", + "integrity": "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", + "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..a1919bf33 --- /dev/null +++ b/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "aptl-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.0", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "@testing-library/svelte": "^5.2.0", + "@types/dompurify": "^3.0.5", + "@vitest/coverage-v8": "^3.2.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "jsdom": "^25.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + }, + "dependencies": { + "dompurify": "^3.3.3", + "marked": "^17.0.5" + } +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 000000000..9fed0b1d9 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,112 @@ +@import 'tailwindcss'; + +@theme { + /* APTL Purple Team palette */ + --color-aptl-bg: #1a1d23; + --color-aptl-surface: #23272f; + --color-aptl-surface-hover: #2a2f38; + --color-aptl-border: #333842; + --color-aptl-text: #e2e8f0; + --color-aptl-text-muted: #94a3b8; + + /* Accent colors */ + --color-aptl-indigo: #6366f1; + --color-aptl-indigo-hover: #818cf8; + --color-aptl-violet: #a855f7; + --color-aptl-teal: #14b8a6; + --color-aptl-red: #ef4444; + --color-aptl-green: #22c55e; + --color-aptl-amber: #f59e0b; + + /* Typography */ + --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; +} + +body { + background-color: var(--color-aptl-bg); + color: var(--color-aptl-text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Markdown prose styling for workbench narrative blocks */ +.prose-aptl { + color: var(--color-aptl-text); + line-height: 1.65; +} + +.prose-aptl h1 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.prose-aptl h2 { + font-size: 1.25rem; + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +.prose-aptl h3 { + font-size: 1rem; + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.25rem; +} + +.prose-aptl p { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + color: var(--color-aptl-text-muted); +} + +.prose-aptl strong { + color: var(--color-aptl-text); + font-weight: 600; +} + +.prose-aptl code { + background-color: var(--color-aptl-surface); + border-radius: 0.25rem; + padding: 0.125rem 0.375rem; + font-family: var(--font-mono); + font-size: 0.8125rem; +} + +.prose-aptl pre { + background-color: var(--color-aptl-bg); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + overflow-x: auto; + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} + +.prose-aptl pre code { + background: none; + padding: 0; +} + +.prose-aptl ul, +.prose-aptl ol { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + padding-left: 1.25rem; + color: var(--color-aptl-text-muted); +} + +.prose-aptl li { + margin-top: 0.25rem; +} + +.prose-aptl a { + color: var(--color-aptl-indigo); + text-decoration: underline; +} + +.prose-aptl a:hover { + color: var(--color-aptl-indigo-hover); +} diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 000000000..50d32e043 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,16 @@ + + + + + + + + + + APTL — Advanced Purple Team Lab + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 000000000..da2a312cd --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,62 @@ +import type { + AppConfig, + LabActionResponse, + LabStatus, + ScenarioDefinition, + ScenarioSummary +} from './types'; + +const BASE = '/api'; +const MAX_ERROR_TEXT_LENGTH = 500; + +async function fetchJSON(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, init); + if (!res.ok) { + let text = await res.text(); + if (text.length > MAX_ERROR_TEXT_LENGTH) { + text = text.slice(0, MAX_ERROR_TEXT_LENGTH) + '...'; + } + throw new Error(`API error ${res.status}: ${text}`); + } + return res.json() as Promise; +} + +export async function getLabStatus(): Promise { + return fetchJSON('/lab/status'); +} + +export async function startLab(): Promise { + return fetchJSON('/lab/start', { method: 'POST' }); +} + +export async function stopLab(): Promise { + return fetchJSON('/lab/stop', { method: 'POST' }); +} + +export async function getScenarios(): Promise { + return fetchJSON('/scenarios'); +} + +export async function getScenario(id: string): Promise { + return fetchJSON(`/scenarios/${encodeURIComponent(id)}`); +} + +export async function getConfig(): Promise { + return fetchJSON('/config'); +} + +/** Create an SSE connection to the lab events stream. */ +export function subscribeLabEvents( + onMessage: (status: LabStatus) => void, + onError?: (event: Event) => void +): EventSource { + const es = new EventSource(`${BASE}/lab/events`); + es.addEventListener('lab_status', (event) => { + const data = JSON.parse((event as MessageEvent).data) as LabStatus; + onMessage(data); + }); + if (onError) { + es.onerror = onError; + } + return es; +} diff --git a/web/src/lib/components/ContainerCard.svelte b/web/src/lib/components/ContainerCard.svelte new file mode 100644 index 000000000..d9dbadbe3 --- /dev/null +++ b/web/src/lib/components/ContainerCard.svelte @@ -0,0 +1,96 @@ + + +
+
+
+
+ +

+ {displayName} +

+
+

+ {container.state} + {#if container.health && container.health !== 'N/A'} + · {container.health} + {/if} +

+
+
+

+ {container.image} +

+ {#if container.ports.length > 0} +
+ {#each container.ports.slice(0, 3) as port} + + {port} + + {/each} + {#if container.ports.length > 3} + + +{container.ports.length - 3} more + + {/if} +
+ {/if} + {#if showTerminal} + + {/if} +
diff --git a/web/src/lib/components/ContainerGrid.svelte b/web/src/lib/components/ContainerGrid.svelte new file mode 100644 index 000000000..aee4cb39a --- /dev/null +++ b/web/src/lib/components/ContainerGrid.svelte @@ -0,0 +1,22 @@ + + +{#if containers.length === 0} +
+

No containers running

+
+{:else} +
+ {#each containers as container (container.name)} + + {/each} +
+{/if} diff --git a/web/src/lib/components/LabStatusBadge.svelte b/web/src/lib/components/LabStatusBadge.svelte new file mode 100644 index 000000000..35dce2a83 --- /dev/null +++ b/web/src/lib/components/LabStatusBadge.svelte @@ -0,0 +1,21 @@ + + +
+ + {running ? 'Running' : 'Stopped'} +
diff --git a/web/src/lib/components/NavBar.svelte b/web/src/lib/components/NavBar.svelte new file mode 100644 index 000000000..0aa7fba42 --- /dev/null +++ b/web/src/lib/components/NavBar.svelte @@ -0,0 +1,25 @@ + + + diff --git a/web/src/lib/components/ScenarioCard.svelte b/web/src/lib/components/ScenarioCard.svelte new file mode 100644 index 000000000..ee430e09d --- /dev/null +++ b/web/src/lib/components/ScenarioCard.svelte @@ -0,0 +1,74 @@ + + + +
+ + {scenario.mode} + + + {scenario.difficulty} + +
+ +

{scenario.name}

+

{scenario.description}

+ +
+ {scenario.estimated_minutes} min + {#if scenario.tags.length > 0} + · + {scenario.tags.slice(0, 3).join(', ')} + {/if} +
+ + {#if scenario.containers_required.length > 0} +
+ {#each scenario.containers_required as c} + + {c} + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/Terminal.svelte b/web/src/lib/components/Terminal.svelte new file mode 100644 index 000000000..379dd8995 --- /dev/null +++ b/web/src/lib/components/Terminal.svelte @@ -0,0 +1,110 @@ + + +
diff --git a/web/src/lib/components/workbench/AttackStepBlock.svelte b/web/src/lib/components/workbench/AttackStepBlock.svelte new file mode 100644 index 000000000..3caf9d615 --- /dev/null +++ b/web/src/lib/components/workbench/AttackStepBlock.svelte @@ -0,0 +1,136 @@ + + +
+ +
+ + {step.step_number} + +
+

{step.technique_name}

+
+ {step.technique_id} + · + {step.tactic} + {#if step.vulnerability} + · + {step.vulnerability} + {/if} +
+
+
+ + +

{step.description}

+ + + {#if step.commands.length > 0} +
+ Commands + {#each step.commands as cmd, i} +
+
{cmd}
+ +
+ {/each} +
+ {/if} + + + {#if step.expected_detections.length > 0} +
+ Expected Detections +
+ {#each step.expected_detections as det} +
+ + {det.product_name} + + {det.description} + {#if det.analytic_uid} + + {det.analytic_uid} + + {/if} +
+ {/each} +
+
+ {/if} + + + {#if step.investigation_hints.length > 0} +
+ Investigation Hints +
    + {#each step.investigation_hints as hint} +
  • {hint}
  • + {/each} +
+
+ {/if} + + + {#if step.remediation.length > 0} +
+ Remediation +
    + {#each step.remediation as item} +
  • {item}
  • + {/each} +
+
+ {/if} + + + {#if step.target} + {#if terminalOpen} + + {:else} + + {/if} + {/if} +
diff --git a/web/src/lib/components/workbench/ContainerStatusBlock.svelte b/web/src/lib/components/workbench/ContainerStatusBlock.svelte new file mode 100644 index 000000000..ead0baa9b --- /dev/null +++ b/web/src/lib/components/workbench/ContainerStatusBlock.svelte @@ -0,0 +1,35 @@ + + +
+ {#each filteredContainers as c (c.name)} +
+ + {c.name} +
+ {/each} +
diff --git a/web/src/lib/components/workbench/HintToggle.svelte b/web/src/lib/components/workbench/HintToggle.svelte new file mode 100644 index 000000000..bf7bb4835 --- /dev/null +++ b/web/src/lib/components/workbench/HintToggle.svelte @@ -0,0 +1,56 @@ + + +{#if sortedHints.length > 0} +
+ {#each sortedHints as hint, i (hint.level)} + {#if i < revealedCount} +
+
+ + Hint {hint.level} + + {#if hint.point_penalty > 0} + + -{hint.point_penalty} pts + + {/if} +
+

{hint.text}

+
+ {/if} + {/each} + + {#if revealedCount < sortedHints.length} + {@const next = sortedHints[revealedCount]} + + {/if} +
+{/if} diff --git a/web/src/lib/components/workbench/NarrativeBlock.svelte b/web/src/lib/components/workbench/NarrativeBlock.svelte new file mode 100644 index 000000000..0aff53e35 --- /dev/null +++ b/web/src/lib/components/workbench/NarrativeBlock.svelte @@ -0,0 +1,15 @@ + + +
+ {@html html} +
diff --git a/web/src/lib/components/workbench/ObjectiveBlock.svelte b/web/src/lib/components/workbench/ObjectiveBlock.svelte new file mode 100644 index 000000000..b65b801e8 --- /dev/null +++ b/web/src/lib/components/workbench/ObjectiveBlock.svelte @@ -0,0 +1,47 @@ + + +
+
+ + {team} + + + {objective.type} + + + {objective.points} pts + +
+ +

{objective.description}

+ + {#if objective.wazuh_alert} +
+ +
+ {/if} + + +
diff --git a/web/src/lib/components/workbench/SectionDivider.svelte b/web/src/lib/components/workbench/SectionDivider.svelte new file mode 100644 index 000000000..cef652cf2 --- /dev/null +++ b/web/src/lib/components/workbench/SectionDivider.svelte @@ -0,0 +1,13 @@ + + +
+
+ {title} +
+
diff --git a/web/src/lib/components/workbench/SiemQueryBlock.svelte b/web/src/lib/components/workbench/SiemQueryBlock.svelte new file mode 100644 index 000000000..275cde74c --- /dev/null +++ b/web/src/lib/components/workbench/SiemQueryBlock.svelte @@ -0,0 +1,31 @@ + + +
+
+ + {product_name} + + {description} +
+
{queryJson}
+
+ + OpenSearch integration coming soon +
+
diff --git a/web/src/lib/components/workbench/TerminalBlock.svelte b/web/src/lib/components/workbench/TerminalBlock.svelte new file mode 100644 index 000000000..44db10635 --- /dev/null +++ b/web/src/lib/components/workbench/TerminalBlock.svelte @@ -0,0 +1,27 @@ + + +
+
+ {displayLabel} + + Maximize + +
+
+ +
+
diff --git a/web/src/lib/components/workbench/WorkbenchStatusBar.svelte b/web/src/lib/components/workbench/WorkbenchStatusBar.svelte new file mode 100644 index 000000000..5e0453f2b --- /dev/null +++ b/web/src/lib/components/workbench/WorkbenchStatusBar.svelte @@ -0,0 +1,82 @@ + + +
+
+ + ← Lab + + +

{meta.name}

+ + + {scenario.mode} + + + +
+ {#each containers as name} + {@const state = containerState(name)} +
+ + {name} +
+ {/each} +
+ + + {#if totalPoints > 0} + + {totalPoints} pts available + + {/if} +
+
diff --git a/web/src/lib/container-state.ts b/web/src/lib/container-state.ts new file mode 100644 index 000000000..be720d07c --- /dev/null +++ b/web/src/lib/container-state.ts @@ -0,0 +1,6 @@ +/** Map a Docker container state string to a Tailwind background color class. */ +export function stateColor(state: string): string { + if (state === 'running') return 'bg-aptl-green'; + if (state === 'exited' || state === 'dead') return 'bg-aptl-red'; + return 'bg-aptl-amber'; +} diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts new file mode 100644 index 000000000..bf421c647 --- /dev/null +++ b/web/src/lib/markdown.ts @@ -0,0 +1,12 @@ +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +/** + * Render a markdown string to sanitized HTML. + * Returns empty string for falsy input. + */ +export function renderMarkdown(input: string | null | undefined): string { + if (!input) return ''; + const raw = marked.parse(input, { async: false }) as string; + return DOMPurify.sanitize(raw); +} diff --git a/web/src/lib/stores/lab.ts b/web/src/lib/stores/lab.ts new file mode 100644 index 000000000..e3db1c54b --- /dev/null +++ b/web/src/lib/stores/lab.ts @@ -0,0 +1,71 @@ +import { writable } from 'svelte/store'; +import type { LabStatus } from '../types'; +import { getLabStatus, subscribeLabEvents } from '../api'; + +export const labStatus = writable({ + running: false, + containers: [], + error: null +}); + +export const labLoading = writable(false); + +let eventSource: EventSource | null = null; +let generation = 0; + +const RECONNECT_DELAY_MS = 5000; + +/** Fetch initial lab status and start SSE subscription. */ +export function initLabStore(): void { + const currentGeneration = ++generation; + + labLoading.set(true); + getLabStatus() + .then((status) => { + if (currentGeneration === generation) { + labStatus.set(status); + } + }) + .catch((err) => { + if (currentGeneration === generation) { + labStatus.update((s) => ({ ...s, error: String(err) })); + } + }) + .finally(() => { + if (currentGeneration === generation) { + labLoading.set(false); + } + }); + + // Start SSE + if (eventSource) { + eventSource.close(); + } + eventSource = subscribeLabEvents( + (status) => { + if (currentGeneration === generation) { + labStatus.set(status); + } + }, + (event) => { + const es = event.target as EventSource; + if (es.readyState === EventSource.CLOSED && currentGeneration === generation) { + // Schedule reconnect + setTimeout(() => { + if (currentGeneration === generation) { + initLabStore(); + } + }, RECONNECT_DELAY_MS); + } + } + ); +} + +/** Stop SSE subscription. */ +export function destroyLabStore(): void { + generation++; + if (eventSource) { + eventSource.close(); + eventSource = null; + } +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 000000000..084eb9368 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,169 @@ +/** Container status from the API. */ +export interface ContainerInfo { + name: string; + state: string; + status: string; + health: string; + image: string; + ports: string[]; +} + +/** Lab status response. */ +export interface LabStatus { + running: boolean; + containers: ContainerInfo[]; + error: string | null; +} + +/** Lab action response (start/stop). */ +export interface LabActionResponse { + success: boolean; + message: string; + error: string | null; +} + +/** Scenario summary for listing. */ +export interface ScenarioSummary { + id: string; + name: string; + description: string; + difficulty: string; + mode: string; + estimated_minutes: number; + tags: string[]; + containers_required: string[]; +} + +// --- Full scenario definition types (mirrors Python core/scenarios.py) --- + +export type Difficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert'; +export type ScenarioMode = 'red' | 'blue' | 'purple'; +export type ObjectiveType = 'manual' | 'wazuh_alert' | 'command_output' | 'file_exists'; +export type PreconditionType = 'exec' | 'file'; + +export interface MitreReference { + tactics: string[]; + techniques: string[]; +} + +export interface ScenarioMetadata { + id: string; + name: string; + description: string; + version: string; + author: string; + difficulty: Difficulty; + estimated_minutes: number; + tags: string[]; + mitre_attack: MitreReference; +} + +export interface ContainerRequirements { + required: string[]; +} + +export interface Precondition { + type: PreconditionType; + container: string; + description: string; + command?: string; + path?: string; + content?: string; +} + +export interface Hint { + level: number; + text: string; + point_penalty: number; +} + +export interface WazuhAlertValidation { + query: Record; + min_matches: number; + time_window_seconds: number; +} + +export interface CommandOutputValidation { + container: string; + command: string; + contains: string[]; + regex?: string; +} + +export interface FileExistsValidation { + container: string; + path: string; + contains?: string; +} + +export interface Objective { + id: string; + description: string; + type: ObjectiveType; + points: number; + hints: Hint[]; + wazuh_alert?: WazuhAlertValidation; + command_output?: CommandOutputValidation; + file_exists?: FileExistsValidation; +} + +export interface ObjectiveSet { + red: Objective[]; + blue: Objective[]; +} + +export interface TimeBonusConfig { + enabled: boolean; + max_bonus: number; + decay_after_minutes: number; +} + +export interface ScoringConfig { + time_bonus: TimeBonusConfig; + passing_score: number; + max_score: number; +} + +export interface ExpectedDetection { + product_name: string; + analytic_uid?: string; + analytic_name?: string; + severity_id: number; + description: string; + max_detection_time_seconds: number; +} + +export interface AttackStep { + step_number: number; + technique_id: string; + technique_name: string; + tactic: string; + description: string; + target: string; + vulnerability: string; + commands: string[]; + prerequisites: string[]; + expected_detections: ExpectedDetection[]; + investigation_hints: string[]; + remediation: string[]; +} + +export interface ScenarioDefinition { + metadata: ScenarioMetadata; + mode: ScenarioMode; + containers: ContainerRequirements; + preconditions: Precondition[]; + objectives: ObjectiveSet; + scoring: ScoringConfig; + attack_chain: string; + steps: AttackStep[]; + defenses?: Record; +} + +/** APTL configuration. */ +export interface AppConfig { + lab_name: string; + network_subnet: string; + containers: Record; + run_storage_backend: string; +} diff --git a/web/src/lib/workbench.ts b/web/src/lib/workbench.ts new file mode 100644 index 000000000..38f7e99b2 --- /dev/null +++ b/web/src/lib/workbench.ts @@ -0,0 +1,122 @@ +import type { AttackStep, Objective, ScenarioDefinition } from './types'; + +// --- Block discriminated union --- + +export interface NarrativeBlock { + type: 'narrative'; + key: string; + content: string; +} + +export interface ContainerStatusBlock { + type: 'container-status'; + key: string; + containers: string[]; +} + +export interface AttackStepBlock { + type: 'attack-step'; + key: string; + step: AttackStep; + stepIndex: number; +} + +export interface SectionDividerBlock { + type: 'section-divider'; + key: string; + title: string; +} + +export interface ObjectiveBlock { + type: 'objective'; + key: string; + objective: Objective; + team: 'red' | 'blue'; +} + +export type WorkbenchBlock = + | NarrativeBlock + | ContainerStatusBlock + | AttackStepBlock + | SectionDividerBlock + | ObjectiveBlock; + +/** + * Build an ordered array of workbench blocks from a scenario definition. + * Pure function -- no side effects, fully testable without DOM. + */ +export function buildBlockSequence(scenario: ScenarioDefinition): WorkbenchBlock[] { + const blocks: WorkbenchBlock[] = []; + const meta = scenario.metadata; + + // 1. Title narrative + const metaParts: string[] = []; + if (meta.difficulty) metaParts.push(`**Difficulty:** ${meta.difficulty}`); + if (meta.estimated_minutes) metaParts.push(`**Time:** ~${meta.estimated_minutes} min`); + if (scenario.mode) metaParts.push(`**Mode:** ${scenario.mode}`); + if (meta.tags.length > 0) metaParts.push(`**Tags:** ${meta.tags.join(', ')}`); + if (meta.mitre_attack.tactics.length > 0) + metaParts.push(`**MITRE Tactics:** ${meta.mitre_attack.tactics.join(', ')}`); + if (meta.mitre_attack.techniques.length > 0) + metaParts.push(`**MITRE Techniques:** ${meta.mitre_attack.techniques.join(', ')}`); + + blocks.push({ + type: 'narrative', + key: 'narrative-title', + content: `# ${meta.name}\n\n${meta.description}\n\n${metaParts.join(' | ')}` + }); + + // 2. Container status + const containers = scenario.containers.required; + if (containers.length > 0) { + blocks.push({ type: 'container-status', key: 'container-status', containers }); + } + + // 3. Attack chain summary + if (scenario.attack_chain) { + blocks.push({ + type: 'narrative', + key: 'narrative-attack-chain', + content: `## Attack Chain\n\n${scenario.attack_chain}` + }); + } + + // 4. Steps + if (scenario.steps.length > 0) { + blocks.push({ type: 'section-divider', key: 'divider-steps', title: 'Attack Steps' }); + for (let i = 0; i < scenario.steps.length; i++) { + blocks.push({ + type: 'attack-step', + key: `step-${scenario.steps[i].step_number}`, + step: scenario.steps[i], + stepIndex: i + }); + } + } + + // 5-7. Objectives + const hasRed = scenario.objectives.red.length > 0; + const hasBlue = scenario.objectives.blue.length > 0; + if (hasRed || hasBlue) { + blocks.push({ type: 'section-divider', key: 'divider-objectives', title: 'Objectives' }); + } + for (const obj of scenario.objectives.red) { + blocks.push({ type: 'objective', key: `obj-red-${obj.id}`, objective: obj, team: 'red' }); + } + for (const obj of scenario.objectives.blue) { + blocks.push({ type: 'objective', key: `obj-blue-${obj.id}`, objective: obj, team: 'blue' }); + } + + // 8. Scoring summary + if (scenario.scoring.max_score > 0) { + const s = scenario.scoring; + let scoringText = `## Scoring\n\n**Max score:** ${s.max_score}`; + if (s.passing_score > 0) scoringText += ` | **Passing:** ${s.passing_score}`; + if (s.time_bonus.enabled) { + scoringText += `\n\n**Time bonus:** up to ${s.time_bonus.max_bonus} points (decays after ${s.time_bonus.decay_after_minutes} min)`; + } + blocks.push({ type: 'narrative', key: 'narrative-scoring', content: scoringText }); + } + + return blocks; +} diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte new file mode 100644 index 000000000..1fb49b2a8 --- /dev/null +++ b/web/src/routes/+error.svelte @@ -0,0 +1,16 @@ + + +
+
+

{$page.status}

+

{$page.error?.message || 'Something went wrong'}

+ + Back to Lab Home + +
+
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 000000000..84933a76e --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,21 @@ + + +
+ +
+ {@render children()} +
+
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 000000000..328928aa7 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,128 @@ + + +
+ +
+
+
+

Lab Home

+

+ {$labStatus.running + ? `${$labStatus.containers.length} containers running` + : 'Lab is stopped'} +

+
+
+ {#if $labStatus.running} + + {:else} + + {/if} +
+
+ + {#if actionError} +
+ {actionError} +
+ {/if} + + {#if $labStatus.error != null && !actionError} +
+ {$labStatus.error} +
+ {/if} + + {#if $labLoading} +
+
+ Loading lab status +
+ {:else} + + {/if} +
+ + +
+
+

Scenarios

+

+ Available attack and defense scenarios +

+
+ + {#if data.scenarios.length === 0} +
+

No scenarios found

+
+ {:else} +
+ {#each data.scenarios as scenario (scenario.id)} + + {/each} +
+ {/if} +
+
diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts new file mode 100644 index 000000000..3b977afb8 --- /dev/null +++ b/web/src/routes/+page.ts @@ -0,0 +1,14 @@ +import type { ScenarioSummary } from '$lib/types'; + +export async function load({ fetch }): Promise<{ scenarios: ScenarioSummary[] }> { + try { + const res = await fetch('/api/scenarios'); + if (res.ok) { + const scenarios: ScenarioSummary[] = await res.json(); + return { scenarios }; + } + } catch { + // API may not be available during SSR build + } + return { scenarios: [] }; +} diff --git a/web/src/routes/scenarios/[id]/+page.svelte b/web/src/routes/scenarios/[id]/+page.svelte new file mode 100644 index 000000000..123599ac5 --- /dev/null +++ b/web/src/routes/scenarios/[id]/+page.svelte @@ -0,0 +1,32 @@ + + + + +
+ {#each blocks as block (block.key)} + {#if block.type === 'narrative'} + + {:else if block.type === 'container-status'} + + {:else if block.type === 'attack-step'} + + {:else if block.type === 'section-divider'} + + {:else if block.type === 'objective'} + + {/if} + {/each} +
diff --git a/web/src/routes/scenarios/[id]/+page.ts b/web/src/routes/scenarios/[id]/+page.ts new file mode 100644 index 000000000..3269af390 --- /dev/null +++ b/web/src/routes/scenarios/[id]/+page.ts @@ -0,0 +1,15 @@ +import { error } from '@sveltejs/kit'; +import type { ScenarioDefinition } from '$lib/types'; + +export async function load({ + params, + fetch +}): Promise<{ scenario: ScenarioDefinition }> { + const res = await fetch(`/api/scenarios/${encodeURIComponent(params.id)}`); + if (!res.ok) { + const text = await res.text(); + error(res.status, `Failed to load scenario: ${text}`); + } + const scenario: ScenarioDefinition = await res.json(); + return { scenario }; +} diff --git a/web/src/routes/terminal/[container]/+page.svelte b/web/src/routes/terminal/[container]/+page.svelte new file mode 100644 index 000000000..0d03fbab5 --- /dev/null +++ b/web/src/routes/terminal/[container]/+page.svelte @@ -0,0 +1,24 @@ + + +
+
+ + ← Back to Lab + + | +

+ Terminal: {container} +

+
+
+ +
+
diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 000000000..b4b7de864 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/web/tests/components/HintToggle.test.ts b/web/tests/components/HintToggle.test.ts new file mode 100644 index 000000000..63e6cade5 --- /dev/null +++ b/web/tests/components/HintToggle.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import HintToggle from '../../src/lib/components/workbench/HintToggle.svelte'; + +describe('HintToggle', () => { + const hints = [ + { level: 1, text: 'First hint', point_penalty: 0 }, + { level: 2, text: 'Second hint', point_penalty: 10 }, + { level: 3, text: 'Third hint', point_penalty: 25 } + ]; + + it('shows a reveal button when hints exist', () => { + render(HintToggle, { props: { hints, objectiveId: 'obj-1' } }); + expect(screen.getByRole('button', { name: /reveal hint 1/i })).toBeTruthy(); + }); + + it('does not show hints initially', () => { + render(HintToggle, { props: { hints, objectiveId: 'obj-1' } }); + expect(screen.queryByText('First hint')).toBeNull(); + }); + + it('reveals first hint on click', async () => { + render(HintToggle, { props: { hints, objectiveId: 'obj-1' } }); + await fireEvent.click(screen.getByRole('button', { name: /reveal hint 1/i })); + expect(screen.getByText('First hint')).toBeTruthy(); + }); + + it('progressively reveals hints in level order', async () => { + render(HintToggle, { props: { hints, objectiveId: 'obj-1' } }); + + // Reveal hint 1 + await fireEvent.click(screen.getByRole('button', { name: /reveal hint 1/i })); + expect(screen.getByText('First hint')).toBeTruthy(); + expect(screen.queryByText('Second hint')).toBeNull(); + + // Reveal hint 2 + await fireEvent.click(screen.getByRole('button', { name: /reveal hint 2/i })); + expect(screen.getByText('Second hint')).toBeTruthy(); + expect(screen.queryByText('Third hint')).toBeNull(); + + // Reveal hint 3 + await fireEvent.click(screen.getByRole('button', { name: /reveal hint 3/i })); + expect(screen.getByText('Third hint')).toBeTruthy(); + }); + + it('displays point penalty for penalized hints', async () => { + render(HintToggle, { props: { hints, objectiveId: 'obj-1' } }); + + // Button for hint 1 (0 penalty) should not show penalty + const btn1 = screen.getByRole('button', { name: /reveal hint 1/i }); + expect(btn1.textContent).not.toContain('pts'); + + // Reveal hint 1, then hint 2 button should show penalty + await fireEvent.click(btn1); + const btn2 = screen.getByRole('button', { name: /reveal hint 2/i }); + expect(btn2.textContent).toContain('-10 pts'); + }); + + it('hides button after all hints revealed', async () => { + render(HintToggle, { + props: { + hints: [{ level: 1, text: 'Only hint', point_penalty: 0 }], + objectiveId: 'obj-1' + } + }); + + await fireEvent.click(screen.getByRole('button', { name: /reveal hint 1/i })); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('renders nothing for empty hints', () => { + const { container } = render(HintToggle, { + props: { hints: [], objectiveId: 'obj-1' } + }); + expect(container.textContent?.trim()).toBe(''); + }); +}); diff --git a/web/tests/lib/api.test.ts b/web/tests/lib/api.test.ts new file mode 100644 index 000000000..2f3b6101e --- /dev/null +++ b/web/tests/lib/api.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Mock EventSource for jsdom +class MockEventSource extends EventTarget { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + + url: string; + readyState = MockEventSource.OPEN; + onerror: ((event: Event) => void) | null = null; + + constructor(url: string) { + super(); + this.url = url; + } + + close() { + this.readyState = MockEventSource.CLOSED; + } +} +vi.stubGlobal('EventSource', MockEventSource); + +import { + getLabStatus, + startLab, + stopLab, + getScenarios, + getScenario, + getConfig, + subscribeLabEvents +} from '../../src/lib/api'; + +describe('API client', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('getLabStatus fetches /api/lab/status', async () => { + const data = { running: true, containers: [], error: null }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(data) + }); + + const result = await getLabStatus(); + expect(result).toEqual(data); + expect(mockFetch).toHaveBeenCalledWith('/api/lab/status', undefined); + }); + + it('startLab posts to /api/lab/start', async () => { + const data = { success: true, message: 'started', error: null }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(data) + }); + + const result = await startLab(); + expect(result).toEqual(data); + expect(mockFetch).toHaveBeenCalledWith('/api/lab/start', { method: 'POST' }); + }); + + it('stopLab posts to /api/lab/stop', async () => { + const data = { success: true, message: 'stopped', error: null }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(data) + }); + + const result = await stopLab(); + expect(result).toEqual(data); + expect(mockFetch).toHaveBeenCalledWith('/api/lab/stop', { method: 'POST' }); + }); + + it('getScenarios fetches /api/scenarios', async () => { + const data = [{ id: 'test', name: 'Test' }]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(data) + }); + + const result = await getScenarios(); + expect(result).toEqual(data); + }); + + it('getConfig fetches /api/config', async () => { + const data = { lab_name: 'aptl', containers: {} }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(data) + }); + + const result = await getConfig(); + expect(result).toEqual(data); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('Internal Server Error') + }); + + await expect(getLabStatus()).rejects.toThrow('API error 500'); + }); + + it('truncates long error text to 500 characters', async () => { + const longText = 'x'.repeat(1000); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve(longText) + }); + + await expect(getLabStatus()).rejects.toThrow(/\.\.\.$/); + }); + + it('getScenario fetches /api/scenarios/:id', async () => { + const data = { metadata: { id: 'test-scenario' } }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(data) + }); + + const result = await getScenario('test-scenario'); + expect(result).toEqual(data); + expect(mockFetch).toHaveBeenCalledWith('/api/scenarios/test-scenario', undefined); + }); + + it('getScenario encodes the scenario ID', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}) + }); + + await getScenario('has spaces/slashes'); + expect(mockFetch).toHaveBeenCalledWith( + '/api/scenarios/has%20spaces%2Fslashes', + undefined + ); + }); + + it('subscribeLabEvents creates EventSource and relays messages', () => { + const onMessage = vi.fn(); + const onError = vi.fn(); + + const es = subscribeLabEvents(onMessage, onError); + + expect(es).toBeInstanceOf(EventSource); + + // Simulate a lab_status SSE event + const statusData = { running: true, containers: [], error: null }; + const event = new MessageEvent('lab_status', { + data: JSON.stringify(statusData) + }); + es.dispatchEvent(event); + + expect(onMessage).toHaveBeenCalledWith(statusData); + + es.close(); + }); + + it('subscribeLabEvents sets onerror handler when provided', () => { + const onMessage = vi.fn(); + const onError = vi.fn(); + + const es = subscribeLabEvents(onMessage, onError); + expect(es.onerror).toBe(onError); + es.close(); + }); + + it('subscribeLabEvents works without error handler', () => { + const onMessage = vi.fn(); + const es = subscribeLabEvents(onMessage); + expect(es.onerror).toBeNull(); + es.close(); + }); +}); diff --git a/web/tests/lib/container-state.test.ts b/web/tests/lib/container-state.test.ts new file mode 100644 index 000000000..17b91cb94 --- /dev/null +++ b/web/tests/lib/container-state.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { stateColor } from '../../src/lib/container-state'; + +describe('stateColor', () => { + it('returns green for running state', () => { + expect(stateColor('running')).toBe('bg-aptl-green'); + }); + + it('returns red for exited state', () => { + expect(stateColor('exited')).toBe('bg-aptl-red'); + }); + + it('returns red for dead state', () => { + expect(stateColor('dead')).toBe('bg-aptl-red'); + }); + + it('returns amber for unknown/other states', () => { + expect(stateColor('created')).toBe('bg-aptl-amber'); + expect(stateColor('paused')).toBe('bg-aptl-amber'); + expect(stateColor('restarting')).toBe('bg-aptl-amber'); + expect(stateColor('unknown')).toBe('bg-aptl-amber'); + }); +}); diff --git a/web/tests/lib/markdown.test.ts b/web/tests/lib/markdown.test.ts new file mode 100644 index 000000000..6b50d6376 --- /dev/null +++ b/web/tests/lib/markdown.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { renderMarkdown } from '../../src/lib/markdown'; + +describe('renderMarkdown', () => { + it('renders basic markdown to HTML', () => { + const result = renderMarkdown('**bold** and *italic*'); + expect(result).toContain('bold'); + expect(result).toContain('italic'); + }); + + it('renders headings', () => { + const result = renderMarkdown('# Title'); + expect(result).toContain('

Title

'); + }); + + it('renders code blocks', () => { + const result = renderMarkdown('`code`'); + expect(result).toContain('code'); + }); + + it('sanitizes XSS: script tags', () => { + const result = renderMarkdown(''); + expect(result).not.toContain('