Files
OPNexus/backend/app/services/rule_analysis/engine.py
T
Julian Boldt b020e38d0a
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.31.0
- **Regel-Bereinigung: Entfernen, Bestätigen, Löschen, Wiederherstellen:** Im Tab **Bereinigung** (Regelwerk › Regel-Analyse) lassen sich Kandidaten jetzt auswählen
  (Checkbox, höchstens so viele Regeln je Lauf wie in den Einstellungen; „vermutlich“ nur mit einer ausdrücklichen Einzelbestätigung je Regel, „unbekannt“ nie).
  **Vorschau** zeigt je Firewall (im Cluster zuerst der Backup) die Regeln mit Befund, Sicherheit und Schutzprüfung; **Ausrollen** deaktiviert die Regeln und wendet an.
  Danach läuft ein Countdown: **Bestätigen** heißt „ich erreiche die Firewall noch“, ohne Bestätigung setzt OPNexus **automatisch zurück**; bis dahin geht auch
  **Jetzt zurücksetzen**. Bestätigte Regeln lassen sich nach der Wartezeit (einstellbar, Standard 7 Tage) **löschen**; vorher sichert OPNexus den Regelinhalt, und
  unter **Verlauf der Bereinigungen** kommt die Regel mit **Wiederherstellen** deaktiviert und möglichst an alter Position zurück (sonst mit Hinweis am Ende der Gruppe);
  **Wieder aktivieren** hat wieder ein Bestätigungsfenster. Wichtig: **Ausrollen, Wiederholen und Wiederherstellen darf nur ein Administrator**, eine Vorschau gehört
  dem, der sie erstellt hat (ein Admin öffnet selbst eine neue); **Bestätigen geht nur in der Browser-Sitzung**, nie per API-Token. **Die Bereinigung bleibt gesperrt,
  bis unter Einstellungen die Verwaltungsquellen** (IP-Adressen oder Netze, von denen aus verwaltet wird) **eingetragen sind**: Regeln, die diesen Zugang treffen
  könnten, deaktiviert OPNexus nie. Dort stehen auch die Obergrenze je Lauf, die Wartezeit bis zum Löschen und **weitere Verwaltungsports** (zusätzlich zu
  API-Port, 443, 80, 22). Tragen Sie die Adresse ein, mit der OPNexus selbst bei den Firewalls ankommt (im Cluster für jeden Knoten). Quellen, Ports und
  Firewall-Adressen sehen nur Administratoren. Der Hinweis „Entfernen folgt“ entfällt.
- **Update: neuere CLI aus dem Zielrelease:** `opnexus update` (auch per Klick im Webinterface) holt sich nach dem Pull die CLI aus dem Updater-Image des
  Zielreleases und startet sich damit neu, damit Korrekturen am Update-Ablauf sofort greifen statt erst beim übernächsten Update. Das geschieht nur, wenn
  `cosign` und `cosign.pub` vorhanden sind und die Signatur des Image-**Digests** in diesem Lauf geprüft wurde (ausgeführt wird genau dieser Digest);
  sonst und bei jedem Fehler läuft das Update mit der vorhandenen CLI weiter. Abschalten: `OPNEXUS_NO_CLI_REFRESH=1`. Wirksam wird es ab dem Update
  *von* dieser Version.
2026-10-22 21:04:32 +02:00

343 lines
15 KiB
Python

