From 2f2a553fc2078266fcd22dd9ee63898b48b0ba1e Mon Sep 17 00:00:00 2001 From: OfficeCom Codex Date: Sun, 26 Jul 2026 03:16:47 +0200 Subject: [PATCH] Add weekly organization security reports --- infra/debug-dashboard/app.py | 39 +++++++++++++++++++ infra/debug-dashboard/static/app.css | 3 +- infra/debug-dashboard/templates/base.html | 2 +- infra/debug-dashboard/templates/reports.html | 8 ++++ .../templates/weekly_report.html | 5 +++ ...csentinel-weekly-organization-reports.json | 35 +++++++++++++++++ infra/postgres/001_ocsentinel.sql | 22 +++++++++++ 7 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 infra/debug-dashboard/templates/reports.html create mode 100644 infra/debug-dashboard/templates/weekly_report.html create mode 100644 infra/n8n/ocsentinel-weekly-organization-reports.json diff --git a/infra/debug-dashboard/app.py b/infra/debug-dashboard/app.py index 12ac60f..2614b27 100644 --- a/infra/debug-dashboard/app.py +++ b/infra/debug-dashboard/app.py @@ -95,6 +95,45 @@ def device(machine_name): return render_template("device.html", report=report, payload=payload, payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False)) +@app.get("/reports") +@requires_auth +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/") +@requires_auth +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("/healthz") def healthz(): try: diff --git a/infra/debug-dashboard/static/app.css b/infra/debug-dashboard/static/app.css index e17010f..3503928 100644 --- a/infra/debug-dashboard/static/app.css +++ b/infra/debug-dashboard/static/app.css @@ -1,10 +1,11 @@ :root { --ink:#17201d; --muted:#66736d; --paper:#f5f3eb; --panel:#fffdf7; --line:#d8d4c6; --green:#236342; --lime:#c7ee6b; --amber:#b86613; --red:#a8342b; } * { box-sizing:border-box; } body { margin:0; color:var(--ink); background:radial-gradient(circle at 86% -10%, #d6efad 0, transparent 28rem), var(--paper); font-family:Georgia, 'Times New Roman', serif; } -.masthead { height:70px; padding:0 6vw; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:rgba(255,253,247,.82); backdrop-filter:blur(10px); } +.masthead { height:70px; padding:0 6vw; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:rgba(255,253,247,.82); backdrop-filter:blur(10px); }.header-links { display:flex; gap:14px; align-items:center; }.header-links a { color:var(--green); font:700 12px Arial,sans-serif; text-decoration:none; } .brand { color:var(--ink); font:700 20px/1 Arial,sans-serif; text-decoration:none; letter-spacing:-.04em; }.brand span { display:inline-grid; place-items:center; margin-right:7px; width:28px; height:28px; background:var(--green); color:#fff; border-radius:50%; font-size:11px; letter-spacing:0; }.badge,.eyebrow { color:var(--muted); font:700 10px/1 Arial,sans-serif; text-transform:uppercase; letter-spacing:.12em; }.badge { border:1px solid var(--line); padding:6px 8px; border-radius:20px; } main { max-width:1280px; margin:auto; padding:58px 6vw 80px; }.hero { max-width:650px; margin-bottom:32px; }.hero h1 { font-size:clamp(34px,5vw,64px); line-height:.98; letter-spacing:-.06em; margin:10px 0; }.hero p { color:var(--muted); font-size:18px; }.hero.compact h1 { font-size:48px; } .metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:1px; margin:25px 0 46px; border:1px solid var(--line); background:var(--line); }.metrics article { min-height:130px; padding:20px; background:var(--panel); }.metrics span { display:block; color:var(--muted); font:700 10px Arial,sans-serif; letter-spacing:.09em; text-transform:uppercase; }.metrics strong { display:block; margin-top:16px; font:700 31px Arial,sans-serif; letter-spacing:-.05em; }.metrics .timestamp { font-size:14px; line-height:1.25; letter-spacing:-.02em; }.warning { color:var(--amber); }.critical { color:var(--red); } .panel { margin-top:26px; padding:26px; background:var(--panel); border:1px solid var(--line); }.panel-heading h2 { margin:8px 0 22px; font-size:28px; letter-spacing:-.04em; }.alert-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }.alert-card { padding:17px; border-left:5px solid var(--amber); background:#fff7e9; color:var(--ink); text-decoration:none; }.alert-card.critical { border-color:var(--red); background:#fff0ed; }.alert-card span,.alert-card small { display:block; font:700 10px Arial,sans-serif; letter-spacing:.08em; text-transform:uppercase; }.alert-card strong { display:block; margin:10px 0; font:700 22px Arial,sans-serif; letter-spacing:-.04em; } table { width:100%; border-collapse:collapse; font-family:Arial,sans-serif; font-size:13px; } th { text-align:left; color:var(--muted); font-size:10px; letter-spacing:.1em; text-transform:uppercase; } th,td { padding:13px 8px; border-bottom:1px solid var(--line); } td a { color:var(--green); font-weight:700; text-decoration:none; }.state { display:inline-block; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px Arial,sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); } pre { margin:0; padding:18px; overflow:auto; color:#dce7da; background:#13221b; border-radius:4px; font:12px/1.5 'Cascadia Code',Consolas,monospace; }.table-wrap { overflow:auto; } +.report-frame { background:#fff; border:1px solid var(--line); box-shadow:0 12px 40px rgba(20,35,27,.1); } @media (max-width:850px) { .metrics { grid-template-columns:repeat(2,1fr); }.metrics article:last-child { grid-column:span 2; }.masthead { padding:0 5vw; }.badge { display:none; } main { padding:38px 5vw; } } diff --git a/infra/debug-dashboard/templates/base.html b/infra/debug-dashboard/templates/base.html index e7e4f81..8b5e294 100644 --- a/infra/debug-dashboard/templates/base.html +++ b/infra/debug-dashboard/templates/base.html @@ -9,7 +9,7 @@
OC Sentinel Debug -
read-only database console
+
{% block content %}{% endblock %}
diff --git a/infra/debug-dashboard/templates/reports.html b/infra/debug-dashboard/templates/reports.html new file mode 100644 index 0000000..8e508a5 --- /dev/null +++ b/infra/debug-dashboard/templates/reports.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Wochenberichte - OCSentinel Debug{% endblock %} +{% block content %} +

