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
Backups, Governance, Regelnutzung. Auf einem Test-Stack mit drei Test-Firewalls geprüft; die Vollsicherung Ende-zu-Ende (sichern, Schaden anrichten, zurückspielen), siehe Handbuch „Konfigurationssicherungen“, „Sichern und Wiederherstellen“. - **Rolle „Operator“**: rollt aus und betreibt (Regeln, NAT, VPN, Wartung, Sicherungen), verwaltet aber keine Benutzer, Einstellungen oder Hosts und lädt keine Roh-Backups herunter. Abgelehnte Versuche stehen im Audit. Tokens eines Operators gehen bis zum Bereich „Ausrollen“. - **Audit-Export** (Einstellungen): Syslog (RFC 5424, UDP/TCP/TLS) und/oder signierter Webhook, mindestens einmal zugestellt, ohne Objektinhalte. Neue Tabelle `audit_export_settings` (Migration `0010`). - **Geplante Backups** mit Aufbewahrung, **verschlüsseltem Offsite-Ziel** (S3-kompatibel, AES-256-GCM, Passphrase bleibt auf dem Server), Benachrichtigungen bei Problemen und einer **Änderungsansicht** zwischen zwei Sicherungen (Geheimnisse nur als Hash). Neue Tabellen `backup_settings`, `backup_uploads`, Spalte `notification_settings.backup_failed_enabled` (Migration `0011`). Die S3-Signatur ist gegen die offiziellen AWS-Beispiele geprüft; ein Lauf gegen einen echten Anbieter steht noch aus. - **Vollsicherung** `opnexus backup --full` und `opnexus restore`: Datenbank und alle Geheimnis-Volumes in einer verschlüsselten Datei; Ende-zu-Ende auf dem Test-Stack geprüft (Sichern, Schaden anrichten, Zurückspielen). - **Regelnutzung**: wann hat jede Regel zuletzt etwas getroffen (aus den pf-Zählern, überlebt Neuladen und Neustart); Befund „Regeln in Benutzung“ in der Sicherheitslage nach 30 Tagen ohne Treffer. Neue Tabelle `rule_hits` (Migration `0012`). - Änderungen an Einstellungen (`/api/settings/…`) und das Auslösen von Sicherungen stehen jetzt im Audit.
56 lines
2.3 KiB
Python
56 lines
2.3 KiB
Python
"""Readable, redacted diff between two stored config backups of the same host.
|
||
|
||
Both files are parsed (defusedxml), the volatile ``revision`` block is dropped, and every leaf that
|
||
looks like a secret (password, key, token, PSK, community...) or is a long blob (certificates,
|
||
private keys) is replaced by a short hash. A changed secret therefore still shows up as a change,
|
||
but its value never leaves the server. Output is a unified diff, capped in size.
|
||
"""
|
||
import difflib
|
||
import hashlib
|
||
import re
|
||
from xml.etree.ElementTree import Element, indent, tostring
|
||
|
||
import defusedxml.ElementTree as safe_ET
|
||
|
||
MAX_LINES = 3000
|
||
MAX_BYTES = 32 * 1024 * 1024
|
||
_SECRET = re.compile(r"password|passwd|psk|secret|key|passphrase|token|prv|hash|community|bindpw|privpass", re.I)
|
||
_HARMLESS_SUFFIX = re.compile(r"(len|length|type|size|bits|file|name)$", re.I)
|
||
_BLOB = 120
|
||
|
||
|
||
class InvalidConfig(ValueError):
|
||
pass
|
||
|
||
|
||
def _redact(root: Element) -> None:
|
||
for el in root.iter():
|
||
if len(el) == 0 and el.text and el.text.strip():
|
||
text = el.text.strip()
|
||
if (_SECRET.search(el.tag) and not _HARMLESS_SUFFIX.search(el.tag)) or len(text) > _BLOB:
|
||
el.text = "‹sha256:" + hashlib.sha256(text.encode()).hexdigest()[:12] + "›"
|
||
|
||
|
||
def normalise(xml_bytes: bytes) -> list[str]:
|
||
if not xml_bytes.strip() or len(xml_bytes) > MAX_BYTES:
|
||
raise InvalidConfig("leer oder zu groß")
|
||
try:
|
||
root = safe_ET.fromstring(xml_bytes)
|
||
except Exception as exc: # defusedxml raises several parse/forbidden-entity types
|
||
raise InvalidConfig(type(exc).__name__) from None
|
||
if root.tag != "opnsense":
|
||
raise InvalidConfig("keine OPNsense-Konfiguration")
|
||
for volatile in root.findall("revision"):
|
||
root.remove(volatile)
|
||
_redact(root)
|
||
indent(root, space=" ")
|
||
return tostring(root, encoding="unicode").splitlines()
|
||
|
||
|
||
def diff(old: bytes, new: bytes) -> dict:
|
||
a, b = normalise(old), normalise(new)
|
||
lines = list(difflib.unified_diff(a, b, "älter", "neuer", lineterm="", n=2))
|
||
added = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
|
||
removed = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
|
||
return {"identical": not lines, "added": added, "removed": removed, "truncated": len(lines) > MAX_LINES, "lines": lines[:MAX_LINES]}
|