mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
Add automatic Kali backend selection
Add automatic Kali backend selection
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user