From f0547024383ab302128da6f0a49a70a53e0615ff Mon Sep 17 00:00:00 2001 From: OfficeCom Codex Date: Mon, 27 Jul 2026 14:31:37 +0200 Subject: [PATCH] Provide importable Outlook weekly report workflow --- ...l-weekly-organization-reports-outlook.json | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 infra/n8n/ocsentinel-weekly-organization-reports-outlook.json diff --git a/infra/n8n/ocsentinel-weekly-organization-reports-outlook.json b/infra/n8n/ocsentinel-weekly-organization-reports-outlook.json new file mode 100644 index 0000000..e19e50e --- /dev/null +++ b/infra/n8n/ocsentinel-weekly-organization-reports-outlook.json @@ -0,0 +1,222 @@ +{ + "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 = (value) => String(value ?? '')\n .replace(/&/g, '&').replace(//g, '>')\n .replace(/\"/g, '"').replace(/'/g, ''')\n .replace(/[^\\x20-\\x7e]/g, (character) => `&#${character.codePointAt(0)};`);\nconst now = new Date();\nconst end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));\nend.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7));\nconst start = new Date(end.getTime() - 7 * 86400000);\nconst formatDate = (value) => new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' });\nconst groups = new Map();\nfor (const item of items) {\n const payload = item.json.payload || {}, ninja = payload.NinjaOne || payload.ninjaOne || {};\n const id = String(ninja.OrganizationId || ninja.organizationId || 'unknown');\n if (!groups.has(id)) groups.set(id, { id, name: String(ninja.OrganizationName || ninja.organizationName || `Organisation ${id}`), devices: [] });\n groups.get(id).devices.push({ name: item.json.machine_name || payload.MachineName || 'Unbekannt', state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(), payload });\n}\nconst output = [];\nfor (const group of groups.values()) {\n let warnings = 0, criticals = 0, totalEvents = 0, cveCritical = 0;\n const ips = new Set(), rows = [];\n for (const device of group.devices.sort((a, b) => a.name.localeCompare(b.name))) {\n if (device.state === 'warning') warnings++;\n if (device.state === 'critical') criticals++;\n cveCritical += Number((device.payload.VulnerabilityCorrelation || {}).CriticalCount || 0);\n const events = (device.payload.Events || []).filter((event) => { const timestamp = new Date(event.Timestamp); return !Number.isNaN(timestamp) && timestamp >= start && timestamp < end; });\n totalEvents += events.length;\n if (!events.length) { rows.push(`${esc(device.name)}Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.Sauber`); continue; }\n const grouped = new Map();\n for (const event of events) {\n const ip = event.SourceIp || '-', account = event.Username || '-', type = event.Target || 'Sicherheitsereignis', key = [type, account, ip].join('|');\n const entry = grouped.get(key) || { ip, account, type, count: 0, latest: event.Timestamp };\n entry.count++; if (new Date(event.Timestamp) > new Date(entry.latest)) entry.latest = event.Timestamp; grouped.set(key, entry); if (ip !== '-') ips.add(ip);\n }\n for (const event of grouped.values()) { const critical = device.state === 'critical'; rows.push(`${esc(device.name)}${esc(event.type)}${esc(event.account)}${esc(formatDate(event.latest))}${event.count}${esc(event.ip)}${critical ? 'Kritisch' : 'Pruefen'}`); }\n }\n const risk = criticals ? ['Kritisch', '#991b1b', '#fee2e2'] : warnings ? ['Beobachten', '#92400e', '#fef3c7'] : ['Unauffaellig', '#166534', '#dcfce7'];\n const metric = (value, label) => `${value}${label}`;\n const html = `
${risk[0]}

OfficeCom Sentinel Sicherheitsbericht

${esc(group.name)} | ${esc(start.toLocaleDateString('de-DE'))} bis ${esc(end.toLocaleDateString('de-DE'))}

${metric(group.devices.length, 'Geräte')}${metric(criticals, 'Kritisch')}${metric(warnings, 'Warnungen')}${metric(totalEvents, 'Ereignisse')}${metric(ips.size, 'Quell-IP-Adressen')}${metric(cveCritical, 'Kritische CVEs')}
${rows.join('')}
SystemVorfallKontoLetzter ZeitpunktAnzahlQuell-IPBewertung

Automatisch erstellt am ${esc(formatDate(now))} durch OfficeCom Sentinel.

`;\n output.push({ json: { organizationId: group.id, organizationName: group.name, periodStartUtc: start.toISOString(), periodEndUtc: end.toISOString(), deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveTotal: 0, cveCritical, reportHtml: html, summaryJson: JSON.stringify({ deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveCritical }) } });\n}\nreturn output;\n" + }, + "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" + } + } + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT coalesce(array_agg(recipient_email ORDER BY recipient_email), ARRAY[]::text[]) AS recipients\nFROM ocsentinel.organization_report_recipient\nWHERE enabled = TRUE AND (organization_id = '*' OR organization_id = $1);", + "options": { + "queryReplacement": "={{ [$json.organizationId] }}" + } + }, + "id": "load-report-recipients", + "name": "Empfaenger aus zentraler Zuordnung laden", + "type": "n8n-nodes-base.postgres", + "typeVersion": 2.5, + "position": [ + 1560, + 300 + ], + "credentials": { + "postgres": { + "id": "WkjY0kIF3kHvREys", + "name": "OCSentinel PostgreSQL" + } + } + }, + { + "parameters": { + "mode": "runOnceForEachItem", + "jsCode": "const report = $('Build Organization HTML Reports').item.json;\nconst recipients = Array.from(new Set($json.recipients || []));\nif (recipients.length === 0) return [];\nreturn { json: { ...report, recipients } };" + }, + "id": "prepare-report-email", + "name": "E-Mail vorbereiten", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1800, + 300 + ] + }, + { + "parameters": { + "fromEmail": "donotreply@officecom.biz", + "toEmail": "={{ $json.recipients.join(', ') }}", + "subject": "=OCSentinel Wochenbericht - {{ $json.organizationName }}", + "html": "={{ $json.reportHtml }}", + "options": { + "appendAttribution": false + } + }, + "id": "send-weekly-report-email", + "name": "Send Weekly Organization Report", + "type": "n8n-nodes-base.emailSend", + "typeVersion": 2.1, + "position": [ + 2040, + 300 + ], + "credentials": { + "smtp": { + "id": "vafGYgYzM9SbxQbW", + "name": "SMTP account" + } + } + } + ], + "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 + }, + { + "node": "Empfaenger aus zentraler Zuordnung laden", + "type": "main", + "index": 0 + } + ] + ] + }, + "Empfaenger aus zentraler Zuordnung laden": { + "main": [ + [ + { + "node": "E-Mail vorbereiten", + "type": "main", + "index": 0 + } + ] + ] + }, + "E-Mail vorbereiten": { + "main": [ + [ + { + "node": "Send Weekly Organization Report", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "timezone": "Europe/Berlin" + }, + "active": true, + "pinData": {}, + "versionId": "af98f45b-192e-49c8-9a1e-e7b1fc7e00b2", + "meta": { + "templateCredsSetupCompleted": true + }, + "tags": [] +}