From d198fc09bf336edec285672cec22f388bc150f60 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 8 Mar 2026 23:59:05 +0000 Subject: [PATCH 01/56] Fix corrupted TLS config --- CHANGELOG.md | 6 +++ src/aptl/core/credentials.py | 18 +++++--- tests/test_credentials.py | 84 +++++++++++++++++++++++++++++++++--- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 484315d79..6a97c3a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ 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.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/src/aptl/core/credentials.py b/src/aptl/core/credentials.py index b382ab52f..73b9c1a57 100644 --- a/src/aptl/core/credentials.py +++ b/src/aptl/core/credentials.py @@ -14,8 +14,11 @@ # Matches: password: "anything" (with optional surrounding whitespace) _PASSWORD_PATTERN = re.compile(r'(password:\s*)"[^"]*"') -# Matches: anything -_KEY_PATTERN = re.compile(r"[^<]*") +# Matches: anything only inside a ... block +_CLUSTER_KEY_PATTERN = re.compile( + r"(.*?)[^<]*(.*?)", + re.DOTALL, +) def sync_dashboard_config(config_path: Path, api_password: str) -> None: @@ -68,15 +71,18 @@ 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 + new_content, count = _CLUSTER_KEY_PATTERN.subn( + lambda m: f"{m.group(1)}{cluster_key}{m.group(2)}", content ) 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/tests/test_credentials.py b/tests/test_credentials.py index 19564a7ea..0e0a376ec 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.""" From 664cb2e4cef38ff6e68303778af4fdf9d73f23a5 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 9 Mar 2026 00:27:45 +0000 Subject: [PATCH 02/56] Fix ReDos --- src/aptl/core/credentials.py | 37 ++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/aptl/core/credentials.py b/src/aptl/core/credentials.py index 73b9c1a57..1f6d1fac5 100644 --- a/src/aptl/core/credentials.py +++ b/src/aptl/core/credentials.py @@ -14,11 +14,8 @@ # Matches: password: "anything" (with optional surrounding whitespace) _PASSWORD_PATTERN = re.compile(r'(password:\s*)"[^"]*"') -# Matches: anything only inside a ... block -_CLUSTER_KEY_PATTERN = re.compile( - r"(.*?)[^<]*(.*?)", - re.DOTALL, -) +# Matches: anything (used only within pre-extracted blocks) +_KEY_PATTERN = re.compile(r"[^<]*") def sync_dashboard_config(config_path: Path, api_password: str) -> None: @@ -71,9 +68,33 @@ def sync_manager_config(config_path: Path, cluster_key: str) -> None: content = config_path.read_text() - new_content, count = _CLUSTER_KEY_PATTERN.subn( - lambda m: f"{m.group(1)}{cluster_key}{m.group(2)}", 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] + new_block, n = _KEY_PATTERN.subn( + lambda _: f"{cluster_key}", block + ) + count += n + result.append(new_block) + pos = block_end + + new_content = "".join(result) if count == 0: log.warning( From 54370db3cabaae65f8526ad5e2dbf5c5ff28f4ac Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 9 Mar 2026 00:59:33 +0000 Subject: [PATCH 03/56] Fix environ load fail causes fallback to lost telemetry --- CHANGELOG.md | 18 ++++++++++++++ src/aptl/cli/scenario.py | 10 ++++++++ tests/test_cli_scenario.py | 51 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 484315d79..77b3f86ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ 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.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) + +## [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/src/aptl/cli/scenario.py b/src/aptl/cli/scenario.py index 2bdc0f4e4..5e96ae7c7 100644 --- a/src/aptl/cli/scenario.py +++ b/src/aptl/cli/scenario.py @@ -13,6 +13,7 @@ from rich.table import Table from aptl.core.config import AptlConfig, find_config, load_config +from aptl.core.env import load_dotenv from aptl.core.events import EventLog, EventType, make_event from aptl.core.flags import collect_flags from aptl.core.run_assembler import assemble_run @@ -510,6 +511,15 @@ def stop( # 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 if session.run_id: diff --git a/tests/test_cli_scenario.py b/tests/test_cli_scenario.py index 02e49d253..363865608 100644 --- a/tests/test_cli_scenario.py +++ b/tests/test_cli_scenario.py @@ -466,6 +466,57 @@ 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 + # --------------------------------------------------------------------------- # Attack step display tests From 1ca29afd92f87403520ca9d959068acb607ba51b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 9 Mar 2026 01:58:18 +0000 Subject: [PATCH 04/56] Fix tests fail to check collectors properly --- CHANGELOG.md | 1 + src/aptl/core/collectors.py | 2 +- tests/test_cli_scenario.py | 90 +++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b3f86ec..a802007ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/src/aptl/core/collectors.py b/src/aptl/core/collectors.py index 8d0c88f8f..7e9ecc37f 100644 --- a/src/aptl/core/collectors.py +++ b/src/aptl/core/collectors.py @@ -36,7 +36,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] diff --git a/tests/test_cli_scenario.py b/tests/test_cli_scenario.py index 363865608..f80b0ec7d 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() @@ -517,6 +520,93 @@ def test_stop_warns_on_missing_dotenv(self, tmp_path, caplog): 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 From cad54a7ec5b1d90d4ac515ae680458eb889abad4 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 9 Mar 2026 04:23:56 +0000 Subject: [PATCH 05/56] Fix stranded ssh sessions --- CHANGELOG.md | 6 ++ mcp/aptl-mcp-common/src/ssh.ts | 30 ++++++- mcp/aptl-mcp-common/tests/ssh.test.ts | 118 +++++++++++++++++++++++++- 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a802007ad..e4d96c393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ 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.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 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/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); + }); +}); From fc94df7817ab62c7cd5a180a30fb3a9cfacf01ca Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 9 Mar 2026 08:13:05 +0000 Subject: [PATCH 06/56] Create test coverage for ts --- mcp/aptl-mcp-common/package-lock.json | 242 ++++++++++++++++++++++++++ mcp/aptl-mcp-common/package.json | 1 + mcp/aptl-mcp-common/vitest.config.ts | 9 +- 3 files changed, 251 insertions(+), 1 deletion(-) 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..1344ec788 100644 --- a/mcp/aptl-mcp-common/package.json +++ b/mcp/aptl-mcp-common/package.json @@ -22,6 +22,7 @@ }, "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/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 +}); From 37eaf46b916890017aa3f2e7637f7d368449de4c Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 03:28:44 +0000 Subject: [PATCH 07/56] Update serena config --- .serena/project.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.serena/project.yml b/.serena/project.yml index 1220db90b..84ce0ba5e 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -126,3 +126,8 @@ 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: From e7c85a2586ac2e536cf0d9e6b5894a46af1b0f57 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 03:47:10 +0000 Subject: [PATCH 08/56] Generate coverage for SonarCloud --- .github/workflows/sonarcloud.yml | 9 +++++++++ CHANGELOG.md | 7 +++++++ sonar-project.properties | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 9f558ddc4..bd7a6b53e 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -23,6 +23,15 @@ jobs: run: | coverage run -m pytest tests/ -q -k "not integration" coverage xml -o coverage.xml + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Run TypeScript tests with coverage + run: | + cd mcp/aptl-mcp-common + npm install + npx vitest run --coverage - name: SonarCloud Scan uses: SonarSource/sonarcloud-github-action@v5 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d96c393..7a8f17100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ 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.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 + ## [4.6.7] - 2026-03-08 ### Fixed diff --git a/sonar-project.properties b/sonar-project.properties index e394b5bce..8ab6573c3 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,7 +2,7 @@ sonar.projectKey=Brad-Edwards_aptl sonar.organization=brad-edwards sonar.sources=src,mcp -sonar.tests=tests +sonar.tests=tests,mcp/aptl-mcp-common/tests sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/dist/**,**/.venv/**,**/build/**,**/*.pyc,**/containers/** sonar.python.version=3.12 From 65aac7388d3ef521db9629431e3ec30778cb7f8a Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 03:56:42 +0000 Subject: [PATCH 09/56] Fix SonarCloud duplicate indexing of MCP test files --- CHANGELOG.md | 1 + sonar-project.properties | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8f17100..9959d2ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/sonar-project.properties b/sonar-project.properties index 8ab6573c3..b42db16a1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.organization=brad-edwards sonar.sources=src,mcp sonar.tests=tests,mcp/aptl-mcp-common/tests -sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/dist/**,**/.venv/**,**/build/**,**/*.pyc,**/containers/** +sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/dist/**,**/.venv/**,**/build/**,**/*.pyc,**/containers/**,mcp/**/tests/** sonar.python.version=3.12 sonar.python.coverage.reportPaths=coverage.xml From d1756584736ce75432330e62a2568d87c697fefe Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 03:57:50 +0000 Subject: [PATCH 10/56] Add mgh workflow skill --- .claude/skills/gh-workflow-monitor/SKILL.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .claude/skills/gh-workflow-monitor/SKILL.md diff --git a/.claude/skills/gh-workflow-monitor/SKILL.md b/.claude/skills/gh-workflow-monitor/SKILL.md new file mode 100644 index 000000000..27fae461b --- /dev/null +++ b/.claude/skills/gh-workflow-monitor/SKILL.md @@ -0,0 +1,15 @@ +--- +name: gh-workflow-monitor +description: Monitor GitHub workflows, propose fixes for failures, and report status +disable-model-invocation: true +--- + +# GitHub Workflow Monitor + +## Step 1: Find the GitHub Actions workflow run associated with the current branch. Use the gh cli. + +## Step 2: Monitor the workflow run for failures. + +## Step 3: If the workflow run fails, propose a fix. + +## Step 4: If the workflow run succeeds, report the status. From dc7f0a820c019fbd652963b4f6256cacfe6fd33e Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 04:07:34 +0000 Subject: [PATCH 11/56] Upgrade CI actions to Node.js 24 and migrate SonarCloud scanner --- .claude/skills/gh-workflow-monitor/SKILL.md | 14 ++++++++++---- .github/workflows/sonarcloud.yml | 8 ++++---- CHANGELOG.md | 7 +++++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.claude/skills/gh-workflow-monitor/SKILL.md b/.claude/skills/gh-workflow-monitor/SKILL.md index 27fae461b..4bdddf20b 100644 --- a/.claude/skills/gh-workflow-monitor/SKILL.md +++ b/.claude/skills/gh-workflow-monitor/SKILL.md @@ -6,10 +6,16 @@ disable-model-invocation: true # GitHub Workflow Monitor -## Step 1: Find the GitHub Actions workflow run associated with the current branch. Use the gh cli. +Use the `gh` for GitHub operations. -## Step 2: Monitor the workflow run for failures. +## Step 1: Switch to plan mode. -## Step 3: If the workflow run fails, propose a fix. +## Step 2: Check current branch. -## Step 4: If the workflow run succeeds, report the status. +## Step 3: If the branch is not green, find the GitHub Actions workflow run associated with the current branch. Use the gh cli. + +## Step 4: Monitor the workflow run for failures. + +## Step 5: If the workflow run fails, propose a fix. + +## Step 6: If the workflow run succeeds, report the status. diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index bd7a6b53e..04987d29b 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -8,11 +8,11 @@ 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 @@ -24,7 +24,7 @@ jobs: coverage run -m pytest tests/ -q -k "not integration" coverage xml -o coverage.xml - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '22' - name: Run TypeScript tests with coverage @@ -33,6 +33,6 @@ jobs: npm install npx vitest run --coverage - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@v5 + uses: SonarSource/sonarqube-scan-action@v7 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9959d2ed8..ba629ef6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ 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.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 From 89d3633d518b4bacc2402d7402213c4b1eef7c13 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 05:00:41 +0000 Subject: [PATCH 12/56] ADR catchup --- CHANGELOG.md | 6 + docs/adrs/README.md | 35 +++++ docs/adrs/adr-000-use-adrs.md | 84 ++++++++++ .../adrs/adr-001-docker-compose-deployment.md | 84 ++++++++++ docs/adrs/adr-002-wazuh-siem.md | 86 +++++++++++ docs/adrs/adr-003-mcp-common-library.md | 88 +++++++++++ docs/adrs/adr-004-persistent-ssh-sessions.md | 110 +++++++++++++ docs/adrs/adr-005-docker-compose-profiles.md | 100 ++++++++++++ .../adrs/adr-006-four-network-segmentation.md | 96 ++++++++++++ docs/adrs/adr-007-python-cli-control-plane.md | 128 ++++++++++++++++ docs/adrs/adr-008-soc-stack-integration.md | 119 +++++++++++++++ docs/adrs/adr-009-scenario-engine.md | 144 ++++++++++++++++++ docs/adrs/adr-010-sonarcloud-quality.md | 56 +++++++ docs/adrs/adr-011-web-ui.md | 123 +++++++++++++++ docs/index.md | 3 + mkdocs.yml | 16 +- 16 files changed, 1277 insertions(+), 1 deletion(-) create mode 100644 docs/adrs/README.md create mode 100644 docs/adrs/adr-000-use-adrs.md create mode 100644 docs/adrs/adr-001-docker-compose-deployment.md create mode 100644 docs/adrs/adr-002-wazuh-siem.md create mode 100644 docs/adrs/adr-003-mcp-common-library.md create mode 100644 docs/adrs/adr-004-persistent-ssh-sessions.md create mode 100644 docs/adrs/adr-005-docker-compose-profiles.md create mode 100644 docs/adrs/adr-006-four-network-segmentation.md create mode 100644 docs/adrs/adr-007-python-cli-control-plane.md create mode 100644 docs/adrs/adr-008-soc-stack-integration.md create mode 100644 docs/adrs/adr-009-scenario-engine.md create mode 100644 docs/adrs/adr-010-sonarcloud-quality.md create mode 100644 docs/adrs/adr-011-web-ui.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ba629ef6b..4ff919c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ 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.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 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..9a7fedd5a --- /dev/null +++ b/docs/adrs/adr-011-web-ui.md @@ -0,0 +1,123 @@ +# ADR-011: Notebook-Style Web UI (SvelteKit + FastAPI) + +## Status + +proposed + +## 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/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/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 From 1873e1a8c914b114b0f2c87e3adfb42cbe9c16b0 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 07:10:29 +0000 Subject: [PATCH 13/56] Notebook UI for range ops --- .claude/skills/implement/SKILL.md | 52 + .gitignore | 5 + CHANGELOG.md | 16 + README.md | 24 +- docker-compose.yml | 47 + docs/adrs/adr-011-web-ui.md | 2 +- pyproject.toml | 7 + src/aptl/api/__init__.py | 1 + src/aptl/api/deps.py | 36 + src/aptl/api/main.py | 39 + src/aptl/api/routers/__init__.py | 1 + src/aptl/api/routers/config.py | 34 + src/aptl/api/routers/lab.py | 114 + src/aptl/api/routers/scenarios.py | 77 + src/aptl/api/schemas.py | 98 + src/aptl/cli/main.py | 3 +- src/aptl/cli/web.py | 42 + tests/test_api_config.py | 52 + tests/test_api_lab.py | 144 + tests/test_api_scenarios.py | 134 + web/Dockerfile | 18 + web/Dockerfile.api | 18 + web/package-lock.json | 3844 ++++++++++++++++++ web/package.json | 28 + web/src/app.css | 32 + web/src/app.html | 13 + web/src/lib/api.ts | 46 + web/src/lib/components/ContainerCard.svelte | 71 + web/src/lib/components/ContainerGrid.svelte | 22 + web/src/lib/components/LabStatusBadge.svelte | 19 + web/src/lib/components/NavBar.svelte | 25 + web/src/lib/components/ScenarioCard.svelte | 73 + web/src/lib/stores/lab.ts | 44 + web/src/lib/stores/scenarios.ts | 20 + web/src/lib/types.ts | 43 + web/src/routes/+layout.svelte | 21 + web/src/routes/+page.svelte | 125 + web/src/routes/+page.ts | 15 + web/svelte.config.js | 12 + web/tests/lib/api.test.ts | 81 + web/tests/setup.ts | 1 + web/tsconfig.json | 14 + web/vite.config.ts | 15 + web/vitest.config.ts | 11 + 44 files changed, 5536 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/implement/SKILL.md create mode 100644 src/aptl/api/__init__.py create mode 100644 src/aptl/api/deps.py create mode 100644 src/aptl/api/main.py create mode 100644 src/aptl/api/routers/__init__.py create mode 100644 src/aptl/api/routers/config.py create mode 100644 src/aptl/api/routers/lab.py create mode 100644 src/aptl/api/routers/scenarios.py create mode 100644 src/aptl/api/schemas.py create mode 100644 src/aptl/cli/web.py create mode 100644 tests/test_api_config.py create mode 100644 tests/test_api_lab.py create mode 100644 tests/test_api_scenarios.py create mode 100644 web/Dockerfile create mode 100644 web/Dockerfile.api create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/app.css create mode 100644 web/src/app.html create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/components/ContainerCard.svelte create mode 100644 web/src/lib/components/ContainerGrid.svelte create mode 100644 web/src/lib/components/LabStatusBadge.svelte create mode 100644 web/src/lib/components/NavBar.svelte create mode 100644 web/src/lib/components/ScenarioCard.svelte create mode 100644 web/src/lib/stores/lab.ts create mode 100644 web/src/lib/stores/scenarios.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+page.svelte create mode 100644 web/src/routes/+page.ts create mode 100644 web/svelte.config.js create mode 100644 web/tests/lib/api.test.ts create mode 100644 web/tests/setup.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts create mode 100644 web/vitest.config.ts 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/.gitignore b/.gitignore index 9e6d96178..205acff4b 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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff919c18..41ea58ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ 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.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 diff --git a/README.md b/README.md index accc9e129..dccbc140d 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) @@ -129,6 +129,28 @@ 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 +``` + ## AI Integration (MCP) Build all MCP servers: diff --git a/docker-compose.yml b/docker-compose.yml index 2b5e9cb7b..ef4d6875c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -903,6 +903,53 @@ 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 + # ========================================================================== # NETWORKS # ========================================================================== diff --git a/docs/adrs/adr-011-web-ui.md b/docs/adrs/adr-011-web-ui.md index 9a7fedd5a..a5312806a 100644 --- a/docs/adrs/adr-011-web-ui.md +++ b/docs/adrs/adr-011-web-ui.md @@ -2,7 +2,7 @@ ## Status -proposed +accepted ## Date diff --git a/pyproject.toml b/pyproject.toml index 3fc39d51f..124d8bbb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,10 +21,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", + "httpx>=0.27.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/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..40bddef13 --- /dev/null +++ b/src/aptl/api/deps.py @@ -0,0 +1,36 @@ +"""Dependency injection for the APTL API.""" + +from pathlib import Path +from typing import Optional + +from aptl.core.config import AptlConfig, find_config, load_config + + +def get_project_dir() -> Path: + """Return the project root directory. + + Checks APTL_PROJECT_DIR env var first, then falls back to cwd. + """ + import os + + env_dir = os.environ.get("APTL_PROJECT_DIR") + if env_dir: + return Path(env_dir) + return Path.cwd() + + +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..af37187ee --- /dev/null +++ b/src/aptl/api/main.py @@ -0,0 +1,39 @@ +"""FastAPI application factory for the APTL web API.""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from aptl.api.routers import config, lab, scenarios + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + app = FastAPI( + title="APTL Web API", + description="Advanced Purple Team Lab — Web Interface API", + version="0.1.0", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://localhost:5173", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(lab.router, prefix="/api") + app.include_router(scenarios.router, prefix="/api") + app.include_router(config.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..690207603 --- /dev/null +++ b/src/aptl/api/routers/config.py @@ -0,0 +1,34 @@ +"""Configuration API endpoints.""" + +import asyncio + +from fastapi import APIRouter + +from aptl.api.deps import get_config, get_project_dir +from aptl.api.schemas import ConfigResponse + +router = APIRouter(tags=["config"]) + + +def _load_config_response() -> ConfigResponse: + """Build a ConfigResponse from the current configuration.""" + project_dir = get_project_dir() + 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 [ + "wazuh", "victim", "kali", "reverse", + "enterprise", "soc", "mail", "fileshare", "dns", + ] + }, + run_storage_backend=config.run_storage.backend, + ) + + +@router.get("/config") +async def get_config_endpoint() -> ConfigResponse: + """Get the current APTL configuration.""" + return await asyncio.to_thread(_load_config_response) diff --git a/src/aptl/api/routers/lab.py b/src/aptl/api/routers/lab.py new file mode 100644 index 000000000..16e09f270 --- /dev/null +++ b/src/aptl/api/routers/lab.py @@ -0,0 +1,114 @@ +"""Lab lifecycle API endpoints.""" + +import asyncio +from typing import AsyncGenerator + +from fastapi import APIRouter +from sse_starlette.sse import EventSourceResponse + +from aptl.api.deps import get_config, 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, +) + +router = APIRouter(tags=["lab"]) + + +def _build_status_response() -> LabStatusResponse: + """Build a LabStatusResponse from the core lab_status function.""" + project_dir = get_project_dir() + 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, + ) + + +@router.get("/lab/status") +async def lab_status() -> LabStatusResponse: + """Get current lab status including container information.""" + return await asyncio.to_thread(_build_status_response) + + +@router.post("/lab/start") +async def lab_start() -> LabActionResponse: + """Start the lab environment. + + Runs the full orchestration in a background thread since it can + take several minutes. Returns immediately with the result. + """ + project_dir = get_project_dir() + result: LabResult = await asyncio.to_thread( + orchestrate_lab_start, project_dir + ) + return LabActionResponse( + success=result.success, + message=result.message, + error=result.error, + ) + + +@router.post("/lab/stop") +async def lab_stop() -> LabActionResponse: + """Stop the lab environment.""" + project_dir = get_project_dir() + result: LabResult = await asyncio.to_thread( + core_stop_lab, project_dir=project_dir + ) + return LabActionResponse( + success=result.success, + message=result.message, + error=result.error, + ) + + +async def _lab_event_generator() -> AsyncGenerator[dict, None]: + """Poll lab status and yield SSE events on state changes.""" + previous_running: bool | None = None + previous_containers: list[str] = [] + + while True: + try: + response = await asyncio.to_thread(_build_status_response) + 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: + yield { + "event": "error", + "data": str(exc), + } + + await asyncio.sleep(5) + + +@router.get("/lab/events") +async def lab_events() -> EventSourceResponse: + """SSE stream of lab status changes. + + Polls lab status every 5 seconds and emits events when the + running state or container list changes. + """ + return EventSourceResponse(_lab_event_generator()) diff --git a/src/aptl/api/routers/scenarios.py b/src/aptl/api/routers/scenarios.py new file mode 100644 index 000000000..cd7b78db6 --- /dev/null +++ b/src/aptl/api/routers/scenarios.py @@ -0,0 +1,77 @@ +"""Scenario API endpoints.""" + +import asyncio +from pathlib import Path + +from fastapi import APIRouter, 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, +) + +router = APIRouter(tags=["scenarios"]) + + +def _scenarios_dir() -> Path: + """Return the scenarios directory.""" + return get_project_dir() / "scenarios" + + +def _load_all_scenarios() -> list[ScenarioSummary]: + """Load all valid scenarios from the scenarios directory.""" + paths = find_scenarios(_scenarios_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) -> ScenarioDefinition: + """Find and load a scenario by ID.""" + paths = find_scenarios(_scenarios_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() -> list[ScenarioSummary]: + """List all available scenarios.""" + return await asyncio.to_thread(_load_all_scenarios) + + +@router.get("/scenarios/{scenario_id}") +async def get_scenario(scenario_id: str) -> dict: + """Get full scenario definition by ID.""" + try: + scenario = await asyncio.to_thread(_load_single_scenario, scenario_id) + except ScenarioNotFoundError: + raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario_id}") + return scenario.model_dump() diff --git a/src/aptl/api/schemas.py b/src/aptl/api/schemas.py new file mode 100644 index 000000000..2f094123a --- /dev/null +++ b/src/aptl/api/schemas.py @@ -0,0 +1,98 @@ +"""API response models for the APTL web interface.""" + +from pydantic import BaseModel + + +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: str = "" + + +class LabActionResponse(BaseModel): + """Response for POST /api/lab/start and POST /api/lab/stop.""" + + success: bool + message: str = "" + error: str = "" + + +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/main.py b/src/aptl/cli/main.py index b9950f348..5905a7ad6 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 app = typer.Typer( name="aptl", @@ -18,6 +18,7 @@ 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") def _version_callback(value: bool) -> None: diff --git a/src/aptl/cli/web.py b/src/aptl/cli/web.py new file mode 100644 index 000000000..dca7fa96b --- /dev/null +++ b/src/aptl/cli/web.py @@ -0,0 +1,42 @@ +"""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."), + 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, + ) diff --git a/tests/test_api_config.py b/tests/test_api_config.py new file mode 100644 index 000000000..0f02e641c --- /dev/null +++ b/tests/test_api_config.py @@ -0,0 +1,52 @@ +"""Tests for config API endpoints.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def api_client(): + """Create a FastAPI test client.""" + from aptl.api.main import app + from starlette.testclient import TestClient + + return TestClient(app) + + +class TestConfigEndpoint: + @patch("aptl.api.routers.config.get_project_dir") + def test_returns_config(self, mock_dir, 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)) + mock_dir.return_value = tmp_path + + 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 + + @patch("aptl.api.routers.config.get_project_dir") + def test_returns_defaults_when_no_config(self, mock_dir, api_client, tmp_path): + mock_dir.return_value = tmp_path + + 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" diff --git a/tests/test_api_lab.py b/tests/test_api_lab.py new file mode 100644 index 000000000..154cef502 --- /dev/null +++ b/tests/test_api_lab.py @@ -0,0 +1,144 @@ +"""Tests for lab API endpoints.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def api_client(): + """Create a FastAPI test client.""" + from aptl.api.main import app + + # Import here to avoid issues if httpx/fastapi not installed + from starlette.testclient import TestClient + + return TestClient(app) + + +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") + @patch("aptl.api.routers.lab.get_project_dir") + def test_returns_running_status(self, mock_dir, mock_status, api_client, tmp_path): + from aptl.core.lab import LabStatus + + mock_dir.return_value = tmp_path + 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") + @patch("aptl.api.routers.lab.get_project_dir") + def test_returns_stopped_status(self, mock_dir, mock_status, api_client, tmp_path): + from aptl.core.lab import LabStatus + + mock_dir.return_value = tmp_path + 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") + @patch("aptl.api.routers.lab.get_project_dir") + def test_returns_error(self, mock_dir, mock_status, api_client, tmp_path): + from aptl.core.lab import LabStatus + + mock_dir.return_value = tmp_path + 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" + + +class TestLabStart: + @patch("aptl.api.routers.lab.orchestrate_lab_start") + @patch("aptl.api.routers.lab.get_project_dir") + def test_start_success(self, mock_dir, mock_start, api_client, tmp_path): + from aptl.core.lab import LabResult + + mock_dir.return_value = tmp_path + 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") + @patch("aptl.api.routers.lab.get_project_dir") + def test_start_failure(self, mock_dir, mock_start, api_client, tmp_path): + from aptl.core.lab import LabResult + + mock_dir.return_value = tmp_path + 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"] + + +class TestLabStop: + @patch("aptl.api.routers.lab.core_stop_lab") + @patch("aptl.api.routers.lab.get_project_dir") + def test_stop_success(self, mock_dir, mock_stop, api_client, tmp_path): + from aptl.core.lab import LabResult + + mock_dir.return_value = tmp_path + 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") + @patch("aptl.api.routers.lab.get_project_dir") + def test_stop_failure(self, mock_dir, mock_stop, api_client, tmp_path): + from aptl.core.lab import LabResult + + mock_dir.return_value = tmp_path + 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 diff --git a/tests/test_api_scenarios.py b/tests/test_api_scenarios.py new file mode 100644 index 000000000..65d7d471f --- /dev/null +++ b/tests/test_api_scenarios.py @@ -0,0 +1,134 @@ +"""Tests for scenario API endpoints.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + + +@pytest.fixture +def api_client(): + """Create a FastAPI test client.""" + from aptl.api.main import app + from starlette.testclient import TestClient + + return TestClient(app) + + +@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: + @patch("aptl.api.routers.scenarios.get_project_dir") + def test_returns_all_scenarios(self, mock_dir, api_client, scenarios_dir): + mock_dir.return_value = scenarios_dir.parent + + 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 + + @patch("aptl.api.routers.scenarios.get_project_dir") + def test_returns_scenario_details(self, mock_dir, api_client, scenarios_dir): + mock_dir.return_value = scenarios_dir.parent + + 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"] + + @patch("aptl.api.routers.scenarios.get_project_dir") + def test_returns_empty_list_when_no_scenarios(self, mock_dir, api_client, tmp_path): + mock_dir.return_value = tmp_path + + response = api_client.get("/api/scenarios") + + assert response.status_code == 200 + assert response.json() == [] + + +class TestGetScenario: + @patch("aptl.api.routers.scenarios.get_project_dir") + def test_returns_full_scenario(self, mock_dir, api_client, scenarios_dir): + mock_dir.return_value = scenarios_dir.parent + + 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" + + @patch("aptl.api.routers.scenarios.get_project_dir") + def test_returns_404_for_unknown(self, mock_dir, api_client, scenarios_dir): + mock_dir.return_value = scenarios_dir.parent + + response = api_client.get("/api/scenarios/nonexistent") + + assert response.status_code == 404 diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 000000000..89c37bba5 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,18 @@ +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/node_modules ./node_modules + +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..022778b55 --- /dev/null +++ b/web/Dockerfile.api @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system deps for Docker CLI +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl && \ + curl -fsSL https://get.docker.com | sh && \ + 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"] diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 000000000..99d51cbb5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3844 @@ +{ + "name": "aptl-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aptl-web", + "version": "0.1.0", + "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", + "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/@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-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/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/@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/@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/@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/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==", + "dev": 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/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/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/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/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/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/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/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/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/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/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-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/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-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/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/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/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/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-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/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/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/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/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/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-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/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/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/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..a806118ba --- /dev/null +++ b/web/package.json @@ -0,0 +1,28 @@ +{ + "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", + "@testing-library/svelte": "^5.2.0", + "@tailwindcss/vite": "^4.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" + } +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 000000000..9869f6d79 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,32 @@ +@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; +} diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 000000000..91bc019b6 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,13 @@ + + + + + + + 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..5707b1327 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,46 @@ +import type { AppConfig, LabActionResponse, LabStatus, ScenarioSummary } from './types'; + +const BASE = '/api'; + +async function fetchJSON(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, init); + if (!res.ok) { + const text = await res.text(); + 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): 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); + }); + return es; +} diff --git a/web/src/lib/components/ContainerCard.svelte b/web/src/lib/components/ContainerCard.svelte new file mode 100644 index 000000000..fbed10084 --- /dev/null +++ b/web/src/lib/components/ContainerCard.svelte @@ -0,0 +1,71 @@ + + +
+
+
+
+ +

