Initial import from Proxmox host
This commit is contained in:
77
src/opnsense_mcp/guards.py
Normal file
77
src/opnsense_mcp/guards.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Safety guardrails for mutating firewall operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from opnsense_mcp.audit import audit_log
|
||||
from opnsense_mcp.config import settings
|
||||
|
||||
|
||||
class GuardError(Exception):
|
||||
"""Raised when a safety guard blocks an operation."""
|
||||
|
||||
|
||||
def require_credentials() -> None:
|
||||
if not settings.api_key or not settings.api_secret:
|
||||
raise GuardError(
|
||||
"OPNSENSE_API_KEY and OPNSENSE_API_SECRET must be set. "
|
||||
"Create an API key in OPNsense: System → Access → Users."
|
||||
)
|
||||
|
||||
|
||||
def require_writes(action: str, payload: dict[str, Any] | None = None) -> None:
|
||||
require_credentials()
|
||||
if settings.dry_run:
|
||||
audit_log(action, {"payload": payload or {}, "blocked": "dry_run"}, dry_run=True)
|
||||
return
|
||||
if not settings.allow_writes:
|
||||
raise GuardError(
|
||||
f"Write operation '{action}' blocked. Set OPNSENSE_ALLOW_WRITES=true to enable."
|
||||
)
|
||||
|
||||
|
||||
def is_dry_run_response(action: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
audit_log(action, {"payload": payload or {}}, dry_run=True)
|
||||
return {
|
||||
"dry_run": True,
|
||||
"action": action,
|
||||
"payload": payload,
|
||||
"message": "Dry run — no changes applied. Set OPNSENSE_DRY_RUN=false to execute.",
|
||||
}
|
||||
|
||||
|
||||
def validate_firewall_rule(rule: dict[str, Any], *, is_new: bool = False) -> None:
|
||||
"""Reject dangerous or mis-tagged firewall rules."""
|
||||
description = str(rule.get("description", ""))
|
||||
if is_new and settings.require_description_prefix:
|
||||
prefix = settings.require_description_prefix
|
||||
if prefix and not description.lower().startswith(prefix.lower()):
|
||||
raise GuardError(
|
||||
f"New rules must have description starting with '{prefix}'. "
|
||||
f"Got: '{description}'"
|
||||
)
|
||||
|
||||
if not settings.block_wan_inbound_any:
|
||||
return
|
||||
|
||||
interface = str(rule.get("interface", rule.get("interface_name", ""))).lower()
|
||||
action = str(rule.get("action", "pass")).lower()
|
||||
source = str(rule.get("source_net", rule.get("source", "any"))).lower()
|
||||
dest = str(rule.get("destination_net", rule.get("destination", "any"))).lower()
|
||||
|
||||
wan_markers = ("wan", "opt", "pppoe", "dhcp")
|
||||
is_wan = any(m in interface for m in wan_markers) or interface in ("", "any")
|
||||
|
||||
if action in ("pass", "allow") and is_wan:
|
||||
if source in ("any", "", "0.0.0.0/0", "::/0") and dest not in ("any", "", "this firewall"):
|
||||
raise GuardError(
|
||||
"Blocked: inbound WAN allow rule with source 'any' is not permitted. "
|
||||
"Use a specific source CIDR or disable OPNSENSE_BLOCK_WAN_INBOUND_ANY."
|
||||
)
|
||||
|
||||
|
||||
def validate_alias_mutation(name: str) -> None:
|
||||
protected = {"bogons", "bogonsv6", "virusprot", "sshlockout", "__wan_network"}
|
||||
if name.lower() in protected:
|
||||
raise GuardError(f"Alias '{name}' is protected and cannot be modified via MCP.")
|
||||
Reference in New Issue
Block a user