Add automatic Kali backend selection

Add automatic Kali backend selection
This commit is contained in:
Dan
2026-07-18 19:44:34 -04:00
committed by GitHub
5 changed files with 160 additions and 10 deletions
+8 -4
View File
@@ -21,6 +21,7 @@ from typing import Any
from . import state
from .history import append_history
from .phases import normalize_phase
from .runtime_backend import resolve_backend
__all__ = [
"execute",
@@ -111,7 +112,8 @@ def _command_argv(
raise ValueError("Docker backend unavailable: docker executable not found")
relative = cwd.relative_to(eng_dir).as_posix()
docker_cwd = "/engagement" if relative == "." else f"/engagement/{relative}"
docker_root = f"/engagements/{eng_dir.name}"
docker_cwd = docker_root if relative == "." else f"{docker_root}/{relative}"
prefix = ["docker", "exec", "-i", "-w", docker_cwd, container]
return prefix + list(argv) if argv is not None else prefix + ["sh", "-lc", command]
@@ -296,7 +298,7 @@ def execute(
*,
eng_dir: str,
phase: str,
backend: str = "local",
backend: str = "auto",
timeout_seconds: Any = DEFAULT_TIMEOUT,
cwd: str = "",
label: str = "",
@@ -309,6 +311,7 @@ def execute(
engagement = _resolve_engagement(eng_dir)
workdir = _resolve_cwd(engagement, cwd)
timeout = _timeout(timeout_seconds)
resolution = resolve_backend(backend, engagement, container=docker_container)
execution_id = str(uuid.uuid4())
started_at = _utc_now()
stem = f"{started_at[:19].replace(':', '')}-{execution_id[:8]}-{_label(label)}"
@@ -327,7 +330,8 @@ def execute(
"schema_version": SCHEMA_VERSION,
"execution_id": execution_id,
"status": "starting",
"backend": backend,
"backend": resolution.resolved,
"runtime": resolution.to_dict(),
"command": command,
"phase": phase,
"cwd": str(workdir),
@@ -363,7 +367,7 @@ def execute(
popen_kwargs["start_new_session"] = True
process_argv = _command_argv(
command, backend, workdir, engagement, docker_container, argv=argv
command, resolution.resolved, workdir, engagement, resolution.container, argv=argv
)
proc = subprocess.Popen(process_argv, **popen_kwargs)
+92
View File
@@ -0,0 +1,92 @@
"""Runtime selection for native Kali/Parrot, Docker Kali, and local fallback."""
from __future__ import annotations
import platform
import shutil
import subprocess
from collections.abc import Callable
from dataclasses import asdict, dataclass
from pathlib import Path
@dataclass(frozen=True)
class BackendResolution:
requested: str
resolved: str
platform: str
container: str = ""
mount: str = ""
fallback_reason: str = ""
def to_dict(self) -> dict[str, str]:
return asdict(self)
def _native_kali_or_parrot(os_release: Path = Path("/etc/os-release")) -> bool:
if platform.system().lower() != "linux" or not os_release.is_file():
return False
text = os_release.read_text(encoding="utf-8", errors="replace").lower()
return "id=kali" in text or "id=parrot" in text or "kali linux" in text or "parrot os" in text
def _docker_container_ready(
container: str, engagement: Path, run: Callable = subprocess.run
) -> tuple[bool, str]:
if shutil.which("docker") is None:
return False, "docker executable not found"
result = run(
["docker", "inspect", "--format", "{{.State.Running}}|{{json .Mounts}}", container],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
check=False,
)
if result.returncode != 0:
return False, f"Docker container {container!r} is unavailable"
running, _, mounts = result.stdout.strip().partition("|")
expected = f"/engagements/{engagement.name}"
if running.strip().lower() != "true":
return False, f"Docker container {container!r} is not running"
if expected not in mounts:
return False, f"Docker container {container!r} does not mount {expected}"
return True, expected
def resolve_backend(
requested: str,
engagement: Path,
*,
container: str = "kali-pentest",
docker_probe: Callable = _docker_container_ready,
native_probe: Callable = _native_kali_or_parrot,
) -> BackendResolution:
"""Resolve a backend without installing packages or starting containers."""
choice = str(requested or "auto").strip().lower()
if choice not in {"auto", "local", "docker"}:
raise ValueError("backend must be auto, local, or docker")
host = platform.system().lower()
if choice == "local":
return BackendResolution(choice, "local", host)
if choice == "docker":
ready, detail = docker_probe(container, engagement)
if not ready:
raise ValueError(f"Docker backend unavailable: {detail}")
return BackendResolution(choice, "docker", host, container, detail)
if native_probe():
return BackendResolution(choice, "local", host)
ready, detail = docker_probe(container, engagement)
if ready:
return BackendResolution(choice, "docker", host, container, detail)
return BackendResolution(choice, "local", host, fallback_reason=detail)
def runtime_readiness(engagement: Path) -> dict[str, object]:
resolution = resolve_backend("auto", engagement)
return {
"native_kali_or_parrot": _native_kali_or_parrot(),
"docker_executable": bool(shutil.which("docker")),
"auto": resolution.to_dict(),
}
+3 -3
View File
@@ -103,7 +103,7 @@ EXEC_SCHEMA = {
"target": {"type": "string", "description": "Explicit primary target host/IP/URL"},
"session_id": {"type": "string"},
"skill_loaded_file": {"type": "string"},
"backend": {"type": "string", "enum": ["local", "docker"], "default": "local"},
"backend": {"type": "string", "enum": ["auto", "local", "docker"], "default": "auto"},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800},
"cwd": {"type": "string", "description": "Engagement-relative working directory"},
"label": {"type": "string"},
@@ -223,7 +223,7 @@ EXEC_BURST_SCHEMA = {
},
"skill_loaded_file": {"type": "string", "description": "skill-load marker path"},
"label": {"type": "string", "description": "optional batch label for logging"},
"backend": {"type": "string", "enum": ["local", "docker"], "default": "local"},
"backend": {"type": "string", "enum": ["auto", "local", "docker"], "default": "auto"},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800},
"cwd": {"type": "string", "description": "Engagement-relative working directory"},
"continue_on_error": {"type": "boolean", "default": False},
@@ -279,7 +279,7 @@ _ADAPTER_COMMON = {
"target": {"type": "string"},
"session_id": {"type": "string"},
"skill_loaded_file": {"type": "string"},
"backend": {"type": "string", "enum": ["local", "docker"], "default": "local"},
"backend": {"type": "string", "enum": ["auto", "local", "docker"], "default": "auto"},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 1800},
"cwd": {"type": "string"},
"label": {"type": "string"},
+4 -3
View File
@@ -9,7 +9,7 @@ import shlex
from functools import wraps
from pathlib import Path
from . import bootstrap, execution, findings, hypotheses, ptt, state
from . import bootstrap, execution, findings, hypotheses, ptt, runtime_backend, state
from . import command as cmd_module
from .adapters import (
build_ffuf,
@@ -446,7 +446,7 @@ def handle_exec(a, **kwargs):
command=a["command"],
eng_dir=a["eng_dir"],
phase=a["phase"],
backend=a.get("backend", "local"),
backend=a.get("backend", "auto"),
timeout_seconds=a.get("timeout_seconds", 180),
cwd=a.get("cwd", ""),
label=a.get("label", ""),
@@ -479,7 +479,7 @@ def handle_exec_burst(a, **kwargs):
session_id = a.get("session_id", "")
skill_loaded_file = a.get("skill_loaded_file", "")
label = a.get("label", "")
backend = a.get("backend", "local")
backend = a.get("backend", "auto")
timeout_seconds = a.get("timeout_seconds", 180)
cwd = a.get("cwd", "")
continue_on_error = bool(a.get("continue_on_error", False))
@@ -714,6 +714,7 @@ def handle_status(a, **kwargs):
"loaded": skill_loaded,
"marker": str(marker) if marker else None,
},
runtime=runtime_backend.runtime_readiness(eng_dir),
)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
import pytest
from plugins.violin_guard import execution
from plugins.violin_guard.runtime_backend import resolve_backend
def test_auto_uses_native_kali_or_parrot(tmp_path: Path) -> None:
resolution = resolve_backend(
"auto", tmp_path, native_probe=lambda: True, docker_probe=lambda *_: (False, "unused")
)
assert resolution.resolved == "local"
assert not resolution.fallback_reason
def test_auto_uses_valid_docker_container(tmp_path: Path) -> None:
resolution = resolve_backend(
"auto",
tmp_path,
native_probe=lambda: False,
docker_probe=lambda *_: (True, "/engagements/x"),
)
assert resolution.resolved == "docker"
assert resolution.mount == "/engagements/x"
def test_auto_falls_back_locally_with_a_reason(tmp_path: Path) -> None:
resolution = resolve_backend(
"auto",
tmp_path,
native_probe=lambda: False,
docker_probe=lambda *_: (False, "docker missing"),
)
assert resolution.resolved == "local"
assert resolution.fallback_reason == "docker missing"
def test_explicit_docker_fails_when_mount_is_invalid(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="does not mount"):
resolve_backend(
"docker", tmp_path, docker_probe=lambda *_: (False, "does not mount /engagements/x")
)
def test_docker_command_uses_engagement_mount(tmp_path: Path, monkeypatch) -> None:
eng = tmp_path / "assessment-a"
eng.mkdir()
monkeypatch.setattr(execution.shutil, "which", lambda _: "docker")
argv = execution._command_argv("id", "docker", eng, eng, "kali-pentest")
assert argv[:5] == ["docker", "exec", "-i", "-w", "/engagements/assessment-a"]