mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
436 lines
15 KiB
Python
436 lines
15 KiB
Python
"""Regression coverage for the guard's model-visible collaboration surface."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from plugins.violin_guard import (
|
|
bootstrap,
|
|
history,
|
|
hypotheses,
|
|
ptt,
|
|
state,
|
|
)
|
|
from plugins.violin_guard import (
|
|
handlers as service,
|
|
)
|
|
from plugins.violin_guard.skill_receipts import SkillViewResult, get_binding
|
|
from tests.guard.receipt_fixture import bind_active_task
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
|
|
|
|
def _engagement(tmp_path: Path) -> Path:
|
|
eng = tmp_path / "engagement"
|
|
assert bootstrap.init_engagement(eng, host="10.10.10.10") == 0
|
|
scope = eng / "scope" / "scope.yaml"
|
|
scope.write_text(
|
|
scope.read_text(encoding="utf-8").replace("confirmed: false", "confirmed: true"),
|
|
encoding="utf-8",
|
|
)
|
|
ptt_path = eng / "state" / "ptt.md"
|
|
ptt_path.write_text(
|
|
ptt_path.read_text(encoding="utf-8").replace("| PT-010 | [ ] |", "| PT-010 | [~] |"),
|
|
encoding="utf-8",
|
|
)
|
|
state.record_session_id(eng, "test-session")
|
|
(eng / "state" / ".skill-loaded-test-session").write_text(
|
|
"skill-loaded: pentest\n", encoding="utf-8"
|
|
)
|
|
bind_active_task(eng, "test-session")
|
|
return eng
|
|
|
|
|
|
def _pending_batch(eng: Path) -> None:
|
|
command = "nmap -sV 10.10.10.10"
|
|
(eng / "evidence" / "executions").mkdir(parents=True, exist_ok=True)
|
|
manifest = eng / "evidence" / "executions" / "batch-command.json"
|
|
stdout = eng / "evidence" / "executions" / "batch-command.stdout.txt"
|
|
stdout.write_text("80/tcp open http\n", encoding="utf-8")
|
|
state.atomic_json(
|
|
manifest,
|
|
{
|
|
"command": command,
|
|
"phase": "RECON",
|
|
"completed_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
|
"exit_code": 0,
|
|
"evidence_paths": {
|
|
"manifest": manifest.relative_to(eng).as_posix(),
|
|
"stdout": stdout.relative_to(eng).as_posix(),
|
|
},
|
|
},
|
|
)
|
|
history.append_history(eng, command, "RECON", 0, manifest.relative_to(eng).as_posix())
|
|
state.mark_pending_sync(eng, command, "RECON", "PT-010")
|
|
|
|
|
|
def _prepare_finding_review(eng: Path) -> None:
|
|
hypotheses.update_hypothesis(
|
|
eng / "hypotheses.md",
|
|
id="001",
|
|
title="HTTP listener is externally reachable",
|
|
status="Validated",
|
|
phase="RECON",
|
|
target="10.10.10.10",
|
|
runtime_evidence="evidence/executions/batch-command.stdout.txt",
|
|
)
|
|
|
|
|
|
def test_create_task_inserts_into_requested_phase_table(tmp_path: Path) -> None:
|
|
path = tmp_path / "ptt.md"
|
|
path.write_text(
|
|
(ROOT / "skills" / "pentest" / "templates" / "ptt.md").read_text(encoding="utf-8"),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
created = ptt.create_task(
|
|
path,
|
|
"PT-099",
|
|
"Validate requested exploit",
|
|
"EXPLOITATION",
|
|
"evidence/exploitation/",
|
|
)
|
|
|
|
assert created.phase == "EXPLOITATION"
|
|
text = path.read_text(encoding="utf-8")
|
|
assert text.index("| PT-099 |") < text.index("## Phase: REPORTING")
|
|
row = next(line for line in text.splitlines() if "| PT-099 |" in line)
|
|
assert len(row.strip().strip("|").split("|")) == 7
|
|
|
|
|
|
def test_status_explains_current_phase_pending_commands_and_skill(tmp_path: Path) -> None:
|
|
eng = _engagement(tmp_path)
|
|
_pending_batch(eng)
|
|
|
|
result = json.loads(service.handle_status({"eng_dir": str(eng)}))
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["current_task"] == "PT-010"
|
|
assert result["current_phase"] == "RECON"
|
|
assert result["pending_batch"]["commands"][0]["required_phase"] == "RECON"
|
|
assert result["phase_requirements"]["EXPLOITATION"]["sync_window"] == 20
|
|
assert result["skill"]["binding_ready"] is True
|
|
assert result["skill"]["legacy_marker_status"] in {"absent", "obsolete"}
|
|
|
|
|
|
@pytest.mark.parametrize("task_status", ["[~]", "[x]", "[!]", "[-]"])
|
|
def test_review_batch_updates_ptt_and_clears_lock(tmp_path: Path, task_status: str) -> None:
|
|
eng = _engagement(tmp_path)
|
|
_pending_batch(eng)
|
|
_prepare_finding_review(eng)
|
|
|
|
result = json.loads(
|
|
service.handle_review_batch(
|
|
{
|
|
"eng_dir": str(eng),
|
|
"id": "PT-010",
|
|
"status": task_status,
|
|
"note": "Reviewed service discovery evidence; HTTP is the next task input",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["task_status"] == task_status
|
|
assert result["released"] is True
|
|
assert not state.has_pending_sync(eng)
|
|
assert "reviewed-batch:" in (eng / "state" / "ptt.md").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_review_batch_does_not_replace_execution_skill_binding(tmp_path: Path, monkeypatch) -> None:
|
|
eng = _engagement(tmp_path)
|
|
_pending_batch(eng)
|
|
original_binding = get_binding(eng, "PT-010")
|
|
monkeypatch.setattr(
|
|
service,
|
|
"HermesSkillViewAdapter",
|
|
lambda: type(
|
|
"Ready",
|
|
(),
|
|
{"view": lambda *_args, **_kwargs: SkillViewResult(True, "pentest review")},
|
|
)(),
|
|
)
|
|
args = {
|
|
"eng_dir": str(eng),
|
|
"id": "PT-010",
|
|
"status": "[~]",
|
|
"note": "Reviewed service discovery evidence",
|
|
"skill": "pentest",
|
|
"outcome": "progress",
|
|
"evidence_paths": ["evidence/executions/batch-command.stdout.txt"],
|
|
"next_action": "enumerate HTTP",
|
|
"next_technique": "http-enumeration",
|
|
}
|
|
|
|
prepared = json.loads(service.handle_review_batch(args))
|
|
assert prepared["status"] == "skill_prepared"
|
|
reviewed = json.loads(service.handle_review_batch(args))
|
|
|
|
assert reviewed["status"] == "ok"
|
|
assert reviewed["binding_task_id"] is None
|
|
assert get_binding(eng, "PT-010") == original_binding
|
|
|
|
|
|
def test_review_batch_creates_finding_from_current_batch_receipts(tmp_path: Path) -> None:
|
|
eng = _engagement(tmp_path)
|
|
_pending_batch(eng)
|
|
_prepare_finding_review(eng)
|
|
|
|
result = json.loads(
|
|
service.handle_review_batch(
|
|
{
|
|
"eng_dir": str(eng),
|
|
"id": "PT-010",
|
|
"status": "[~]",
|
|
"note": "Reviewed the HTTP service receipt",
|
|
"finding": {
|
|
"finding_id": "FIND-001",
|
|
"hypothesis_id": "H-001",
|
|
"title": "Exposed HTTP service",
|
|
"severity": "Info",
|
|
"description": "An HTTP listener is reachable on the approved target.",
|
|
"impact": "The service contributes to the externally reachable attack surface.",
|
|
"remediation": (
|
|
"Confirm the listener is intended and restrict it when unnecessary."
|
|
),
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
finding = eng / result["finding"]["path"]
|
|
assert result["finding_path"] == result["finding"]["path"]
|
|
assert finding.is_file()
|
|
text = finding.read_text(encoding="utf-8")
|
|
assert "batch-command.stdout.txt" in text
|
|
assert "## Remediation" in text
|
|
|
|
|
|
def test_review_batch_creates_finding_without_fp_check_preparation(tmp_path: Path) -> None:
|
|
eng = _engagement(tmp_path)
|
|
_pending_batch(eng)
|
|
hypotheses.update_hypothesis(
|
|
eng / "hypotheses.md",
|
|
id="001",
|
|
title="HTTP listener is externally reachable",
|
|
status="Validated",
|
|
phase="RECON",
|
|
target="10.10.10.10",
|
|
runtime_evidence="evidence/executions/batch-command.stdout.txt",
|
|
)
|
|
|
|
result = json.loads(
|
|
service.handle_review_batch(
|
|
{
|
|
"eng_dir": str(eng),
|
|
"id": "PT-010",
|
|
"status": "[~]",
|
|
"note": "Reviewed the HTTP service receipt",
|
|
"finding": {
|
|
"hypothesis_id": "H-001",
|
|
"title": "Exposed HTTP service",
|
|
"severity": "Info",
|
|
"description": "An HTTP listener is reachable on the approved target.",
|
|
"impact": "The service contributes to the externally reachable attack surface.",
|
|
"remediation": "Confirm the listener is intended.",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert result["released"] is True
|
|
assert result["finding"]["finding_id"] == "FIND-001"
|
|
finding = eng / result["finding"]["path"]
|
|
assert finding.is_file()
|
|
text = finding.read_text(encoding="utf-8")
|
|
assert "# FIND-001: Exposed HTTP service" in text
|
|
assert "batch-command.stdout.txt" in text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutation", "expected"),
|
|
[
|
|
("history", "exact history"),
|
|
("task", "does not match batch task"),
|
|
("phase", "not phase-compatible"),
|
|
("finding", "must be non-empty"),
|
|
],
|
|
)
|
|
def test_invalid_review_batch_leaves_sync_lock_active(
|
|
tmp_path: Path, mutation: str, expected: str
|
|
) -> None:
|
|
eng = _engagement(tmp_path)
|
|
_pending_batch(eng)
|
|
args = {
|
|
"eng_dir": str(eng),
|
|
"id": "PT-010",
|
|
"status": "[~]",
|
|
"note": "Review receipt",
|
|
}
|
|
if mutation == "history":
|
|
(eng / "state" / "history.md").write_text("# History\n", encoding="utf-8")
|
|
elif mutation == "task":
|
|
args["id"] = "PT-011"
|
|
elif mutation == "phase":
|
|
sync_path = eng / "state" / "sync.json"
|
|
sync_data = state.read_json(sync_path)
|
|
sync_data["pending"]["commands"][0]["phase"] = "EXPLOITATION"
|
|
state.atomic_json(sync_path, sync_data)
|
|
else:
|
|
args["finding"] = {
|
|
"title": "",
|
|
"severity": "Info",
|
|
"description": "Description",
|
|
"impact": "Impact",
|
|
"remediation": "Remediation",
|
|
}
|
|
|
|
result = json.loads(service.handle_review_batch(args))
|
|
|
|
assert result["status"] == "blocked"
|
|
assert expected in result["error"]
|
|
assert result["next_action"]
|
|
assert state.has_pending_sync(eng)
|
|
|
|
|
|
def test_review_batch_retry_reuses_marker_and_finding_after_partial_failure(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
eng = _engagement(tmp_path)
|
|
_pending_batch(eng)
|
|
_prepare_finding_review(eng)
|
|
args = {
|
|
"eng_dir": str(eng),
|
|
"id": "PT-010",
|
|
"status": "[~]",
|
|
"note": "Reviewed HTTP receipt",
|
|
"finding": {
|
|
"finding_id": "FIND-001",
|
|
"hypothesis_id": "H-001",
|
|
"title": "Exposed HTTP service",
|
|
"severity": "Info",
|
|
"description": "An HTTP listener is reachable.",
|
|
"impact": "The service increases the reachable attack surface.",
|
|
"remediation": "Restrict the listener when it is not required.",
|
|
},
|
|
}
|
|
real_clear = state.clear_pending_sync
|
|
|
|
def fail_clear(_eng_dir: str | Path) -> None:
|
|
raise OSError("simulated clear failure")
|
|
|
|
monkeypatch.setattr(state, "clear_pending_sync", fail_clear)
|
|
first = json.loads(service.handle_review_batch(args))
|
|
assert first["status"] == "blocked"
|
|
assert state.has_pending_sync(eng)
|
|
|
|
monkeypatch.setattr(state, "clear_pending_sync", real_clear)
|
|
retry = json.loads(service.handle_review_batch(args))
|
|
|
|
assert retry["status"] == "ok"
|
|
assert retry["finding"]["reused"] is True
|
|
ptt_text = (eng / "state" / "ptt.md").read_text(encoding="utf-8")
|
|
assert ptt_text.count("[reviewed-batch:") == 1
|
|
assert len(list((eng / "evidence" / "findings").glob("FIND-*.md"))) == 1
|
|
assert not state.has_pending_sync(eng)
|
|
|
|
|
|
def test_sync_windows_are_phase_aware() -> None:
|
|
assert state.sync_credit_limit("RECON") == 10
|
|
assert state.sync_credit_limit("EXPLOITATION") == 20
|
|
assert state.sync_credit_limit("PRIVESC") == 20
|
|
|
|
|
|
def test_update_hypothesis_supports_discipline_fields(tmp_path: Path) -> None:
|
|
hyp_file = tmp_path / "hypotheses.md"
|
|
hyp_file.write_text(
|
|
"# Hypothesis Board\n\n## Active Theories\n\n",
|
|
encoding="utf-8",
|
|
)
|
|
h = hypotheses.update_hypothesis(
|
|
hyp_file,
|
|
id="001",
|
|
title="SQLi in authentication form",
|
|
status="Candidate",
|
|
confidence="0.8",
|
|
timebox="4 tool batches",
|
|
cheapest_test="' OR 1=1 --",
|
|
kill_criteria="Response status 404 or no error output",
|
|
next_step="Run sqlmap probe",
|
|
linked_findings="FIND-001",
|
|
)
|
|
assert h.confidence == "0.8"
|
|
assert h.timebox == "4 tool batches"
|
|
assert h.cheapest_test == "' OR 1=1 --"
|
|
assert h.kill_criteria == "Response status 404 or no error output"
|
|
assert h.next_step == "Run sqlmap probe"
|
|
assert h.linked_findings == "FIND-001"
|
|
|
|
parsed = hypotheses.parse_hypotheses(hyp_file)
|
|
assert len(parsed) == 1
|
|
assert parsed[0].confidence == "0.8"
|
|
assert parsed[0].cheapest_test == "' OR 1=1 --"
|
|
|
|
text = hyp_file.read_text(encoding="utf-8")
|
|
assert "- **Confidence:** 0.8" in text
|
|
assert "- **Timebox:** 4 tool batches" in text
|
|
assert "- **Cheapest test:** ' OR 1=1 --" in text
|
|
assert "- **Kill criteria:** Response status 404 or no error output" in text
|
|
|
|
|
|
def test_update_hypothesis_preserves_board_sections(tmp_path: Path) -> None:
|
|
template_path = ROOT / "skills" / "pentest" / "templates" / "hypothesis-board.md"
|
|
hyp_file = tmp_path / "hypotheses.md"
|
|
hyp_file.write_text(template_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
|
|
h = hypotheses.update_hypothesis(
|
|
hyp_file,
|
|
id="001",
|
|
title="Command injection in search endpoint",
|
|
status="Candidate",
|
|
)
|
|
assert h.id == "001"
|
|
text = hyp_file.read_text(encoding="utf-8")
|
|
assert "## Active Theories" in text
|
|
assert "### H-001: Command injection in search endpoint" in text
|
|
assert "## Observations (ungrouped)" in text
|
|
assert "## Investigation Chains" in text
|
|
assert "## Decoy Trail (killed approaches — do NOT re-enter)" in text
|
|
assert "## Research Log" in text
|
|
assert "## Resolved Theories" in text
|
|
|
|
|
|
def test_update_hypothesis_keeps_distinct_ids_isolated(tmp_path: Path) -> None:
|
|
hyp_file = tmp_path / "hypotheses.md"
|
|
hyp_file.write_text("# Hypothesis Board\n\n", encoding="utf-8")
|
|
evidence = tmp_path / "evidence" / "executions" / "access-control.json"
|
|
evidence.parent.mkdir(parents=True)
|
|
evidence.write_text('{"status":"completed"}\n', encoding="utf-8")
|
|
hypotheses.update_hypothesis(
|
|
hyp_file,
|
|
id="H-021",
|
|
title="Existing access-control finding",
|
|
status="Validated",
|
|
runtime_evidence="evidence/executions/access-control.json",
|
|
)
|
|
hypotheses.update_hypothesis(
|
|
hyp_file,
|
|
id="H-030",
|
|
title="CVE research candidate",
|
|
status="Candidate",
|
|
cve_research="Vendor advisory checked; no relevant CVE.",
|
|
)
|
|
|
|
records = {item.id: item for item in hypotheses.parse_hypotheses(hyp_file)}
|
|
assert set(records) == {"021", "030"}
|
|
assert records["021"].title == "Existing access-control finding"
|
|
assert records["021"].status == "Validated"
|
|
assert records["030"].title == "CVE research candidate"
|
|
assert records["030"].cve_research.startswith("Vendor advisory")
|