152 lines
5.3 KiB
Python
Executable File
152 lines
5.3 KiB
Python
Executable File
"""Runtime executor adapters — subprocess wrappers for each supported backend.
|
|
|
|
Security note (F-01 / F-02 remediation):
|
|
All adapters now invoke ``subprocess.run([...])`` with explicit argument
|
|
lists instead of building a shell string via f-string interpolation.
|
|
This eliminates the shell-injection surface from untrusted
|
|
``container`` / ``vm`` / ``command`` fields.
|
|
|
|
Supports:
|
|
- host: run locally via subprocess (default)
|
|
- lxc: run inside an LXC container via ``lxc exec``
|
|
- libvirt: run inside a libvirt VM via ``virsh domexec``
|
|
- tmux: run in a detached tmux session
|
|
- podman: run inside a rootless Podman container
|
|
- firecracker: run inside a Firecracker microVM via SSH
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
from typing import Dict, Mapping, Optional
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
def _shlex_split(command: str) -> list[str]:
|
|
"""Tokenize a shell command string into argv.
|
|
|
|
Used when the caller hands us a single ``command`` string that the
|
|
underlying tool will exec directly (no shell). We split it with
|
|
:func:`shlex.split` so subprocess receives a proper argv list and
|
|
no metacharacter injection is possible.
|
|
"""
|
|
return shlex.split(command)
|
|
|
|
|
|
def _sanitize_path(path: Optional[str]) -> Optional[str]:
|
|
"""Reject path values that look like they are trying to escape argv.
|
|
|
|
A path is allowed if it does not contain a NUL byte and does not
|
|
start with ``-`` (which would be interpreted as a flag by most
|
|
tools). Returns the path unchanged, or ``None`` if rejected.
|
|
"""
|
|
if path is None:
|
|
return None
|
|
if "\x00" in path:
|
|
return None
|
|
if path.startswith("-"):
|
|
return None
|
|
return path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Host
|
|
# ---------------------------------------------------------------------------
|
|
def run_host(command: str, cwd: Optional[str], env: Optional[Mapping[str, str]]) -> int:
|
|
"""Execute a command on the host.
|
|
|
|
The command is tokenized with :func:`shlex.split` and passed to
|
|
``subprocess.run`` as an argv list — never via ``shell=True``.
|
|
"""
|
|
argv = _shlex_split(command)
|
|
if not argv:
|
|
return 2 # ENOENT-ish — nothing to run
|
|
return subprocess.run(argv, cwd=cwd, env=dict(env) if env else None).returncode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LXC
|
|
# ---------------------------------------------------------------------------
|
|
def run_lxc(container: str, command: str) -> int:
|
|
"""Execute a command inside an LXC container."""
|
|
container = _sanitize_path(container)
|
|
if not container:
|
|
return 2
|
|
# Pass the command through `bash -lc` *inside* the container by giving
|
|
# lxc exec a literal argv — lxc itself does the right thing here.
|
|
argv = ["lxc", "exec", container, "--", "bash", "-lc", command]
|
|
return subprocess.run(argv).returncode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# libvirt
|
|
# ---------------------------------------------------------------------------
|
|
def run_libvirt(vm_name: str, command: str) -> int:
|
|
"""Execute a command inside a libvirt VM via qemu-agent."""
|
|
vm_name = _sanitize_path(vm_name)
|
|
if not vm_name:
|
|
return 2
|
|
argv = ["virsh", "domexec", vm_name, "--", "bash", "-c", command]
|
|
return subprocess.run(argv).returncode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Podman
|
|
# ---------------------------------------------------------------------------
|
|
def run_podman(container: str, command: str, cwd: Optional[str] = None) -> int:
|
|
"""Execute a command inside a Podman container."""
|
|
container = _sanitize_path(container)
|
|
if not container:
|
|
return 2
|
|
argv = ["podman", "exec"]
|
|
if cwd:
|
|
cwd_clean = _sanitize_path(cwd)
|
|
if cwd_clean:
|
|
argv += ["--workdir", cwd_clean]
|
|
argv += [container, "bash", "-c", command]
|
|
return subprocess.run(argv).returncode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Firecracker (SSH)
|
|
# ---------------------------------------------------------------------------
|
|
def run_firecracker(
|
|
host: str,
|
|
ssh_key: str,
|
|
ssh_port: int,
|
|
command: str,
|
|
cwd: Optional[str] = None,
|
|
) -> int:
|
|
"""Execute a command inside a Firecracker microVM via SSH.
|
|
|
|
Builds the SSH argv explicitly — no f-string shell interpolation.
|
|
The remote ``cd`` + command is delivered as a single argument to
|
|
``ssh``, which then runs it through the remote user's shell. This
|
|
is the standard ssh idiom and is safe because the *local* subprocess
|
|
layer sees a clean argv list.
|
|
"""
|
|
host = _sanitize_path(host)
|
|
ssh_key = _sanitize_path(ssh_key)
|
|
if not host or not ssh_key:
|
|
return 2
|
|
try:
|
|
port = int(ssh_port)
|
|
except (TypeError, ValueError):
|
|
return 2
|
|
remote = f"cd {cwd or '/root'} && {command}"
|
|
argv = [
|
|
"ssh",
|
|
"-i", ssh_key,
|
|
"-p", str(port),
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "ConnectTimeout=5",
|
|
f"root@{host}",
|
|
remote,
|
|
]
|
|
return subprocess.run(argv).returncode
|