Files
OPNexus/backend/app/services/rule_analysis/resolve.py
T
Julian Boldt 18d489693d
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.26.0
Geplant als MINOR-Release **1.26.0** (Regel-Analyse). Backend, MCP und Oberfläche.

- **Regel-Analyse „Welche Regel greift?“ (API):** `GET /api/hosts/{id}/rule-analysis/match` und
  `GET /api/rule-analysis/match` (ganze Flotte, Auswahl oder Gerätegruppe) beantworten für Quelle, Ziel, Protokoll
  und Port, ob eine Firewall das erste Paket einer neuen Verbindung durchlässt oder blockiert, welche Regel das
  entscheidet und warum (Prüfprotokoll Regel für Regel). Grundlage ist der tatsächlich geladene pf-Regelsatz
  inklusive automatischer Regeln, Floating- und Gruppenregeln, `quick`/letzter Treffer, Negation, verschachtelter
  Aliase (pf-Tabellentest der Firewall), Port-Aliase und Adressfamilien. Jede Antwort trägt eine ehrliche
  Konfidenz **sicher / vermutlich / unbekannt** mit Begründungscodes; was nicht belegbar ist (z. B. `tagged`,
  nicht auflösbare Aliase, geratenes Interface, NAT in der Nähe), wird nie als „sicher“ gemeldet. Nur lesend,
  auch mit `read`-Token nutzbar; es wird nichts gespeichert und nichts an der Firewall geändert.
- **MCP:** neues Werkzeug `rule_match` für KI-Assistenten (gleiche Abfrage, ohne Prüfprotokoll).
- **Seite „Regel-Analyse“ (Regelwerk):** Formular für Hosts oder Gerätegruppe, Quell-/Ziel-IP, Protokoll, Ports,
  Eingangs-Interface („automatisch“ oder Liste) und optional die Ausgangsseite. Die Abfrage steht in der Adresszeile
  (teilbar, wird beim Öffnen sofort ausgeführt). Je Firewall eine Karte mit Erlaubt/Blockiert/Abgewiesen, Sicherheit
  des Urteils, entscheidender Regel und Hinweisen; ein „unbekannt“-Ergebnis erscheint bewusst nicht als Urteil.
  Aufklappbares „Warum?“ zeigt jede geprüfte Regel mit ✓/✗/? je Feld, überstimmte Regeln durchgestrichen. Auch mit
  Nur-Lesen-Rolle nutzbar.
2026-10-21 13:12:11 +02:00

212 lines
9.0 KiB
Python

