164 lines
5.0 KiB
Python
164 lines
5.0 KiB
Python
"""Shell command executor with strict security controls."""
|
|
|
|
import asyncio
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
from app.config import get_settings
|
|
from app.executors.base import BaseExecutor, ExecutionResult
|
|
from app.utils.validation import ValidationError, validate_shell_command
|
|
|
|
|
|
class ShellExecutor(BaseExecutor):
|
|
"""Execute shell commands with strict security controls.
|
|
|
|
Security measures:
|
|
- Absolute path allowlist for commands
|
|
- Per-command argument validation via regex
|
|
- Forbidden shell metacharacter blocking
|
|
- No shell=True (prevents shell injection)
|
|
- Timeout enforcement with watchdog
|
|
- Runs via asyncio.to_thread to avoid blocking
|
|
|
|
Payload:
|
|
{
|
|
"cmd": "/usr/bin/ls", # Must be absolute path
|
|
"args": "-la /opt/data", # Optional arguments
|
|
"timeout": 60 # Optional timeout override
|
|
}
|
|
|
|
Result:
|
|
{
|
|
"exit_code": 0,
|
|
"stdout": "...",
|
|
"stderr": "...",
|
|
"duration_ms": 123.45
|
|
}
|
|
"""
|
|
|
|
@property
|
|
def task_type(self) -> str:
|
|
return "SHELL"
|
|
|
|
async def execute(self, payload: dict[str, Any]) -> ExecutionResult:
|
|
"""Execute a shell command.
|
|
|
|
Args:
|
|
payload: Must contain "cmd", optionally "args" and "timeout"
|
|
|
|
Returns:
|
|
ExecutionResult with command output
|
|
"""
|
|
self.validate_payload(payload, ["cmd"])
|
|
settings = get_settings()
|
|
|
|
cmd = payload["cmd"]
|
|
args_str = payload.get("args", "")
|
|
timeout_override = payload.get("timeout")
|
|
|
|
# Validate command and arguments
|
|
try:
|
|
validated_cmd, args_list, default_timeout = validate_shell_command(cmd, args_str)
|
|
except ValidationError as e:
|
|
self.logger.warning("shell_validation_failed", cmd=cmd, error=str(e))
|
|
return ExecutionResult(
|
|
success=False,
|
|
data={"exit_code": -1, "stdout": "", "stderr": ""},
|
|
error=f"Validation failed: {e}",
|
|
)
|
|
|
|
# Determine timeout
|
|
timeout = timeout_override if timeout_override is not None else default_timeout
|
|
timeout = min(timeout, settings.shell_timeout) # Cap at global max
|
|
|
|
self.logger.info(
|
|
"shell_executing",
|
|
cmd=validated_cmd,
|
|
args=args_list,
|
|
timeout=timeout,
|
|
)
|
|
|
|
start_time = time.time()
|
|
|
|
try:
|
|
# Run in thread pool to avoid blocking event loop
|
|
result = await asyncio.wait_for(
|
|
self._run_subprocess(validated_cmd, args_list),
|
|
timeout=timeout * 2, # Watchdog at 2x timeout
|
|
)
|
|
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
exit_code, stdout, stderr = result
|
|
|
|
success = exit_code == 0
|
|
|
|
self.logger.info(
|
|
"shell_completed",
|
|
cmd=validated_cmd,
|
|
exit_code=exit_code,
|
|
duration_ms=duration_ms,
|
|
)
|
|
|
|
return ExecutionResult(
|
|
success=success,
|
|
data={
|
|
"exit_code": exit_code,
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
},
|
|
error=stderr if not success else None,
|
|
duration_ms=duration_ms,
|
|
)
|
|
|
|
except asyncio.TimeoutError:
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
self.logger.error("shell_timeout", cmd=validated_cmd, timeout=timeout)
|
|
return ExecutionResult(
|
|
success=False,
|
|
data={"exit_code": -1, "stdout": "", "stderr": ""},
|
|
error=f"Command timed out after {timeout} seconds",
|
|
duration_ms=duration_ms,
|
|
)
|
|
|
|
except Exception as e:
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
self.logger.error("shell_error", cmd=validated_cmd, error=str(e))
|
|
return ExecutionResult(
|
|
success=False,
|
|
data={"exit_code": -1, "stdout": "", "stderr": ""},
|
|
error=str(e),
|
|
duration_ms=duration_ms,
|
|
)
|
|
|
|
async def _run_subprocess(
|
|
self,
|
|
cmd: str,
|
|
args: list[str],
|
|
) -> tuple[int, str, str]:
|
|
"""Run subprocess in thread pool.
|
|
|
|
Args:
|
|
cmd: Command to run (absolute path)
|
|
args: Command arguments
|
|
|
|
Returns:
|
|
Tuple of (exit_code, stdout, stderr)
|
|
"""
|
|
import subprocess
|
|
|
|
def _run() -> tuple[int, str, str]:
|
|
# Build full command list
|
|
full_cmd = [cmd] + args
|
|
|
|
# Run WITHOUT shell=True for security
|
|
result = subprocess.run(
|
|
full_cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=get_settings().shell_timeout,
|
|
)
|
|
|
|
return result.returncode, result.stdout, result.stderr
|
|
|
|
return await asyncio.to_thread(_run)
|