Files
violin/tests/guard/integration/test_ctf_workflow_fixes.py

368 lines
12 KiB
Python

import json
import pytest
from plugins.violin_guard import adapters, ptt, state
from plugins.violin_guard import handlers as service
from plugins.violin_guard.skill_receipts import SkillViewResult
from tests.guard.receipt_fixture import bind_active_task
_SCOPE_YAML = """targets:
ip_addresses: ["10.129.2.5"]
in_scope_urls: ["http://10.129.2.5"]
exclusions: {}
assessment_hosts:
callback_hosts: ["10.10.14.233"]
authorized_parties: ["test owner"]
authorisation:
confirmed: true
rules_of_engagement:
allowed_actions: [recon, vuln-research, exploitation]
forbidden_actions: []
engagement:
name: ctf-test
date: "2026-07-24"
type: ctf
client: test
"""
@pytest.fixture
def ctf_eng(tmp_path):
eng = tmp_path / "eng"
eng.mkdir()
state_dir = eng / "state"
state_dir.mkdir()
scope_dir = eng / "scope"
scope_dir.mkdir()
evidence_dir = eng / "evidence" / "executions"
evidence_dir.mkdir(parents=True)
(scope_dir / "scope.yaml").write_text(_SCOPE_YAML, encoding="utf-8")
(state_dir / "history.md").write_text("# History\n", encoding="utf-8")
(state_dir / "hypotheses.md").write_text("# Hypotheses\n", encoding="utf-8")
ptt_content = """# Pentesting Task Tree
## Phase: RECON
| PT-001 | [~] | Recon target service | initial note |
## Phase: EXPLOITATION
| PT-002 | [ ] | Exploit Cobbler CVE-2024-47533 | initial note |
"""
(state_dir / "ptt.md").write_text(ptt_content, encoding="utf-8")
bootstrap_data = {
"schema_version": 2,
"eng_dir": str(eng),
"target": "10.129.2.5",
"phase": "RECON",
"initialized_at": "2026-07-24T00:00:00Z",
}
(state_dir / "bootstrap.json").write_text(json.dumps(bootstrap_data), encoding="utf-8")
return eng
def test_listener_with_vpn_ip_allowed(ctf_eng, monkeypatch):
"""Verify violin_listener with attacker VPN IP is not blocked as out-of-scope target."""
monkeypatch.setattr(adapters, "_installed_netcat_variant", lambda binary: ("nc", "openbsd"))
def fake_exec(args, **kwargs):
return json.dumps({"status": "ok", "executed": True, "command": args.get("command")})
monkeypatch.setattr(service, "handle_exec", fake_exec)
res_str = service.handle_listener(
{
"eng_dir": str(ctf_eng),
"bind_host": "10.10.14.233",
"port": 4444,
"phase": "EXPLOITATION",
}
)
res = json.loads(res_str)
assert res.get("status") == "ok", res
assert "10.10.14.233" in res.get("command", "")
def test_semantic_lock_requires_research_plus_meaningful_pivot(ctf_eng):
state.record_semantic_review(
ctf_eng,
task_id="PT-001",
hypothesis_id="H-001",
skill="recon",
technique="port-scan",
outcome="no_progress",
evidence_paths=[],
next_action="none",
next_technique="port-scan",
)
# Manually inject a lock into semantic-progress.json to simulate stuck state
semantic_file = ctf_eng / "state" / "semantic-progress.json"
data = state.read_json(semantic_file)
data["lock"] = {"reason": "stuck test"}
state.atomic_json(semantic_file, data)
assert state.read_json(semantic_file).get("lock") is not None
# A hypothesis edit alone is not evidence of progress and must retain the lock.
service.handle_record_hypothesis(
{
"eng_dir": str(ctf_eng),
"id": "H-001",
"title": "Cobbler CVE-2024-47533 Vulnerability",
"vuln_class": "command-injection",
"status": "Candidate",
}
)
assert state.read_json(semantic_file).get("lock") is not None
state.record_research_attempt(ctf_eng, "web_search", True)
state.record_semantic_review(
ctf_eng,
task_id="PT-001",
hypothesis_id="H-001",
skill="recon",
technique="port-scan",
outcome="no_progress",
evidence_paths=[],
next_action="test HTTP behavior",
next_technique="http-enumeration",
)
assert state.read_json(semantic_file).get("lock") is None
def test_batch_review_with_running_background_tunnel(ctf_eng):
"""Verify reviewing a batch with a still-running background command succeeds."""
bind_active_task(ctf_eng)
state.mark_pending_sync(
ctf_eng, "ssh -f -N -L 8080:127.0.0.1:80 user@10.129.2.5", "RECON", "PT-001"
)
# Create background execution receipt
exec_id = "11111111-2222-3333-4444-555555555555"
exec_record = {
"execution_id": exec_id,
"command": "ssh -f -N -L 8080:127.0.0.1:80 user@10.129.2.5",
"phase": "RECON",
"background": True,
"status": "running",
"pid": 99999,
"evidence_paths": {
"manifest": f"evidence/executions/{exec_id}.json",
"stdout": f"evidence/executions/{exec_id}.stdout",
"stderr": f"evidence/executions/{exec_id}.stderr",
},
}
exec_dir = ctf_eng / "evidence" / "executions"
exec_dir.mkdir(parents=True, exist_ok=True)
state.atomic_json(exec_dir / f"{exec_id}.json", exec_record)
(exec_dir / f"{exec_id}.stdout").write_text("", encoding="utf-8")
(exec_dir / f"{exec_id}.stderr").write_text("", encoding="utf-8")
res_str = service.handle_review_batch(
{
"eng_dir": str(ctf_eng),
"id": "PT-001",
"outcome": "progress",
"status": "[~]",
"note": "Tunnel running in background",
}
)
res = json.loads(res_str)
assert res.get("status") == "ok", res
assert res.get("released") is True
def test_stale_active_ptt_task_auto_superseded(ctf_eng, monkeypatch):
"""Verify starting a new task when no pending batch exists auto-supersedes the prior task."""
monkeypatch.setattr(
service.HermesSkillViewAdapter,
"view",
lambda self, skill, **kwargs: SkillViewResult(True, content="test content"),
)
bind_active_task(ctf_eng)
tasks = ptt.parse_ptt(ctf_eng / "state" / "ptt.md")
active = ptt.find_active_task(tasks)
assert active.id == "PT-001"
# Start PT-002 without pending sync (step 1: prepare skill delivery)
res_str1 = service.handle_record_ptt(
{
"eng_dir": str(ctf_eng),
"id": "PT-002",
"status": "[~]",
"skill": "pentest",
"technique": "cobbler-rce",
"hypothesis_id": "H-001",
"note": "Starting exploitation task",
}
)
res1 = json.loads(res_str1)
assert res1.get("status") == "skill_prepared", res1
# Step 2: confirm binding and apply PTT transition
res_str2 = service.handle_record_ptt(
{
"eng_dir": str(ctf_eng),
"id": "PT-002",
"status": "[~]",
"skill": "pentest",
"technique": "cobbler-rce",
"hypothesis_id": "H-001",
"note": "Starting exploitation task",
}
)
res2 = json.loads(res_str2)
assert res2.get("status") == "ok", res2
updated_tasks = ptt.parse_ptt(ctf_eng / "state" / "ptt.md")
new_active = ptt.find_active_task(updated_tasks)
assert new_active.id == "PT-002"
old_task = next(t for t in updated_tasks if t.id == "PT-001")
assert old_task.status == "[x]"
assert "superseded-by:PT-002" in old_task.note
def test_listener_with_ipv6_bind_host_allowed(ctf_eng, monkeypatch):
"""Verify violin_listener with IPv6 loopback bind_host (::1 or [::1]:4444) is allowed."""
monkeypatch.setattr(adapters, "_installed_netcat_variant", lambda binary: ("nc", "openbsd"))
def fake_exec(args, **kwargs):
return json.dumps({"status": "ok", "executed": True, "command": args.get("command")})
monkeypatch.setattr(service, "handle_exec", fake_exec)
res_str = service.handle_listener(
{
"eng_dir": str(ctf_eng),
"bind_host": "::1",
"port": 4444,
"phase": "EXPLOITATION",
}
)
res = json.loads(res_str)
assert res.get("status") == "ok", res
res_str_bracket = service.handle_listener(
{
"eng_dir": str(ctf_eng),
"bind_host": "[::1]:4444",
"port": 4444,
"phase": "EXPLOITATION",
}
)
res_bracket = json.loads(res_str_bracket)
assert res_bracket.get("status") == "ok", res_bracket
def test_invalid_ptt_start_status_error_message(ctf_eng):
"""Verify starting a task with status [x] gives an accurate error message."""
ptt_path = ctf_eng / "state" / "ptt.md"
ptt.update_task(ptt_path, "PT-002", "[x]", "closed task")
updated_tasks = ptt.parse_ptt(ptt_path)
from plugins.violin_guard.handlers.ptt_handlers import _start_ptt_task
with pytest.raises(ValueError, match=r"must be \[\ \] or \[\~\] before it can be started"):
_start_ptt_task(
ptt_path, updated_tasks, "PT-002", "[~]", "Attempt restart", eng_dir=ctf_eng
)
def test_parse_target_token_ipv6_url():
"""Verify targets._parse_target_token correctly extracts IPv6 address from URL."""
from plugins.violin_guard import targets
res = targets._parse_target_token("http://[2001:db8::1]:8080/api")
assert res == "2001:db8::1"
def test_update_hypothesis_merge_existing_fields(ctf_eng):
"""Verify update_hypothesis preserves existing runtime_evidence during partial field update."""
from plugins.violin_guard import hypotheses
h_file = ctf_eng / "hypotheses.md"
evidence = ctf_eng / "evidence" / "executions" / "1.json"
evidence.parent.mkdir(parents=True, exist_ok=True)
evidence.write_text('{"status":"completed"}', encoding="utf-8")
h1 = hypotheses.update_hypothesis(
h_file,
id="H-001",
title="Original Title",
status="Validated",
runtime_evidence="evidence/executions/1.json",
in_scope_hosts={"10.129.2.5"},
target="10.129.2.5",
)
assert h1.status == "Validated"
# Update only the title; should not wipe out runtime_evidence or fail validation
h2 = hypotheses.update_hypothesis(
h_file,
id="H-001",
title="Updated Title",
in_scope_hosts={"10.129.2.5"},
)
assert h2.title == "Updated Title"
assert h2.runtime_evidence == "evidence/executions/1.json"
assert h2.status == "Validated"
def test_findings_lowercase_hypothesis_id(ctf_eng):
"""Verify _validate_from_pending_batch accepts lowercase 'h-001' hypothesis_id."""
from plugins.violin_guard import findings, hypotheses
h_file = ctf_eng / "hypotheses.md"
evidence = ctf_eng / "evidence" / "executions" / "1.json"
evidence.parent.mkdir(parents=True, exist_ok=True)
evidence.write_text('{"status":"completed"}', encoding="utf-8")
hypotheses.update_hypothesis(
h_file,
id="H-001",
title="SQLi",
status="Validated",
runtime_evidence="evidence/executions/1.json",
in_scope_hosts={"10.129.2.5"},
target="10.129.2.5",
)
pending = {
"batch_id": "b1",
"commands": [{"command": "echo test"}],
"ptt_task_id": "PT-001",
"phase": "RECON",
}
# Write a dummy execution receipt matched by pending command
exec_dir = ctf_eng / "evidence" / "executions"
exec_record = {
"command": "echo test",
"evidence_paths": {"manifest": "evidence/executions/e1.json"},
}
(exec_dir / "e1.json").write_text(json.dumps(exec_record), encoding="utf-8")
draft = findings._validate_from_pending_batch(
ctf_eng,
pending,
title="SQLi Finding",
severity="High",
description="Desc",
impact="Impact",
remediation="Remediation",
finding_id="FIND-001",
hypothesis_id="h-001",
)
assert draft["hypothesis_id"] == "H-001"
def test_terminal_policy_ipv6_target_blocked():
"""Verify raw terminal guard blocks IPv6 host literals."""
from plugins.violin_guard import terminal_policy
msg = terminal_policy.block_terminal_command("nc 2001:db8::1 80")
assert msg is not None
assert "RAW TERMINAL TARGET EXECUTION BLOCKED" in msg