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
- **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.
194 lines
12 KiB
Python
194 lines
12 KiB
Python
"""Pure parts of the rule cleanup (1.31): engine.match_rule, the management-access protection, the infrastructure
|
|
exclusion, the confidence caps and the cluster usage merge. No database, no firewall."""
|
|
import ipaddress
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from app.services import rule_hit_service
|
|
from app.services.rule_analysis import cleanup, protection
|
|
from app.services.rule_analysis.context import Context, IfInfo
|
|
from app.services.rule_analysis.engine import Query, match_rule
|
|
from app.services.rule_analysis.model import Endpoint, NormRule
|
|
from app.services.rule_cleanup_service import selectable_level
|
|
|
|
NOW = datetime(2026, 10, 22, 12, 0, tzinfo=timezone.utc)
|
|
ip = ipaddress.ip_address
|
|
net = ipaddress.ip_network
|
|
|
|
|
|
def ctx(**kw) -> Context:
|
|
interfaces = {
|
|
"wan": IfInfo("wan", "vtnet0", "WAN", nets=[net("203.0.113.0/24")], addrs=[ip("203.0.113.57")], has_gateway=True),
|
|
"lan": IfInfo("lan", "vtnet1", "LAN", nets=[net("10.20.30.0/24")], addrs=[ip("10.20.30.2")], vips=[ip("10.20.30.1")]),
|
|
}
|
|
return Context(rows=[], pf=None, interfaces=interfaces, groups={}, aliases=kw.get("aliases", {}), port_forwards=[])
|
|
|
|
|
|
def nrule(*, src="", dst="", dport="", protos=frozenset({"6"}), interfaces=frozenset({"wan"}), action="pass", negated=False,
|
|
tagged="", uuid="u1") -> NormRule:
|
|
items = [i for i in src.split(",") if i]
|
|
return NormRule(uuid=uuid, label=uuid, description=uuid, order=(400000, 1), order_known=True, section="interface",
|
|
enabled=True, action=action, quick=True, interfaces=set(interfaces), if_not=False, direction="in",
|
|
af={4}, protos=None if protos is None else set(protos), src=Endpoint(raw=src, negated=negated, items=items),
|
|
dst=Endpoint(raw=dst, items=[i for i in dst.split(",") if i]), sport="", dport=dport, tagged=tagged)
|
|
|
|
|
|
SOURCES = [net("203.0.113.10/32")]
|
|
|
|
|
|
def check(rule, c=None, sources=SOURCES):
|
|
c = c or ctx()
|
|
targets, ports = protection.management_targets("https://203.0.113.57:4443", c)
|
|
return protection.management_check(rule, c, sources, targets, ports)
|
|
|
|
|
|
def test_targets_are_the_api_address_and_port_plus_every_firewall_address():
|
|
targets, ports = protection.management_targets("https://203.0.113.57:4443", ctx())
|
|
assert targets[0] == ip("203.0.113.57") and ip("10.20.30.1") in targets and ip("127.0.0.1") not in targets
|
|
assert ports == [4443, 443, 80, 22]
|
|
|
|
|
|
def test_a_rule_for_the_management_path_is_protected():
|
|
assert check(nrule(src="203.0.113.0/24", dport="443"))["result"] == "true"
|
|
assert check(nrule(src="", dport="4443"))["result"] == "true" # any source, API port
|
|
assert check(nrule(src="203.0.113.10", dport="", protos=None))["result"] == "true"
|
|
|
|
|
|
def test_only_a_proven_non_match_is_removable():
|
|
assert check(nrule(src="10.20.30.0/24", dport="443"))["result"] == "false" # other source
|
|
assert check(nrule(src="203.0.113.0/24", dport="8081"))["result"] == "false" # other port
|
|
assert check(nrule(src="203.0.113.0/24", dport="443", protos={"17"}))["result"] == "false" # udp
|
|
|
|
|
|
def test_unknown_is_protected(): # case 18
|
|
unknown = check(nrule(src="tbl", dport="443"), ctx(aliases={"tbl": {"name": "tbl", "type": "urltable"}}))
|
|
assert unknown["result"] == "unknown" and unknown["match"]["port"] in (4443, 443)
|
|
tagged = check(nrule(src="", dport="443", tagged="mark"))
|
|
assert tagged["result"] == "unknown" and "TAGGED" in tagged["reasons"]
|
|
q = Query(src=ip("203.0.113.10"), dst=ip("203.0.113.57"), proto="6", proto_label="tcp", dport=443)
|
|
assert match_rule(nrule(interfaces={"nosuchgroup"}), q, ctx(), iface="wan").value is None
|
|
|
|
|
|
def test_network_sources_are_judged_for_every_address():
|
|
small = [net("203.0.113.0/28")]
|
|
assert check(nrule(src="203.0.113.14", dport="443"), sources=small)["result"] == "true" # one address inside
|
|
assert check(nrule(src="203.0.113.20", dport="443"), sources=small)["result"] == "false"
|
|
big = [net("10.0.0.0/8")]
|
|
assert check(nrule(src="10.20.30.0/24", dport="443"), sources=big)["result"] == "true" # ranges overlap
|
|
assert check(nrule(src="192.0.2.0/24", dport="443"), sources=big)["result"] == "false"
|
|
assert check(nrule(src="lan", dport="443"), sources=big)["result"] == "true" # interface network
|
|
assert check(nrule(src="10.0.0.0/8", dport="443", negated=True), sources=big)["result"] == "false" # !whole net
|
|
assert check(nrule(src="tbl", dport="443"), ctx(aliases={"tbl": {"type": "urltable"}}), sources=big)["result"] == "unknown"
|
|
|
|
|
|
def test_no_query_possible_is_protected():
|
|
got = check(nrule(src="", dport="443"), sources=[net("2001:db8::/64")]) # no IPv6 address on the firewall
|
|
assert got["result"] == "unknown" and got["reasons"] == ["NO_MANAGEMENT_QUERY"]
|
|
|
|
|
|
def test_carp_and_pfsync_rules_are_infrastructure():
|
|
c = ctx()
|
|
assert protection.infrastructure_reason(nrule(protos={"112"}), c, hasync=None, clustered=False) == "infrastructure"
|
|
assert protection.infrastructure_reason(nrule(protos={"240"}), c, hasync=None, clustered=False) == "infrastructure"
|
|
lan_any = nrule(src="10.20.30.0/24", protos=None, interfaces={"lan"})
|
|
assert protection.infrastructure_reason(lan_any, c, hasync=None, clustered=False) == "infrastructure" # CARP adverts
|
|
lan_tcp = nrule(src="10.20.30.0/24", dport="8081", interfaces={"lan"})
|
|
assert protection.infrastructure_reason(lan_tcp, c, hasync=None, clustered=True) is None
|
|
wan_any = nrule(src="", protos=None, interfaces={"wan"})
|
|
off = {"hasync": {"pfsyncinterface": {"": {"selected": 1}, "wan": {"selected": 0}}, "pfsyncpeerip": ""}}
|
|
on = {"hasync": {"pfsyncinterface": {"": {"selected": 0}, "wan": {"selected": 1}}, "pfsyncpeerip": "203.0.113.58"}}
|
|
assert protection.infrastructure_reason(wan_any, c, hasync=off, clustered=True) is None
|
|
assert protection.infrastructure_reason(wan_any, c, hasync=on, clustered=True) == "infrastructure"
|
|
# HA settings not readable on a cluster node: every interface counts as a possible pfsync interface
|
|
assert protection.infrastructure_reason(wan_any, c, hasync=None, clustered=True) == "infrastructure"
|
|
|
|
|
|
def test_confidence_caps():
|
|
rule = nrule()
|
|
unused = {"categories": [{"kind": "unused", "confidence": "sure"}]}
|
|
assert selectable_level(unused, rule, unused_days=30, external=False)[0] == "sure"
|
|
assert selectable_level(unused, rule, unused_days=29, external=False)[0] == "probable"
|
|
assert selectable_level(unused, nrule(action="block"), unused_days=90, external=False)[0] == "probable"
|
|
assert selectable_level(unused, nrule(action="reject"), unused_days=90, external=False)[0] == "probable"
|
|
assert selectable_level(unused, rule, unused_days=90, external=True)[0] == "probable"
|
|
both = {"categories": [{"kind": "unused", "confidence": "unknown"}, {"kind": "duplicate", "confidence": "sure"}]}
|
|
assert selectable_level(both, nrule(action="block"), unused_days=7, external=False)[0] == "sure" # best category
|
|
assert selectable_level({"categories": [{"kind": "shadowed", "confidence": "unknown"}]}, rule, unused_days=30, external=False)[0] == "unknown"
|
|
assert selectable_level({"categories": [{"kind": "disabled_long", "confidence": "sure"}]}, rule, unused_days=30, external=False)[0] is None
|
|
|
|
|
|
class Hit:
|
|
def __init__(self, uuid, first_days, hit_days=None, sampled_minutes=10, enabled=True):
|
|
self.rule_uuid, self.enabled = uuid, enabled
|
|
self.first_seen_at = NOW - timedelta(days=first_days)
|
|
self.last_hit_at = None if hit_days is None else NOW - timedelta(days=hit_days)
|
|
self.last_sampled_at = NOW - timedelta(minutes=sampled_minutes)
|
|
|
|
|
|
def test_cluster_usage_merge(): # case 16
|
|
master = {"r": Hit("r", 100, hit_days=1)}
|
|
backup = {"r": Hit("r", 100)}
|
|
merged = rule_hit_service.merge_cluster_hits(backup, [master])
|
|
assert rule_hit_service.classify(merged["r"], NOW, 30) == "used"
|
|
both_idle = rule_hit_service.merge_cluster_hits({"r": Hit("r", 100)}, [{"r": Hit("r", 20)}])
|
|
assert rule_hit_service.classify(both_idle["r"], NOW, 30) == "collecting" # window = latest start
|
|
stale = rule_hit_service.merge_cluster_hits({"r": Hit("r", 100)}, [{"r": Hit("r", 100, sampled_minutes=60 * 48)}])
|
|
assert stale["r"].last_sampled_at == NOW - timedelta(hours=48) # as fresh as the oldest node
|
|
missing = rule_hit_service.merge_cluster_hits({"r": Hit("r", 100)}, [{}])
|
|
assert missing["r"].peer_missing and rule_hit_service.classify(missing["r"], NOW, 30) == "unused"
|
|
rule = nrule()
|
|
rule.loaded = True
|
|
assert cleanup.unused_confidence(rule, missing["r"], NOW) == "probable"
|
|
assert cleanup.unused_confidence(rule, both_idle["r"], NOW) == "sure"
|
|
|
|
|
|
def _live_rule(**kw):
|
|
"""A rule normalized inside the live fixture (tests/fixtures/rule_analysis_fw.json: built-in groups wireguard,
|
|
openvpn, enc0 with members "" and unassigned devices without identifier)."""
|
|
from app.services.rule_analysis.normalize import normalize
|
|
from tests.test_rule_analysis import build, mvc
|
|
row = mvc("bbbbbbbb-0000-4000-8000-000000000001", seq=900, proto="tcp", **kw)
|
|
c = build([row])
|
|
rules, _ = normalize(c)
|
|
return next(r for r in rules if r.uuid == row["uuid"]), c
|
|
|
|
|
|
def test_rules_on_builtin_groups_without_known_members_are_protected(): # review finding 1
|
|
for group in ("wireguard", "openvpn", "enc0"):
|
|
rule, c = _live_rule(interface=group, src="203.0.113.10", dst="(self)", dport="443")
|
|
got = check(rule, c)
|
|
assert got["result"] == "unknown" and "IFACE_MEMBERS_UNKNOWN" in got["reasons"], (group, got)
|
|
assert got["match"]["interface"] == protection.UNASSIGNED
|
|
# the virtual ingress does not make every rule protected
|
|
rule, c = _live_rule(interface="wireguard", src="203.0.113.10", dst="(self)", dport="8081")
|
|
assert check(rule, c)["result"] == "false"
|
|
rule, c = _live_rule(interface="lan", src="10.20.30.0/24", dst="(self)", dport="443")
|
|
assert check(rule, c)["result"] == "false"
|
|
# floating "any interface" and "not lan" also match a packet on wg0
|
|
rule, c = _live_rule(interface="", src="203.0.113.10", dst="(self)", dport="22")
|
|
assert check(rule, c)["result"] == "true"
|
|
rule, c = _live_rule(interface="lan", interfacenot="1", src="203.0.113.10", dst="(self)", dport="22")
|
|
assert check(rule, c)["result"] == "true"
|
|
|
|
|
|
def test_unassigned_interface_outcome():
|
|
c = ctx()
|
|
c.groups = {"mygroup": {"lan"}, "wireguard": set()}
|
|
assert protection.unassigned_interface(nrule(interfaces={"lan"}), c).value is False
|
|
assert protection.unassigned_interface(nrule(interfaces={"mygroup"}), c).value is False
|
|
assert protection.unassigned_interface(nrule(interfaces={"wireguard"}), c).value is None
|
|
assert protection.unassigned_interface(nrule(interfaces={"nosuch"}), c).value is None
|
|
assert protection.unassigned_interface(nrule(interfaces=set()), c).value is True
|
|
|
|
|
|
def test_redacted_plan_names_no_source_port_or_firewall_address(): # review finding 8
|
|
from app.services.rule_cleanup_service import redact
|
|
plan = {"kind": "enable", "sources": ["203.0.113.10/32"], "ports": [2222], "nodes": [
|
|
{"host_id": "h", "base_url": "https://203.0.113.57", "items": [
|
|
{"uuid": "u", "protection": {"result": "true", "queries": 3, "reasons": [],
|
|
"match": {"source": "203.0.113.10/32", "destination": "203.0.113.57"}}}]}]}
|
|
out = redact({"plan": plan, "operation": {"plan": plan}})
|
|
for p in (out["plan"], out["operation"]["plan"]):
|
|
assert "203.0.113" not in str(p) and "2222" not in str(p)
|
|
assert p["sources_configured"] is True and p["nodes"][0]["items"][0]["protection"]["result"] == "true"
|
|
assert plan["sources"] and plan["nodes"][0]["base_url"] # the stored plan itself is untouched
|