Archiv

Wochenberichte

Je Organisation automatisch durch n8n erzeugt.

+
+{% for row in reports %}{% else %}{% endfor %} +
OrganisationZeitraumGeraeteWarnungKritischEventsErstellt
{{ row[1] }}{{ row[2] }} bis {{ row[3] }}{{ row[5] }}{{ row[6] }}{{ row[7] }}{{ row[8] }}{{ row[4] }}
Noch keine Wochenberichte erzeugt.
+{% endblock %} diff --git a/infra/debug-dashboard/templates/weekly_report.html b/infra/debug-dashboard/templates/weekly_report.html new file mode 100644 index 0000000..03c65bd --- /dev/null +++ b/infra/debug-dashboard/templates/weekly_report.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} +{% block title %}{{ report[0] }} - Wochenbericht{% endblock %} +{% block content %} +
{{ report[4] | safe }}
+{% endblock %} diff --git a/infra/n8n/ocsentinel-weekly-organization-reports.json b/infra/n8n/ocsentinel-weekly-organization-reports.json new file mode 100644 index 0000000..dc03fbf --- /dev/null +++ b/infra/n8n/ocsentinel-weekly-organization-reports.json @@ -0,0 +1,35 @@ +{ + "id": "OCwRpt7eK3mQ2xL9", + "name": "OCSentinel - Weekly Organization Reports", + "nodes": [ + { + "parameters": { "rule": { "interval": [{ "field": "weeks", "weeksInterval": 1, "triggerAtDay": [1], "triggerAtHour": 7, "triggerAtMinute": 20 }] } }, + "id": "schedule-weekly-reports", "name": "Every Monday 07:20", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [300, 300] + }, + { + "parameters": { "operation": "executeQuery", "query": "WITH latest AS (\n SELECT DISTINCT ON (d.id) d.machine_name, r.alert_state, r.payload\n FROM ocsentinel.scan_report AS r\n JOIN ocsentinel.device AS d ON d.id = r.device_id\n WHERE r.received_at >= now() - interval '8 days'\n ORDER BY d.id, r.generated_at_utc DESC, r.received_at DESC\n)\nSELECT machine_name, alert_state, payload\nFROM latest\nORDER BY payload #>> '{NinjaOne,OrganizationName}', machine_name;" }, + "id": "load-weekly-data", "name": "Load Latest Device Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [560, 300], + "credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } } + }, + { + "parameters": { "jsCode": "const esc=v=>String(v??'').replace(/&/g,'&').replace(//g,'>').replace(/\\\"/g,'"').replace(/'/g,''');\nconst now=new Date(),end=new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate()));\nend.setUTCDate(end.getUTCDate()-((end.getUTCDay()+6)%7)); const start=new Date(end-7*86400000);\nconst groups=new Map();\nfor(const item of items){const p=item.json.payload||{},n=p.NinjaOne||p.ninjaOne||{},id=String(n.OrganizationId||n.organizationId||'unknown'),name=String(n.OrganizationName||n.organizationName||`Organisation ${id}`);if(!groups.has(id))groups.set(id,{id,name,devices:[]});groups.get(id).devices.push({machine:item.json.machine_name||p.MachineName||'Unbekannt',state:String(item.json.alert_state||p.AlertState||'unknown').toLowerCase(),p});}\nconst result=[];\nfor(const group of groups.values()){let warnings=0,criticals=0,total=0,cveTotal=0,cveCritical=0;const ips=new Set(),alerts=[],clean=[];for(const d of group.devices){if(d.state==='warning')warnings++;if(d.state==='critical')criticals++;const vc=d.p.VulnerabilityCorrelation||{};cveTotal+=Number(vc.TotalCount||0);cveCritical+=Number(vc.CriticalCount||0);const events=(d.p.Events||[]).filter(e=>{const t=new Date(e.Timestamp);return !Number.isNaN(t)&&t>=start&&t${esc(d.machine)}Keine--0-Log sauber / Keine Angriffe`);continue;}const rows=new Map();for(const e of events){const ip=e.SourceIp||'-',account=e.Username||'-',type=e.Target||'Sicherheitsereignis',key=[type,account,ip].join('|'),row=rows.get(key)||{ip,account,type,count:0,last:e.Timestamp};row.count++;if(new Date(e.Timestamp)>new Date(row.last))row.last=e.Timestamp;rows.set(key,row);if(ip!=='-')ips.add(ip);}for(const r of rows.values()){alerts.push(`${esc(d.machine)}${esc(r.type)}${esc(r.account)}${esc(new Date(r.last).toLocaleString('de-DE',{timeZone:'Europe/Berlin'}))}${r.count}${esc(r.ip)}${d.state==='critical'?'Problem entdeckt (kritisch)':'Problem entdeckt'}`);}}const summary={deviceCount:group.devices.length,warningCount:warnings,criticalCount:criticals,totalEvents:total,uniqueIps:ips.size,cveTotal,cveCritical};const html=`

OCSentinel - Konsolidierter Sicherheitsbericht

${esc(group.name)}
Berichtszeitraum: ${start.toLocaleDateString('de-DE')} bis ${end.toLocaleDateString('de-DE')}
Erstellt am: ${now.toLocaleString('de-DE',{timeZone:'Europe/Berlin'})}
${summary.deviceCount} Geräte | ${summary.criticalCount} kritisch | ${summary.warningCount} Warnungen | ${summary.totalEvents} Ereignisse | ${summary.uniqueIps} IPs | ${summary.cveCritical} kritische CVEs
${alerts.join('')}${clean.join('')}
ServerVorfall-TypBetroffenes KontoLetzter ZeitpunktAnzahlAngreifer-IPStatus / Bemerkung
Automatisch durch OCSentinel und n8n erzeugt.
`;result.push({json:{organizationId:group.id,organizationName:group.name,periodStartUtc:start.toISOString(),periodEndUtc:end.toISOString(),...summary,reportHtml:html,summaryJson:JSON.stringify(summary)}});}return result;" }, + "id": "build-weekly-reports", "name": "Build Organization HTML Reports", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [820, 300] + }, + { + "parameters": { "operation": "executeQuery", "query": "INSERT INTO ocsentinel.weekly_organization_report (organization_id, organization_name, period_start_utc, period_end_utc, device_count, warning_count, critical_count, total_events, unique_ips, cve_total, cve_critical, report_html, summary)\nVALUES ($1, $2, $3::timestamptz, $4::timestamptz, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)\nON CONFLICT (organization_id, period_start_utc) DO UPDATE SET organization_name=EXCLUDED.organization_name, period_end_utc=EXCLUDED.period_end_utc, generated_at=now(), device_count=EXCLUDED.device_count, warning_count=EXCLUDED.warning_count, critical_count=EXCLUDED.critical_count, total_events=EXCLUDED.total_events, unique_ips=EXCLUDED.unique_ips, cve_total=EXCLUDED.cve_total, cve_critical=EXCLUDED.cve_critical, report_html=EXCLUDED.report_html, summary=EXCLUDED.summary\nRETURNING id;", "options": { "queryReplacement": "={{ [$json.organizationId, $json.organizationName, $json.periodStartUtc, $json.periodEndUtc, $json.deviceCount, $json.warningCount, $json.criticalCount, $json.totalEvents, $json.uniqueIps, $json.cveTotal, $json.cveCritical, $json.reportHtml, $json.summaryJson] }}" } }, + "id": "store-weekly-reports", "name": "Store Weekly Organization Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [1080, 300], + "credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } } + } + ], + "connections": { + "Every Monday 07:20": { "main": [[{ "node": "Load Latest Device Reports", "type": "main", "index": 0 }]] }, + "Load Latest Device Reports": { "main": [[{ "node": "Build Organization HTML Reports", "type": "main", "index": 0 }]] }, + "Build Organization HTML Reports": { "main": [[{ "node": "Store Weekly Organization Reports", "type": "main", "index": 0 }]] } + }, + "settings": { "executionOrder": "v1", "timezone": "Europe/Berlin" }, + "active": true, + "pinData": {}, + "versionId": "af98f45b-192e-49c8-9a1e-e7b1fc7e00b2", + "meta": { "templateCredsSetupCompleted": true }, + "tags": [] +} diff --git a/infra/postgres/001_ocsentinel.sql b/infra/postgres/001_ocsentinel.sql index 2ef60cf..18036e9 100644 --- a/infra/postgres/001_ocsentinel.sql +++ b/infra/postgres/001_ocsentinel.sql @@ -47,6 +47,28 @@ CREATE TABLE IF NOT EXISTS ocsentinel.ingest_nonce ( CREATE INDEX IF NOT EXISTS ix_ocsentinel_ingest_nonce_expires ON ocsentinel.ingest_nonce (expires_at); +CREATE TABLE IF NOT EXISTS ocsentinel.weekly_organization_report ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id TEXT NOT NULL, + organization_name TEXT NOT NULL, + period_start_utc TIMESTAMPTZ NOT NULL, + period_end_utc TIMESTAMPTZ NOT NULL, + generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + device_count INTEGER NOT NULL DEFAULT 0, + warning_count INTEGER NOT NULL DEFAULT 0, + critical_count INTEGER NOT NULL DEFAULT 0, + total_events INTEGER NOT NULL DEFAULT 0, + unique_ips INTEGER NOT NULL DEFAULT 0, + cve_total INTEGER NOT NULL DEFAULT 0, + cve_critical INTEGER NOT NULL DEFAULT 0, + report_html TEXT NOT NULL, + summary JSONB NOT NULL DEFAULT '{}'::jsonb, + UNIQUE (organization_id, period_start_utc) +); + +CREATE INDEX IF NOT EXISTS ix_ocsentinel_weekly_report_organization_generated + ON ocsentinel.weekly_organization_report (organization_id, generated_at DESC); + CREATE OR REPLACE VIEW ocsentinel.current_device_status AS SELECT DISTINCT ON (d.id) d.machine_name,