import hashlib import hmac import json import os from datetime import datetime, timezone import psycopg from flask import Flask, abort, redirect, render_template, request, url_for app = Flask(__name__) CURRENT_EVENT_HOURS = 24 STALE_REPORT_HOURS = 36 def db_connection(): return psycopg.connect( host=os.environ["DB_HOST"], port=os.getenv("DB_PORT", "5432"), dbname=os.environ["DB_NAME"], user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], connect_timeout=5, ) def csrf_token(): secret = os.environ["DASHBOARD_CSRF_SECRET"].encode("utf-8") return hmac.new(secret, b"recipient-rules", hashlib.sha256).hexdigest() def require_csrf(): supplied = request.form.get("csrf_token", "") if not hmac.compare_digest(supplied, csrf_token()): abort(400) def event_metadata(payload): latest_event = None for event in (payload or {}).get("Events", []): value = event.get("Timestamp") if not value: continue try: timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: continue if timestamp.tzinfo is None: timestamp = timestamp.replace(tzinfo=timezone.utc) if latest_event is None or timestamp > latest_event: latest_event = timestamp if latest_event is None: return {"is_current": False, "label": "keine Ereignisse", "timestamp": None} age_seconds = max(0, int((datetime.now(timezone.utc) - latest_event.astimezone(timezone.utc)).total_seconds())) if age_seconds < 3600: age_label = f"vor {max(1, age_seconds // 60)} Min." elif age_seconds < 86400: age_label = f"vor {age_seconds // 3600} Std." else: age_label = f"vor {age_seconds // 86400} Tg." return { "is_current": age_seconds <= CURRENT_EVENT_HOURS * 3600, "label": age_label, "timestamp": latest_event, } @app.get("/") def overview(): with db_connection() as connection, connection.cursor() as cursor: cursor.execute("SELECT * FROM ocsentinel.organization_summary") summary = cursor.fetchone() cursor.execute( """ SELECT machine_name, organization_name, received_at, alert_state, total_events, unique_ip_count, cve_total, cve_critical, payload FROM ( SELECT machine_name, received_at, alert_state, total_events, unique_ip_count, cve_total, cve_critical, payload #>> '{NinjaOne,OrganizationName}' AS organization_name, payload FROM ocsentinel.current_device_status ) AS status ORDER BY received_at DESC NULLS LAST LIMIT 100 """ ) reports = cursor.fetchall() cursor.execute( """ SELECT count(*) AS known_devices, count(*) FILTER (WHERE received_at >= now() - interval '36 hours') AS reporting_devices, count(*) FILTER (WHERE received_at IS NULL OR received_at < now() - interval '36 hours') AS stale_devices FROM ocsentinel.current_device_status """ ) coverage = cursor.fetchone() cursor.execute( """ SELECT machine_name, alert_state, total_events, unique_ip_count, received_at, payload FROM ocsentinel.current_device_status WHERE alert_state IN ('warning', 'critical') ORDER BY CASE alert_state WHEN 'critical' THEN 0 ELSE 1 END, received_at DESC """ ) alerts = cursor.fetchall() cursor.execute( """ WITH latest AS ( SELECT DISTINCT ON (d.machine_name_key, date_trunc('day', r.received_at)) date_trunc('day', r.received_at)::date AS day, r.alert_state, r.total_events FROM ocsentinel.scan_report AS r JOIN ocsentinel.device AS d ON d.id = r.device_id WHERE r.received_at >= now() - interval '14 days' ORDER BY d.machine_name_key, date_trunc('day', r.received_at), r.received_at DESC ) SELECT day, count(*) FILTER (WHERE alert_state = 'warning') AS warning_count, count(*) FILTER (WHERE alert_state = 'critical') AS critical_count, coalesce(sum(total_events), 0) AS event_count FROM latest GROUP BY day ORDER BY day """ ) trend = cursor.fetchall() cursor.execute( """ SELECT coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') AS organization_id, coalesce(nullif(payload #>> '{NinjaOne,OrganizationName}', ''), 'Organisation unbekannt') AS organization_name, count(*) AS device_count, count(*) FILTER (WHERE alert_state = 'warning') AS warning_count, count(*) FILTER (WHERE alert_state = 'critical') AS critical_count, max(received_at) AS last_received_at FROM ocsentinel.current_device_status GROUP BY 1, 2 ORDER BY critical_count DESC, warning_count DESC, organization_name """ ) organizations = cursor.fetchall() report_rows = [] for row in reports: event = event_metadata(row[8]) report_rows.append( { "machine_name": row[0], "organization_name": row[1], "received_at": row[2], "alert_state": row[3], "total_events": row[4], "unique_ip_count": row[5], "event": event, } ) alert_rows = [] for row in alerts: event = event_metadata(row[5]) alert_rows.append( { "machine_name": row[0], "alert_state": row[1], "total_events": row[2], "unique_ip_count": row[3], "received_at": row[4], "event": event, } ) trend_rows = [ { "day": row[0], "warning_count": row[1], "critical_count": row[2], "event_count": row[3], } for row in trend ] trend_max = max([row["event_count"] for row in trend_rows] or [1]) organization_rows = [ { "id": row[0], "name": row[1], "device_count": row[2], "warning_count": row[3], "critical_count": row[4], "last_received_at": row[5], } for row in organizations ] return render_template( "overview.html", summary=summary, coverage=coverage, reports=report_rows, alerts=alert_rows, current_alert_count=sum(alert["event"]["is_current"] for alert in alert_rows), trend=trend_rows, trend_max=trend_max, organizations=organization_rows, ) @app.get("/organizations/") def organization(organization_id): with db_connection() as connection, connection.cursor() as cursor: cursor.execute( """ SELECT machine_name, received_at, alert_state, total_events, unique_ip_count, cve_critical, payload FROM ocsentinel.current_device_status WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s ORDER BY CASE alert_state WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, machine_name """, (organization_id,), ) devices = cursor.fetchall() if not devices: abort(404) organization_name = (devices[0][6] or {}).get("NinjaOne", {}).get("OrganizationName") or "Organisation unbekannt" return render_template( "organization.html", organization_id=organization_id, organization_name=organization_name, devices=devices, critical_count=sum(row[2] == "critical" for row in devices), warning_count=sum(row[2] == "warning" for row in devices), ) @app.get("/device/") def device(machine_name): with db_connection() as connection, connection.cursor() as cursor: cursor.execute( """ SELECT machine_name, first_seen_at, last_seen_at, last_client_version, generated_at_utc, received_at, alert_state, base_alert_state, total_events, unique_ip_count, cve_total, cve_critical, payload FROM ocsentinel.current_device_status WHERE machine_name = %s """, (machine_name,), ) report = cursor.fetchone() if report is None: abort(404) payload = report[12] event_groups = {} for entry in payload.get("Events") or payload.get("events") or []: event_type = entry.get("Target") or entry.get("target") or "Sicherheitsereignis" account = entry.get("Username") or entry.get("username") or "-" source_ip = entry.get("SourceIp") or entry.get("sourceIp") or "-" key = (event_type, account, source_ip) group = event_groups.setdefault( key, {"type": event_type, "account": account, "source_ip": source_ip, "count": 0, "latest": "-"}, ) group["count"] += 1 timestamp = entry.get("Timestamp") or entry.get("timestamp") or "-" if timestamp > group["latest"]: group["latest"] = timestamp security_events = sorted( event_groups.values(), key=lambda entry: (entry["latest"], entry["count"]), reverse=True, )[:25] return render_template( "device.html", report=report, event=event_metadata(payload), payload=payload, security_events=security_events, ransomware_beta=payload.get("RansomwareBeta") or payload.get("ransomwareBeta") or {}, payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False), ) @app.get("/reports") def reports(): with db_connection() as connection, connection.cursor() as cursor: cursor.execute( """ SELECT id, organization_name, period_start_utc, period_end_utc, generated_at, device_count, warning_count, critical_count, total_events FROM ocsentinel.weekly_organization_report ORDER BY period_end_utc DESC, organization_name """ ) weekly_reports = cursor.fetchall() return render_template("reports.html", reports=weekly_reports) @app.get("/reports/") def weekly_report(report_id): with db_connection() as connection, connection.cursor() as cursor: cursor.execute( """ SELECT organization_name, period_start_utc, period_end_utc, generated_at, report_html FROM ocsentinel.weekly_organization_report WHERE id = %s """, (report_id,), ) report = cursor.fetchone() if report is None: abort(404) return render_template("weekly_report.html", report=report) @app.get("/recipients") def recipients(): with db_connection() as connection, connection.cursor() as cursor: cursor.execute( """ SELECT id, organization_id, organization_name, recipient_email, enabled FROM ocsentinel.organization_report_recipient ORDER BY organization_id = '*', organization_name, recipient_email """ ) rules = cursor.fetchall() cursor.execute( """ SELECT DISTINCT payload #>> '{NinjaOne,OrganizationId}', payload #>> '{NinjaOne,OrganizationName}' FROM ocsentinel.current_device_status WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', '') <> '' ORDER BY 2 """ ) organizations = cursor.fetchall() return render_template("recipients.html", rules=rules, organizations=organizations, csrf_token=csrf_token()) @app.post("/recipients") def add_recipient(): require_csrf() organization_id = request.form.get("organization_id", "").strip() organization_name = request.form.get("organization_name", "").strip() recipient_email = request.form.get("recipient_email", "").strip().lower() if not organization_id or not organization_name or "@" not in recipient_email: abort(400) with db_connection() as connection, connection.cursor() as cursor: cursor.execute( """ INSERT INTO ocsentinel.organization_report_recipient (organization_id, organization_name, recipient_email) VALUES (%s, %s, %s) ON CONFLICT (organization_id, recipient_email) DO NOTHING """, (organization_id, organization_name, recipient_email), ) connection.commit() return redirect(url_for("recipients")) @app.post("/recipients//toggle") def toggle_recipient(rule_id): require_csrf() with db_connection() as connection, connection.cursor() as cursor: cursor.execute( "UPDATE ocsentinel.organization_report_recipient SET enabled = NOT enabled WHERE id = %s", (rule_id,), ) connection.commit() return redirect(url_for("recipients")) @app.post("/recipients//delete") def delete_recipient(rule_id): require_csrf() with db_connection() as connection, connection.cursor() as cursor: cursor.execute("DELETE FROM ocsentinel.organization_report_recipient WHERE id = %s", (rule_id,)) connection.commit() return redirect(url_for("recipients")) @app.get("/healthz") def healthz(): try: with db_connection() as connection, connection.cursor() as cursor: cursor.execute("SELECT 1") return {"status": "ok"} except Exception: return {"status": "unavailable"}, 503 if __name__ == "__main__": app.run(host="0.0.0.0", port=8080)