mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
362 lines
13 KiB
Python
362 lines
13 KiB
Python
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from plugins.violin_guard import adapters, execution
|
|
from plugins.violin_guard.handlers import adapter_handlers
|
|
|
|
|
|
def _engagement(tmp_path: Path) -> Path:
|
|
eng = tmp_path / "engagement"
|
|
(eng / "state").mkdir(parents=True)
|
|
(eng / "evidence").mkdir()
|
|
(eng / "state" / "history.md").write_text("# History\n", encoding="utf-8")
|
|
return eng
|
|
|
|
|
|
def test_local_executor_records_receipt_and_history(tmp_path):
|
|
eng = _engagement(tmp_path)
|
|
receipt = execution.execute(
|
|
"echo violin-test",
|
|
eng_dir=str(eng),
|
|
phase="recon",
|
|
timeout_seconds=10,
|
|
label="smoke",
|
|
)
|
|
assert receipt["executed"] is True
|
|
assert receipt["exit_code"] == 0
|
|
assert "violin-test" in receipt["stdout_preview"]
|
|
assert (eng / receipt["evidence_paths"]["manifest"]).exists()
|
|
assert "echo violin-test" in (eng / "state" / "history.md").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_structured_argv_preserves_argument_boundaries(tmp_path):
|
|
eng = _engagement(tmp_path)
|
|
value = "value with spaces (and parentheses)"
|
|
receipt = execution.execute(
|
|
"echo structured-argv",
|
|
argv=[sys.executable, "-c", "import sys; print(sys.argv[1])", value],
|
|
eng_dir=str(eng),
|
|
phase="recon",
|
|
timeout_seconds=10,
|
|
)
|
|
|
|
assert receipt["exit_code"] == 0
|
|
assert receipt["stdout_preview"].strip() == value
|
|
|
|
|
|
def test_failed_to_start_is_audited_without_execution_credit(tmp_path, monkeypatch):
|
|
eng = _engagement(tmp_path)
|
|
reservation = execution.state.reserve_sync_credit(eng, "recon", 2)
|
|
before = execution.state.sync_credit_remaining(eng, "recon")
|
|
|
|
def fail_start(*_args, **_kwargs):
|
|
raise OSError("launch denied")
|
|
|
|
monkeypatch.setattr(execution.subprocess, "Popen", fail_start)
|
|
receipt = execution.execute(
|
|
"nmap 10.10.10.10",
|
|
argv=[sys.executable, "-c", "print('never')"],
|
|
eng_dir=str(eng),
|
|
phase="recon",
|
|
backend="local",
|
|
ptt_task_id="PT-001",
|
|
sync_reservation=reservation,
|
|
)
|
|
assert receipt["status"] == "failed_to_start"
|
|
assert receipt["executed"] is False
|
|
assert execution.state.sync_credit_remaining(eng, "recon") == before
|
|
assert not execution.state.has_pending_sync(eng)
|
|
execution.state.release_reserved_sync_credit(eng, reservation)
|
|
assert execution.state.sync_credit_remaining(eng, "recon") == 10
|
|
|
|
|
|
def test_failed_to_track_terminates_and_accounts_conservatively(tmp_path, monkeypatch):
|
|
eng = _engagement(tmp_path)
|
|
monkeypatch.setattr(execution, "_process_create_time", lambda _proc: None)
|
|
receipt = execution.execute(
|
|
"tracked command",
|
|
argv=[sys.executable, "-c", "import time; time.sleep(10)"],
|
|
eng_dir=str(eng),
|
|
phase="recon",
|
|
backend="local",
|
|
ptt_task_id="PT-001",
|
|
)
|
|
assert receipt["status"] == "failed_to_track"
|
|
assert receipt["executed"] is True
|
|
assert execution.state.sync_credit_remaining(eng, "recon") == 9
|
|
assert execution.state.has_pending_sync(eng)
|
|
|
|
|
|
def test_background_execution_is_tracked_until_completion(tmp_path):
|
|
eng = _engagement(tmp_path)
|
|
receipt = execution.execute(
|
|
"echo managed-listener",
|
|
argv=[sys.executable, "-c", "import time; print('ready'); time.sleep(0.2)"],
|
|
eng_dir=str(eng),
|
|
phase="recon",
|
|
timeout_seconds=5,
|
|
background=True,
|
|
)
|
|
|
|
assert receipt["status"] == "running"
|
|
assert isinstance(receipt["pid"], int)
|
|
assert isinstance(receipt["pid_create_time"], float)
|
|
assert receipt["deadline_at"].endswith("Z")
|
|
deadline = time.monotonic() + 5
|
|
current = receipt
|
|
while current["status"] == "running" and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
current = execution.status(str(eng), receipt["execution_id"])
|
|
|
|
assert current["status"] == "completed"
|
|
assert current["history_recorded"] is True
|
|
assert "echo managed-listener" in (eng / "state" / "history.md").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_background_execution_can_be_cancelled_by_execution_id(tmp_path):
|
|
eng = _engagement(tmp_path)
|
|
receipt = execution.execute(
|
|
"echo cancellable-listener",
|
|
argv=[sys.executable, "-c", "import time; time.sleep(30)"],
|
|
eng_dir=str(eng),
|
|
phase="recon",
|
|
timeout_seconds=60,
|
|
background=True,
|
|
)
|
|
|
|
cancelled = execution.cancel(str(eng), receipt["execution_id"])
|
|
assert cancelled["cancel_requested"] is True
|
|
deadline = time.monotonic() + 5
|
|
current = cancelled
|
|
while current["status"] == "running" and time.monotonic() < deadline:
|
|
time.sleep(0.05)
|
|
current = execution.status(str(eng), receipt["execution_id"])
|
|
assert current["status"] == "cancelled"
|
|
|
|
|
|
def test_process_identity_rejects_reused_pid():
|
|
proc = execution.psutil.Process(os.getpid())
|
|
record = {"pid": proc.pid, "pid_create_time": proc.create_time()}
|
|
assert execution._matching_process(record) is not None
|
|
record["pid_create_time"] += 10
|
|
assert execution._matching_process(record) is None
|
|
|
|
|
|
def test_executor_rejects_cwd_escape(tmp_path):
|
|
eng = _engagement(tmp_path)
|
|
with pytest.raises(ValueError, match="inside the engagement"):
|
|
execution.execute("echo blocked", eng_dir=str(eng), phase="recon", cwd="..")
|
|
|
|
|
|
def test_adapter_builders_are_structured_and_bounded():
|
|
assert "FUZZ" in adapters.build_ffuf(
|
|
{
|
|
"url": "http://10.0.0.1/FUZZ",
|
|
"wordlist": "/tmp/common.txt",
|
|
}
|
|
)
|
|
|
|
|
|
def test_ffuf_wordlist_resolution_uses_requested_then_portable_fallbacks(tmp_path, monkeypatch):
|
|
requested = tmp_path / "requested.txt"
|
|
requested.write_text("admin\n", encoding="utf-8")
|
|
assert adapters.resolve_ffuf_wordlist(requested) == str(requested)
|
|
|
|
seclists = tmp_path / "SecLists"
|
|
fallback = seclists / "Discovery" / "Web-Content" / "common.txt"
|
|
fallback.parent.mkdir(parents=True)
|
|
fallback.write_text("api\n", encoding="utf-8")
|
|
monkeypatch.setenv("SECLISTS", str(seclists))
|
|
assert adapters.resolve_ffuf_wordlist(tmp_path / "missing.txt") == str(fallback)
|
|
|
|
|
|
def test_ffuf_wordlist_resolution_has_actionable_failure(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SECLISTS", raising=False)
|
|
monkeypatch.setattr(adapters.Path, "is_file", lambda _path: False)
|
|
with pytest.raises(adapters.AdapterError, match="install the seclists package"):
|
|
adapters.resolve_ffuf_wordlist(tmp_path / "missing.txt")
|
|
|
|
|
|
def test_ffuf_handler_dispatches_the_resolved_wordlist(monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_exec(args, **kwargs):
|
|
captured["args"] = args
|
|
captured["kwargs"] = kwargs
|
|
return json.dumps({"status": "ok"})
|
|
|
|
monkeypatch.setattr(
|
|
adapter_handlers, "resolve_ffuf_wordlist", lambda _requested: "/resolved/common.txt"
|
|
)
|
|
monkeypatch.setattr(adapter_handlers, "_get_handle_exec", lambda: fake_exec)
|
|
result = json.loads(
|
|
adapter_handlers.handle_ffuf(
|
|
{
|
|
"eng_dir": "/eng",
|
|
"phase": "RECON",
|
|
"target": "https://target.example",
|
|
"url": "https://target.example/FUZZ",
|
|
"wordlist": "/missing/common.txt",
|
|
}
|
|
)
|
|
)
|
|
assert result["status"] == "ok"
|
|
assert captured["args"]["wordlist"] == "/resolved/common.txt"
|
|
assert "-w /resolved/common.txt" in captured["args"]["command"]
|
|
assert "/resolved/common.txt" in captured["kwargs"]["_internal_argv"]
|
|
|
|
|
|
def test_ffuf_handler_resolves_auth_token_from_engagement_evidence(tmp_path, monkeypatch):
|
|
engagement = tmp_path / "eng"
|
|
token_path = engagement / "evidence" / "recon" / "access.token"
|
|
token_path.parent.mkdir(parents=True)
|
|
token_path.write_text("secret-token\n", encoding="utf-8")
|
|
captured = {}
|
|
|
|
def fake_exec(args, **kwargs):
|
|
captured["args"] = args
|
|
return json.dumps({"status": "ok"})
|
|
|
|
monkeypatch.setattr(
|
|
adapter_handlers, "resolve_ffuf_wordlist", lambda _requested: "/resolved/common.txt"
|
|
)
|
|
monkeypatch.setattr(adapter_handlers, "_get_handle_exec", lambda: fake_exec)
|
|
result = json.loads(
|
|
adapter_handlers.handle_ffuf(
|
|
{
|
|
"eng_dir": str(engagement),
|
|
"phase": "RECON",
|
|
"target": "https://target.example",
|
|
"url": "https://target.example/FUZZ",
|
|
"auth_token_file": "evidence/recon/access.token",
|
|
}
|
|
)
|
|
)
|
|
assert result["status"] == "ok"
|
|
assert "Authorization: Bearer secret-token" in captured["args"]["headers"]
|
|
|
|
|
|
def test_ffuf_auth_token_file_must_be_under_evidence(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(
|
|
adapter_handlers, "resolve_ffuf_wordlist", lambda _requested: "/resolved/common.txt"
|
|
)
|
|
result = json.loads(
|
|
adapter_handlers.handle_ffuf(
|
|
{
|
|
"eng_dir": str(tmp_path / "eng"),
|
|
"phase": "RECON",
|
|
"target": "https://target.example",
|
|
"url": "https://target.example/FUZZ",
|
|
"auth_token_file": "state/access.token",
|
|
}
|
|
)
|
|
)
|
|
assert result["status"] == "error"
|
|
assert "inside the engagement evidence directory" in result["error"]
|
|
|
|
|
|
def test_listener_flags_are_pinned_per_netcat_variant():
|
|
assert adapters.detect_netcat_variant("OpenBSD netcat (Debian patchlevel 1.219-1)") == "openbsd"
|
|
assert adapters.detect_netcat_variant("Ncat: Version 7.95 ( https://nmap.org/ncat )") == "ncat"
|
|
assert adapters.detect_netcat_variant("[v1.10-47] traditional netcat") == "traditional"
|
|
|
|
openbsd = adapters.build_netcat_listener(
|
|
{"binary": "nc", "variant": "openbsd", "port": 4444, "keep_open": True}
|
|
)
|
|
assert openbsd == "nc -l -v -k 4444"
|
|
ncat = adapters.build_netcat_listener(
|
|
{"binary": "ncat", "variant": "ncat", "port": 4444, "keep_open": True}
|
|
)
|
|
assert ncat == "ncat --listen --verbose --keep-open 4444"
|
|
with pytest.raises(adapters.AdapterError, match="no supported keep-open"):
|
|
adapters.build_netcat_listener(
|
|
{"binary": "nc", "variant": "traditional", "port": 4444, "keep_open": True}
|
|
)
|
|
|
|
|
|
def test_listener_variant_detection_is_cached(monkeypatch):
|
|
calls = []
|
|
adapters._installed_netcat_variant.cache_clear()
|
|
monkeypatch.setattr(adapters.shutil, "which", lambda _: "/usr/bin/nc")
|
|
|
|
def fake_run(*args, **kwargs):
|
|
calls.append(args)
|
|
return adapters.subprocess.CompletedProcess(args[0], 0, "", "OpenBSD netcat")
|
|
|
|
monkeypatch.setattr(adapters.subprocess, "run", fake_run)
|
|
first = adapters.build_netcat_listener({"binary": "nc", "port": 4444})
|
|
second = adapters.build_netcat_listener({"binary": "nc", "port": 5555})
|
|
|
|
assert first.endswith("4444")
|
|
assert second.endswith("5555")
|
|
assert len(calls) == 1
|
|
adapters._installed_netcat_variant.cache_clear()
|
|
|
|
|
|
def test_search_exploit_reports_missing_tool(monkeypatch):
|
|
monkeypatch.setattr(adapters.shutil, "which", lambda _: None)
|
|
result = adapters.search_exploit({"product": "OpenSSH", "version": "9.0"})
|
|
assert result["available"] is False
|
|
assert result["executed_candidates"] is not True
|
|
|
|
|
|
def test_projectdiscovery_httpx_detection():
|
|
assert adapters.is_projectdiscovery_httpx("httpx v1.3.0 ProjectDiscovery") is True
|
|
assert adapters.is_projectdiscovery_httpx("-status-code -tech-detect") is True
|
|
assert adapters.is_projectdiscovery_httpx("Error: Option '-h' requires 2 arguments.") is False
|
|
assert adapters.is_projectdiscovery_httpx("httpx [OPTIONS] URL") is False
|
|
|
|
|
|
def test_httpx_binary_resolution_detects_python_httpx(monkeypatch):
|
|
adapters._installed_httpx_binary.cache_clear()
|
|
monkeypatch.setattr(
|
|
adapters.shutil,
|
|
"which",
|
|
lambda name: "/usr/bin/httpx" if name == "httpx" else None,
|
|
)
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
return adapters.subprocess.CompletedProcess(
|
|
cmd, 1, "", "Error: Option '-h' requires 2 arguments."
|
|
)
|
|
|
|
monkeypatch.setattr(adapters.subprocess, "run", fake_run)
|
|
|
|
with pytest.raises(adapters.AdapterError, match="Python httpx HTTP client"):
|
|
adapters.build_httpx({"target": "http://10.0.0.1"})
|
|
|
|
probe = adapters.available("httpx")
|
|
assert probe.available is False
|
|
assert "Python httpx" in probe.message
|
|
|
|
adapters._installed_httpx_binary.cache_clear()
|
|
|
|
|
|
def test_httpx_binary_resolution_prefers_httpx_toolkit(monkeypatch):
|
|
adapters._installed_httpx_binary.cache_clear()
|
|
monkeypatch.setattr(
|
|
adapters.shutil,
|
|
"which",
|
|
lambda name: "/usr/bin/httpx-toolkit" if name == "httpx-toolkit" else "/usr/bin/httpx",
|
|
)
|
|
cmd = adapters.build_httpx({"target": "http://10.0.0.1"})
|
|
assert cmd.startswith("httpx-toolkit")
|
|
adapters._installed_httpx_binary.cache_clear()
|
|
|
|
|
|
def test_httpx_binary_resolution_returns_false_when_missing(monkeypatch):
|
|
adapters._installed_httpx_binary.cache_clear()
|
|
monkeypatch.setattr(adapters.shutil, "which", lambda name: None)
|
|
probe = adapters.available("httpx")
|
|
assert probe.available is False
|
|
assert "not installed" in probe.message
|
|
|
|
with pytest.raises(adapters.AdapterError, match="not installed or not on PATH"):
|
|
adapters.build_httpx({"target": "http://10.0.0.1"})
|
|
adapters._installed_httpx_binary.cache_clear()
|