468 lines
16 KiB
Python
468 lines
16 KiB
Python
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/<organization_id>")
|
|
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("/network")
|
|
def network():
|
|
with db_connection() as connection, connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT machine_name, received_at, payload
|
|
FROM ocsentinel.current_device_status
|
|
WHERE received_at >= now() - interval '14 days'
|
|
"""
|
|
)
|
|
reports = cursor.fetchall()
|
|
|
|
flows = {}
|
|
for machine_name, received_at, payload in reports:
|
|
for event in (payload or {}).get("Events", []):
|
|
source_ip = event.get("SourceIp") or ""
|
|
if not source_ip or source_ip in {"-", "127.0.0.1", "::1"}:
|
|
continue
|
|
account = event.get("Username") or "[unbekannt]"
|
|
target = event.get("Target") or "Anmeldung"
|
|
key = (source_ip, machine_name, account, target)
|
|
entry = flows.setdefault(
|
|
key,
|
|
{
|
|
"source_ip": source_ip,
|
|
"machine_name": machine_name,
|
|
"account": account,
|
|
"target": target,
|
|
"count": 0,
|
|
"last_seen": received_at,
|
|
},
|
|
)
|
|
entry["count"] += 1
|
|
timestamp = event.get("Timestamp")
|
|
if timestamp and (entry["last_seen"] is None or str(timestamp) > str(entry["last_seen"])):
|
|
entry["last_seen"] = timestamp
|
|
|
|
flow_rows = sorted(flows.values(), key=lambda entry: (entry["count"], str(entry["last_seen"])), reverse=True)[:60]
|
|
max_count = max([entry["count"] for entry in flow_rows] or [1])
|
|
source_count = len({entry["source_ip"] for entry in flow_rows})
|
|
target_count = len({entry["machine_name"] for entry in flow_rows})
|
|
return render_template(
|
|
"network.html",
|
|
flows=flow_rows,
|
|
max_count=max_count,
|
|
source_count=source_count,
|
|
target_count=target_count,
|
|
total_events=sum(entry["count"] for entry in flow_rows),
|
|
)
|
|
|
|
|
|
@app.get("/device/<machine_name>")
|
|
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/<int:report_id>")
|
|
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/<int:rule_id>/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/<int:rule_id>/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)
|