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() 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, } ) 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), ) @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] return render_template( "device.html", report=report, event=event_metadata(payload), payload=payload, 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)