Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions backend/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 8 additions & 2 deletions backend/secuscan/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 29 additions & 64 deletions backend/secuscan/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
"""
Expand Down Expand Up @@ -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."""
Expand Down
21 changes: 21 additions & 0 deletions backend/secuscan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading