diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7674241..2be39bf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(docker exec:*)", "Bash(python3:*)", "Bash(docker-compose restart:*)", - "Bash(docker-compose build:*)" + "Bash(docker-compose build:*)", + "Bash(docker restart:*)", + "Bash(docker network inspect:*)" ], "deny": [] } diff --git a/JOURNAL.md b/JOURNAL.md index 59a5731..17a4141 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -329,4 +329,30 @@ Die Session-Daten werden erst gefüllt, wenn der License Server API implementier - OpenPyXL für Excel-Export - CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität - Automatische Spaltenbreite in Excel -- BOM für UTF-8 CSV (Excel-Kompatibilität) \ No newline at end of file +- BOM für UTF-8 CSV (Excel-Kompatibilität) + +### 2025-01-06 - Audit-Log implementiert +- Vollständiges Änderungsprotokoll für alle Aktionen +- Filterbare Übersicht mit Pagination +- Detaillierte Anzeige von Änderungen + +**Neue Features:** +- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP +- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT +- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen +- **Filter-Optionen**: Nach Benutzer, Aktion und Entität +- **Detail-Anzeige**: Aufklappbare Änderungsdetails +- **Navigation**: Audit-Link in allen Templates + +**Geänderte/Neue Dateien:** +- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes) +- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route) +- v2_adminpanel/templates/audit_log.html (neu erstellt) +- Alle Templates (Audit-Navigation hinzugefügt) + +**Technische Details:** +- JSONB für strukturierte Datenspeicherung +- Performance-Indizes auf timestamp, username und entity +- Farbcodierung für verschiedene Aktionen +- 50 Einträge pro Seite mit Pagination +- IP-Adresse und User-Agent Tracking \ No newline at end of file diff --git a/v2/cookies.txt b/v2/cookies.txt new file mode 100644 index 0000000..0dbc954 --- /dev/null +++ b/v2/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 1751984506 session NYjsxmcb81rBYjc17nkaARMbfQAgYxkTegLkLrxWfmM diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py index 58d8016..97cbeed 100644 --- a/v2_adminpanel/app.py +++ b/v2_adminpanel/app.py @@ -1,5 +1,6 @@ import os import psycopg2 +from psycopg2.extras import Json from flask import Flask, render_template, request, redirect, session, url_for, send_file from flask_session import Session from functools import wraps @@ -7,6 +8,7 @@ from dotenv import load_dotenv import pandas as pd from datetime import datetime import io +import json load_dotenv() @@ -39,6 +41,37 @@ def get_connection(): conn.set_client_encoding('UTF8') return conn +# Audit-Log-Funktion +def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): + """Protokolliert Änderungen im Audit-Log""" + conn = get_connection() + cur = conn.cursor() + + try: + username = session.get('username', 'system') + ip_address = request.remote_addr if request else None + user_agent = request.headers.get('User-Agent') if request else None + + # Konvertiere Dictionaries zu JSONB + old_json = Json(old_values) if old_values else None + new_json = Json(new_values) if new_values else None + + cur.execute(""" + INSERT INTO audit_log + (username, action, entity_type, entity_id, old_values, new_values, + ip_address, user_agent, additional_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (username, action, entity_type, entity_id, old_json, new_json, + ip_address, user_agent, additional_info)) + + conn.commit() + except Exception as e: + print(f"Audit log error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": @@ -55,6 +88,7 @@ def login(): (username == admin2_user and password == admin2_pass)): session['logged_in'] = True session['username'] = username + log_audit('LOGIN', 'user', additional_info=f"Erfolgreiche Anmeldung") return redirect(url_for('dashboard')) else: return render_template("login.html", error="Ungültige Anmeldedaten") @@ -63,6 +97,8 @@ def login(): @app.route("/logout") def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") session.pop('logged_in', None) session.pop('username', None) return redirect(url_for('login')) @@ -190,9 +226,22 @@ def create_license(): cur.execute(""" INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active) VALUES (%s, %s, %s, %s, %s, TRUE) + RETURNING id """, (license_key, customer_id, license_type, valid_from, valid_until)) + license_id = cur.fetchone()[0] conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': name, + 'customer_email': email, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until + }) cur.close() conn.close() @@ -290,6 +339,13 @@ def edit_license(license_id): cur = conn.cursor() if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + # Update license license_key = request.form["license_key"] license_type = request.form["license_type"] @@ -305,6 +361,24 @@ def edit_license(license_id): """, (license_key, license_type, valid_from, valid_until, is_active, license_id)) conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active + }) + cur.close() conn.close() @@ -334,9 +408,28 @@ def delete_license(license_id): conn = get_connection() cur = conn.cursor() + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + cur.close() conn.close() @@ -418,6 +511,10 @@ def edit_customer(customer_id): cur = conn.cursor() if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + # Update customer name = request.form["name"] email = request.form["email"] @@ -429,6 +526,18 @@ def edit_customer(customer_id): """, (name, email, customer_id)) conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1] + }, + new_values={ + 'name': name, + 'email': email + }) + cur.close() conn.close() @@ -477,10 +586,23 @@ def delete_customer(customer_id): conn.close() return redirect("/customers") + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + # Kunde löschen wenn keine Lizenzen vorhanden cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + cur.close() conn.close() @@ -588,6 +710,10 @@ def export_licenses(): # Export Format export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") filename = f'lizenzen_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}' if export_format == 'csv': @@ -665,6 +791,10 @@ def export_customers(): # Export Format export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") filename = f'kunden_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}' if export_format == 'csv': @@ -708,5 +838,78 @@ def export_customers(): download_name=f'{filename}.xlsx' ) +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 50 + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + username=session.get('username')) + if __name__ == "__main__": app.run(host="0.0.0.0", port=443, ssl_context='adhoc') diff --git a/v2_adminpanel/init.sql b/v2_adminpanel/init.sql index d27099f..a23ae53 100644 --- a/v2_adminpanel/init.sql +++ b/v2_adminpanel/init.sql @@ -29,3 +29,23 @@ CREATE TABLE IF NOT EXISTS sessions ( ended_at TIMESTAMP, is_active BOOLEAN DEFAULT TRUE ); + +-- Audit-Log-Tabelle für Änderungsprotokolle +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + username TEXT NOT NULL, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + old_values JSONB, + new_values JSONB, + ip_address TEXT, + user_agent TEXT, + additional_info TEXT +); + +-- Index für bessere Performance bei Abfragen +CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC); +CREATE INDEX idx_audit_log_username ON audit_log(username); +CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id); diff --git a/v2_adminpanel/templates/audit_log.html b/v2_adminpanel/templates/audit_log.html new file mode 100644 index 0000000..545f838 --- /dev/null +++ b/v2_adminpanel/templates/audit_log.html @@ -0,0 +1,235 @@ + + + + + Audit-Log - Admin Panel + + + + + + +
+
+

