diff --git a/PLUGINS.md b/PLUGINS.md index 97528fde..28193954 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -270,3 +270,33 @@ python scripts/validate_plugin.py --plugin plugins/nmap The validation checks metadata JSON, required fields, checksums, and custom parser imports when applicable. + +## Per-Plugin Sandbox Overrides + +Each plugin's `metadata.json` may include an optional `sandbox` key that +overrides the global process sandbox defaults for that specific scanner. + +```json +{ + "id": "example_plugin", + ... + "sandbox": { + "timeout_seconds": 300, + "max_memory_mb": 1024, + "max_output_bytes": 10485760, + "allow_network": true + } +} +``` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `timeout_seconds` | integer | `120` | Max wall-clock seconds before SIGTERM → 3s grace → SIGKILL. | +| `max_memory_mb` | integer | `512` | Virtual memory cap in MB; enforced via `RLIMIT_AS` on Linux. | +| `max_output_bytes` | integer | `5242880` | Max bytes captured from the subprocess stdout/stderr. | +| `allow_network` | boolean | `true` | Whether the subprocess may make network connections (reserved for future use). | + +Only the fields that differ from the global default need to be specified — +missing fields inherit the global value. Plugins that require longer execution +windows (e.g. comprehensive nmap scans, sqlmap aggressions) can raise +`timeout_seconds` to prevent premature termination. diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 179a0020..e780a039 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -4,3 +4,4 @@ httpx>=0.28.1 ruff>=0.15.12 pytest-asyncio>=0.24.0 anyio>=4.0.0 +trio>=0.27.0 diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index a7deae68..f86d4f30 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -76,12 +76,18 @@ class Settings(BaseSettings): trusted_proxies: List[str] = ["127.0.0.1", "::1"] - # Sandbox + # Sandbox — Docker isolation docker_enabled: bool = False - sandbox_timeout: int = 600 # seconds + sandbox_timeout: int = 600 # seconds (legacy, used by _resolve_execution_timeout) sandbox_cpu_quota: float = 0.5 sandbox_memory_mb: int = 512 + # Sandbox — subprocess resource limits + sandbox_timeout_seconds: int = 120 + sandbox_max_memory_mb: int = 512 + sandbox_max_output_bytes: int = 5_242_880 + sandbox_allow_network: bool = True + # Task-start payload limits (tunable via env vars) task_start_max_body_bytes: int = 64_000 # 64 KB total JSON body task_start_max_field_length: int = 1_000 # max chars per string input value diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 44bda2ae..f3986ed7 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -18,8 +18,9 @@ from .config import settings from .database import get_db from .plugins import get_plugin_manager -from .models import TaskStatus from .ratelimit import concurrent_limiter +from .models import TaskStatus, SandboxConfig +from .sandbox_executor import sandbox_execute from .risk_scoring import compute_risk_score, compute_risk_factors @@ -323,28 +324,33 @@ async def execute_task(self, task_id: str): logger.info(f"Executing task {task_id}: {' '.join(command)}") await self._broadcast(task_id, "status", TaskStatus.RUNNING.value) - # Execute command start_time = time.time() - output, exit_code = await self._execute_command( + output, exit_code, violation_reason = await self._execute_command( command, task_id, timeout=self._resolve_execution_timeout(inputs), ) duration = time.time() - start_time - # Save raw output raw_path = Path(settings.raw_output_dir) / f"{task_id}.txt" output = redact(output) with open(raw_path, 'w') as f: f.write(output) - # Some CLI tools use non-zero exit codes for "no result" states while still - # producing a complete, parseable report. Let plugin metadata opt into that. - final_status, error_message = self._classify_command_result( - plugin=plugin, - output=output, - exit_code=exit_code, - ) + if violation_reason: + status_map = { + "timeout": TaskStatus.TERMINATED_TIMEOUT.value, + "memory_limit": TaskStatus.TERMINATED_MEMORY.value, + "output_limit": TaskStatus.TERMINATED_OUTPUT.value, + } + final_status = status_map.get(violation_reason, TaskStatus.FAILED.value) + error_message = f"Sandbox violation: {violation_reason}" + else: + final_status, error_message = self._classify_command_result( + plugin=plugin, + output=output, + exit_code=exit_code, + ) await db.execute( """ @@ -465,63 +471,22 @@ async def _execute_command( self, command: list, task_id: str, - timeout: int = 600 + timeout: int = 600, ) -> tuple: - """ - Execute command in subprocess and stream output. - - Args: - command: Command as list - task_id: Task identifier for logging - timeout: Execution timeout in seconds - - Returns: - Tuple of (output, exit_code) - """ + config = SandboxConfig( + timeout_seconds=timeout, + max_memory_mb=settings.sandbox_max_memory_mb, + max_output_bytes=settings.sandbox_max_output_bytes, + allow_network=settings.sandbox_allow_network, + ) try: - process = await asyncio.create_subprocess_exec( - *command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT - ) - - output_lines = [] - - async def read_stream(): - stdout = process.stdout - if stdout is None: - return - - while not stdout.at_eof(): - line = await stdout.readline() - if line: - decoded_line = line.decode('utf-8', errors='replace') - output_lines.append(decoded_line) - await self._broadcast(task_id, "output", decoded_line) - - try: - await asyncio.wait_for(read_stream(), timeout=timeout) - await process.wait() - return "".join(output_lines), process.returncode if process.returncode is not None else -1 - - except asyncio.TimeoutError: - process.kill() - await process.wait() - return "".join(output_lines) + "\nTask timed out", -1 - - except asyncio.CancelledError: - # Handle task cancellation by killing the subprocess - logger.warning(f"Task {task_id} cancelled. Killing process {process.pid}") - try: - process.kill() - await process.wait() - except Exception as e: - logger.error(f"Error killing process for cancelled task {task_id}: {e}") - raise - + stdout, stderr, exit_code, violation_reason = await sandbox_execute(command, config) + return stdout, exit_code, violation_reason + except asyncio.CancelledError: + raise except Exception as e: logger.error(f"Failed to execute command: {e}") - return f"Execution error: {str(e)}", -1 + return f"Execution error: {str(e)}", -1, None def _resolve_execution_timeout(self, inputs: Dict[str, Any]) -> int: """Resolve per-task process timeout from plugin inputs.""" diff --git a/backend/secuscan/models.py b/backend/secuscan/models.py index 5d4b52e5..951839b8 100644 --- a/backend/secuscan/models.py +++ b/backend/secuscan/models.py @@ -24,6 +24,17 @@ class TaskStatus(str, Enum): COMPLETED = "completed" FAILED = "failed" CANCELLED = "cancelled" + TERMINATED_TIMEOUT = "terminated:timeout" + TERMINATED_MEMORY = "terminated:memory_limit" + TERMINATED_OUTPUT = "terminated:output_limit" + + +class SandboxConfig(BaseModel): + """Resource constraints applied to every plugin subprocess execution""" + timeout_seconds: int = Field(default=120, description="Max wall-clock seconds before SIGTERM") + max_memory_mb: int = Field(default=512, description="Max virtual memory in MB (RLIMIT_AS on Linux)") + max_output_bytes: int = Field(default=5_242_880, description="Max bytes captured from stdout/stderr") + allow_network: bool = Field(default=True, description="Whether subprocess can make network calls") class PluginFieldType(str, Enum): @@ -74,6 +85,8 @@ class PluginMetadata(BaseModel): dependencies: Optional[Dict[str, List[str]]] = None docker_image: Optional[str] = None + sandbox: Optional[SandboxConfig] = None + checksum: Optional[str] = None signature: Optional[str] = None @@ -162,6 +175,14 @@ class PluginListResponse(BaseModel): total: int +class SandboxViolation(Exception): + """Raised when sandbox constraints are violated.""" + + def __init__(self, reason: str): + super().__init__(reason) + self.reason = reason + + class ErrorResponse(BaseModel): """Error response""" error: str diff --git a/backend/secuscan/sandbox_executor.py b/backend/secuscan/sandbox_executor.py new file mode 100644 index 00000000..ba4e7935 --- /dev/null +++ b/backend/secuscan/sandbox_executor.py @@ -0,0 +1,184 @@ +import asyncio +import logging +import platform +from asyncio import subprocess +from typing import List, Optional, Tuple + +from .models import SandboxConfig + +logger = logging.getLogger(__name__) + +IS_LINUX = platform.system() == "Linux" + +CHUNK_SIZE = 64 * 1024 +SIGTERM_GRACE = 3.0 + + +def resolve_sandbox_config(plugin_sandbox: Optional[SandboxConfig] = None) -> SandboxConfig: + """Merge global settings with optional per-plugin sandbox overrides.""" + from .config import settings + base = SandboxConfig( + timeout_seconds=settings.sandbox_timeout_seconds, + max_memory_mb=settings.sandbox_max_memory_mb, + max_output_bytes=settings.sandbox_max_output_bytes, + allow_network=settings.sandbox_allow_network, + ) + if not plugin_sandbox: + return base + overrides = plugin_sandbox.model_dump(exclude_none=True) + return base.model_copy(update=overrides) + + +def _build_preexec_fn(config: SandboxConfig): + """Build preexec_fn for Linux that applies RLIMIT_AS.""" + mem_limit = config.max_memory_mb * 1024 * 1024 + + def _apply_limits(): + import resource + resource.setrlimit(resource.RLIMIT_AS, (mem_limit, mem_limit)) + + return _apply_limits + + +def classify_memory_violation( + exit_code: int, + stderr_text: str, + rss_bytes: int, + limit_bytes: int, +) -> bool: + """Post-mortem heuristic to classify whether failure was caused by memory exhaustion.""" + if exit_code in (-11, 139): + return True + if "MemoryError" in stderr_text or "Cannot allocate memory" in stderr_text: + return True + if rss_bytes >= limit_bytes * 95 // 100 and exit_code != 0: + return True + return False + + +async def _terminate_process(process): + """Graceful SIGTERM -> 3s grace -> SIGKILL escalation. Always reaps.""" + try: + process.terminate() + except ProcessLookupError: + return + try: + await asyncio.wait_for(process.wait(), timeout=SIGTERM_GRACE) + except asyncio.TimeoutError: + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() + + +async def _read_stream(stream, buffer, state): + """Read from a stream in 64KB chunks, respecting max_output_bytes limit.""" + while True: + chunk = await stream.read(CHUNK_SIZE) + if not chunk: + break + if state["limit_hit"]: + break + remaining = state["max_bytes"] - state["total_bytes"] + if remaining <= 0: + state["limit_hit"] = True + break + if len(chunk) > remaining: + chunk = chunk[:remaining] + state["limit_hit"] = True + buffer.extend(chunk) + state["total_bytes"] += len(chunk) + + +async def sandbox_execute( + cmd: List[str], + config: SandboxConfig, +) -> Tuple[str, str, int, Optional[str]]: + """ + Execute a subprocess under sandbox resource constraints. + + Returns (stdout_str, stderr_str, exit_code, violation_reason). + violation_reason is None on success, or one of + "timeout", "memory_limit", "output_limit". + """ + preexec_fn = _build_preexec_fn(config) if IS_LINUX else None + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + preexec_fn=preexec_fn, + ) + + stdout_buffer = bytearray() + stderr_buffer = bytearray() + + state = { + "total_bytes": 0, + "max_bytes": config.max_output_bytes, + "limit_hit": False, + } + + violation_reason = None + + reader_task = asyncio.gather( + _read_stream(process.stdout, stdout_buffer, state), + _read_stream(process.stderr, stderr_buffer, state), + ) + + try: + try: + await asyncio.wait_for(reader_task, timeout=config.timeout_seconds) + except asyncio.TimeoutError: + if state["limit_hit"]: + violation_reason = "output_limit" + else: + violation_reason = "timeout" + reader_task.cancel() + try: + await reader_task + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + await _terminate_process(process) + else: + if state["limit_hit"]: + violation_reason = "output_limit" + await _terminate_process(process) + else: + await process.wait() + except asyncio.CancelledError: + violation_reason = None + if not reader_task.done(): + reader_task.cancel() + try: + await reader_task + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + raise + finally: + if process.returncode is None: + await _terminate_process(process) + + stdout_str = stdout_buffer.decode("utf-8", errors="replace") + stderr_str = stderr_buffer.decode("utf-8", errors="replace") + exit_code = process.returncode if process.returncode is not None else -1 + + if violation_reason is None and exit_code != 0: + rss_bytes = 0 + try: + import resource + usage = resource.getrusage(resource.RUSAGE_CHILDREN) + if IS_LINUX: + rss_bytes = usage.ru_maxrss * 1024 + else: + rss_bytes = usage.ru_maxrss + except (ImportError, AttributeError): + pass + + limit_bytes = config.max_memory_mb * 1024 * 1024 + + if classify_memory_violation(exit_code, stderr_str, rss_bytes, limit_bytes): + violation_reason = "memory_limit" + + return stdout_str, stderr_str, exit_code, violation_reason diff --git a/frontend/src/pages/TaskDetails.tsx b/frontend/src/pages/TaskDetails.tsx index 0b4e7631..813894a6 100644 --- a/frontend/src/pages/TaskDetails.tsx +++ b/frontend/src/pages/TaskDetails.tsx @@ -115,6 +115,15 @@ function formatToolLabel(tool?: string, pluginId?: string) { return normalized.toUpperCase() } +function formatStatusLabel(status: string): string { + switch (status) { + case 'terminated:timeout': return 'TERMINATED (TIMEOUT)' + case 'terminated:memory_limit': return 'TERMINATED (MEMORY LIMIT)' + case 'terminated:output_limit': return 'TERMINATED (OUTPUT LIMIT)' + default: return status.toUpperCase() + } +} + const containerVariants = { hidden: { opacity: 0 }, visible: { @@ -315,7 +324,7 @@ export default function TaskDetails() { try { const data = JSON.parse(e.data) setTask((prev: Task | null) => prev ? { ...prev, status: data.status } : null) - if (['completed', 'failed', 'cancelled'].includes(data.status)) { + if (['completed', 'failed', 'cancelled', 'terminated:timeout', 'terminated:memory_limit', 'terminated:output_limit'].includes(data.status)) { es.close() loadTask() } @@ -441,11 +450,11 @@ export default function TaskDetails() { const completedTime = task.completed_at ? formatLocaleTime(task.completed_at, { hour: '2-digit', minute: '2-digit' }) : '--:--' - const isTerminal = ['completed', 'failed', 'cancelled'].includes(task.status) + const isTerminal = ['completed', 'failed', 'cancelled', 'terminated:timeout', 'terminated:memory_limit', 'terminated:output_limit'].includes(task.status) const durationLabel = isTerminal ? (task.duration_seconds ? `${Math.floor(task.duration_seconds / 60)}M ${Math.floor(task.duration_seconds % 60)}S` - : (task.status === 'completed' ? '0M 0S' : 'TERMINATED')) + : (task.status === 'completed' ? '0M 0S' : task.status.startsWith('terminated:') ? formatStatusLabel(task.status) : 'TERMINATED')) : 'ACTIVE' const severityCounts = findings.reduce((acc: Record, finding: any) => { const key = (finding.severity || 'info').toLowerCase() @@ -493,7 +502,9 @@ export default function TaskDetails() { ? 'bg-rag-red/15 text-rag-red border-rag-red/30' : task.status === 'cancelled' ? 'bg-silver/10 text-silver/70 border-silver/15' - : 'bg-rag-amber/15 text-rag-amber border-rag-amber/30' + : task.status.startsWith('terminated:') + ? 'bg-rag-amber/15 text-rag-amber border-rag-amber/30' + : 'bg-rag-amber/15 text-rag-amber border-rag-amber/30' const severityTone = (severity?: string) => { const normalized = (severity || '').toLowerCase() @@ -528,7 +539,7 @@ export default function TaskDetails() { ['Target', task.target], ['Tool', toolLabel], ['Plugin', task.plugin_id || 'N/A'], - ['Status', task.status], + ['Status', formatStatusLabel(task.status)], ['Start Time', task.started_at ? formatDateLong(task.started_at) : 'PENDING'], ['Finish Time', task.completed_at ? formatDateLong(task.completed_at) : 'ACTIVE'], ['Duration', durationLabel], @@ -592,7 +603,7 @@ export default function TaskDetails() { : [ `${String(result?.structured?.total_count || findings.length)} security findings indexed for ${task.target}.`, `Risk analysis identifies ${severityCounts[dominantSeverity] || 0} ${dominantSeverity.toUpperCase()} priority items.`, - `Current assessment status: ${task.status.toUpperCase()}.`, + `Current assessment status: ${formatStatusLabel(task.status)}.`, `Scanning engines performed comprehensive inspection via ${toolLabel}.`, ] const previewFindings = findings.slice(0, 5) @@ -658,8 +669,11 @@ export default function TaskDetails() { Copy ID - - {task.status} + + {formatStatusLabel(task.status)}

@@ -674,7 +688,7 @@ export default function TaskDetails() {
- {(task.status === 'completed' || task.status === 'failed') && ( + {(task.status === 'completed' || task.status === 'failed' || task.status.startsWith('terminated:')) && (