"""Tests der Sicherheitsgrenzen des Kerns. Jeder Test hier deckt eine Grenze ab, die im Vorgaengerprojekt entweder fehlte oder nur im Template durchgesetzt wurde. """ from __future__ import annotations import json import sqlite3 from datetime import datetime, timedelta, timezone import pytest from tesm_core import audit from tesm_core.db import Database, NestedConnectionError from tesm_core.extension import core from tesm_core.keystore import DecryptionError, Keystore from tesm_core.migrations import split_statements from tesm_core.rbac import service as rbac from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset from tesm_core.security import passwords, totp # --------------------------------------------------------------------------- # # Rechtebaum # --------------------------------------------------------------------------- # def _tree() -> PermissionTree: return PermissionTree( [ Area( key="ops", label="Betrieb", icon="cpu", resources=( Resource( "devices", "Geraete", (Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.EXECUTE), ), Resource("switches", "Switche", (Action.VIEW, Action.EDIT)), ), ), Area( key="admin", label="Verwaltung", icon="sliders", resources=(Resource("users", "Benutzer", (Action.VIEW, Action.CREATE)),), ), ] ) def test_area_columns_only_contain_used_actions(): tree = _tree() ops = tree.area("ops") admin = tree.area("admin") assert Action.EXECUTE in ops.columns() assert Action.EXECUTE not in admin.columns() assert Action.DELETE not in admin.columns() def test_sanitize_adds_view_and_area_switch(): tree = _tree() result = tree.sanitize(["devices.delete"]) assert result == ["devices.delete", "devices.view", "ops.view"] def test_sanitize_drops_unknown_permissions(): tree = _tree() assert tree.sanitize(["gibtsnicht.view", "devices.view"]) == ["devices.view", "ops.view"] def test_kill_switch_disables_all_children(): tree = _tree() granted = {"devices.view", "devices.edit", "switches.view"} assert tree.effective(granted) == set() # ohne ops.view greift nichts assert tree.effective(granted | {"ops.view"}) == granted | {"ops.view"} def test_duplicate_resource_keys_rejected(): with pytest.raises(ValueError): PermissionTree( [ Area("a", "A", "grid", (Resource("x", "X", (Action.VIEW,)),)), Area("b", "B", "grid", (Resource("x", "X", (Action.VIEW,)),)), ] ) def test_resource_without_view_rejected(): with pytest.raises(ValueError): PermissionTree([Area("a", "A", "grid", (Resource("x", "X", (Action.EDIT,)),))]) def test_preset_expansion(): tree = _tree() preset = RolePreset( key="ops-read", name="Betrieb lesen", description="", grants=(("devices", (Action.VIEW,)), ("switches", "*")), ) assert tree.expand_preset(preset) == [ "devices.view", "ops.view", "switches.edit", "switches.view", ] # --------------------------------------------------------------------------- # # Eskalationsschutz # --------------------------------------------------------------------------- # class _Actor: def __init__(self, is_admin: bool, permissions: set[str]): self.is_admin = is_admin self.id = 42 self._permissions = permissions def has(self, *permissions: str) -> bool: return self.is_admin or all(p in self._permissions for p in permissions) def test_no_amplification_blocks_unknown_permission(): tree = _tree() actor = _Actor(False, {"ops.view", "devices.view"}) with pytest.raises(rbac.PermissionDenied) as exc: rbac.guard_no_amplification(actor, tree, ["devices.delete"]) assert "nicht besitzen" in str(exc.value) def test_no_amplification_allows_own_permissions(): tree = _tree() actor = _Actor(False, {"ops.view", "devices.view"}) rbac.guard_no_amplification(actor, tree, ["devices.view", "ops.view"]) def test_admin_may_grant_anything(): tree = _tree() rbac.guard_no_amplification(_Actor(True, set()), tree, ["devices.delete"]) def test_group_editor_cannot_escalate_via_new_group(core_app, make_user): """Der zentrale Fall: ein Gruppenverwalter darf sich nicht selbst hoeherstufen.""" from tesm_core.auth.service import load_user extension = core(core_app) row = make_user("gruppenchef", "Ein-gutes-Passwort-1", permissions=("groups.create", "groups.view")) with core_app.app_context(), extension.database.session() as conn: actor = load_user(conn, int(row["id"])) assert actor is not None with extension.database.transaction(conn): with pytest.raises(rbac.PermissionDenied): rbac.create_group( conn, name="Hintertuer", description="", permissions=["users.create", "administration.view"], tree=extension.permissions, actor=actor, ) def test_last_admin_cannot_be_demoted(core_app, make_user): from tesm_core.auth.service import load_user extension = core(core_app) admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True) with core_app.app_context(), extension.database.session() as conn: actor = load_user(conn, int(admin_row["id"])) second = conn.execute( "INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, updated_at) " "VALUES ('zweiter','x',1,'local',datetime('now'),datetime('now'))" ) second_id = int(second.lastrowid or 0) with extension.database.transaction(conn): # Solange zwei Admins existieren, ist das Herabstufen erlaubt. rbac.update_user( conn, second_id, actor=actor, tree=extension.permissions, is_admin=False ) with extension.database.transaction(conn): with pytest.raises(rbac.PermissionDenied): rbac.guard_last_admin(conn, int(admin_row["id"]), action="Herabstufen") def test_cannot_change_own_admin_flag(core_app, make_user): from tesm_core.auth.service import load_user extension = core(core_app) row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True) with core_app.app_context(), extension.database.session() as conn: actor = load_user(conn, int(row["id"])) with extension.database.transaction(conn): with pytest.raises(rbac.PermissionDenied): rbac.update_user( conn, int(row["id"]), actor=actor, tree=extension.permissions, is_admin=False ) def test_non_admin_cannot_touch_admin_account(core_app, make_user): from tesm_core.auth.service import load_user extension = core(core_app) admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True) editor_row = make_user("helfer", "Ein-gutes-Passwort-1", permissions=("users.edit", "users.view")) with core_app.app_context(), extension.database.session() as conn: actor = load_user(conn, int(editor_row["id"])) with extension.database.transaction(conn): with pytest.raises(rbac.PermissionDenied): rbac.update_user( conn, int(admin_row["id"]), actor=actor, tree=extension.permissions, is_locked=True, ) def test_system_group_permissions_locked(core_app, make_user): from tesm_core.auth.service import load_user extension = core(core_app) row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True) with core_app.app_context(), extension.database.session() as conn: actor = load_user(conn, int(row["id"])) default = next(g for g in rbac.list_groups(conn) if g.is_default) with extension.database.transaction(conn): with pytest.raises(rbac.PermissionDenied): rbac.update_group( conn, default.id, permissions=[], tree=extension.permissions, actor=actor, ) # Mit ausdruecklicher Freischaltung geht es. rbac.update_group( conn, default.id, permissions=[], tree=extension.permissions, actor=actor, unlock_system=True, ) def test_presets_are_not_reset_on_restart(core_app): """Anders als im Vorgaenger bleiben Anpassungen an Systemgruppen erhalten.""" extension = core(core_app) with extension.database.session() as conn: default = next(g for g in rbac.list_groups(conn) if g.is_default) with extension.database.transaction(conn): conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default.id,)) created = rbac.ensure_presets(conn, extension.permissions, extension.role_presets) assert created == [] assert rbac.group_permissions(conn, default.id) == [] # --------------------------------------------------------------------------- # # Datenbank # --------------------------------------------------------------------------- # def test_second_connection_during_write_transaction_raises(tmp_path): database = Database(tmp_path / "test.db") with database.session() as conn: conn.execute("CREATE TABLE t (a INTEGER)") with database.transaction(conn): conn.execute("INSERT INTO t VALUES (1)") with pytest.raises(NestedConnectionError) as exc: database.connect() assert "Schreibtransaktion" in str(exc.value) def test_nested_transactions_commit_once(tmp_path): database = Database(tmp_path / "test.db") with database.session() as conn: conn.execute("CREATE TABLE t (a INTEGER)") with database.transaction(conn): conn.execute("INSERT INTO t VALUES (1)") with database.transaction(conn): conn.execute("INSERT INTO t VALUES (2)") assert Database.value(conn, "SELECT COUNT(*) FROM t") == 2 def test_inner_rollback_keeps_outer(tmp_path): database = Database(tmp_path / "test.db") with database.session() as conn: conn.execute("CREATE TABLE t (a INTEGER)") with database.transaction(conn): conn.execute("INSERT INTO t VALUES (1)") try: with database.transaction(conn): conn.execute("INSERT INTO t VALUES (2)") raise RuntimeError("abbruch") except RuntimeError: pass assert Database.value(conn, "SELECT COUNT(*) FROM t") == 1 def test_foreign_keys_enforced(tmp_path): database = Database(tmp_path / "test.db") with database.session() as conn: conn.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)") conn.execute("CREATE TABLE child (p INTEGER REFERENCES parent(id))") with pytest.raises(sqlite3.IntegrityError): conn.execute("INSERT INTO child VALUES (99)") def test_wal_mode_active(tmp_path): database = Database(tmp_path / "test.db") with database.session() as conn: assert Database.value(conn, "PRAGMA journal_mode").lower() == "wal" def test_split_statements_handles_comments_and_strings(): script = """ -- ein Kommentar mit ; Semikolon CREATE TABLE t (a TEXT DEFAULT 'x;y'); INSERT INTO t VALUES ('a;b'); /* Block; Kommentar */ """ statements = split_statements(script) assert len(statements) == 2 assert "x;y" in statements[0] assert "a;b" in statements[1] # --------------------------------------------------------------------------- # # Schluessel und Verschluesselung # --------------------------------------------------------------------------- # def test_keystore_roundtrip_and_aad_binding(tmp_path): keystore = Keystore(tmp_path / "data.keys") token = keystore.encrypt("geheim", aad="credentials.password:1") assert keystore.decrypt(token, aad="credentials.password:1") == "geheim" # Dasselbe Chiffrat an anderer Stelle eingespielt -> Fehler. with pytest.raises(DecryptionError): keystore.decrypt(token, aad="credentials.password:2") def test_keystore_rotation_keeps_old_values_readable(tmp_path): keystore = Keystore(tmp_path / "data.keys") old_token = keystore.encrypt("alt", aad="x") new_id = keystore.rotate() assert new_id == "2" assert keystore.decrypt(old_token, aad="x") == "alt" assert keystore.needs_reencrypt(old_token) is True new_token = keystore.encrypt("neu", aad="x") assert keystore.needs_reencrypt(new_token) is False def test_keystore_file_is_private(tmp_path): import os import stat path = tmp_path / "data.keys" Keystore(path) if os.name == "posix": mode = path.stat().st_mode assert not mode & (stat.S_IRGRP | stat.S_IROTH) def test_tampered_ciphertext_rejected(tmp_path): keystore = Keystore(tmp_path / "data.keys") token = keystore.encrypt("geheim", aad="x") prefix, key_id, nonce, ciphertext = token.split(".", 3) broken = f"{prefix}.{key_id}.{nonce}.{ciphertext[:-4]}AAAA" with pytest.raises(DecryptionError): keystore.decrypt(broken, aad="x") # --------------------------------------------------------------------------- # # Passwoerter und TOTP # --------------------------------------------------------------------------- # def test_argon2_hash_and_verify(): stored = passwords.hash_password("Ein-gutes-Passwort-1") assert stored.startswith("$argon2id$") assert passwords.verify_password(stored, "Ein-gutes-Passwort-1") is True assert passwords.verify_password(stored, "falsch") is False assert passwords.needs_rehash(stored) is False def test_bcrypt_legacy_hash_accepted_and_flagged_for_rehash(): bcrypt = pytest.importorskip("bcrypt") legacy = bcrypt.hashpw(b"Altes-Passwort-1", bcrypt.gensalt(rounds=4)).decode() assert passwords.is_legacy_hash(legacy) is True assert passwords.verify_password(legacy, "Altes-Passwort-1") is True assert passwords.needs_rehash(legacy) is True def test_password_normalisation_across_platforms(): import unicodedata nfd = unicodedata.normalize("NFD", "Grüße-aus-München-1") nfc = unicodedata.normalize("NFC", "Grüße-aus-München-1") assert nfd != nfc stored = passwords.hash_password(nfd) assert passwords.verify_password(stored, nfc) is True def test_password_policy_rules(): policy = passwords.PasswordPolicy(min_length=12) assert policy.check("Ein-gutes-Passwort-1") == [] assert any("Zeichen erforderlich" in p for p in policy.check("kurz")) assert any("Zeichenarten" in p for p in policy.check("aaaaaaaaaaaaaaa")) assert any("Benutzernamen" in p for p in policy.check("Tester-Passwort-1", username="tester")) assert any("haeufig" in p for p in policy.check("passwort1234")) def test_totp_generates_and_verifies(): secret = totp.generate_secret() code = totp.current_code(secret) assert totp.verify_code(secret, code) is True assert totp.verify_code(secret, "000000") is False assert totp.verify_code(secret, code, now=0) is False def test_totp_drift_tolerance(): secret = totp.generate_secret() now = 1_700_000_000 previous = totp.code_at(secret, int(now // totp.PERIOD) - 1) assert totp.verify_code(secret, previous, now=now) is True far = totp.code_at(secret, int(now // totp.PERIOD) - 5) assert totp.verify_code(secret, far, now=now) is False def test_totp_uri_contains_issuer_and_secret(): secret = totp.generate_secret() uri = totp.provisioning_uri(secret, account="anna", issuer="TESM Test") assert uri.startswith("otpauth://totp/") assert secret in uri assert "issuer=TESM%20Test" in uri # --------------------------------------------------------------------------- # # Aenderungsprotokoll # --------------------------------------------------------------------------- # def test_audit_chain_detects_modification(core_app): extension = core(core_app) with extension.database.session() as conn: with extension.database.transaction(conn): for index in range(5): audit.log(conn, actor="tester", action="test.event", target=f"ziel-{index}") assert audit.verify_chain(conn)["ok"] is True with extension.database.transaction(conn): conn.execute("UPDATE audit_log SET detail='manipuliert' WHERE id=3") result = audit.verify_chain(conn) assert result["ok"] is False assert result["broken_at"] == 3 assert "Hash" in result["reason"] def test_audit_chain_detects_deletion(core_app): extension = core(core_app) with extension.database.session() as conn: with extension.database.transaction(conn): for index in range(5): audit.log(conn, actor="tester", action="test.event", target=str(index)) with extension.database.transaction(conn): conn.execute("DELETE FROM audit_log WHERE id=3") result = audit.verify_chain(conn) assert result["ok"] is False def test_audit_archiving_moves_full_days(core_app, tmp_path): extension = core(core_app) old_day = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0) with extension.database.session() as conn: with extension.database.transaction(conn): for index in range(40): audit.log( conn, actor="tester", action="test.old", target=str(index), ts=old_day + timedelta(seconds=index), ) for index in range(5): audit.log(conn, actor="tester", action="test.new", target=str(index)) with extension.database.transaction(conn): written = audit.archive_old_rows(conn, tmp_path / "archiv", threshold=10, target=5) assert written # Uebrig bleiben ausschliesslich Zeilen des laufenden Tages (inkl. der # Startzeile, die die App beim Migrieren selbst geschrieben hat). today = datetime.now(timezone.utc).date().isoformat() remaining = Database.all(conn, "SELECT DISTINCT substr(ts,1,10) AS day FROM audit_log") assert [row["day"] for row in remaining] == [today] lines = (tmp_path / "archiv" / written[0].name).read_text(encoding="utf-8").splitlines() assert len(lines) == 40 assert json.loads(lines[0])["action"] == "test.old" # --------------------------------------------------------------------------- # # Sitzungen # --------------------------------------------------------------------------- # def test_session_id_rotates_on_login(client, make_user, login): make_user("anna", "Ein-gutes-Passwort-1") client.get("/login") before = client.get_cookie("tesm_session") login("anna", "Ein-gutes-Passwort-1") after = client.get_cookie("tesm_session") assert after is not None if before is not None: assert before.value != after.value def test_session_revocation_takes_effect_immediately(client, core_app, make_user, login): from tesm_core.security import sessions as session_store row = make_user("anna", "Ein-gutes-Passwort-1") login("anna", "Ein-gutes-Passwort-1") assert client.get("/konto/").status_code == 200 extension = core(core_app) with extension.database.session() as conn, extension.database.transaction(conn): session_store.revoke_all_for_user(conn, int(row["id"]), reason="test") response = client.get("/konto/") assert response.status_code == 302 assert "/login" in response.headers["Location"] def test_password_change_revokes_other_sessions(core_app, make_user): from tesm_core.security import sessions as session_store extension = core(core_app) row = make_user("anna", "Ein-gutes-Passwort-1") first = core_app.test_client() second = core_app.test_client() for client_instance in (first, second): page = client_instance.get("/login") body = page.get_data(as_text=True) marker = 'name="csrf_token" value="' start = body.index(marker) + len(marker) token = body[start : body.index('"', start)] client_instance.post( "/login", data={"username": "anna", "password": "Ein-gutes-Passwort-1", "csrf_token": token}, follow_redirects=True, ) with extension.database.session() as conn: assert len(session_store.active_for_user(conn, int(row["id"]))) == 2 page = first.get("/konto/sicherheit") body = page.get_data(as_text=True) marker = 'name="csrf_token" value="' start = body.index(marker) + len(marker) token = body[start : body.index('"', start)] first.post( "/konto/passwort", data={ "current_password": "Ein-gutes-Passwort-1", "new_password": "Neues-Passwort-2026", "repeat_password": "Neues-Passwort-2026", "csrf_token": token, }, follow_redirects=True, ) assert second.get("/konto/").status_code == 302 assert first.get("/konto/").status_code == 200 def test_reauth_required_for_sensitive_action(client, make_user, login, csrf_token): make_user("anna", "Ein-gutes-Passwort-1") login("anna", "Ein-gutes-Passwort-1") # Frisch angemeldet gilt die Bestaetigung -- danach kuenstlich altern lassen. from flask import session as flask_session with client.session_transaction() as sess: sess["_reauth_at"] = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat( timespec="seconds" ) token = csrf_token("/konto/sicherheit") response = client.post("/konto/zwei-faktor/starten", data={"csrf_token": token}) assert response.status_code == 302 assert "/bestaetigen" in response.headers["Location"] assert flask_session is not None # --------------------------------------------------------------------------- # # Der Rechteweg muss tatsaechlich begehbar sein # --------------------------------------------------------------------------- # def test_units_do_not_disable_the_privilege_path(): """``NoNewPrivileges`` schaltet setuid ab -- und damit sudo, und damit den Helfer. Auf beiden Testhosts stand "Helfer erreichbar", waehrend in Wahrheit jede Systemaktion an ``sudo: the "no new privileges" flag is set`` scheiterte: nginx uebernehmen, Netzwerk aendern, Zertifikat anfordern, Kea schreiben. Die Haertung hatte genau den Mechanismus abgeschaltet, den sie schuetzen sollte. """ from pathlib import Path root = Path(__file__).resolve().parent.parent / "deploy" / "systemd" units = list(root.glob("*.service")) assert units for unit in units: text = unit.read_text(encoding="utf-8") assert "NoNewPrivileges=yes" not in text, ( f"{unit.name}: verhindert sudo und damit jede Systemaktion." ) # Gemessen: auch ein eingeschraenktes Bounding Set bricht sudo mit # "unable to change to root gid" -- inklusive CapabilityBoundingSet=CAP_NET_RAW. for line in text.splitlines(): if line.startswith("CapabilityBoundingSet="): raise AssertionError( f"{unit.name}: {line!r} nimmt sudo CAP_SETUID/CAP_SETGID." ) # Die eigentliche Grenze bleibt bestehen. # "strict" sperrt auch den Helfer aus /etc aus -- gemessen. assert "ProtectSystem=full" in text, unit.name assert "ProtectSystem=strict" not in text, ( f"{unit.name}: strict sperrt den Helfer aus /etc aus." ) assert "PrivateTmp=yes" in text, unit.name assert "User=root" not in text, unit.name def test_the_helper_is_probed_not_just_looked_for(): """"Datei liegt da" ist keine Auskunft darueber, ob der Aufruf gelingt.""" import inspect from tesm_core.sysops import SysOps assert hasattr(SysOps, "selftest") source = inspect.getsource(SysOps.selftest) assert 'self.run("service-status", "nginx")' in source assert 'self.run("nginx-test")' not in source, ( "Ein fehlgeschlagenes nginx -t heisst 'Konfiguration kaputt', nicht 'Helfer kaputt'." ) # Die beiden Fehlerbilder, die im Betrieb wirklich vorkommen, werden benannt. assert "no new privileges" in source assert "a password is required" in source def test_selftest_explains_a_blocked_sudo(monkeypatch): from tesm_core.sysops import Result, SysOps class Blocked(SysOps): @property def available(self) -> bool: return True def run(self, verb, *args, stdin="", timeout=None): return Result( False, "", 'sudo: The "no new privileges" flag is set, which prevents sudo', 1 ) result = Blocked(app_key="tesm").selftest() assert not result.ok assert "NoNewPrivileges" in result.message assert "systemctl" in result.message def test_selftest_reports_a_missing_sudoers_rule(): from tesm_core.sysops import Result, SysOps class NeedsPassword(SysOps): @property def available(self) -> bool: return True def run(self, verb, *args, stdin="", timeout=None): return Result(False, "", "sudo: a password is required", 1) result = NeedsPassword(app_key="tesm").selftest() assert not result.ok assert "sudoers" in result.message def test_selftest_says_so_when_the_helper_is_absent(tmp_path): from tesm_core.sysops import SysOps result = SysOps(app_key="tesm", helper=tmp_path / "gibt-es-nicht").selftest() assert not result.ok assert "fehlt" in result.message def test_authenticate_keeps_directory_groups(core_app, monkeypatch): """Die Verzeichnisgruppen duerfen auf dem Weg nach oben nicht verlorengehen. ``_verify_credentials`` hat sie korrekt geliefert, ``authenticate`` hat aus dem Ergebnis aber ein neues Objekt gebaut und das Feld dabei fallen lassen. Sichtbar war davon nichts: die Anmeldung gelang, nur band sich keine Freigabe mehr ein. Genau solche stillen Verluste faengt dieser Test. """ from tesm_core.auth import directory, service from tesm_core.extension import core profil = directory.DirectoryProfile( username="mitarbeiter", dn="CN=mitarbeiter,OU=Benutzer,DC=firma,DC=local", group_dns=("CN=GG_Technik,OU=Gruppen,DC=firma,DC=local",), ) monkeypatch.setattr(directory, "is_enabled", lambda conn: True) monkeypatch.setattr( directory, "authenticate", lambda conn, name, pw: directory.DirectoryResult("ok", profile=profil) ) with core_app.test_request_context("/login"): with core(core_app).database.session() as conn: outcome = service.authenticate(conn, "mitarbeiter", "egal", ip="127.0.0.1") assert outcome.ok, outcome.message assert outcome.group_dns == profil.group_dns, "die Gruppen sind unterwegs verlorengegangen"