📋 Audit-Log

+
+ 📊 Dashboard + 📋 Lizenzen + 👥 Kunden + 🟢 Sessions +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Zurücksetzen +
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + {% endfor %} + +
ZeitstempelBenutzerAktionEntitätDetailsIP-Adresse
{{ log[1].strftime('%d.%m.%Y %H:%M:%S') }}{{ log[2] }} + + {% if log[3] == 'CREATE' %}➕ Erstellt + {% elif log[3] == 'UPDATE' %}✏️ Bearbeitet + {% elif log[3] == 'DELETE' %}🗑️ Gelöscht + {% elif log[3] == 'LOGIN' %}🔑 Anmeldung + {% elif log[3] == 'LOGOUT' %}🚪 Abmeldung + {% elif log[3] == 'EXPORT' %}📥 Export + {% else %}{{ log[3] }} + {% endif %} + + + {{ log[4] }} + {% if log[5] %} + #{{ log[5] }} + {% endif %} + + {% if log[10] %} +
{{ log[10] }}
+ {% endif %} + + {% if log[6] and log[3] == 'DELETE' %} +
+ Gelöschte Werte +
+ {% for key, value in log[6].items() %} + {{ key }}: {{ value }}
+ {% endfor %} +
+
+ {% elif log[6] and log[7] and log[3] == 'UPDATE' %} +
+ Änderungen anzeigen +
+ Vorher:
+ {% for key, value in log[6].items() %} + {% if log[7][key] != value %} + {{ key }}: {{ value }}
+ {% endif %} + {% endfor %} +
+ Nachher:
+ {% for key, value in log[7].items() %} + {% if log[6][key] != value %} + {{ key }}: {{ value }}
+ {% endif %} + {% endfor %} +
+
+ {% elif log[7] and log[3] == 'CREATE' %} +
+ Erstellte Werte +
+ {% for key, value in log[7].items() %} + {{ key }}: {{ value }}
+ {% endfor %} +
+
+ {% endif %} +
+ {{ log[8] or '-' }} +
+ + {% if not logs %} +
+

Keine Audit-Log-Einträge gefunden.

+
+ {% endif %} +
+ + + {% if total_pages > 1 %} + + {% endif %} +
+
+
+ + + + \ No newline at end of file diff --git a/v2_adminpanel/templates/customers.html b/v2_adminpanel/templates/customers.html index d5cd092..98b485b 100644 --- a/v2_adminpanel/templates/customers.html +++ b/v2_adminpanel/templates/customers.html @@ -24,6 +24,7 @@ ➕ Neue Lizenz 📋 Lizenzen 🟢 Sessions + 📋 Audit
diff --git a/v2_adminpanel/templates/edit_customer.html b/v2_adminpanel/templates/edit_customer.html index a81e1b6..48230cc 100644 --- a/v2_adminpanel/templates/edit_customer.html +++ b/v2_adminpanel/templates/edit_customer.html @@ -19,7 +19,14 @@