"""Does a rule field match the queried packet? Addresses, interfaces, aliases, ports.
Every function answers with an ``Outcome`` (True/False/None + reason codes). Whatever cannot
be proven from the firewall's own data is ``None`` (unknown), never a guess.
"""
from __future__ import annotations
import ipaddress
import re
from app.services.rule_analysis.context import Context
from app.services.rule_analysis.model import IPAddr, Outcome, o_or, t_not
MAX_ALIAS_DEPTH = 10
TABLE_ONLY_TYPES = {"url", "urltable", "geoip", "asn", "mac", "dynipv6host", "authgroup", "external", "internal"}
ADDRESS_TYPES = {"host", "network", "networkgroup"}
SERVICES = {
"ftp-data": 20, "ftp": 21, "ssh": 22, "telnet": 23, "smtp": 25, "domain": 53, "dns": 53, "bootps": 67,
"bootpc": 68, "tftp": 69, "http": 80, "www": 80, "kerberos": 88, "pop3": 110, "ntp": 123, "imap": 143,
"snmp": 161, "snmptrap": 162, "ldap": 389, "https": 443, "smb": 445, "microsoft-ds": 445, "isakmp": 500,
"syslog": 514, "submission": 587, "ldaps": 636, "imaps": 993, "pop3s": 995, "openvpn": 1194,
"ms-sql-s": 1433, "radius": 1812, "radius-acct": 1813, "nfs": 2049, "mysql": 3306, "rdp": 3389,
"ipsec-nat-t": 4500, "sip": 5060, "postgresql": 5432, "dhcpv6-client": 546, "dhcpv6-server": 547,
"http-alt": 8080, "wireguard": 51820,
}
EPHEMERAL = (1024, 65535)
def _ip_in(token: str, ip: IPAddr) -> bool | None:
"""Literal IP / CIDR / range; None when the token is not a literal."""
token = token.strip()
if "-" in token and not token.startswith("-"):
low, _, high = token.partition("-")
try:
low_ip, high_ip = ipaddress.ip_address(low.strip()), ipaddress.ip_address(high.strip())
except ValueError:
return None
return low_ip.version == ip.version and low_ip <= ip <= high_ip
try:
net = ipaddress.ip_network(token, strict=False)
except ValueError:
return None
return net.version == ip.version and ip in net
def _interface_ref(token: str, ip: IPAddr, ctx: Context) -> Outcome | None:
name = token.strip()
lower = name.lower()
m = re.fullmatch(r"__(.+)_network", lower)
if m and m.group(1) in ctx.interfaces:
lower = m.group(1)
info = ctx.interfaces.get(lower)
if info is not None: # "lan" = the interface's networks (pf: (dev:network), incl. VIP subnets)
return Outcome(any(ip in net for net in info.nets))
if lower.endswith("ip") and lower[:-2] in ctx.interfaces: # "lanip" = the interface address
info = ctx.interfaces[lower[:-2]]
if ip in info.addrs:
return Outcome(True)
if ip in info.vips:
return Outcome(None, ["VIP_AS_INTERFACE_ADDRESS"])
return Outcome(False)
return None
def addr_item(token: str, ip: IPAddr, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> Outcome:
token = token.strip()
if token.lower() in ("any", ""):
return Outcome(True)
if token.lower() in ("(self)", "self", "this firewall"):
return Outcome(ip in ctx.self_addrs)
literal = _ip_in(token, ip)
if literal is not None:
return Outcome(literal)
ref = _interface_ref(token, ip, ctx)
if ref is not None:
return ref
return alias_contains(token, ip, ctx, depth=depth, seen=seen)
def alias_contains(name: str, ip: IPAddr, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> Outcome:
if name in seen:
return Outcome(None, ["ALIAS_CYCLE"])
if depth > MAX_ALIAS_DEPTH:
return Outcome(None, ["ALIAS_TOO_DEEP"])
alias = ctx.aliases.get(name)
refs = ctx.refs.get(str(ip))
if alias is None:
# pf knows tables the alias list may not show (e.g. internal ones): trust a positive hit.
if refs is not None and name in refs:
return Outcome(True)
return Outcome(None, ["ALIAS_MISSING"])
kind = str(alias.get("type") or "").lower()
if depth == 0 and refs is not None:
# pf's own table test: authoritative for every alias type (nesting, "!" entries, FQDNs).
return Outcome(name in refs)
if kind in ADDRESS_TYPES:
return _alias_content(alias, ip, ctx, depth, (*seen, name))
if refs is not None:
return Outcome(name in refs)
return Outcome(None, ["ALIAS_TABLE_UNAVAILABLE"])
def _alias_content(alias: dict, ip: IPAddr, ctx: Context, depth: int, seen: tuple) -> Outcome:
entries = [e.strip() for e in re.split(r"[\n,]", str(alias.get("content") or "")) if e.strip()]
if any(e.startswith("!") for e in entries):
return Outcome(None, ["ALIAS_EXCLUSION"])
results = []
for entry in entries:
literal = _ip_in(entry, ip)
if literal is not None:
results.append(Outcome(literal))
elif entry in ctx.aliases or entry in seen:
results.append(alias_contains(entry, ip, ctx, depth=depth + 1, seen=seen))
elif "." in entry or ":" in entry:
results.append(Outcome(None, ["FQDN_UNRESOLVED"]))
else:
results.append(Outcome(None, ["ALIAS_MISSING"]))
return o_or(results)
def endpoint_match(endpoint, ip: IPAddr, ctx: Context) -> Outcome:
if endpoint.is_any:
return Outcome(True)
result = o_or([addr_item(item, ip, ctx) for item in endpoint.items])
if endpoint.negated:
return Outcome(t_not(result.value), result.reasons)
return result
# ── Ports ─────────────────────────────────────────────────────────────────
def port_ranges(spec: str, ctx: Context, *, depth: int = 0, seen: tuple = ()) -> tuple[list[tuple[int, int]] | None, list[str]]:
"""The port ranges a spec stands for; (None, reasons) when that cannot be known."""
ranges: list[tuple[int, int]] = []
reasons: list[str] = []
for part in [p.strip() for p in re.split(r"[\n,]", spec) if p.strip()]:
m = re.fullmatch(r"(\w[\w-]*?)\s*[-:]\s*(\w[\w-]*)", part) if not part.isdigit() and part not in ctx.aliases else None
if part.isdigit():
ranges.append((int(part), int(part)))
elif m and _port_number(m.group(1)) is not None and _port_number(m.group(2)) is not None:
ranges.append((_port_number(m.group(1)), _port_number(m.group(2))))
elif part.lower() in SERVICES:
ranges.append((SERVICES[part.lower()],) * 2)
elif part in ctx.aliases:
if part in seen or depth >= MAX_ALIAS_DEPTH:
return None, ["ALIAS_CYCLE" if part in seen else "ALIAS_TOO_DEEP"]
alias = ctx.aliases[part]
if str(alias.get("type") or "").lower() != "port":
return None, ["PORT_ALIAS_UNRESOLVED"]
sub, why = port_ranges(str(alias.get("content") or ""), ctx, depth=depth + 1, seen=(*seen, part))
if sub is None:
return None, why
ranges += sub
else:
return None, ["PORT_ALIAS_UNRESOLVED" if part.isidentifier() else "PORT_UNPARSEABLE"]
return ranges, reasons
def _port_number(token: str) -> int | None:
if token.isdigit():
return int(token)
return SERVICES.get(token.lower())
def port_match(spec: str, port: int | None, ctx: Context, *, ephemeral_default: bool = False) -> Outcome:
spec = (spec or "").strip()
if spec.lower() in ("", "any"):
return Outcome(True)
ranges, reasons = port_ranges(spec, ctx)
if ranges is None:
return Outcome(None, reasons)
if port is not None:
return Outcome(any(low <= port <= high for low, high in ranges))
if not ephemeral_default:
return Outcome(None, ["PORT_NOT_GIVEN"])
# No source port given: the query stands for an ephemeral client port (1024-65535).
low_e, high_e = EPHEMERAL
overlap = [(max(low, low_e), min(high, high_e)) for low, high in ranges if high >= low_e and low <= high_e]
if not overlap:
return Outcome(False)
covered, cursor = True, low_e
for low, high in sorted(overlap):
if low > cursor:
covered = False
break
cursor = max(cursor, high + 1)
if covered and cursor > high_e:
return Outcome(True)
return Outcome(None, ["SRC_PORT_NOT_GIVEN"])
# ── Interfaces ────────────────────────────────────────────────────────────
def iface_match(interfaces: set[str], if_not: bool, iface: str, ctx: Context) -> Outcome:
if not interfaces:
return Outcome(True)
results = []
for name in interfaces:
if name == iface:
results.append(Outcome(True))
elif name in ctx.interfaces:
results.append(Outcome(False))
elif ctx.groups is not None and name in ctx.groups:
results.append(Outcome(iface in ctx.groups[name]))
else:
results.append(Outcome(None, ["IFACE_UNRESOLVED" if ctx.groups is not None else "GROUPS_UNAVAILABLE"]))
result = o_or(results)
if if_not:
return Outcome(t_not(result.value), result.reasons)
return result