+ {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} +
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..19774b61a --- /dev/null +++ b/web/src/lib/components/LabStatusBadge.svelte @@ -0,0 +1,19 @@ + + +
+ + {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..3d01a4c75 --- /dev/null +++ b/web/src/lib/components/ScenarioCard.svelte @@ -0,0 +1,73 @@ + + +
+
+ + {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/stores/lab.ts b/web/src/lib/stores/lab.ts new file mode 100644 index 000000000..e39e08609 --- /dev/null +++ b/web/src/lib/stores/lab.ts @@ -0,0 +1,44 @@ +import { writable } from 'svelte/store'; +import type { LabStatus } from '../types'; +import { getLabStatus, subscribeLabEvents } from '../api'; + +export const labStatus = writable({ + running: false, + containers: [], + error: '' +}); + +export const labLoading = writable(false); + +let eventSource: EventSource | null = null; + +/** Fetch initial lab status and start SSE subscription. */ +export function initLabStore(): void { + labLoading.set(true); + getLabStatus() + .then((status) => { + labStatus.set(status); + }) + .catch((err) => { + labStatus.update((s) => ({ ...s, error: String(err) })); + }) + .finally(() => { + labLoading.set(false); + }); + + // Start SSE + if (eventSource) { + eventSource.close(); + } + eventSource = subscribeLabEvents((status) => { + labStatus.set(status); + }); +} + +/** Stop SSE subscription. */ +export function destroyLabStore(): void { + if (eventSource) { + eventSource.close(); + eventSource = null; + } +} diff --git a/web/src/lib/stores/scenarios.ts b/web/src/lib/stores/scenarios.ts new file mode 100644 index 000000000..1800b27d4 --- /dev/null +++ b/web/src/lib/stores/scenarios.ts @@ -0,0 +1,20 @@ +import { writable } from 'svelte/store'; +import type { ScenarioSummary } from '../types'; +import { getScenarios } from '../api'; + +export const scenarios = writable([]); +export const scenariosLoading = writable(false); +export const scenariosError = writable(''); + +export async function loadScenarios(): Promise { + scenariosLoading.set(true); + scenariosError.set(''); + try { + const data = await getScenarios(); + scenarios.set(data); + } catch (err) { + scenariosError.set(String(err)); + } finally { + scenariosLoading.set(false); + } +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 000000000..116b41487 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,43 @@ +/** 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; +} + +/** Lab action response (start/stop). */ +export interface LabActionResponse { + success: boolean; + message: string; + error: string; +} + +/** 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[]; +} + +/** APTL configuration. */ +export interface AppConfig { + lab_name: string; + network_subnet: string; + containers: Record; + run_storage_backend: string; +} 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..f0e5847f0 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,125 @@ + + +
+ +
+
+
+

Lab Home

+

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

+
+
+ {#if $labStatus.running} + + {:else} + + {/if} +
+
+ + {#if actionError} +
+ {actionError} +
+ {/if} + + {#if $labStatus.error && !actionError} +
+ {$labStatus.error} +
+ {/if} + + {#if $labLoading} +
+
+
+ {: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..dcfadd35c --- /dev/null +++ b/web/src/routes/+page.ts @@ -0,0 +1,15 @@ +import { getScenarios } from '$lib/api'; +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/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/lib/api.test.ts b/web/tests/lib/api.test.ts new file mode 100644 index 000000000..10077cf2a --- /dev/null +++ b/web/tests/lib/api.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { getLabStatus, startLab, stopLab, getScenarios, getConfig } from '../../src/lib/api'; + +describe('API client', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('getLabStatus fetches /api/lab/status', async () => { + const data = { running: true, containers: [], error: '' }; + 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: '' }; + 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: '' }; + 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'); + }); +}); diff --git a/web/tests/setup.ts b/web/tests/setup.ts new file mode 100644 index 000000000..1ddf7a665 --- /dev/null +++ b/web/tests/setup.ts @@ -0,0 +1 @@ +import '@testing-library/svelte/vitest'; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 000000000..3c67b3ffd --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8400', + changeOrigin: true + } + } + } +}); diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 000000000..179362dd4 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['tests/**/*.test.ts'], + environment: 'jsdom', + setupFiles: ['tests/setup.ts'] + } +}); From 00e8effcae9301c59bf055e1d71b6b30b4e2321a Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 18:01:36 +0000 Subject: [PATCH 14/56] Fix CI SonarCloud failure: install web deps for API tests --- .github/workflows/sonarcloud.yml | 2 +- CHANGELOG.md | 7 +++++++ tests/test_api_config.py | 2 ++ tests/test_api_lab.py | 2 ++ tests/test_api_scenarios.py | 2 ++ 5 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 04987d29b..919b0e238 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -18,7 +18,7 @@ jobs: - 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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 41ea58ab8..98fc9c0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ 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.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 diff --git a/tests/test_api_config.py b/tests/test_api_config.py index 0f02e641c..f146e9f7a 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -6,6 +6,8 @@ import pytest +pytest.importorskip("fastapi", reason="Web dependencies not installed") + @pytest.fixture def api_client(): diff --git a/tests/test_api_lab.py b/tests/test_api_lab.py index 154cef502..da4f6b702 100644 --- a/tests/test_api_lab.py +++ b/tests/test_api_lab.py @@ -5,6 +5,8 @@ import pytest +pytest.importorskip("fastapi", reason="Web dependencies not installed") + @pytest.fixture def api_client(): diff --git a/tests/test_api_scenarios.py b/tests/test_api_scenarios.py index 65d7d471f..83358168e 100644 --- a/tests/test_api_scenarios.py +++ b/tests/test_api_scenarios.py @@ -6,6 +6,8 @@ import pytest import yaml +pytest.importorskip("fastapi", reason="Web dependencies not installed") + @pytest.fixture def api_client(): From b491f9631f0bb3e6e6e9bf5af42bad3eefc75c53 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 20:16:08 +0000 Subject: [PATCH 15/56] Improve test coveragE --- tests/test_api_config.py | 21 +++++ tests/test_api_lab.py | 154 ++++++++++++++++++++++++++++++++++++ tests/test_api_scenarios.py | 53 +++++++++++++ 3 files changed, 228 insertions(+) diff --git a/tests/test_api_config.py b/tests/test_api_config.py index f146e9f7a..c346e6fb5 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -1,6 +1,7 @@ """Tests for config API endpoints.""" import json +import os from pathlib import Path from unittest.mock import patch @@ -52,3 +53,23 @@ def test_returns_defaults_when_no_config(self, mock_dir, api_client, tmp_path): data = response.json() assert data["lab_name"] == "aptl" assert data["run_storage_backend"] == "local" + + +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() diff --git a/tests/test_api_lab.py b/tests/test_api_lab.py index da4f6b702..aa47a1351 100644 --- a/tests/test_api_lab.py +++ b/tests/test_api_lab.py @@ -1,5 +1,6 @@ """Tests for lab API endpoints.""" +import asyncio import json from unittest.mock import MagicMock, patch @@ -144,3 +145,156 @@ def test_stop_failure(self, mock_dir, mock_stop, api_client, tmp_path): assert response.status_code == 200 data = response.json() assert data["success"] is False + + +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): + 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() + 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): + from aptl.api.routers.lab import _lab_event_generator + from aptl.api.schemas import ContainerInfo, LabStatusResponse + + call_count = 0 + + def changing_status(): + 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() + 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): + 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() + 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): + 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() + 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 + + def test_lab_events_endpoint_returns_sse(self): + """Verify the endpoint function returns an EventSourceResponse.""" + from aptl.api.routers.lab import lab_events + from sse_starlette.sse import EventSourceResponse + + result = asyncio.run(lab_events()) + 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 index 83358168e..0ec04aaef 100644 --- a/tests/test_api_scenarios.py +++ b/tests/test_api_scenarios.py @@ -134,3 +134,56 @@ def test_returns_404_for_unknown(self, mock_dir, 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 + + @patch("aptl.api.routers.scenarios.get_project_dir") + def test_list_skips_invalid_scenarios(self, mock_dir, api_client, scenarios_dir_with_invalid): + mock_dir.return_value = scenarios_dir_with_invalid.parent + + 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 + + @patch("aptl.api.routers.scenarios.get_project_dir") + def test_get_returns_404_for_invalid_scenario(self, mock_dir, api_client, scenarios_dir_with_invalid): + mock_dir.return_value = scenarios_dir_with_invalid.parent + + response = api_client.get("/api/scenarios/broken") + + assert response.status_code == 404 From e15c5564cba8fb601087e31e811b0d5f56e6f356 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 21:22:32 +0000 Subject: [PATCH 16/56] Harden web UI: logging, di, sse, docker security --- .dockerignore | 20 +++ .github/workflows/sonarcloud.yml | 7 +- CHANGELOG.md | 38 +++++ README.md | 9 ++ pyproject.toml | 1 - sonar-project.properties | 8 +- src/aptl/api/deps.py | 14 +- src/aptl/api/main.py | 10 +- src/aptl/api/routers/config.py | 22 +-- src/aptl/api/routers/lab.py | 103 ++++++++++--- src/aptl/api/routers/scenarios.py | 39 +++-- src/aptl/api/schemas.py | 6 +- src/aptl/cli/web.py | 5 + src/aptl/core/config.py | 12 +- tests/test_api_config.py | 40 +++-- tests/test_api_lab.py | 149 ++++++++++++++----- tests/test_api_scenarios.py | 45 ++---- web/.dockerignore | 6 + web/Dockerfile | 3 +- web/Dockerfile.api | 15 +- web/src/app.html | 3 + web/src/lib/api.ts | 14 +- web/src/lib/components/ContainerCard.svelte | 11 +- web/src/lib/components/LabStatusBadge.svelte | 2 + web/src/lib/components/ScenarioCard.svelte | 2 +- web/src/lib/stores/lab.ts | 41 ++++- web/src/lib/stores/scenarios.ts | 20 --- web/src/lib/types.ts | 4 +- web/src/routes/+error.svelte | 16 ++ web/src/routes/+page.svelte | 7 +- web/src/routes/+page.ts | 1 - web/tests/lib/api.test.ts | 17 ++- web/vitest.config.ts | 7 +- 33 files changed, 511 insertions(+), 186 deletions(-) create mode 100644 .dockerignore create mode 100644 web/.dockerignore delete mode 100644 web/src/lib/stores/scenarios.ts create mode 100644 web/src/routes/+error.svelte 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/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 919b0e238..e94f4f9ac 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -27,11 +27,16 @@ jobs: uses: actions/setup-node@v6 with: node-version: '22' - - name: Run TypeScript tests with coverage + - 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 vitest run --coverage - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@v7 env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fc9c0ab..d4969a61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,44 @@ 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.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 diff --git a/README.md b/README.md index dccbc140d..47156efdb 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,15 @@ cd web && npm install && npm run dev 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/pyproject.toml b/pyproject.toml index 124d8bbb8..5599d7ca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ web = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", "sse-starlette>=2.0.0", - "httpx>=0.27.0", ] dev = [ "pytest>=8.0.0", diff --git a/sonar-project.properties b/sonar-project.properties index b42db16a1..17c754388 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,11 +1,11 @@ sonar.projectKey=Brad-Edwards_aptl sonar.organization=brad-edwards -sonar.sources=src,mcp -sonar.tests=tests,mcp/aptl-mcp-common/tests -sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/dist/**,**/.venv/**,**/build/**,**/*.pyc,**/containers/**,mcp/**/tests/** +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.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/deps.py b/src/aptl/api/deps.py index 40bddef13..1d50d7756 100644 --- a/src/aptl/api/deps.py +++ b/src/aptl/api/deps.py @@ -3,6 +3,8 @@ from pathlib import Path from typing import Optional +from fastapi import HTTPException + from aptl.core.config import AptlConfig, find_config, load_config @@ -10,13 +12,21 @@ 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: - return Path(env_dir) - return Path.cwd() + 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: diff --git a/src/aptl/api/main.py b/src/aptl/api/main.py index af37187ee..08755007b 100644 --- a/src/aptl/api/main.py +++ b/src/aptl/api/main.py @@ -4,10 +4,16 @@ from fastapi.middleware.cors import CORSMiddleware from aptl.api.routers import config, lab, scenarios +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", @@ -21,8 +27,8 @@ def create_app() -> FastAPI: "http://localhost:5173", ], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST"], + allow_headers=["Content-Type", "Accept"], ) app.include_router(lab.router, prefix="/api") diff --git a/src/aptl/api/routers/config.py b/src/aptl/api/routers/config.py index 690207603..cea6a8092 100644 --- a/src/aptl/api/routers/config.py +++ b/src/aptl/api/routers/config.py @@ -1,34 +1,38 @@ """Configuration API endpoints.""" import asyncio +from pathlib import Path -from fastapi import APIRouter +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() -> ConfigResponse: +def _load_config_response(project_dir: Path) -> ConfigResponse: """Build a ConfigResponse from the current configuration.""" - project_dir = get_project_dir() 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 [ - "wazuh", "victim", "kali", "reverse", - "enterprise", "soc", "mail", "fileshare", "dns", - ] + for name in ContainerSettings.model_fields }, run_storage_backend=config.run_storage.backend, ) @router.get("/config") -async def get_config_endpoint() -> ConfigResponse: +async def get_config_endpoint( + project_dir: Path = Depends(get_project_dir), +) -> ConfigResponse: """Get the current APTL configuration.""" - return await asyncio.to_thread(_load_config_response) + log.info("GET /config") + return await asyncio.to_thread(_load_config_response, project_dir) diff --git a/src/aptl/api/routers/lab.py b/src/aptl/api/routers/lab.py index 16e09f270..f9864cad3 100644 --- a/src/aptl/api/routers/lab.py +++ b/src/aptl/api/routers/lab.py @@ -1,12 +1,13 @@ """Lab lifecycle API endpoints.""" import asyncio +from pathlib import Path from typing import AsyncGenerator -from fastapi import APIRouter +from fastapi import APIRouter, Depends from sse_starlette.sse import EventSourceResponse -from aptl.api.deps import get_config, get_project_dir +from aptl.api.deps import get_project_dir from aptl.api.schemas import ( ContainerInfo, LabActionResponse, @@ -18,68 +19,102 @@ 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() -> LabStatusResponse: +def _build_status_response(project_dir: Path) -> LabStatusResponse: """Build a LabStatusResponse from the core lab_status function.""" - project_dir = get_project_dir() 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, + error=status.error or None, ) @router.get("/lab/status") -async def lab_status() -> LabStatusResponse: +async def lab_status( + project_dir: Path = Depends(get_project_dir), +) -> LabStatusResponse: """Get current lab status including container information.""" - return await asyncio.to_thread(_build_status_response) + 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() -> LabActionResponse: +async def lab_start( + project_dir: Path = Depends(get_project_dir), +) -> LabActionResponse: """Start the lab environment. - Runs the full orchestration in a background thread since it can - take several minutes. Returns immediately with the result. + Blocks until orchestration completes or times out (30 min). """ - project_dir = get_project_dir() - result: LabResult = await asyncio.to_thread( - orchestrate_lab_start, project_dir - ) + 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, + error=result.error or None, ) @router.post("/lab/stop") -async def lab_stop() -> LabActionResponse: +async def lab_stop( + project_dir: Path = Depends(get_project_dir), +) -> LabActionResponse: """Stop the lab environment.""" - project_dir = get_project_dir() + 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, + error=result.error or None, ) -async def _lab_event_generator() -> AsyncGenerator[dict, None]: - """Poll lab status and yield SSE events on state changes.""" +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) + 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 @@ -96,19 +131,41 @@ async def _lab_event_generator() -> AsyncGenerator[dict, None]: 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() -> EventSourceResponse: +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. """ - return EventSourceResponse(_lab_event_generator()) + 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 index cd7b78db6..9dec3e890 100644 --- a/src/aptl/api/routers/scenarios.py +++ b/src/aptl/api/routers/scenarios.py @@ -3,7 +3,7 @@ import asyncio from pathlib import Path -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from aptl.api.deps import get_project_dir from aptl.api.schemas import ScenarioSummary @@ -14,18 +14,21 @@ find_scenarios, load_scenario, ) +from aptl.utils.logging import get_logger + +log = get_logger("api.scenarios") router = APIRouter(tags=["scenarios"]) -def _scenarios_dir() -> Path: +def _scenarios_dir(project_dir: Path) -> Path: """Return the scenarios directory.""" - return get_project_dir() / "scenarios" + return project_dir / "scenarios" -def _load_all_scenarios() -> list[ScenarioSummary]: +def _load_all_scenarios(project_dir: Path) -> list[ScenarioSummary]: """Load all valid scenarios from the scenarios directory.""" - paths = find_scenarios(_scenarios_dir()) + paths = find_scenarios(_scenarios_dir(project_dir)) summaries: list[ScenarioSummary] = [] for path in paths: try: @@ -48,9 +51,11 @@ def _load_all_scenarios() -> list[ScenarioSummary]: return summaries -def _load_single_scenario(scenario_id: str) -> ScenarioDefinition: +def _load_single_scenario( + scenario_id: str, project_dir: Path +) -> ScenarioDefinition: """Find and load a scenario by ID.""" - paths = find_scenarios(_scenarios_dir()) + paths = find_scenarios(_scenarios_dir(project_dir)) for path in paths: try: scenario = load_scenario(path) @@ -62,16 +67,28 @@ def _load_single_scenario(scenario_id: str) -> ScenarioDefinition: @router.get("/scenarios") -async def list_scenarios() -> list[ScenarioSummary]: +async def list_scenarios( + project_dir: Path = Depends(get_project_dir), +) -> list[ScenarioSummary]: """List all available scenarios.""" - return await asyncio.to_thread(_load_all_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) -> dict: +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/%s", scenario_id) try: - scenario = await asyncio.to_thread(_load_single_scenario, scenario_id) + scenario = await asyncio.to_thread( + _load_single_scenario, scenario_id, project_dir + ) except ScenarioNotFoundError: + log.warning("Scenario not found: %s", scenario_id) raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario_id}") return scenario.model_dump() diff --git a/src/aptl/api/schemas.py b/src/aptl/api/schemas.py index 2f094123a..cc9bc1079 100644 --- a/src/aptl/api/schemas.py +++ b/src/aptl/api/schemas.py @@ -1,5 +1,7 @@ """API response models for the APTL web interface.""" +from typing import Optional + from pydantic import BaseModel @@ -65,7 +67,7 @@ class LabStatusResponse(BaseModel): running: bool containers: list[ContainerInfo] = [] - error: str = "" + error: Optional[str] = None class LabActionResponse(BaseModel): @@ -73,7 +75,7 @@ class LabActionResponse(BaseModel): success: bool message: str = "" - error: str = "" + error: Optional[str] = None class ScenarioSummary(BaseModel): diff --git a/src/aptl/cli/web.py b/src/aptl/cli/web.py index dca7fa96b..575215cd1 100644 --- a/src/aptl/cli/web.py +++ b/src/aptl/cli/web.py @@ -12,6 +12,7 @@ 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).", @@ -39,4 +40,8 @@ def serve( host=host, port=port, reload=reload, + workers=workers, + log_level="info", + timeout_keep_alive=65, + access_log=True, ) diff --git a/src/aptl/core/config.py b/src/aptl/core/config.py index fc477ca1d..2bbb2843e 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 self.model_fields + if getattr(self, name) + ] class RunStorageConfig(BaseModel): diff --git a/tests/test_api_config.py b/tests/test_api_config.py index c346e6fb5..7ae515aa0 100644 --- a/tests/test_api_config.py +++ b/tests/test_api_config.py @@ -11,17 +11,20 @@ @pytest.fixture -def api_client(): - """Create a FastAPI test client.""" +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 - return TestClient(app) + app.dependency_overrides[get_project_dir] = lambda: tmp_path + client = TestClient(app) + yield client + app.dependency_overrides.clear() class TestConfigEndpoint: - @patch("aptl.api.routers.config.get_project_dir") - def test_returns_config(self, mock_dir, api_client, tmp_path): + def test_returns_config(self, api_client, tmp_path): config = { "lab": {"name": "test-lab", "network_subnet": "172.20.0.0/16"}, "containers": { @@ -32,7 +35,6 @@ def test_returns_config(self, mock_dir, api_client, tmp_path): }, } (tmp_path / "aptl.json").write_text(json.dumps(config)) - mock_dir.return_value = tmp_path response = api_client.get("/api/config") @@ -43,10 +45,7 @@ def test_returns_config(self, mock_dir, api_client, tmp_path): assert data["containers"]["wazuh"] is True assert data["containers"]["victim"] is True - @patch("aptl.api.routers.config.get_project_dir") - def test_returns_defaults_when_no_config(self, mock_dir, api_client, tmp_path): - mock_dir.return_value = tmp_path - + def test_returns_defaults_when_no_config(self, api_client): response = api_client.get("/api/config") assert response.status_code == 200 @@ -54,6 +53,18 @@ def test_returns_defaults_when_no_config(self, mock_dir, api_client, tmp_path): 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.""" @@ -73,3 +84,12 @@ def test_returns_cwd_when_env_not_set(self): 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_lab.py b/tests/test_api_lab.py index aa47a1351..48333ac13 100644 --- a/tests/test_api_lab.py +++ b/tests/test_api_lab.py @@ -10,14 +10,16 @@ @pytest.fixture -def api_client(): - """Create a FastAPI test client.""" +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 - - # Import here to avoid issues if httpx/fastapi not installed from starlette.testclient import TestClient - return TestClient(app) + app.dependency_overrides[get_project_dir] = lambda: tmp_path + client = TestClient(app) + yield client + app.dependency_overrides.clear() class TestHealthEndpoint: @@ -29,11 +31,9 @@ def test_health_returns_ok(self, api_client): class TestLabStatus: @patch("aptl.api.routers.lab.core_lab_status") - @patch("aptl.api.routers.lab.get_project_dir") - def test_returns_running_status(self, mock_dir, mock_status, api_client, tmp_path): + def test_returns_running_status(self, mock_status, api_client): from aptl.core.lab import LabStatus - mock_dir.return_value = tmp_path mock_status.return_value = LabStatus( running=True, containers=[ @@ -50,11 +50,9 @@ def test_returns_running_status(self, mock_dir, mock_status, api_client, tmp_pat assert data["containers"][0]["name"] == "aptl-victim" @patch("aptl.api.routers.lab.core_lab_status") - @patch("aptl.api.routers.lab.get_project_dir") - def test_returns_stopped_status(self, mock_dir, mock_status, api_client, tmp_path): + def test_returns_stopped_status(self, mock_status, api_client): from aptl.core.lab import LabStatus - mock_dir.return_value = tmp_path mock_status.return_value = LabStatus(running=False, containers=[]) response = api_client.get("/api/lab/status") @@ -65,11 +63,9 @@ def test_returns_stopped_status(self, mock_dir, mock_status, api_client, tmp_pat assert data["containers"] == [] @patch("aptl.api.routers.lab.core_lab_status") - @patch("aptl.api.routers.lab.get_project_dir") - def test_returns_error(self, mock_dir, mock_status, api_client, tmp_path): + def test_returns_error(self, mock_status, api_client): from aptl.core.lab import LabStatus - mock_dir.return_value = tmp_path mock_status.return_value = LabStatus( running=False, error="docker not found" ) @@ -80,14 +76,23 @@ def test_returns_error(self, mock_dir, mock_status, api_client, tmp_path): 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") - @patch("aptl.api.routers.lab.get_project_dir") - def test_start_success(self, mock_dir, mock_start, api_client, tmp_path): + def test_start_success(self, mock_start, api_client): from aptl.core.lab import LabResult - mock_dir.return_value = tmp_path mock_start.return_value = LabResult(success=True, message="Lab started") response = api_client.post("/api/lab/start") @@ -98,11 +103,9 @@ def test_start_success(self, mock_dir, mock_start, api_client, tmp_path): assert data["message"] == "Lab started" @patch("aptl.api.routers.lab.orchestrate_lab_start") - @patch("aptl.api.routers.lab.get_project_dir") - def test_start_failure(self, mock_dir, mock_start, api_client, tmp_path): + def test_start_failure(self, mock_start, api_client): from aptl.core.lab import LabResult - mock_dir.return_value = tmp_path mock_start.return_value = LabResult( success=False, error="Missing .env" ) @@ -114,14 +117,34 @@ def test_start_failure(self, mock_dir, mock_start, api_client, tmp_path): 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") - @patch("aptl.api.routers.lab.get_project_dir") - def test_stop_success(self, mock_dir, mock_stop, api_client, tmp_path): + def test_stop_success(self, mock_stop, api_client): from aptl.core.lab import LabResult - mock_dir.return_value = tmp_path mock_stop.return_value = LabResult(success=True, message="Lab stopped") response = api_client.post("/api/lab/stop") @@ -131,11 +154,9 @@ def test_stop_success(self, mock_dir, mock_stop, api_client, tmp_path): assert data["success"] is True @patch("aptl.api.routers.lab.core_stop_lab") - @patch("aptl.api.routers.lab.get_project_dir") - def test_stop_failure(self, mock_dir, mock_stop, api_client, tmp_path): + def test_stop_failure(self, mock_stop, api_client): from aptl.core.lab import LabResult - mock_dir.return_value = tmp_path mock_stop.return_value = LabResult( success=False, error="compose down failed" ) @@ -147,6 +168,39 @@ def test_stop_failure(self, mock_dir, mock_stop, api_client, tmp_path): 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.""" @@ -156,14 +210,14 @@ class TestLabEventGenerator: @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): + 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() + gen = _lab_event_generator(tmp_path) event = await gen.__anext__() await gen.aclose() return event @@ -175,13 +229,13 @@ async def run(): @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): + 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(): + def changing_status(project_dir): nonlocal call_count call_count += 1 if call_count <= 1: @@ -194,7 +248,7 @@ def changing_status(): mock_build.side_effect = changing_status async def run(): - gen = _lab_event_generator() + gen = _lab_event_generator(tmp_path) first = await gen.__anext__() second = await gen.__anext__() await gen.aclose() @@ -207,13 +261,13 @@ async def run(): @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): + 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() + gen = _lab_event_generator(tmp_path) event = await gen.__anext__() await gen.aclose() return event @@ -224,7 +278,7 @@ async def run(): @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): + 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 @@ -232,7 +286,7 @@ def test_skips_duplicate_status(self, mock_build): mock_build.return_value = LabStatusResponse(running=False, containers=[]) async def run(): - gen = _lab_event_generator() + 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. @@ -248,12 +302,33 @@ async def run(): got_second = asyncio.run(run()) assert got_second is False - def test_lab_events_endpoint_returns_sse(self): + @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 - result = asyncio.run(lab_events()) + # Call with project_dir directly (simulating Depends injection) + result = asyncio.run(lab_events(project_dir=tmp_path)) assert isinstance(result, EventSourceResponse) diff --git a/tests/test_api_scenarios.py b/tests/test_api_scenarios.py index 0ec04aaef..bcff76aeb 100644 --- a/tests/test_api_scenarios.py +++ b/tests/test_api_scenarios.py @@ -10,12 +10,16 @@ @pytest.fixture -def api_client(): - """Create a FastAPI test client.""" +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 - return TestClient(app) + app.dependency_overrides[get_project_dir] = lambda: tmp_path + client = TestClient(app) + yield client + app.dependency_overrides.clear() @pytest.fixture @@ -78,10 +82,7 @@ def scenarios_dir(tmp_path): class TestListScenarios: - @patch("aptl.api.routers.scenarios.get_project_dir") - def test_returns_all_scenarios(self, mock_dir, api_client, scenarios_dir): - mock_dir.return_value = scenarios_dir.parent - + def test_returns_all_scenarios(self, api_client, scenarios_dir): response = api_client.get("/api/scenarios") assert response.status_code == 200 @@ -91,10 +92,7 @@ def test_returns_all_scenarios(self, mock_dir, api_client, scenarios_dir): assert "recon-nmap" in ids assert "brute-force" in ids - @patch("aptl.api.routers.scenarios.get_project_dir") - def test_returns_scenario_details(self, mock_dir, api_client, scenarios_dir): - mock_dir.return_value = scenarios_dir.parent - + def test_returns_scenario_details(self, api_client, scenarios_dir): response = api_client.get("/api/scenarios") data = response.json() @@ -105,10 +103,7 @@ def test_returns_scenario_details(self, mock_dir, api_client, scenarios_dir): assert nmap["estimated_minutes"] == 15 assert "kali" in nmap["containers_required"] - @patch("aptl.api.routers.scenarios.get_project_dir") - def test_returns_empty_list_when_no_scenarios(self, mock_dir, api_client, tmp_path): - mock_dir.return_value = tmp_path - + def test_returns_empty_list_when_no_scenarios(self, api_client): response = api_client.get("/api/scenarios") assert response.status_code == 200 @@ -116,10 +111,7 @@ def test_returns_empty_list_when_no_scenarios(self, mock_dir, api_client, tmp_pa class TestGetScenario: - @patch("aptl.api.routers.scenarios.get_project_dir") - def test_returns_full_scenario(self, mock_dir, api_client, scenarios_dir): - mock_dir.return_value = scenarios_dir.parent - + def test_returns_full_scenario(self, api_client, scenarios_dir): response = api_client.get("/api/scenarios/recon-nmap") assert response.status_code == 200 @@ -127,10 +119,7 @@ def test_returns_full_scenario(self, mock_dir, api_client, scenarios_dir): assert data["metadata"]["id"] == "recon-nmap" assert data["mode"] == "red" - @patch("aptl.api.routers.scenarios.get_project_dir") - def test_returns_404_for_unknown(self, mock_dir, api_client, scenarios_dir): - mock_dir.return_value = scenarios_dir.parent - + def test_returns_404_for_unknown(self, api_client, scenarios_dir): response = api_client.get("/api/scenarios/nonexistent") assert response.status_code == 404 @@ -168,10 +157,7 @@ def scenarios_dir_with_invalid(self, tmp_path): return sdir - @patch("aptl.api.routers.scenarios.get_project_dir") - def test_list_skips_invalid_scenarios(self, mock_dir, api_client, scenarios_dir_with_invalid): - mock_dir.return_value = scenarios_dir_with_invalid.parent - + def test_list_skips_invalid_scenarios(self, api_client, scenarios_dir_with_invalid): response = api_client.get("/api/scenarios") assert response.status_code == 200 @@ -180,10 +166,7 @@ def test_list_skips_invalid_scenarios(self, mock_dir, api_client, scenarios_dir_ assert "valid-scenario" in ids assert "broken" not in ids - @patch("aptl.api.routers.scenarios.get_project_dir") - def test_get_returns_404_for_invalid_scenario(self, mock_dir, api_client, scenarios_dir_with_invalid): - mock_dir.return_value = scenarios_dir_with_invalid.parent - + 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/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 index 89c37bba5..7245900da 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -11,7 +11,8 @@ FROM node:22-slim WORKDIR /app COPY --from=builder /app/build ./build COPY --from=builder /app/package.json . -COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package-lock.json* . +RUN npm ci --omit=dev EXPOSE 3000 ENV NODE_ENV=production diff --git a/web/Dockerfile.api b/web/Dockerfile.api index 022778b55..62edfd567 100644 --- a/web/Dockerfile.api +++ b/web/Dockerfile.api @@ -2,10 +2,17 @@ FROM python:3.12-slim WORKDIR /app -# Install system deps for Docker CLI +# Install Docker CLI via official APT repo (not curl|sh) RUN apt-get update && \ - apt-get install -y --no-install-recommends curl && \ - curl -fsSL https://get.docker.com | sh && \ + 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 @@ -15,4 +22,4 @@ RUN pip install --no-cache-dir ".[web]" EXPOSE 8400 -CMD ["uvicorn", "aptl.api.main:app", "--host", "0.0.0.0", "--port", "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/src/app.html b/web/src/app.html index 91bc019b6..50d32e043 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -4,6 +4,9 @@ + + + APTL — Advanced Purple Team Lab %sveltekit.head% diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5707b1327..1c0e55bd3 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,11 +1,15 @@ import type { AppConfig, LabActionResponse, LabStatus, 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) { - const text = await res.text(); + 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; @@ -36,11 +40,17 @@ export async function getConfig(): Promise { } /** Create an SSE connection to the lab events stream. */ -export function subscribeLabEvents(onMessage: (status: LabStatus) => void): EventSource { +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 index fbed10084..15de6a24f 100644 --- a/web/src/lib/components/ContainerCard.svelte +++ b/web/src/lib/components/ContainerCard.svelte @@ -25,6 +25,15 @@ return 'bg-aptl-text-muted'; }); + const statusLabel = $derived.by(() => { + if (container.state === 'running') { + if (container.health === 'healthy') return 'healthy'; + if (container.health === 'unhealthy') return 'unhealthy'; + return 'running'; + } + return 'stopped'; + }); + const displayName = $derived( container.name.replace(/^aptl-/, '').replace(/-\d+$/, '') ); @@ -36,7 +45,7 @@
- +