Kunde bearbeiten

- ← Zurück zur Übersicht +
+ 📈 Dashboard + ➕ Neue Lizenz + 📋 Lizenzen + 👥 Kunden + 🟢 Sessions + 📋 Audit +
diff --git a/v2_adminpanel/templates/edit_license.html b/v2_adminpanel/templates/edit_license.html index 4fe4b7d..b2c2a02 100644 --- a/v2_adminpanel/templates/edit_license.html +++ b/v2_adminpanel/templates/edit_license.html @@ -19,7 +19,14 @@
diff --git a/v2_adminpanel/templates/index.html b/v2_adminpanel/templates/index.html index f6b2e79..930365e 100644 --- a/v2_adminpanel/templates/index.html +++ b/v2_adminpanel/templates/index.html @@ -24,6 +24,7 @@ 📋 Lizenzen 👥 Kunden 🟢 Sessions + 📋 Audit
diff --git a/v2_adminpanel/templates/licenses.html b/v2_adminpanel/templates/licenses.html index add47dc..86287da 100644 --- a/v2_adminpanel/templates/licenses.html +++ b/v2_adminpanel/templates/licenses.html @@ -29,6 +29,7 @@ ➕ Neue Lizenz 👥 Kunden 🟢 Sessions + 📋 Audit
diff --git a/v2_testing/test_audit_json.py b/v2_testing/test_audit_json.py new file mode 100644 index 0000000..84873c9 --- /dev/null +++ b/v2_testing/test_audit_json.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +import requests +import urllib3 +import subprocess +import json + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +base_url = "https://localhost:443" +admin_user = {"username": "rac00n", "password": "1248163264"} + +def login(session): + """Login to admin panel""" + login_data = { + "username": admin_user["username"], + "password": admin_user["password"] + } + response = session.post(f"{base_url}/login", data=login_data, verify=False, allow_redirects=False) + return response.status_code == 302 + +def test_json_logging(): + """Test JSON value logging in audit log""" + session = requests.Session() + login(session) + + print("Testing JSON Value Storage in Audit Log") + print("=" * 50) + + # 1. Create a license (should log new_values as JSON) + print("\n1. Creating license to test JSON logging...") + license_data = { + "customer_name": "JSON Test GmbH", + "email": "json@test.de", + "license_key": "JSON-TEST-KEY", + "license_type": "full", + "valid_from": "2025-01-01", + "valid_until": "2025-12-31" + } + response = session.post(f"{base_url}/create", data=license_data, verify=False, allow_redirects=False) + print("✓ License created") + + # 2. Get the license ID + result = subprocess.run([ + "docker", "exec", "db", "psql", "-U", "adminuser", "-d", "meinedatenbank", "-t", + "-c", "SELECT id FROM licenses WHERE license_key = 'JSON-TEST-KEY';" + ], capture_output=True, text=True) + license_id = result.stdout.strip() + + if license_id: + # 3. Edit the license (should log both old_values and new_values) + print("\n2. Editing license to test old/new JSON values...") + + # First get the edit page to ensure we have the right form + response = session.get(f"{base_url}/license/edit/{license_id}", verify=False) + + # Now update + updated_data = { + "license_key": "JSON-TEST-UPDATED", + "license_type": "test", + "valid_from": "2025-01-01", + "valid_until": "2025-06-30", + "is_active": "on" + } + response = session.post(f"{base_url}/license/edit/{license_id}", + data=updated_data, + verify=False, + allow_redirects=False) + print("✓ License updated") + + # 4. Check the audit log for JSON values + print("\n3. Checking audit log for JSON values...") + result = subprocess.run([ + "docker", "exec", "db", "psql", "-U", "adminuser", "-d", "meinedatenbank", + "-c", """SELECT action, entity_type, + CASE WHEN old_values IS NULL THEN 'NULL' + ELSE jsonb_pretty(old_values) END as old_vals, + CASE WHEN new_values IS NULL THEN 'NULL' + ELSE jsonb_pretty(new_values) END as new_vals + FROM audit_log + WHERE entity_type IN ('license', 'customer') + AND (old_values IS NOT NULL OR new_values IS NOT NULL) + ORDER BY timestamp DESC + LIMIT 5;""" + ], capture_output=True, text=True) + + print(result.stdout) + + # 5. Test specific JSON queries + print("\n4. Testing JSON queries...") + + # Query for specific license key in new_values + result = subprocess.run([ + "docker", "exec", "db", "psql", "-U", "adminuser", "-d", "meinedatenbank", "-t", + "-c", """SELECT COUNT(*) + FROM audit_log + WHERE new_values->>'license_key' LIKE 'JSON%';""" + ], capture_output=True, text=True) + + count = int(result.stdout.strip()) + if count > 0: + print(f"✓ Found {count} entries with JSON license keys") + + # Query for updates (where both old and new values exist) + result = subprocess.run([ + "docker", "exec", "db", "psql", "-U", "adminuser", "-d", "meinedatenbank", "-t", + "-c", """SELECT COUNT(*) + FROM audit_log + WHERE old_values IS NOT NULL + AND new_values IS NOT NULL;""" + ], capture_output=True, text=True) + + update_count = int(result.stdout.strip()) + print(f"✓ Found {update_count} UPDATE entries with both old and new values") + + # 6. Clean up test data + print("\n5. Cleaning up test data...") + subprocess.run([ + "docker", "exec", "db", "psql", "-U", "adminuser", "-d", "meinedatenbank", + "-c", "DELETE FROM licenses WHERE license_key LIKE 'JSON%';" + ], capture_output=True) + print("✓ Test data cleaned up") + +# Run the test +test_json_logging() \ No newline at end of file diff --git a/v2_testing/test_audit_log.py b/v2_testing/test_audit_log.py new file mode 100644 index 0000000..4dfdfdf --- /dev/null +++ b/v2_testing/test_audit_log.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +import requests +import urllib3 +import subprocess +import time +import json + +# Disable SSL warnings +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +base_url = "https://localhost:443" +admin_user = {"username": "rac00n", "password": "1248163264"} + +def login(session): + """Login to admin panel""" + login_data = { + "username": admin_user["username"], + "password": admin_user["password"] + } + response = session.post(f"{base_url}/login", data=login_data, verify=False, allow_redirects=False) + return response.status_code == 302 + +def test_audit_table(): + """Test if audit_log table exists""" + print("1. Checking Audit Log Table:") + print("-" * 40) + + result = subprocess.run([ + "docker", "exec", "db", "psql", "-U", "adminuser", "-d", "meinedatenbank", + "-c", "\\d audit_log" + ], capture_output=True, text=True) + + if "Table \"public.audit_log\"" in result.stdout: + print("✓ Audit log table exists") + print("\nTable structure:") + print(result.stdout) + return True + else: + print("✗ Audit log table not found - creating it") + # Create table + subprocess.run([ + "docker", "exec", "db", "psql", "-U", "adminuser", "-d", "meinedatenbank", + "-f", "/docker-entrypoint-initdb.d/init.sql" + ], capture_output=True) + return False + +def test_audit_logging(): + """Test various actions to generate audit logs""" + session = requests.Session() + + print("\n2. Testing Audit Log Generation:") + print("-" * 40) + + # Test 1: Login + print("Testing LOGIN audit...") + login(session) + print("✓ Login performed") + + # Test 2: Create license + print("\nTesting CREATE audit...") + license_data = { + "customer_name": "Audit Test GmbH", + "email": "audit@test.de", + "license_key": "AUDIT-TEST-001", + "license_type": "test", + "valid_from": "2025-01-01", + "valid_until": "2025-12-31" + } + response = session.post(f"{base_url}/create", data=license_data, verify=False, allow_redirects=False) + if response.status_code == 302: + print("✓ License created") + + # Test 3: Export + print("\nTesting EXPORT audit...") + response = session.get(f"{base_url}/export/licenses?format=csv", verify=False) + if response.status_code == 200: + print("✓ Export performed") + + # Test 4: Logout + print("\nTesting LOGOUT audit...") + response = session.get(f"{base_url}/logout", verify=False, allow_redirects=False) + if response.status_code == 302: + print("✓ Logout performed") + + # Wait for logs to be written + time.sleep(1) + +def test_audit_page(): + """Test the audit log page""" + session = requests.Session() + login(session) + + print("\n3. Testing Audit Log Page:") + print("-" * 40) + + response = session.get(f"{base_url}/audit", verify=False) + + if response.status_code == 200: + print("✓ Audit log page accessible") + + content = response.text + + # Check for expected elements + checks = [ + ("Audit-Log", "Page title"), + ("Zeitstempel", "Timestamp column"), + ("Benutzer", "User column"), + ("Aktion", "Action column"), + ("Entität", "Entity column"), + ("IP-Adresse", "IP address column"), + ("LOGIN", "Login action"), + ("LOGOUT", "Logout action"), + ("CREATE", "Create action"), + ("EXPORT", "Export action") + ] + + found = 0 + for check_text, description in checks: + if check_text in content: + found += 1 + + print(f"✓ Found {found}/{len(checks)} expected elements") + + # Check filters + if '