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
- **Sicherheit (Ergebnis eines unabhängigen Reviews der Versionen 1.18–1.23):** - Aufgabentrennung: Mit „nur Admins rollen aus“ ist jetzt auch das Löschen verwalteter Objekte (entfernt sie von den Firewalls), Zertifikat-/ACME-Push, Dienste starten/stoppen und „Reihenfolge erneut durchsetzen“ den Admins vorbehalten, nicht nur `/deploy`. - Backup-Änderungsansicht: weitere Geheimnis-Felder (`otp_seed`, `pass`, `pwd`, …) werden ausgeblendet, die Platzhalter sind pro Abruf gesalzen (kein Wörterbuchabgleich, kein Vergleich über Abrufe/Firewalls). - SSO: der Anmeldeversuch ist per Cookie an den Browser gebunden (kein Login-CSRF, abgefangene Code/State-Paare sind nutzlos); SSO-Sitzungen dauern 12 Stunden statt 30 Tage; Gruppen-Strings werden nur an Komma/Zeilenumbruch getrennt (Gruppen mit Leerzeichen); Fehlertext des Anbieters wird für das Log bereinigt. - Das Ändern des eigenen Passworts widerruft jetzt auch die eigenen API-Tokens. - Management-Tunnel: der Hub routet nicht mehr zwischen Firewalls oder ins Compose-Netz (`FORWARD` verworfen, `ip_forward=0`); loopback-/link-local-Netze sind als Tunnel-Netz nicht erlaubt. - Sicherheitslage: eine defekte `config.xml` stoppt die Prüfung der übrigen Firewalls nicht mehr; die Auswertung läuft außerhalb der Ereignisschleife. - Behoben: S3-Offsite mit Port 443 im Endpunkt (Signatur passte nicht); Variablenwerte mit angehängtem Zeilenumbruch werden abgelehnt. - Fertiges **Grafana-Dashboard** (`docs/grafana-opnexus.json`) für die Prometheus-Metriken von OPNexus.
301 lines
14 KiB
Python
301 lines
14 KiB
Python
"""Security posture of a firewall: hardening checks and rule hygiene.
|
|
|
|
The checks read the firewall's own config.xml (one read-only download, parsed
|
|
in memory with defusedxml, never stored) plus two facts OPNexus already knows:
|
|
whether firmware updates are pending and how old the newest local backup is.
|
|
The XML layout was verified against real OPNsense 26.7 configs: rules live in
|
|
``OPNsense/Firewall/Filter/rules/rule`` (MVC) or, on installs not yet migrated,
|
|
in ``filter/rule`` (legacy). Both are normalised to the same shape below.
|
|
|
|
``evaluate`` is pure (bytes in, findings out). A finding is
|
|
``{id, severity, passed, params}``; ``passed`` is None when a check could not be
|
|
decided. The score starts at 100 and loses a penalty per failed check, so one
|
|
critical finding (an any-any pass rule on the WAN) already turns the host red.
|
|
"""
|
|
import asyncio
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from xml.etree.ElementTree import Element
|
|
|
|
import defusedxml.ElementTree as safe_ET
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.compliance_result import ComplianceResult
|
|
from app.models.host import OpnsenseHost
|
|
from app.services import backup_store, rule_hit_service
|
|
from app.services import opnsense_client as client
|
|
from app.services.opnsense_client import OpnsenseAPIError
|
|
|
|
MAX_CONFIG_BYTES = 32 * 1024 * 1024
|
|
BACKUP_MAX_AGE_DAYS = 7
|
|
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
|
|
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
|
MAX_EXAMPLES = 5
|
|
# German titles for notifications (the UI translates the check ids itself).
|
|
TITLES_DE = {
|
|
"webgui_https": "Web-Oberfläche nur über HTTPS", "ssh_hardened": "SSH abgesichert", "snmp_community": "SNMP-Community nicht „public“",
|
|
"wan_any_any": "Keine Allow-all-Regel am WAN", "internal_any_any": "Keine Allow-all-Regel mit beliebiger Quelle (intern)",
|
|
"rules_shadowed": "Keine verdeckten Regeln", "rules_duplicate": "Keine doppelten Regeln", "rules_documented": "Regeln sind beschriftet",
|
|
"rules_unused": "Regeln in Benutzung", "ntp_configured": "Zeitserver gesetzt", "remote_logging": "Logs liegen auch woanders",
|
|
"ids_enabled": "Einbruchserkennung aktiv", "firmware_current": "Firmware aktuell", "backup_recent": "Aktuelles Backup",
|
|
}
|
|
SEVERITIES_DE = {"critical": "kritisch", "high": "hoch", "medium": "mittel", "low": "niedrig", "info": "Hinweis"}
|
|
|
|
|
|
def grade(score: int) -> str:
|
|
return "good" if score >= 90 else "fair" if score >= 70 else "poor"
|
|
|
|
|
|
def _text(el: Element | None, path: str) -> str | None:
|
|
node = el.find(path) if el is not None else None
|
|
if node is None or node.text is None or not node.text.strip():
|
|
return None
|
|
return node.text.strip()
|
|
|
|
|
|
# ── Rule normalisation ───────────────────────────────────────────────────
|
|
|
|
def _endpoint(node: Element | None) -> tuple[str, bool, str | None]:
|
|
"""(network, negated, port) of a legacy <source>/<destination> element."""
|
|
if node is None:
|
|
return "any", False, None
|
|
negated = node.find("not") is not None
|
|
port = _text(node, "port")
|
|
if node.find("any") is not None:
|
|
return "any", negated, port
|
|
for tag in ("network", "address"):
|
|
value = _text(node, tag)
|
|
if value:
|
|
return value, negated, port
|
|
return "any", negated, port
|
|
|
|
|
|
def _mvc_rule(rule: Element) -> dict:
|
|
return {
|
|
"enabled": _text(rule, "enabled") != "0",
|
|
"action": (_text(rule, "action") or "pass").lower(),
|
|
"interfaces": [i for i in (_text(rule, "interface") or "").split(",") if i],
|
|
"direction": _text(rule, "direction") or "in",
|
|
"ipprotocol": _text(rule, "ipprotocol") or "inet",
|
|
"protocol": (_text(rule, "protocol") or "any").lower(),
|
|
"src": _text(rule, "source_net") or "any", "src_not": _text(rule, "source_not") == "1",
|
|
"sport": _text(rule, "source_port"),
|
|
"dst": _text(rule, "destination_net") or "any", "dst_not": _text(rule, "destination_not") == "1",
|
|
"dport": _text(rule, "destination_port"),
|
|
"floating": False,
|
|
"quick": _text(rule, "quick") != "0",
|
|
"descr": _text(rule, "description") or "",
|
|
"order": int(_text(rule, "sequence") or 0),
|
|
}
|
|
|
|
|
|
def _legacy_rule(rule: Element, index: int) -> dict:
|
|
src, src_not, sport = _endpoint(rule.find("source"))
|
|
dst, dst_not, dport = _endpoint(rule.find("destination"))
|
|
return {
|
|
"enabled": rule.find("disabled") is None,
|
|
"action": (_text(rule, "type") or "pass").lower(),
|
|
"interfaces": [i for i in (_text(rule, "interface") or "").split(",") if i],
|
|
"direction": _text(rule, "direction") or "in",
|
|
"ipprotocol": _text(rule, "ipprotocol") or "inet",
|
|
"protocol": (_text(rule, "protocol") or "any").lower(),
|
|
"src": src, "src_not": src_not, "sport": sport, "dst": dst, "dst_not": dst_not, "dport": dport,
|
|
"floating": _text(rule, "floating") == "yes",
|
|
"quick": _text(rule, "quick") != "0",
|
|
"descr": _text(rule, "descr") or "",
|
|
"order": index,
|
|
}
|
|
|
|
|
|
def extract_rules(root: Element) -> list[list[dict]]:
|
|
"""Rule lists in evaluation order, one per source (MVC, legacy)."""
|
|
mvc = sorted((_mvc_rule(r) for r in root.findall("OPNsense/Firewall/Filter/rules/rule")), key=lambda r: r["order"])
|
|
legacy = [_legacy_rule(r, i) for i, r in enumerate(root.findall("filter/rule"))]
|
|
return [group for group in (mvc, legacy) if group]
|
|
|
|
|
|
def wan_interfaces(root: Element) -> set[str]:
|
|
"""Interfaces facing the outside: the one named wan*, or any with an upstream gateway."""
|
|
result = set()
|
|
for node in root.findall("interfaces/*"):
|
|
if node.tag.lower().startswith("wan") or _text(node, "gateway"):
|
|
result.add(node.tag)
|
|
return result
|
|
|
|
|
|
def _is_any_any(rule: dict) -> bool:
|
|
return (
|
|
rule["enabled"] and rule["action"] == "pass" and rule["src"] == "any" and not rule["src_not"]
|
|
and rule["dst"] == "any" and not rule["dst_not"] and rule["protocol"] == "any"
|
|
and not rule["sport"] and not rule["dport"]
|
|
)
|
|
|
|
|
|
|
|
def _covers(a: dict, b: dict) -> bool:
|
|
"""Every packet matched by rule b is already matched by the earlier rule a.
|
|
Only exact-or-any comparisons, so it never claims coverage it cannot prove."""
|
|
if not (a["enabled"] and b["enabled"]) or a["floating"] or b["floating"] or not a["quick"]:
|
|
return False
|
|
if not a["interfaces"] or not set(b["interfaces"]) <= set(a["interfaces"]):
|
|
return False
|
|
if a["direction"] != b["direction"] or a["ipprotocol"] != b["ipprotocol"]:
|
|
return False
|
|
if a["protocol"] != "any" and a["protocol"] != b["protocol"]:
|
|
return False
|
|
for side in ("src", "dst"):
|
|
if a[side + "_not"] or b[side + "_not"]:
|
|
if (a[side], a[side + "_not"]) != (b[side], b[side + "_not"]):
|
|
return False
|
|
elif a[side] != "any" and a[side] != b[side]:
|
|
return False
|
|
for port in ("sport", "dport"):
|
|
if a[port] and a[port] != b[port]:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _label(rule: dict) -> str:
|
|
return rule["descr"] or f"{rule['action']} {rule['src']} → {rule['dst']}"
|
|
|
|
|
|
def analyse_rules(groups: list[list[dict]]) -> dict:
|
|
shadowed, duplicates = [], []
|
|
for rules in groups:
|
|
for j, later in enumerate(rules):
|
|
for earlier in rules[:j]:
|
|
if _covers(earlier, later):
|
|
(duplicates if earlier["action"] == later["action"] and _covers(later, earlier) else shadowed).append(_label(later))
|
|
break
|
|
return {"shadowed": shadowed, "duplicates": duplicates}
|
|
|
|
|
|
# ── Checks ───────────────────────────────────────────────────────────────
|
|
|
|
def _finding(check_id: str, severity: str, passed: bool | None, **params) -> dict:
|
|
return {"id": check_id, "severity": severity, "passed": passed, "params": params}
|
|
|
|
|
|
def _listing(items: list[str]) -> dict:
|
|
return {"count": len(items), "examples": items[:MAX_EXAMPLES]}
|
|
|
|
|
|
def evaluate(xml_bytes: bytes, *, firmware_status: str | None, newest_backup_age_s: float | None,
|
|
unused_rules: dict | None = None) -> list[dict]:
|
|
if len(xml_bytes) > MAX_CONFIG_BYTES or not xml_bytes.strip():
|
|
raise ValueError("config.xml missing or too large")
|
|
root = safe_ET.fromstring(xml_bytes)
|
|
if root.tag != "opnsense":
|
|
raise ValueError("not an OPNsense config.xml")
|
|
findings: list[dict] = []
|
|
|
|
# Management access
|
|
protocol = _text(root, "system/webgui/protocol")
|
|
findings.append(_finding("webgui_https", "high", protocol == "https", protocol=protocol or "http"))
|
|
|
|
ssh = root.find("system/ssh")
|
|
ssh_enabled = ssh is not None and _text(ssh, "enabled") in ("enabled", "1")
|
|
root_login = ssh_enabled and _text(ssh, "permitrootlogin") == "1"
|
|
password_login = ssh_enabled and _text(ssh, "passwordauth") == "1"
|
|
if root_login and password_login:
|
|
findings.append(_finding("ssh_hardened", "high", False, reason="root_password"))
|
|
elif password_login:
|
|
findings.append(_finding("ssh_hardened", "medium", False, reason="password"))
|
|
else:
|
|
findings.append(_finding("ssh_hardened", "high", True))
|
|
|
|
community = _text(root, "snmpd/rocommunity")
|
|
if root.find("snmpd") is not None:
|
|
findings.append(_finding("snmp_community", "high", (community or "").lower() not in ("", "public")))
|
|
|
|
# Rules
|
|
wans = wan_interfaces(root)
|
|
groups = extract_rules(root)
|
|
all_rules = [rule for group in groups for rule in group]
|
|
wan_open = [_label(r) for r in all_rules if _is_any_any(r) and set(r["interfaces"]) & wans]
|
|
findings.append(_finding("wan_any_any", "critical", not wan_open, **_listing(wan_open)))
|
|
inner_open = [_label(r) for r in all_rules if _is_any_any(r) and r["interfaces"] and not set(r["interfaces"]) & wans]
|
|
findings.append(_finding("internal_any_any", "medium", not inner_open, **_listing(inner_open)))
|
|
analysis = analyse_rules(groups)
|
|
findings.append(_finding("rules_shadowed", "medium", not analysis["shadowed"], **_listing(analysis["shadowed"])))
|
|
findings.append(_finding("rules_duplicate", "low", not analysis["duplicates"], **_listing(analysis["duplicates"])))
|
|
undocumented = [f"{r['action']} {r['src']} → {r['dst']}" for r in all_rules if r["enabled"] and not r["descr"]]
|
|
findings.append(_finding("rules_documented", "low", not undocumented, **_listing(undocumented)))
|
|
|
|
# Usage (from the pf counters OPNexus has been sampling; None = no data yet)
|
|
if unused_rules is not None:
|
|
names = unused_rules["unused"]
|
|
findings.append(_finding("rules_unused", "low", None if (not names and unused_rules["collecting"]) else not names, **_listing(names)))
|
|
|
|
# Operations
|
|
findings.append(_finding("ntp_configured", "low", bool(_text(root, "system/timeservers"))))
|
|
syslog_targets = [d for d in root.findall("OPNsense/Syslog/destinations/destination") if _text(d, "enabled") != "0"]
|
|
findings.append(_finding("remote_logging", "low", bool(syslog_targets) or bool(_text(root, "syslog/remoteserver"))))
|
|
findings.append(_finding("ids_enabled", "info", _text(root, "OPNsense/IDS/general/enabled") == "1"))
|
|
|
|
# From what OPNexus already knows
|
|
findings.append(_finding("firmware_current", "medium", None if firmware_status is None else firmware_status != "update"))
|
|
if newest_backup_age_s is None:
|
|
findings.append(_finding("backup_recent", "medium", False, age_days=None))
|
|
else:
|
|
age_days = int(newest_backup_age_s // 86400)
|
|
findings.append(_finding("backup_recent", "medium", age_days < BACKUP_MAX_AGE_DAYS, age_days=age_days))
|
|
return findings
|
|
|
|
|
|
def score(findings: list[dict]) -> int:
|
|
return max(0, 100 - sum(PENALTY[f["severity"]] for f in findings if f["passed"] is False))
|
|
|
|
|
|
def counts(findings: list[dict]) -> dict:
|
|
result = {severity: 0 for severity in SEVERITY_ORDER}
|
|
for finding in findings:
|
|
if finding["passed"] is False:
|
|
result[finding["severity"]] += 1
|
|
return result
|
|
|
|
|
|
# ── Running it for a host ────────────────────────────────────────────────
|
|
|
|
async def check_host(db: AsyncSession, host: OpnsenseHost) -> ComplianceResult:
|
|
"""Download the config, evaluate, store (one row per host, replaced each time)."""
|
|
row = await db.scalar(select(ComplianceResult).where(ComplianceResult.host_id == host.id))
|
|
if row is None:
|
|
row = ComplianceResult(host_id=host.id)
|
|
db.add(row)
|
|
row.checked_at = datetime.now(timezone.utc)
|
|
try:
|
|
xml = await client.download_backup(host)
|
|
try:
|
|
firmware_status = (await client.get_firmware_status(host)).get("status")
|
|
except OpnsenseAPIError:
|
|
firmware_status = None
|
|
snapshots = backup_store.list_snapshots(host.id)
|
|
age = (datetime.now(timezone.utc) - snapshots[0]["created_at"]).total_seconds() if snapshots else None
|
|
try:
|
|
await rule_hit_service.sample_host(db, host)
|
|
except OpnsenseAPIError:
|
|
pass # usage data is a bonus; the check does not depend on it
|
|
# Untrusted XML, O(n^2) rule analysis: keep it off the event loop, and treat ANY parse failure as 'invalid'.
|
|
findings = await asyncio.to_thread(
|
|
evaluate, xml, firmware_status=firmware_status, newest_backup_age_s=age,
|
|
unused_rules=await rule_hit_service.unused_summary(db, host.id),
|
|
)
|
|
row.findings, row.score, row.error = findings, score(findings), None
|
|
except OpnsenseAPIError as exc:
|
|
row.findings, row.score, row.error = [], None, "unreachable"
|
|
row.error_detail = str(exc)[:300]
|
|
except Exception as exc: # ParseError is a SyntaxError, not a ValueError; one bad config must not stop the other hosts
|
|
row.findings, row.score, row.error = [], None, "invalid_config"
|
|
row.error_detail = f"{type(exc).__name__}: {exc}"[:300]
|
|
await db.commit()
|
|
return row
|
|
|
|
|
|
async def check_all(db: AsyncSession) -> int:
|
|
hosts = (await db.scalars(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)).order_by(OpnsenseHost.name))).all()
|
|
for host in hosts:
|
|
await check_host(db, host)
|
|
return len(hosts)
|