"""Which rule decides a packet, following pf semantics.
pf walks the loaded ruleset top to bottom; the *last* matching rule wins unless a matching
rule is ``quick``, which ends the walk. OPNsense orders rules by ``sort_order`` (system,
floating, groups, interfaces). The analysis looks at the first packet of a new connection.
Confidence:
- ``rule_confidence`` is "sure" only when no rule that could still change the outcome
matched with an unknown result;
- ``confidence`` (the verdict) is "sure" when every such unknown rule has the same action as
the deciding rule (then the verdict holds either way), "unknown" otherwise;
- notes about guesses (interface guessed from the address, NAT nearby, pf ruleset missing,
TCP flags, rate limits) lower a "sure" to "probable".
"""
from __future__ import annotations
import ipaddress
from dataclasses import dataclass, field
from app.services.rule_analysis import resolve
from app.services.rule_analysis.context import Context
from app.services.rule_analysis.model import IPAddr, NormRule, Outcome, t_and, tri_str
from app.services.rule_analysis.normalize import norm_proto, normalize
SURE, PROBABLE, UNKNOWN = "sure", "probable", "unknown"
_RANK = {SURE: 2, PROBABLE: 1, UNKNOWN: 0}
DOWNGRADING_NOTES = {
"IFACE_GUESSED", "EGRESS_GUESSED", "NAT_NEARBY", "TCP_FLAGS", "RATE_LIMIT", "ORDER_GUESSED",
"ORDER_MISMATCH", "PF_RULESET_UNAVAILABLE", "NAT_UNAVAILABLE", "QUICK_UNKNOWN",
}
_PORT_PROTOS = {"6", "17"}
_ICMP_PROTOS = {"1", "58"}
ICMP_NAMES = {
4: {"0": "echorep", "3": "unreach", "4": "squench", "5": "redir", "8": "echoreq", "9": "routeradv",
"10": "routersol", "11": "timex", "12": "paramprob", "13": "timereq", "14": "timerep"},
6: {"1": "unreach", "2": "toobig", "3": "timex", "4": "paramprob", "128": "echoreq", "129": "echorep",
"133": "routersol", "134": "routeradv", "135": "neighbrsol", "136": "neighbradv", "137": "redir"},
}
@dataclass
class Query:
src: IPAddr
dst: IPAddr
proto: str # normalized ("6", "17", "1", "58", other number or name)
proto_label: str
sport: int | None = None
dport: int | None = None
icmptype: str | None = None
iface: str | None = None # None = guess from the source address
direction: str = "in"
egress: str | None = "none" # "none" = only the first leg, None = guess, else identifier
@property
def af(self) -> int:
return self.src.version
@dataclass
class LegResult:
direction: str
iface: str | None
iface_source: str
action: str | None
decisive: NormRule | None
confidence: str
rule_confidence: str
notes: list[str] = field(default_factory=list)
unknowns: list[str] = field(default_factory=list)
steps: list[dict] = field(default_factory=list)
def _min_conf(*values: str) -> str:
return min(values, key=lambda v: _RANK[v])
def _max_conf(*values: str) -> str:
return max(values, key=lambda v: _RANK[v])
def _icmp_name(q: Query) -> str | None:
if q.icmptype is None:
return None
return ICMP_NAMES[q.af].get(q.icmptype, q.icmptype)
def rule_fields(r: NormRule, q: Query, direction: str, iface: str, ctx: Context,
tags: set[str], first_leg_iface: str | None) -> dict[str, Outcome]:
fields: dict[str, Outcome] = {
"interface": resolve.iface_match(r.interfaces, r.if_not, iface, ctx),
"direction": Outcome(r.direction in ("any", direction)),
"family": Outcome(q.af in r.af),
"protocol": Outcome(True if r.protos is None else q.proto in r.protos),
"source": resolve.endpoint_match(r.src, q.src, ctx),
"destination": resolve.endpoint_match(r.dst, q.dst, ctx),
}
if q.proto in _PORT_PROTOS:
fields["source_port"] = resolve.port_match(r.sport, q.sport, ctx, ephemeral_default=True)
fields["destination_port"] = resolve.port_match(r.dport, q.dport, ctx)
elif r.sport or r.dport:
fields["ports"] = Outcome(False) # pf only generates port rules for tcp/udp
if r.icmptypes is not None and q.proto in _ICMP_PROTOS:
name = _icmp_name(q)
fields["icmp_type"] = Outcome(None, ["ICMP_TYPE_NOT_GIVEN"]) if name is None else \
Outcome(name in r.icmptypes or q.icmptype in r.icmptypes)
if r.tagged:
fields["tagged"] = Outcome(True) if r.tagged in tags else Outcome(None, ["TAGGED"])
if r.received_on:
if direction == "in":
fields["received_on"] = Outcome(r.received_on == iface)
elif first_leg_iface:
fields["received_on"] = Outcome(r.received_on == first_leg_iface)
else:
fields["received_on"] = Outcome(None, ["RECEIVED_ON"])
if r.action is None:
fields["action"] = Outcome(None, ["ACTION_UNKNOWN"])
return fields
def match_rule(r: NormRule, q: Query, ctx: Context, *, iface: str | None = None,
source: Outcome | None = None, overrides: dict[str, Outcome] | None = None) -> Outcome:
"""Could the rule's own fields match the packet ``q`` arriving on ``iface``? Order, ``quick``, other rules and
whether the rule is enabled or loaded play no part: this is the question "is this rule about such a packet at
all", which the cleanup asks about the way the admin reaches the firewall (DESIGN-1.31 §3.2).
``iface`` defaults to ``q.iface`` and then to the interface guessed from the address; an interface that cannot
be resolved is UNKNOWN. ``source`` replaces the source field (the caller judged it for a whole network, see
rule_analysis/protection.py); ``overrides`` replaces further fields by name (e.g. ``interface`` for a packet
arriving on a device that is not an assigned interface). Tags set by earlier rules are not known here, so ``tagged`` is UNKNOWN. Only
``False`` is a proof that the rule does not match; ``None`` (unknown) must be treated like ``True``."""
iface = iface or q.iface or guess_iface(q.src if q.direction == "in" else q.dst, ctx)
if iface is None:
return Outcome(None, ["IFACE_UNRESOLVED"])
fields = rule_fields(r, q, q.direction, iface, ctx, set(), None)
if source is not None:
fields["source"] = source
for name, outcome in (overrides or {}).items():
if name in fields or name == "interface":
fields[name] = outcome
value = t_and(*(f.value for f in fields.values()))
reasons = sorted({reason for f in fields.values() if f.value is None for reason in f.reasons}) if value is None else []
return Outcome(value, reasons)
def _step(r: NormRule, fields: dict[str, Outcome], result, effect: str) -> dict:
return {
"rule": r.summary(), "result": tri_str(result), "effect": effect,
"fields": {k: tri_str(v.value) for k, v in fields.items()},
"reasons": sorted({reason for v in fields.values() if v.value is None for reason in v.reasons}),
}
def eval_leg(rules: list[NormRule], q: Query, ctx: Context, direction: str, iface: str,
first_leg_iface: str | None = None) -> LegResult:
steps: list[dict] = []
candidate: NormRule | None = None
possible: list[tuple[NormRule, bool]] = [] # (rule, came after the current candidate)
tags: set[str] = set()
final: NormRule | None = None
for r in rules:
if not r.enabled:
steps.append({"rule": r.summary(), "result": "false", "effect": "disabled", "fields": {}, "reasons": []})
continue
if r.loaded is False:
steps.append({"rule": r.summary(), "result": "false", "effect": "not_loaded", "fields": {}, "reasons": []})
continue
fields = rule_fields(r, q, direction, iface, ctx, tags, first_leg_iface)
result = t_and(*(f.value for f in fields.values()))
if result is False:
steps.append(_step(r, fields, result, "no_match"))
continue
if result is None or r.quick is None:
possible.append((r, True))
steps.append(_step(r, fields, result, "possible"))
continue
if r.quick:
final = r
for s in steps:
if s["effect"] == "candidate":
s["effect"] = "overridden"
steps.append(_step(r, fields, result, "final"))
break
if candidate is not None:
for s in steps:
if s["effect"] == "candidate":
s["effect"] = "overridden"
candidate = r
possible = [(p, False) for p, _ in possible]
if r.tag:
tags.add(r.tag)
steps.append(_step(r, fields, result, "candidate"))
decisive = final or candidate
if final is None:
for s in steps:
if s["effect"] == "candidate":
s["effect"] = "decisive"
else:
steps[-1]["effect"] = "decisive"
action = decisive.action if decisive else "pass" # pf without any matching rule passes
# Unknown matches that could still change the outcome: quick (or quick-unknown) ones always,
# non-quick ones only after the deciding candidate (an earlier one is overridden anyway).
relevant = [p for p, after in possible if p.quick is not False or (final is None and after)]
rule_conf = SURE if not relevant else PROBABLE
conf = SURE if all(p.action == action for p in relevant) else UNKNOWN
unknowns = sorted({reason for s in steps if s["effect"] == "possible" for reason in s["reasons"]})
if any(p.quick is None for p, _ in possible):
unknowns = sorted({*unknowns, "QUICK_UNKNOWN"})
notes: list[str] = []
if decisive is None:
notes.append("PF_DEFAULT_PASS")
else:
notes += decisive.extra_notes
if decisive.gateway and action == "pass":
notes.append("POLICY_ROUTE")
if decisive.statetype.lower() == "none" or "no state" in getattr(decisive.pf_line, "text", ""):
notes.append("STATELESS")
if decisive.sched:
notes.append("SCHEDULE_NOW")
if any(s["effect"] == "not_loaded" and s["rule"]["loaded"] is False for s in steps):
notes.append("RULES_NOT_LOADED")
return LegResult(direction=direction, iface=iface, iface_source="given", action=action, decisive=decisive,
confidence=conf, rule_confidence=rule_conf, notes=notes, unknowns=unknowns, steps=steps)
def guess_iface(ip: IPAddr, ctx: Context) -> str | None:
"""The interface whose connected network holds ``ip`` (longest prefix), else the one with
the default gateway."""
best, best_len = None, -1
for info in ctx.interfaces.values():
if info.identifier == "lo0":
continue
for net in info.nets:
if net.version == ip.version and ip in net and net.prefixlen > best_len and not net.is_link_local:
best, best_len = info.identifier, net.prefixlen
if best:
return best
gateways = [i.identifier for i in ctx.interfaces.values() if i.has_gateway]
return gateways[0] if len(gateways) == 1 else None
def _nat_nearby(q: Query, iface: str, ctx: Context) -> bool:
for row in ctx.port_forwards or []:
if str(row.get("disabled") or "0") == "1" or str(row.get("enabled", "1")) == "0":
continue
if str(row.get("nordr") or "0") == "1": # "no rdr" exclusions (anti-lockout) redirect nothing
continue
if q.dport is not None and resolve.port_match(str(row.get("destination.port") or ""), q.dport, ctx).value is False:
continue
ifaces = {i.strip().lower() for i in str(row.get("interface") or "").split(",") if i.strip()}
if ifaces and iface not in ifaces:
continue
proto = str(row.get("protocol") or "").lower()
if proto and proto not in ("any", "tcp/udp") and norm_proto(proto) != q.proto:
continue
return True
return False
def evaluate(ctx: Context, q: Query) -> dict:
rules, order_notes = normalize(ctx)
context_notes = [*ctx.notes, *order_notes]
legs: list[LegResult] = []
iface, iface_source = q.iface, "given"
if iface is None:
iface, iface_source = guess_iface(q.src if q.direction == "in" else q.dst, ctx), "guessed"
if iface is None:
return _result(q, legs, context_notes + ["IFACE_UNRESOLVED"], unresolved=True)
first = eval_leg(rules, q, ctx, q.direction, iface)
first.iface_source = iface_source
if iface_source == "guessed":
first.notes.append("IFACE_GUESSED")
if q.direction == "in" and (ctx.port_forwards is None or _nat_nearby(q, iface, ctx)):
first.notes.append("NAT_NEARBY" if ctx.port_forwards is not None else "NAT_UNAVAILABLE")
legs.append(first)
if q.direction == "in" and q.egress != "none":
egress, source = q.egress, "given"
if egress is None:
egress, source = guess_iface(q.dst, ctx), "guessed"
if egress is None:
context_notes.append("EGRESS_UNRESOLVED")
else:
out = eval_leg(rules, q, ctx, "out", egress, first_leg_iface=iface)
out.iface_source = source
if source == "guessed":
out.notes.append("EGRESS_GUESSED")
legs.append(out)
return _result(q, legs, context_notes)
def _leg_conf(leg: LegResult, context_notes: list[str]) -> tuple[str, str]:
downgrade = any(n in DOWNGRADING_NOTES for n in (*leg.notes, *context_notes))
conf, rule_conf = leg.confidence, leg.rule_confidence
if downgrade:
conf = _min_conf(conf, PROBABLE)
rule_conf = _min_conf(rule_conf, PROBABLE)
return conf, rule_conf
def _result(q: Query, legs: list[LegResult], context_notes: list[str], unresolved: bool = False) -> dict:
leg_dicts, confs = [], []
for leg in legs:
conf, rule_conf = _leg_conf(leg, context_notes)
confs.append((leg, conf, rule_conf))
leg_dicts.append({
"direction": leg.direction, "interface": leg.iface, "interface_source": leg.iface_source,
"action": leg.action, "confidence": conf, "rule_confidence": rule_conf,
"decisive_rule": leg.decisive.summary() if leg.decisive else None,
"notes": sorted(set(leg.notes)), "unknowns": leg.unknowns,
})
if unresolved or not legs:
action, conf, rule_conf, decisive = None, UNKNOWN, UNKNOWN, None
else:
blocking = [c for c in confs if c[0].action != "pass"]
if blocking:
leg, _, rule_conf = blocking[0]
action, decisive = leg.action, leg.decisive
# Blocked if any blocking leg is right: the best of their confidences counts.
conf = _max_conf(*(c[1] for c in blocking))
if conf == SURE and any(c[0].action != action for c in blocking):
conf = PROBABLE
else:
leg, _, rule_conf = confs[0]
action, decisive = "pass", leg.decisive
conf = _min_conf(*(c[1] for c in confs))
return {
"query": {
"src": str(q.src), "dst": str(q.dst), "proto": q.proto_label, "sport": q.sport, "dport": q.dport,
"icmptype": q.icmptype, "direction": q.direction,
"assumptions": ["FIRST_PACKET"] + (["SRC_PORT_EPHEMERAL"] if q.sport is None and q.proto in _PORT_PROTOS else []),
},
"action": action, "confidence": conf, "rule_confidence": rule_conf,
"decisive_rule": decisive.summary() if decisive else None,
"notes": sorted({*context_notes, *(n for leg in legs for n in leg.notes)}),
"unknowns": sorted({u for leg in legs for u in leg.unknowns}),
"legs": leg_dicts,
"trace": [{"leg": i, "direction": leg.direction, "interface": leg.iface, "steps": leg.steps}
for i, leg in enumerate(legs)],
}
def parse_ip(value: str) -> IPAddr:
return ipaddress.ip_address(value.strip())