Files
OPNexus/backend/tests/test_audit_export.py
T
Julian Boldt b2c69f51a0
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.19.0
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.
2026-10-20 00:26:57 +02:00

240 lines
10 KiB
Python

"""Audit export: cursor semantics, wire formats (syslog UDP/TCP, signed webhook), admin-only settings."""
import asyncio
import hashlib
import hmac
import json
import socket
import threading
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import select
from app.database import SessionLocal
from app.models.audit import AuditEvent
from app.models.audit_export import AuditExportSettings
from app.services import audit_export_service as svc
from app.services import notification_secret_store
from tests.test_api_tokens import _login
async def _event(n: int, *, age_s: float = 60, status="accepted", details=None) -> AuditEvent:
async with SessionLocal() as db:
event = AuditEvent(
created_at=datetime.now(timezone.utc) - timedelta(seconds=age_s + (10 - n) * 0.001), correlation_id="c", actor="admin", kind="rule",
action="deploy", status=status, entity_name=f"rule-{n}", message=f"m{n}", details=details or {},
)
db.add(event)
await db.commit()
return event
async def _configure(**fields):
async with SessionLocal() as db:
row = await svc.get_settings(db)
for key, value in fields.items():
setattr(row, key, value)
await db.commit()
async def _run(lag=0):
async with SessionLocal() as db:
return await svc.run_once(db, lag_seconds=lag)
async def _state():
async with SessionLocal() as db:
return await svc.get_settings(db)
class _UdpReceiver:
def __init__(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(("127.0.0.1", 0))
self.sock.settimeout(2)
self.port = self.sock.getsockname()[1]
def receive(self, n):
return [self.sock.recvfrom(65535)[0].decode() for _ in range(n)]
class _TcpReceiver:
def __init__(self):
self.server = socket.socket()
self.server.bind(("127.0.0.1", 0))
self.server.listen(1)
self.port = self.server.getsockname()[1]
self.data = b""
self.thread = threading.Thread(target=self._serve, daemon=True)
self.thread.start()
def _serve(self):
conn, _ = self.server.accept()
while chunk := conn.recv(65535):
self.data += chunk
conn.close()
@pytest.fixture(autouse=True)
async def _reset(tmp_path):
async with SessionLocal() as db:
await db.execute(AuditExportSettings.__table__.delete())
await db.commit()
yield
@pytest.mark.asyncio
async def test_idle_when_nothing_is_enabled():
await _event(1)
assert await _run() == 0
assert (await _state()).cursor_created_at is None
@pytest.mark.asyncio
async def test_first_round_starts_from_now_without_replaying_history():
await _event(1, age_s=3600)
udp = _UdpReceiver()
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port)
assert await _run() == 0 # sets the cursor
assert (await _state()).cursor_created_at is not None
await asyncio.sleep(0.05)
await _event(2, age_s=0)
assert await _run() == 1
(line,) = udp.receive(1)
assert '"entity_name": "rule-2"' in line and "rule-1" not in line
@pytest.mark.asyncio
async def test_syslog_udp_line_is_rfc5424_with_severity_by_status_and_no_details():
udp = _UdpReceiver()
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port, cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1, status="accepted", details={"after_payload": {"password": "hunter2"}})
await _event(2, status="denied")
assert await _run() == 2
ok, bad = udp.receive(2)
assert ok.startswith("<134>1 ") and bad.startswith("<132>1 ") # local0.info / local0.warning
assert " opnexus - AUDIT - {" in ok
body = json.loads(ok.split(" - AUDIT - ", 1)[1])
assert body["actor"] == "admin" and body["kind"] == "rule" and "details" not in body
assert "hunter2" not in ok
@pytest.mark.asyncio
async def test_syslog_tcp_uses_octet_counting():
tcp = _TcpReceiver()
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=tcp.port, syslog_protocol="tcp",
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1)
assert await _run() == 1
tcp.thread.join(timeout=3)
length, _, rest = tcp.data.partition(b" ")
assert int(length) == len(rest) and rest.startswith(b"<134>1 ")
@pytest.mark.asyncio
async def test_failure_keeps_the_cursor_and_the_next_round_retries(respx_mock):
await _configure(webhook_enabled=True, webhook_url="https://hook.test/secret-token",
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1)
route = respx_mock.post("https://hook.test/secret-token").mock(return_value=httpx.Response(500))
assert await _run() == 0
state = await _state()
assert state.cursor_id is None and "HTTP 500" in state.last_error and "secret-token" not in state.last_error
route.mock(return_value=httpx.Response(200))
assert await _run() == 1
state = await _state()
assert state.last_error is None and state.last_success_at is not None and state.cursor_id is not None
assert await _run() == 0 # nothing new
@pytest.mark.asyncio
async def test_webhook_batch_is_signed(respx_mock):
notification_secret_store.save_secret(svc.SECRET_NAME, "s3cret")
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", webhook_signing_secret_set=True,
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1)
await _event(2)
route = respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
assert await _run() == 2
request = route.calls.last.request
assert request.headers["X-OPNexus-Signature"] == "sha256=" + hmac.new(b"s3cret", request.content, hashlib.sha256).hexdigest()
payload = json.loads(request.content)
assert [e["entity_name"] for e in payload["events"]] == ["rule-1", "rule-2"] and payload["source"] == "opnexus"
@pytest.mark.asyncio
async def test_missing_signing_secret_fails_closed(respx_mock):
notification_secret_store.delete_secret(svc.SECRET_NAME)
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", webhook_signing_secret_set=True,
cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1)
assert await _run() == 0 # respx would fail the test on any request: nothing was sent
assert "nicht lesbar" in (await _state()).last_error
@pytest.mark.asyncio
async def test_batches_are_ordered_and_resume_without_gaps_or_duplicates(respx_mock, monkeypatch):
monkeypatch.setattr(svc, "BATCH", 2)
await _configure(webhook_enabled=True, webhook_url="https://hook.test/in", cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
for n in range(1, 6):
await _event(n)
route = respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
assert [await _run(), await _run(), await _run(), await _run()] == [2, 2, 1, 0]
names = [e["entity_name"] for call in route.calls for e in json.loads(call.request.content)["events"]]
assert names == [f"rule-{n}" for n in range(1, 6)]
@pytest.mark.asyncio
async def test_very_fresh_events_wait_for_the_lag():
udp = _UdpReceiver()
await _configure(syslog_enabled=True, syslog_host="127.0.0.1", syslog_port=udp.port, cursor_created_at=datetime.now(timezone.utc) - timedelta(hours=1))
await _event(1, age_s=0.2)
assert await _run(lag=3) == 0
assert await _run(lag=0) == 1
# ── Settings API ─────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_settings_roundtrip_hides_the_secret_and_enabling_starts_from_now(client):
got = (await client.get("/api/settings/audit-export")).json()
assert got["syslog_enabled"] is False and got["webhook_signing_secret_set"] is False
put = await client.put("/api/settings/audit-export", json={
"syslog_enabled": True, "syslog_host": "127.0.0.1", "syslog_port": 5514, "syslog_protocol": "tcp",
"webhook_signing_secret": "shh"})
assert put.status_code == 200, put.text
body = put.json()
assert body["syslog_protocol"] == "tcp" and body["webhook_signing_secret_set"] is True and "shh" not in put.text
assert (await _state()).cursor_created_at is not None
cleared = (await client.put("/api/settings/audit-export", json={"clear_webhook_signing_secret": True, "syslog_enabled": False})).json()
assert cleared["webhook_signing_secret_set"] is False
assert (await _state()).cursor_created_at is None
@pytest.mark.asyncio
async def test_settings_validation(client):
for body in ({"syslog_protocol": "carrier-pigeon"}, {"syslog_port": 0}, {"webhook_url": "x" * 501}):
assert (await client.put("/api/settings/audit-export", json=body)).status_code == 422
@pytest.mark.asyncio
async def test_test_endpoint_needs_a_channel_and_reports_delivery_failures(client, respx_mock):
assert (await client.post("/api/settings/audit-export/test")).status_code == 502
await client.put("/api/settings/audit-export", json={"webhook_enabled": True, "webhook_url": "https://hook.test/in"})
respx_mock.post("https://hook.test/in").mock(return_value=httpx.Response(200))
assert (await client.post("/api/settings/audit-export/test")).json() == {"success": True}
@pytest.mark.asyncio
async def test_only_admins_may_read_or_change_it(client):
for role in ("operator", "read_only"):
name = f"exp-{role}"
assert (await client.post("/api/users", json={"username": name, "password": "export-password-1", "role": role})).status_code == 201
user = await _login(name, "export-password-1")
try:
assert (await user.get("/api/settings/audit-export")).status_code == 403
assert (await user.put("/api/settings/audit-export", json={"syslog_enabled": True})).status_code == 403
finally:
await user.aclose()