{displayName}

diff --git a/web/src/lib/components/LabStatusBadge.svelte b/web/src/lib/components/LabStatusBadge.svelte index 19774b61a..35dce2a83 100644 --- a/web/src/lib/components/LabStatusBadge.svelte +++ b/web/src/lib/components/LabStatusBadge.svelte @@ -7,6 +7,8 @@
+ import { page } from '$app/stores'; + + +
+
+

{$page.status}

+

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

+ + Back to Lab Home + +
+
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index f0e5847f0..328928aa7 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -58,6 +58,7 @@
{/if} - {#if $labStatus.error && !actionError} + {#if $labStatus.error != null && !actionError}
@@ -91,10 +93,11 @@ {/if} {#if $labLoading} -
+
+ Loading lab status
{:else} diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index dcfadd35c..3b977afb8 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -1,4 +1,3 @@ -import { getScenarios } from '$lib/api'; import type { ScenarioSummary } from '$lib/types'; export async function load({ fetch }): Promise<{ scenarios: ScenarioSummary[] }> { diff --git a/web/tests/lib/api.test.ts b/web/tests/lib/api.test.ts index 10077cf2a..d65964ace 100644 --- a/web/tests/lib/api.test.ts +++ b/web/tests/lib/api.test.ts @@ -12,7 +12,7 @@ describe('API client', () => { }); it('getLabStatus fetches /api/lab/status', async () => { - const data = { running: true, containers: [], error: '' }; + const data = { running: true, containers: [], error: null }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(data) @@ -24,7 +24,7 @@ describe('API client', () => { }); it('startLab posts to /api/lab/start', async () => { - const data = { success: true, message: 'started', error: '' }; + const data = { success: true, message: 'started', error: null }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(data) @@ -36,7 +36,7 @@ describe('API client', () => { }); it('stopLab posts to /api/lab/stop', async () => { - const data = { success: true, message: 'stopped', error: '' }; + const data = { success: true, message: 'stopped', error: null }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(data) @@ -78,4 +78,15 @@ describe('API client', () => { 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(/\.\.\.$/); + }); }); diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 179362dd4..a9aea0ebb 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -6,6 +6,11 @@ export default defineConfig({ test: { include: ['tests/**/*.test.ts'], environment: 'jsdom', - setupFiles: ['tests/setup.ts'] + setupFiles: ['tests/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + reportsDirectory: 'coverage' + } } }); From bbecc17647e2fb092d2f0aaeef6ec24a9be8bbda Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 20 Mar 2026 21:27:27 +0000 Subject: [PATCH 17/56] Run svelte-kit sync before web frontend tests --- .github/workflows/sonarcloud.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e94f4f9ac..26306ceef 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -36,6 +36,7 @@ jobs: run: | cd web npm install + npx svelte-kit sync npx vitest run --coverage - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@v7 From 8f99855b635af96ca3300a179508f134ee6a17a1 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 21 Mar 2026 01:35:49 +0000 Subject: [PATCH 18/56] Add missing dependencies and update skills --- .claude/skills/gh-workflow-monitor/SKILL.md | 2 +- web/package-lock.json | 794 ++++++++++++++++++++ web/package.json | 3 +- 3 files changed, 797 insertions(+), 2 deletions(-) diff --git a/.claude/skills/gh-workflow-monitor/SKILL.md b/.claude/skills/gh-workflow-monitor/SKILL.md index 4bdddf20b..c4f14cd6a 100644 --- a/.claude/skills/gh-workflow-monitor/SKILL.md +++ b/.claude/skills/gh-workflow-monitor/SKILL.md @@ -18,4 +18,4 @@ Use the `gh` for GitHub operations. ## Step 5: If the workflow run fails, propose a fix. -## Step 6: If the workflow run succeeds, report the status. +## Step 6: If the workflow run succeeds, exit plan mode and report the status. diff --git a/web/package-lock.json b/web/package-lock.json index 99d51cbb5..31be02236 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.0.0", "@testing-library/svelte": "^5.2.0", + "@vitest/coverage-v8": "^3.2.4", "jsdom": "^25.0.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", @@ -22,6 +23,20 @@ "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", @@ -51,6 +66,16 @@ "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", @@ -61,6 +86,22 @@ "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", @@ -71,6 +112,30 @@ "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", @@ -628,6 +693,34 @@ "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", @@ -678,6 +771,17 @@ "@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", @@ -1645,6 +1749,40 @@ "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", @@ -1836,6 +1974,35 @@ "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", @@ -1853,6 +2020,29 @@ "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", @@ -1930,6 +2120,26 @@ "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", @@ -1960,6 +2170,21 @@ "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", @@ -2099,6 +2324,20 @@ "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", @@ -2277,6 +2516,23 @@ } } }, + "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", @@ -2358,6 +2614,61 @@ "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", @@ -2378,6 +2689,16 @@ "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", @@ -2433,6 +2754,13 @@ "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", @@ -2490,6 +2818,16 @@ "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", @@ -2514,6 +2852,83 @@ "@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", @@ -2884,6 +3299,34 @@ "@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/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2917,6 +3360,32 @@ "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", @@ -2970,6 +3439,13 @@ "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", @@ -2983,6 +3459,16 @@ "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", @@ -2990,6 +3476,23 @@ "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", @@ -3208,6 +3711,19 @@ "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", @@ -3215,6 +3731,29 @@ "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", @@ -3222,6 +3761,19 @@ "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", @@ -3261,6 +3813,103 @@ "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", @@ -3281,6 +3930,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/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", @@ -3394,6 +4056,21 @@ "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", @@ -3777,6 +4454,22 @@ "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", @@ -3794,6 +4487,107 @@ "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", diff --git a/web/package.json b/web/package.json index a806118ba..1ee8d36d8 100644 --- a/web/package.json +++ b/web/package.json @@ -15,8 +15,9 @@ "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "^2.15.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@testing-library/svelte": "^5.2.0", "@tailwindcss/vite": "^4.0.0", + "@testing-library/svelte": "^5.2.0", + "@vitest/coverage-v8": "^3.2.4", "jsdom": "^25.0.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", From 93f13d4d34460cfd5a20e9690af28f37a64a159d Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 21 Mar 2026 02:06:52 +0000 Subject: [PATCH 19/56] Coverage, security rating, pydantic v2.11 deprecation --- src/aptl/api/main.py | 2 +- src/aptl/core/config.py | 2 +- tests/test_cli_web.py | 67 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_web.py diff --git a/src/aptl/api/main.py b/src/aptl/api/main.py index 08755007b..09d474ecb 100644 --- a/src/aptl/api/main.py +++ b/src/aptl/api/main.py @@ -26,7 +26,7 @@ def create_app() -> FastAPI: "http://localhost:3000", "http://localhost:5173", ], - allow_credentials=True, + allow_credentials=False, allow_methods=["GET", "POST"], allow_headers=["Content-Type", "Accept"], ) diff --git a/src/aptl/core/config.py b/src/aptl/core/config.py index 2bbb2843e..3dcd1094e 100644 --- a/src/aptl/core/config.py +++ b/src/aptl/core/config.py @@ -58,7 +58,7 @@ class ContainerSettings(BaseModel): def enabled_profiles(self) -> list[str]: """Return docker compose profile names for enabled containers.""" return [ - name for name in self.model_fields + name for name in type(self).model_fields if getattr(self, name) ] 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 From 6bf4b31a50aaec348902315c1ec876dde3446f5b Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 21 Mar 2026 02:14:37 +0000 Subject: [PATCH 20/56] Fix security hotspots --- sonar-project.properties | 1 + src/aptl/api/main.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 17c754388..2c45e166f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,6 +4,7 @@ sonar.organization=brad-edwards 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 diff --git a/src/aptl/api/main.py b/src/aptl/api/main.py index 09d474ecb..ca22dcfc1 100644 --- a/src/aptl/api/main.py +++ b/src/aptl/api/main.py @@ -20,7 +20,7 @@ def create_app() -> FastAPI: version="0.1.0", ) - app.add_middleware( + app.add_middleware( # NOSONAR — localhost-only origins; this is a local lab tool, not internet-facing CORSMiddleware, allow_origins=[ "http://localhost:3000", From 8cef98beb10552b947e83915ca8d1b7d2e737546 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 21 Mar 2026 02:47:22 +0000 Subject: [PATCH 21/56] rewrite lcov paths and add store tests --- .github/workflows/sonarcloud.yml | 1 + web/tests/lib/stores.test.ts | 101 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 web/tests/lib/stores.test.ts diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 26306ceef..68b478a9d 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -38,6 +38,7 @@ jobs: npm install npx svelte-kit sync npx vitest run --coverage + sed -i 's|^SF:|SF:web/|' coverage/lcov.info - name: SonarCloud Scan uses: SonarSource/sonarqube-scan-action@v7 env: diff --git a/web/tests/lib/stores.test.ts b/web/tests/lib/stores.test.ts new file mode 100644 index 000000000..947d91e3d --- /dev/null +++ b/web/tests/lib/stores.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { get } from 'svelte/store'; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Mock EventSource +class MockEventSource { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + + url: string; + readyState = MockEventSource.OPEN; + onerror: ((event: Event) => void) | null = null; + private listeners: Record void)[]> = {}; + + constructor(url: string) { + this.url = url; + } + + addEventListener(type: string, listener: (event: MessageEvent) => void) { + if (!this.listeners[type]) this.listeners[type] = []; + this.listeners[type].push(listener); + } + + close() { + this.readyState = MockEventSource.CLOSED; + } + + // Test helper to simulate events + emit(type: string, data: string) { + for (const listener of this.listeners[type] || []) { + listener(new MessageEvent(type, { data })); + } + } +} + +vi.stubGlobal('EventSource', MockEventSource); + +describe('lab store', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('initLabStore fetches status and sets store', async () => { + const statusData = { running: true, containers: [], error: null }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(statusData) + }); + + const { labStatus, labLoading, initLabStore, destroyLabStore } = await import( + '../../src/lib/stores/lab' + ); + + initLabStore(); + + // Wait for the async fetch to complete + await vi.waitFor(() => { + expect(get(labLoading)).toBe(false); + }); + + expect(get(labStatus).running).toBe(true); + + destroyLabStore(); + }); + + it('initLabStore handles fetch error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const { labStatus, labLoading, initLabStore, destroyLabStore } = await import( + '../../src/lib/stores/lab' + ); + + initLabStore(); + + await vi.waitFor(() => { + expect(get(labLoading)).toBe(false); + }); + + expect(get(labStatus).error).toContain('Network error'); + + destroyLabStore(); + }); + + it('destroyLabStore closes EventSource', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ running: false, containers: [], error: null }) + }); + + const { initLabStore, destroyLabStore } = await import('../../src/lib/stores/lab'); + + initLabStore(); + destroyLabStore(); + + // No error thrown = success + }); +}); From fc04b3aa530dcb2055e4ccc147d52110acc2ada3 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 21 Mar 2026 02:55:08 +0000 Subject: [PATCH 22/56] Remove user controlled data from log statements --- src/aptl/api/routers/scenarios.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/aptl/api/routers/scenarios.py b/src/aptl/api/routers/scenarios.py index 9dec3e890..45bc28430 100644 --- a/src/aptl/api/routers/scenarios.py +++ b/src/aptl/api/routers/scenarios.py @@ -83,12 +83,12 @@ async def get_scenario( project_dir: Path = Depends(get_project_dir), ) -> dict: """Get full scenario definition by ID.""" - log.info("GET /scenarios/%s", scenario_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: %s", scenario_id) - raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario_id}") + log.warning("Scenario not found") + raise HTTPException(status_code=404, detail="Scenario not found") return scenario.model_dump() From 39d94ac2b70683ced5dd889408504d709c765d37 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 21 Mar 2026 03:59:10 +0000 Subject: [PATCH 23/56] Add in browser terminal access --- CHANGELOG.md | 13 ++ pyproject.toml | 1 + src/aptl/api/main.py | 3 +- src/aptl/api/routers/terminal.py | 134 +++++++++++++ tests/test_api_terminal.py | 186 ++++++++++++++++++ web/package-lock.json | 27 +++ web/package.json | 3 + web/src/lib/components/ContainerCard.svelte | 16 ++ web/src/lib/components/Terminal.svelte | 110 +++++++++++ .../routes/terminal/[container]/+page.svelte | 24 +++ web/vite.config.ts | 3 +- 11 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 src/aptl/api/routers/terminal.py create mode 100644 tests/test_api_terminal.py create mode 100644 web/src/lib/components/Terminal.svelte create mode 100644 web/src/routes/terminal/[container]/+page.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md index d4969a61b..d8bc125e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [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 victim, kali, and reverse 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 diff --git a/pyproject.toml b/pyproject.toml index 5599d7ca5..4f604ee1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ web = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", "sse-starlette>=2.0.0", + "asyncssh>=2.17.0", ] dev = [ "pytest>=8.0.0", diff --git a/src/aptl/api/main.py b/src/aptl/api/main.py index ca22dcfc1..8da52ee00 100644 --- a/src/aptl/api/main.py +++ b/src/aptl/api/main.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from aptl.api.routers import config, lab, scenarios +from aptl.api.routers import config, lab, scenarios, terminal from aptl.utils.logging import get_logger, setup_logging log = get_logger("api") @@ -34,6 +34,7 @@ def create_app() -> FastAPI: app.include_router(lab.router, prefix="/api") app.include_router(scenarios.router, prefix="/api") app.include_router(config.router, prefix="/api") + app.include_router(terminal.router, prefix="/api") @app.get("/api/health") async def health() -> dict: diff --git a/src/aptl/api/routers/terminal.py b/src/aptl/api/routers/terminal.py new file mode 100644 index 000000000..1f6eb18cc --- /dev/null +++ b/src/aptl/api/routers/terminal.py @@ -0,0 +1,134 @@ +"""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 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 = msg.get("cols", 80) + rows = msg.get("rows", 24) + process.change_terminal_size(cols, rows) + except (asyncio.CancelledError, WebSocketDisconnect, json.JSONDecodeError): + pass + + +@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. + """ + # 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, + ) + 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", port) + try: + await websocket.send_json( + {"type": "error", "message": f"SSH connection failed: {exc}"} + ) + 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/tests/test_api_terminal.py b/tests/test_api_terminal.py new file mode 100644 index 000000000..1ad433207 --- /dev/null +++ b/tests/test_api_terminal.py @@ -0,0 +1,186 @@ +"""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 + + +class TestTerminalWebSocket: + @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") 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") 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") as ws: + ws.send_text(json.dumps({"type": "stdin", "data": "ls\n"})) + # Give the relay tasks time to process + import time + time.sleep(0.1) + + 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") 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") as ws: + ws.send_text(json.dumps({"type": "resize", "cols": 120, "rows": 40})) + import time + time.sleep(0.1) + + 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") 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") as ws: + msg = ws.receive_json() + assert msg["type"] == "error" + assert "Connection refused" in msg["message"] + + @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"]: + with api_client.websocket_connect(f"/api/terminal/ws/{name}") 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/web/package-lock.json b/web/package-lock.json index 31be02236..1c9da86d1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,6 +14,9 @@ "@tailwindcss/vite": "^4.0.0", "@testing-library/svelte": "^5.2.0", "@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", @@ -1908,6 +1911,30 @@ "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", diff --git a/web/package.json b/web/package.json index 1ee8d36d8..a36941004 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,9 @@ "@tailwindcss/vite": "^4.0.0", "@testing-library/svelte": "^5.2.0", "@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", diff --git a/web/src/lib/components/ContainerCard.svelte b/web/src/lib/components/ContainerCard.svelte index 15de6a24f..d9dbadbe3 100644 --- a/web/src/lib/components/ContainerCard.svelte +++ b/web/src/lib/components/ContainerCard.svelte @@ -34,9 +34,15 @@ return 'stopped'; }); + const SSH_CONTAINERS = new Set(['victim', 'kali', 'reverse', 'workstation']); + const displayName = $derived( container.name.replace(/^aptl-/, '').replace(/-\d+$/, '') ); + + const showTerminal = $derived( + container.state === 'running' && SSH_CONTAINERS.has(displayName) + );
{/if} + {#if showTerminal} + + {/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/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/vite.config.ts b/web/vite.config.ts index 3c67b3ffd..21b54ece5 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -8,7 +8,8 @@ export default defineConfig({ proxy: { '/api': { target: 'http://localhost:8400', - changeOrigin: true + changeOrigin: true, + ws: true } } } From 9fccc91101b33ad163573e17ea3ace3ba0c89f1e Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 21 Mar 2026 04:03:43 +0000 Subject: [PATCH 24/56] Review and security review fixes --- CHANGELOG.md | 2 +- src/aptl/api/routers/terminal.py | 14 ++++++++------ tests/test_api_terminal.py | 11 ++++------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8bc125e0..8506221cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 victim, kali, and reverse containers + - 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 diff --git a/src/aptl/api/routers/terminal.py b/src/aptl/api/routers/terminal.py index 1f6eb18cc..1571a1bf4 100644 --- a/src/aptl/api/routers/terminal.py +++ b/src/aptl/api/routers/terminal.py @@ -58,11 +58,13 @@ async def _relay_ws_to_ssh( if msg_type == "stdin": process.stdin.write(msg.get("data", "")) elif msg_type == "resize": - cols = msg.get("cols", 80) - rows = msg.get("rows", 24) + 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, json.JSONDecodeError): + except (asyncio.CancelledError, WebSocketDisconnect): pass + except json.JSONDecodeError: + log.debug("Malformed WebSocket message, ignoring") @router.websocket("/terminal/ws/{container}") @@ -107,7 +109,7 @@ async def terminal_ws( port=port, username=username, client_keys=[str(key_path)], - known_hosts=None, + known_hosts=None, # localhost-only; containers regenerate host keys on rebuild ) process = await conn.create_process( term_type="xterm-256color", @@ -119,10 +121,10 @@ async def terminal_ws( _relay_ws_to_ssh(websocket, process), ) except asyncssh.Error as exc: - log.exception("SSH connection error on port %d", port) + log.exception("SSH connection error on port %d: %s", port, exc) try: await websocket.send_json( - {"type": "error", "message": f"SSH connection failed: {exc}"} + {"type": "error", "message": "SSH connection failed"} ) except Exception: pass diff --git a/tests/test_api_terminal.py b/tests/test_api_terminal.py index 1ad433207..89023e743 100644 --- a/tests/test_api_terminal.py +++ b/tests/test_api_terminal.py @@ -94,10 +94,9 @@ def test_stdin_forwarded_to_ssh(self, mock_status, mock_asyncssh, api_client): with api_client.websocket_connect("/api/terminal/ws/victim") as ws: ws.send_text(json.dumps({"type": "stdin", "data": "ls\n"})) - # Give the relay tasks time to process - import time - time.sleep(0.1) + # 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") @@ -135,8 +134,6 @@ def test_resize_calls_change_terminal_size(self, mock_status, mock_asyncssh, api with api_client.websocket_connect("/api/terminal/ws/victim") as ws: ws.send_text(json.dumps({"type": "resize", "cols": 120, "rows": 40})) - import time - time.sleep(0.1) process.change_terminal_size.assert_called_with(120, 40) @@ -171,14 +168,14 @@ def test_ssh_error_sends_error_message(self, mock_status, mock_asyncssh, api_cli with api_client.websocket_connect("/api/terminal/ws/victim") as ws: msg = ws.receive_json() assert msg["type"] == "error" - assert "Connection refused" in msg["message"] + 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"]: + for name in ["victim", "kali", "reverse", "workstation"]: with api_client.websocket_connect(f"/api/terminal/ws/{name}") as ws: msg = ws.receive_json() # Should get "lab not running" error, not unknown container From bdb69dffcdf98d71bbb40c9b372990c02d0dfab3 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 21 Mar 2026 04:49:50 +0000 Subject: [PATCH 25/56] Add interactive scenario workbench UI --- CHANGELOG.md | 18 ++ web/package-lock.json | 36 ++- web/package.json | 5 + web/src/app.css | 80 +++++ web/src/lib/api.ts | 12 +- web/src/lib/components/ScenarioCard.svelte | 7 +- .../workbench/AttackStepBlock.svelte | 126 ++++++++ .../workbench/ContainerStatusBlock.svelte | 43 +++ .../components/workbench/HintToggle.svelte | 56 ++++ .../workbench/NarrativeBlock.svelte | 15 + .../workbench/ObjectiveBlock.svelte | 49 +++ .../workbench/SectionDivider.svelte | 13 + .../workbench/SiemQueryBlock.svelte | 31 ++ .../components/workbench/TerminalBlock.svelte | 27 ++ .../workbench/WorkbenchStatusBar.svelte | 87 ++++++ web/src/lib/markdown.ts | 12 + web/src/lib/types.ts | 126 ++++++++ web/src/lib/workbench.ts | 114 +++++++ web/src/routes/scenarios/[id]/+page.svelte | 32 ++ web/src/routes/scenarios/[id]/+page.ts | 14 + web/tests/components/HintToggle.test.ts | 77 +++++ web/tests/lib/markdown.test.ts | 48 +++ web/tests/lib/workbench.test.ts | 279 ++++++++++++++++++ web/vitest.config.ts | 4 + 24 files changed, 1304 insertions(+), 7 deletions(-) create mode 100644 web/src/lib/components/workbench/AttackStepBlock.svelte create mode 100644 web/src/lib/components/workbench/ContainerStatusBlock.svelte create mode 100644 web/src/lib/components/workbench/HintToggle.svelte create mode 100644 web/src/lib/components/workbench/NarrativeBlock.svelte create mode 100644 web/src/lib/components/workbench/ObjectiveBlock.svelte create mode 100644 web/src/lib/components/workbench/SectionDivider.svelte create mode 100644 web/src/lib/components/workbench/SiemQueryBlock.svelte create mode 100644 web/src/lib/components/workbench/TerminalBlock.svelte create mode 100644 web/src/lib/components/workbench/WorkbenchStatusBar.svelte create mode 100644 web/src/lib/markdown.ts create mode 100644 web/src/lib/workbench.ts create mode 100644 web/src/routes/scenarios/[id]/+page.svelte create mode 100644 web/src/routes/scenarios/[id]/+page.ts create mode 100644 web/tests/components/HintToggle.test.ts create mode 100644 web/tests/lib/markdown.test.ts create mode 100644 web/tests/lib/workbench.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8506221cd..3c52e58be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ 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.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 + - 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 + ## [4.10.0] - 2026-03-21 ### Added diff --git a/web/package-lock.json b/web/package-lock.json index 1c9da86d1..b5eff8989 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,6 +7,11 @@ "": { "name": "aptl-web", "version": "0.1.0", + "dependencies": { + "@types/dompurify": "^3.0.5", + "dompurify": "^3.3.3", + "marked": "^17.0.5" + }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.0", "@sveltejs/kit": "^2.15.0", @@ -1717,6 +1722,15 @@ "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==", + "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", @@ -1735,7 +1749,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/types": { @@ -2336,6 +2349,15 @@ "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", @@ -3354,6 +3376,18 @@ "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", diff --git a/web/package.json b/web/package.json index a36941004..a1919bf33 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@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", @@ -28,5 +29,9 @@ "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 index 9869f6d79..9fed0b1d9 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -30,3 +30,83 @@ body { -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/lib/api.ts b/web/src/lib/api.ts index 1c0e55bd3..da2a312cd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,4 +1,10 @@ -import type { AppConfig, LabActionResponse, LabStatus, ScenarioSummary } from './types'; +import type { + AppConfig, + LabActionResponse, + LabStatus, + ScenarioDefinition, + ScenarioSummary +} from './types'; const BASE = '/api'; const MAX_ERROR_TEXT_LENGTH = 500; @@ -31,8 +37,8 @@ export async function getScenarios(): Promise { return fetchJSON('/scenarios'); } -export async function getScenario(id: string): Promise> { - return fetchJSON>(`/scenarios/${encodeURIComponent(id)}`); +export async function getScenario(id: string): Promise { + return fetchJSON(`/scenarios/${encodeURIComponent(id)}`); } export async function getConfig(): Promise { diff --git a/web/src/lib/components/ScenarioCard.svelte b/web/src/lib/components/ScenarioCard.svelte index d17eb0f16..ee430e09d 100644 --- a/web/src/lib/components/ScenarioCard.svelte +++ b/web/src/lib/components/ScenarioCard.svelte @@ -36,8 +36,9 @@ }); -
@@ -70,4 +71,4 @@ {/each}
{/if} -
+ diff --git a/web/src/lib/components/workbench/AttackStepBlock.svelte b/web/src/lib/components/workbench/AttackStepBlock.svelte new file mode 100644 index 000000000..be11978fa --- /dev/null +++ b/web/src/lib/components/workbench/AttackStepBlock.svelte @@ -0,0 +1,126 @@ + + +
+ +
+ + {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} +
diff --git a/web/src/lib/components/workbench/ContainerStatusBlock.svelte b/web/src/lib/components/workbench/ContainerStatusBlock.svelte new file mode 100644 index 000000000..f2550f76a --- /dev/null +++ b/web/src/lib/components/workbench/ContainerStatusBlock.svelte @@ -0,0 +1,43 @@ + + +
+ {#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..792c3bac7 --- /dev/null +++ b/web/src/lib/components/workbench/ObjectiveBlock.svelte @@ -0,0 +1,49 @@ + + +
+
+ + {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..6cabef453 --- /dev/null +++ b/web/src/lib/components/workbench/WorkbenchStatusBar.svelte @@ -0,0 +1,87 @@ + + +
+
+ + ← 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/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/types.ts b/web/src/lib/types.ts index 03110d828..084eb9368 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -34,6 +34,132 @@ export interface ScenarioSummary { 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; diff --git a/web/src/lib/workbench.ts b/web/src/lib/workbench.ts new file mode 100644 index 000000000..d7803a0ee --- /dev/null +++ b/web/src/lib/workbench.ts @@ -0,0 +1,114 @@ +import type { AttackStep, Objective, ScenarioDefinition } from './types'; + +// --- Block discriminated union --- + +export interface NarrativeBlock { + type: 'narrative'; + content: string; +} + +export interface ContainerStatusBlock { + type: 'container-status'; + containers: string[]; +} + +export interface AttackStepBlock { + type: 'attack-step'; + step: AttackStep; + stepIndex: number; +} + +export interface SectionDividerBlock { + type: 'section-divider'; + title: string; +} + +export interface ObjectiveBlock { + type: 'objective'; + 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', + 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', containers }); + } + + // 3. Attack chain summary + if (scenario.attack_chain) { + blocks.push({ + type: 'narrative', + content: `## Attack Chain\n\n${scenario.attack_chain}` + }); + } + + // 4. Steps + if (scenario.steps.length > 0) { + blocks.push({ type: 'section-divider', title: 'Attack Steps' }); + for (let i = 0; i < scenario.steps.length; i++) { + blocks.push({ + type: 'attack-step', + 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', title: 'Objectives' }); + } + for (const obj of scenario.objectives.red) { + blocks.push({ type: 'objective', objective: obj, team: 'red' }); + } + for (const obj of scenario.objectives.blue) { + blocks.push({ type: 'objective', 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', content: scoringText }); + } + + return blocks; +} diff --git a/web/src/routes/scenarios/[id]/+page.svelte b/web/src/routes/scenarios/[id]/+page.svelte new file mode 100644 index 000000000..5b20f8a36 --- /dev/null +++ b/web/src/routes/scenarios/[id]/+page.svelte @@ -0,0 +1,32 @@ + + + + +
+ {#each blocks as block, i (i)} + {#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..1bbac997d --- /dev/null +++ b/web/src/routes/scenarios/[id]/+page.ts @@ -0,0 +1,14 @@ +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(); + throw new Error(`Failed to load scenario: ${text}`); + } + const scenario: ScenarioDefinition = await res.json(); + return { scenario }; +} 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/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('
diff --git a/web/src/lib/components/workbench/ObjectiveBlock.svelte b/web/src/lib/components/workbench/ObjectiveBlock.svelte index 792c3bac7..b65b801e8 100644 --- a/web/src/lib/components/workbench/ObjectiveBlock.svelte +++ b/web/src/lib/components/workbench/ObjectiveBlock.svelte @@ -10,14 +10,12 @@ let { objective, team }: Props = $props(); - const accentColor = $derived(team === 'red' ? 'aptl-violet' : 'aptl-teal'); const borderClass = $derived( team === 'red' ? 'border-aptl-violet/30' : 'border-aptl-teal/30' ); const badgeBg = $derived( team === 'red' ? 'bg-aptl-violet/10 text-aptl-violet' : 'bg-aptl-teal/10 text-aptl-teal' ); - const typeBadge = $derived(`bg-${accentColor}/10 text-${accentColor}`);
@@ -39,7 +37,7 @@
diff --git a/web/src/lib/components/workbench/WorkbenchStatusBar.svelte b/web/src/lib/components/workbench/WorkbenchStatusBar.svelte index 6cabef453..5e0453f2b 100644 --- a/web/src/lib/components/workbench/WorkbenchStatusBar.svelte +++ b/web/src/lib/components/workbench/WorkbenchStatusBar.svelte @@ -1,6 +1,7 @@