mirror of
https://github.com/Strategic-Automation/violin.git
synced 2026-08-14 12:33:37 +02:00
Enforce scope authorization and exclusions
This commit is contained in:
@@ -195,7 +195,12 @@ def init_engagement(
|
|||||||
result.print()
|
result.print()
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
result.add_info(f"engagement initialised and guard-clean: {eng_dir}")
|
if ctf:
|
||||||
|
result.add_info(f"engagement initialised and ready for authorised CTF work: {eng_dir}")
|
||||||
|
else:
|
||||||
|
result.add_info(
|
||||||
|
f"engagement initialised; confirm scope authorisation before target work: {eng_dir}"
|
||||||
|
)
|
||||||
result.print()
|
result.print()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ No subprocess calls — pure functions returning dataclasses.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -24,6 +25,7 @@ __all__ = [
|
|||||||
"SkillLoadResult",
|
"SkillLoadResult",
|
||||||
"check_command",
|
"check_command",
|
||||||
"validate_scope",
|
"validate_scope",
|
||||||
|
"check_scope_authorization",
|
||||||
"check_skill_load",
|
"check_skill_load",
|
||||||
"check_history_staleness",
|
"check_history_staleness",
|
||||||
"check_hypothesis_freshness",
|
"check_hypothesis_freshness",
|
||||||
@@ -128,6 +130,14 @@ def validate_scope(scope_path: Path) -> ScopeResult:
|
|||||||
if section not in data:
|
if section not in data:
|
||||||
result.add_error(f"scope.yaml missing required section: {section}")
|
result.add_error(f"scope.yaml missing required section: {section}")
|
||||||
|
|
||||||
|
# A real scope must name the approving party and be explicitly confirmed.
|
||||||
|
parties = data.get("authorized_parties")
|
||||||
|
if not isinstance(parties, list) or not any(str(item).strip() for item in parties):
|
||||||
|
result.add_error("scope.authorized_parties must be a non-empty list")
|
||||||
|
authorisation = data.get("authorisation")
|
||||||
|
if not isinstance(authorisation, dict) or authorisation.get("confirmed") is not True:
|
||||||
|
result.add_error("scope.authorisation.confirmed must be true before target execution")
|
||||||
|
|
||||||
# targets.ip_addresses
|
# targets.ip_addresses
|
||||||
targets = data.get("targets", {})
|
targets = data.get("targets", {})
|
||||||
if "ip_addresses" not in targets:
|
if "ip_addresses" not in targets:
|
||||||
@@ -137,8 +147,11 @@ def validate_scope(scope_path: Path) -> ScopeResult:
|
|||||||
|
|
||||||
# rules_of_engagement
|
# rules_of_engagement
|
||||||
roe = data.get("rules_of_engagement", {})
|
roe = data.get("rules_of_engagement", {})
|
||||||
if "allowed_actions" not in roe:
|
allowed_actions = roe.get("allowed_actions") if isinstance(roe, dict) else None
|
||||||
result.add_error("scope.rules_of_engagement.allowed_actions is required")
|
if not isinstance(allowed_actions, list) or not any(
|
||||||
|
str(item).strip() for item in allowed_actions
|
||||||
|
):
|
||||||
|
result.add_error("scope.rules_of_engagement.allowed_actions must be a non-empty list")
|
||||||
|
|
||||||
# engagement.date
|
# engagement.date
|
||||||
engagement = data.get("engagement", {})
|
engagement = data.get("engagement", {})
|
||||||
@@ -149,6 +162,39 @@ def validate_scope(scope_path: Path) -> ScopeResult:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_PHASE_ACTION_TERMS = {
|
||||||
|
Phase.SCOPING: ("scope",),
|
||||||
|
Phase.RECON: ("recon", "discovery", "banner", "version", "scan", "enumerat"),
|
||||||
|
Phase.VULN_RESEARCH: ("vuln", "research", "cve", "exploitdb"),
|
||||||
|
Phase.EXPLOITATION: ("exploit", "validation", "poc"),
|
||||||
|
Phase.POST_EXPLOITATION: ("post-exploit", "post exploitation", "exploit", "validation"),
|
||||||
|
Phase.PRIVESC: ("privilege", "privesc", "exploit", "validation"),
|
||||||
|
Phase.FLAGS: ("flag", "capture"),
|
||||||
|
Phase.REPORTING: ("report",),
|
||||||
|
Phase.RETROSPECTIVE: ("retrospective",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_scope_authorization(scope: dict[str, Any] | None, phase: Phase) -> CheckResult:
|
||||||
|
"""Ensure the approved rules of engagement allow the requested phase."""
|
||||||
|
result = CheckResult()
|
||||||
|
if not isinstance(scope, dict):
|
||||||
|
return result
|
||||||
|
roe = scope.get("rules_of_engagement") or {}
|
||||||
|
allowed = [str(item).lower() for item in roe.get("allowed_actions", []) or []]
|
||||||
|
forbidden = [str(item).lower() for item in roe.get("forbidden_actions", []) or []]
|
||||||
|
terms = _PHASE_ACTION_TERMS[phase]
|
||||||
|
if any(any(term in action for term in terms) for action in forbidden):
|
||||||
|
result.add_error(
|
||||||
|
f"phase {phase.value} conflicts with scope.rules_of_engagement.forbidden_actions"
|
||||||
|
)
|
||||||
|
if not any(any(term in action for term in terms) for action in allowed):
|
||||||
|
result.add_error(
|
||||||
|
f"phase {phase.value} is not permitted by scope.rules_of_engagement.allowed_actions"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# DANGEROUS-PATTERN ENFORCEMENT (audit P0: destructive commands were never
|
# DANGEROUS-PATTERN ENFORCEMENT (audit P0: destructive commands were never
|
||||||
# blocked). These patterns are hard BLOCKs — yolo cannot bypass them.
|
# blocked). These patterns are hard BLOCKs — yolo cannot bypass them.
|
||||||
@@ -212,6 +258,15 @@ def _extract_target_candidates(command: str) -> list[str]:
|
|||||||
cands.append(host.lower())
|
cands.append(host.lower())
|
||||||
for m in _IPV4_CIDR.finditer(command):
|
for m in _IPV4_CIDR.finditer(command):
|
||||||
cands.append(m.group(0).lower())
|
cands.append(m.group(0).lower())
|
||||||
|
# Validate colon-containing tokens with ipaddress instead of treating the
|
||||||
|
# leading hextet of an IPv6 address as a host:port pair.
|
||||||
|
for token in re.findall(r"(?<![\w:])\[?[0-9A-Fa-f:]{2,}\]?(?:/\d{1,3})?", command):
|
||||||
|
candidate = token.strip("[]").lower()
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(candidate, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
cands.append(candidate)
|
||||||
for m in _HOST_PORT.finditer(command):
|
for m in _HOST_PORT.finditer(command):
|
||||||
cands.append(m.group(1).lower())
|
cands.append(m.group(1).lower())
|
||||||
for m in _FQDN.finditer(command):
|
for m in _FQDN.finditer(command):
|
||||||
@@ -225,35 +280,63 @@ def _extract_target_candidates(command: str) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _values(value: Any):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for nested in value.values():
|
||||||
|
yield from _values(nested)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for nested in value:
|
||||||
|
yield from _values(nested)
|
||||||
|
elif value is not None:
|
||||||
|
yield str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_scope_host(value: str) -> str:
|
||||||
|
match = re.match(r"https?://([^\s/]+)", value, flags=re.IGNORECASE)
|
||||||
|
host = match.group(1) if match else value
|
||||||
|
if host.startswith("[") and "]" in host:
|
||||||
|
return host[1 : host.index("]")].lower()
|
||||||
|
return host.rsplit(":", 1)[0].lower() if host.count(":") == 1 else host.lower()
|
||||||
|
|
||||||
|
|
||||||
def _scope_allowed_hosts(scope: dict) -> set[str]:
|
def _scope_allowed_hosts(scope: dict) -> set[str]:
|
||||||
allowed: set[str] = set()
|
allowed: set[str] = set()
|
||||||
targets = scope.get("targets", {}) or {}
|
targets = scope.get("targets", {}) or {}
|
||||||
for ip in targets.get("ip_addresses", []) or []:
|
for key in ("ip_addresses", "in_scope_urls", "urls", "domains", "hostnames", "roles"):
|
||||||
allowed.add(str(ip).lower())
|
for value in _values(targets.get(key, [])):
|
||||||
for url in targets.get("in_scope_urls", []) or []:
|
allowed.add(_normalise_scope_host(value))
|
||||||
m = re.match(r"https?://([^\s/]+)", str(url))
|
|
||||||
if m:
|
|
||||||
allowed.add(m.group(1).lower())
|
|
||||||
roles = targets.get("roles", {}) or {}
|
|
||||||
if isinstance(roles, dict):
|
|
||||||
for v in roles.values():
|
|
||||||
allowed.add(str(v).lower())
|
|
||||||
for h in targets.get("hostnames", []) or []:
|
|
||||||
allowed.add(str(h).lower())
|
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
|
|
||||||
def _scope_excluded_hosts(scope: dict) -> set[str]:
|
def _scope_excluded_hosts(scope: dict) -> set[str]:
|
||||||
excluded: set[str] = set()
|
excluded: set[str] = set()
|
||||||
for item in scope.get("exclusions", {}) or []:
|
for item in _values(scope.get("exclusions", {})):
|
||||||
if isinstance(item, str):
|
excluded.add(_normalise_scope_host(item))
|
||||||
excluded.add(item.lower())
|
|
||||||
elif isinstance(item, dict):
|
|
||||||
for v in item.values():
|
|
||||||
excluded.add(str(v).lower())
|
|
||||||
return excluded
|
return excluded
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_networks(scope: dict, section: str) -> list[ipaddress._BaseNetwork]:
|
||||||
|
values = []
|
||||||
|
targets = scope.get(section, {}) or {}
|
||||||
|
for key in ("ip_addresses", "cidrs"):
|
||||||
|
values.extend(_values(targets.get(key, [])))
|
||||||
|
networks = []
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
networks.append(ipaddress.ip_network(value, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return networks
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_network(candidate: str, networks: list[ipaddress._BaseNetwork]) -> bool:
|
||||||
|
try:
|
||||||
|
network = ipaddress.ip_network(candidate, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return any(network.version == allowed.version and network.subnet_of(allowed) for allowed in networks)
|
||||||
|
|
||||||
|
|
||||||
def check_scope_targets(scope_path: Path, command: str) -> CheckResult:
|
def check_scope_targets(scope_path: Path, command: str) -> CheckResult:
|
||||||
"""Block commands whose IP/CIDR target is outside the engagement scope."""
|
"""Block commands whose IP/CIDR target is outside the engagement scope."""
|
||||||
result = CheckResult()
|
result = CheckResult()
|
||||||
@@ -270,12 +353,22 @@ def check_scope_targets(scope_path: Path, command: str) -> CheckResult:
|
|||||||
|
|
||||||
allowed = _scope_allowed_hosts(data)
|
allowed = _scope_allowed_hosts(data)
|
||||||
excluded = _scope_excluded_hosts(data)
|
excluded = _scope_excluded_hosts(data)
|
||||||
|
allowed_networks = _scope_networks(data, "targets")
|
||||||
|
excluded_networks = _scope_networks(data, "exclusions")
|
||||||
for cand in _extract_target_candidates(command):
|
for cand in _extract_target_candidates(command):
|
||||||
if cand in excluded:
|
if cand in excluded or _matches_network(cand, excluded_networks):
|
||||||
|
result.add_error(f"excluded target {cand} must not be touched")
|
||||||
continue
|
continue
|
||||||
if cand in allowed:
|
if cand in allowed:
|
||||||
continue
|
continue
|
||||||
if _IPV4_CIDR.fullmatch(cand):
|
if _matches_network(cand, allowed_networks):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(cand, strict=False)
|
||||||
|
is_ip = True
|
||||||
|
except ValueError:
|
||||||
|
is_ip = False
|
||||||
|
if is_ip:
|
||||||
result.add_error(f"out-of-scope target {cand} (not present in scope.yaml)")
|
result.add_error(f"out-of-scope target {cand} (not present in scope.yaml)")
|
||||||
else:
|
else:
|
||||||
result.add_warning(f"host {cand} is not present in scope.yaml; verify authorization")
|
result.add_warning(f"host {cand} is not present in scope.yaml; verify authorization")
|
||||||
@@ -406,6 +499,9 @@ def check_command(args: CheckCommandArgs) -> CheckResult:
|
|||||||
result.errors.extend(scope_result.errors)
|
result.errors.extend(scope_result.errors)
|
||||||
result.warnings.extend(scope_result.warnings)
|
result.warnings.extend(scope_result.warnings)
|
||||||
|
|
||||||
|
authorisation_result = check_scope_authorization(scope_result.scope_data, phase)
|
||||||
|
result.errors.extend(authorisation_result.errors)
|
||||||
|
|
||||||
# 2b. Scope target enforcement (audit P0). Extract command targets and
|
# 2b. Scope target enforcement (audit P0). Extract command targets and
|
||||||
# block anything that lands on an out-of-scope IP/CIDR.
|
# block anything that lands on an out-of-scope IP/CIDR.
|
||||||
target_result = check_scope_targets(scope_path, args.command)
|
target_result = check_scope_targets(scope_path, args.command)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ _SCOPE = """targets:
|
|||||||
roles:
|
roles:
|
||||||
web: 10.10.10.10
|
web: 10.10.10.10
|
||||||
exclusions: {}
|
exclusions: {}
|
||||||
|
authorized_parties: ["test owner"]
|
||||||
|
authorisation:
|
||||||
|
confirmed: true
|
||||||
rules_of_engagement:
|
rules_of_engagement:
|
||||||
allowed_actions: [recon, vuln-research, exploitation]
|
allowed_actions: [recon, vuln-research, exploitation]
|
||||||
forbidden_actions: []
|
forbidden_actions: []
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ def _init_e2e(tmp_path, skill_file, allowed=("recon", "vuln-research", "exploita
|
|||||||
" ip_addresses: [10.10.10.10]\n"
|
" ip_addresses: [10.10.10.10]\n"
|
||||||
" in_scope_urls: []\n"
|
" in_scope_urls: []\n"
|
||||||
"exclusions: {}\n"
|
"exclusions: {}\n"
|
||||||
|
"authorized_parties: [test-owner]\n"
|
||||||
|
"authorisation:\n"
|
||||||
|
" confirmed: true\n"
|
||||||
"rules_of_engagement:\n"
|
"rules_of_engagement:\n"
|
||||||
f" allowed_actions: [{', '.join(allowed)}]\n"
|
f" allowed_actions: [{', '.join(allowed)}]\n"
|
||||||
" forbidden_actions: []\n"
|
" forbidden_actions: []\n"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ _PLATFORM_SCOPE = """targets:
|
|||||||
ip_addresses: ["10.10.10.10"]
|
ip_addresses: ["10.10.10.10"]
|
||||||
in_scope_urls: []
|
in_scope_urls: []
|
||||||
exclusions: {}
|
exclusions: {}
|
||||||
|
authorized_parties: ["test owner"]
|
||||||
|
authorisation:
|
||||||
|
confirmed: true
|
||||||
rules_of_engagement:
|
rules_of_engagement:
|
||||||
allowed_actions: [recon, vuln-research, exploitation]
|
allowed_actions: [recon, vuln-research, exploitation]
|
||||||
forbidden_actions: []
|
forbidden_actions: []
|
||||||
@@ -323,12 +326,12 @@ def test_init_engagement_creates_compliant_artifacts(tmp_path):
|
|||||||
rc = bootstrap.init_engagement(str(eng))
|
rc = bootstrap.init_engagement(str(eng))
|
||||||
assert rc == 0, "init-engagement should succeed"
|
assert rc == 0, "init-engagement should succeed"
|
||||||
|
|
||||||
# scope.yaml present and parses clean (no REVIEW on required fields)
|
# A default engagement is structurally complete but deliberately remains
|
||||||
|
# unapproved until the operator confirms authorisation.
|
||||||
scope = yaml.safe_load((eng / "scope" / "scope.yaml").read_text(encoding="utf-8"))
|
scope = yaml.safe_load((eng / "scope" / "scope.yaml").read_text(encoding="utf-8"))
|
||||||
assert scope["targets"]["ip_addresses"] == ["10.129.45.228"]
|
assert scope["targets"]["ip_addresses"] == ["10.129.45.228"]
|
||||||
assert command.validate_scope(eng / "scope" / "scope.yaml").exit_code() == 0, (
|
validation = command.validate_scope(eng / "scope" / "scope.yaml")
|
||||||
"filled scope must be guard-clean"
|
assert any("authorisation.confirmed" in error for error in validation.errors)
|
||||||
)
|
|
||||||
|
|
||||||
# bootstrap reports complete (exit 0) or REVIEW-only (pristine PTT is
|
# bootstrap reports complete (exit 0) or REVIEW-only (pristine PTT is
|
||||||
# legitimate on a brand-new engagement — no task touched yet).
|
# legitimate on a brand-new engagement — no task touched yet).
|
||||||
@@ -348,11 +351,11 @@ def test_auto_repair_creates_missing_artifacts(tmp_path):
|
|||||||
# After self-heal, bootstrap must be clean (0) or REVIEW-only (2).
|
# After self-heal, bootstrap must be clean (0) or REVIEW-only (2).
|
||||||
assert int(res) in (0, 2), f"auto-repair should self-heal to clean, got {res}"
|
assert int(res) in (0, 2), f"auto-repair should self-heal to clean, got {res}"
|
||||||
|
|
||||||
# Artifacts now exist and scope is guard-clean
|
# Artifacts now exist; a real operator still has to confirm authorisation.
|
||||||
for rel in ("scope/scope.yaml", "state/ptt.md", "hypotheses.md", "state/history.md"):
|
for rel in ("scope/scope.yaml", "state/ptt.md", "hypotheses.md", "state/history.md"):
|
||||||
assert (eng / rel).exists(), f"auto-repair should create {rel}"
|
assert (eng / rel).exists(), f"auto-repair should create {rel}"
|
||||||
yaml.safe_load((eng / "scope" / "scope.yaml").read_text(encoding="utf-8"))
|
yaml.safe_load((eng / "scope" / "scope.yaml").read_text(encoding="utf-8"))
|
||||||
assert command.validate_scope(eng / "scope" / "scope.yaml").exit_code() == 0
|
assert command.validate_scope(eng / "scope" / "scope.yaml").errors
|
||||||
|
|
||||||
|
|
||||||
def test_exec_auto_records_history_but_requires_explicit_ptt_review(monkeypatch, tmp_path):
|
def test_exec_auto_records_history_but_requires_explicit_ptt_review(monkeypatch, tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Fail-closed authorization and target-scope regression tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plugins.violin_guard.core.command import check_scope_targets, validate_scope
|
||||||
|
|
||||||
|
|
||||||
|
def _write_scope(path: Path, *, confirmed: bool = True) -> None:
|
||||||
|
path.write_text(
|
||||||
|
f"""targets:
|
||||||
|
ip_addresses: [10.10.10.10]
|
||||||
|
cidrs: [2001:db8::/32]
|
||||||
|
domains: [allowed.example]
|
||||||
|
exclusions:
|
||||||
|
ip_addresses: [10.10.10.99]
|
||||||
|
cidrs: [2001:db8:dead::/48]
|
||||||
|
domains: [excluded.example]
|
||||||
|
authorized_parties: [test owner]
|
||||||
|
authorisation:
|
||||||
|
confirmed: {str(confirmed).lower()}
|
||||||
|
rules_of_engagement:
|
||||||
|
allowed_actions: [host/port discovery, exploit validation]
|
||||||
|
forbidden_actions: [post-exploitation]
|
||||||
|
engagement:
|
||||||
|
date: "2026-07-13"
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfirmed_scope_is_a_hard_block(tmp_path: Path) -> None:
|
||||||
|
scope = tmp_path / "scope.yaml"
|
||||||
|
_write_scope(scope, confirmed=False)
|
||||||
|
assert any("authorisation.confirmed" in error for error in validate_scope(scope).errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclusions_and_ipv6_cidrs_are_enforced(tmp_path: Path) -> None:
|
||||||
|
scope = tmp_path / "scope.yaml"
|
||||||
|
_write_scope(scope)
|
||||||
|
|
||||||
|
assert check_scope_targets(scope, "nmap 10.10.10.99").errors
|
||||||
|
assert check_scope_targets(scope, "nmap 2001:db8:dead::1").errors
|
||||||
|
assert not check_scope_targets(scope, "nmap 2001:db8:beef::1").errors
|
||||||
|
assert check_scope_targets(scope, "curl https://excluded.example").errors
|
||||||
@@ -11,6 +11,11 @@ from plugins.violin_guard.core import bootstrap, execution, service, state
|
|||||||
def _engagement(tmp_path: Path) -> Path:
|
def _engagement(tmp_path: Path) -> Path:
|
||||||
eng = tmp_path / "10.10.10.10-2026-07-13"
|
eng = tmp_path / "10.10.10.10-2026-07-13"
|
||||||
assert bootstrap.init_engagement(eng, host="10.10.10.10") == 0
|
assert bootstrap.init_engagement(eng, host="10.10.10.10") == 0
|
||||||
|
scope_path = eng / "scope" / "scope.yaml"
|
||||||
|
scope_path.write_text(
|
||||||
|
scope_path.read_text(encoding="utf-8").replace("confirmed: false", "confirmed: true"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
(eng / "state" / ".skill-loaded-test").write_text("skill-loaded: test\n", encoding="utf-8")
|
(eng / "state" / ".skill-loaded-test").write_text("skill-loaded: test\n", encoding="utf-8")
|
||||||
ptt_path = eng / "state" / "ptt.md"
|
ptt_path = eng / "state" / "ptt.md"
|
||||||
ptt_path.write_text(
|
ptt_path.write_text(
|
||||||
|
|||||||
Reference in New Issue
Block a user