Compare commits
17 Commits
v1.3.7
...
v1.5.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3ca95dfa5 | ||
|
|
58ad77242f | ||
|
|
412178055f | ||
|
|
c52fea835c | ||
|
|
e711dc7029 | ||
|
|
a81d8b9830 | ||
|
|
64841d36e7 | ||
|
|
66dcfe09b6 | ||
|
|
c74d5582b0 | ||
|
|
4b9202b47c | ||
|
|
a5cea5ebad | ||
|
|
db0533a4cf | ||
|
|
f054702438 | ||
|
|
e357e6329d | ||
|
|
585b4f91b1 | ||
|
|
1eedac4a76 | ||
|
|
5d5828db49 |
@@ -93,7 +93,8 @@ jobs:
|
||||
exit 0
|
||||
}
|
||||
$artifactUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/$tag/OCSentinelClient-win-x64.zip"
|
||||
./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl
|
||||
$channel = if ($tag -match '-beta(?:\.|$)') { 'beta' } else { 'stable' }
|
||||
./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl -Channel $channel
|
||||
|
||||
- name: Publish Gitea release assets
|
||||
shell: pwsh
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
artifacts/
|
||||
__pycache__/
|
||||
reports/
|
||||
_extracted/
|
||||
_tools/
|
||||
|
||||
@@ -12,6 +12,9 @@ OfficeCom Sentinel is the hardened endpoint client for Windows event correlation
|
||||
- setup EXE builder: `build/build-client-installer.ps1`
|
||||
- update manifest builder: `build/build-release-manifest.ps1`
|
||||
- release checklist: `docs/release-checklist.md`
|
||||
- product roadmap: `docs/roadmap.md`
|
||||
- code quality standard: `docs/code-quality.md`
|
||||
- beta deployment: `docs/beta-deployment.md`
|
||||
- internal server-side target example: `infra/postgres-target.example.json`
|
||||
|
||||
## Build
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
{
|
||||
"warningEventThreshold": 1,
|
||||
"criticalEventThreshold": 20,
|
||||
"warningUniqueIpThreshold": 1,
|
||||
"criticalUniqueIpThreshold": 10,
|
||||
"warningEventThreshold": 10,
|
||||
"criticalEventThreshold": 30,
|
||||
"warningUniqueIpThreshold": 5,
|
||||
"criticalUniqueIpThreshold": 12,
|
||||
"loginBurstWindowMinutes": 15,
|
||||
"warningLoginBurstCount": 5,
|
||||
"criticalLoginBurstCount": 20,
|
||||
"warningSprayAccountCount": 5,
|
||||
"criticalSprayAccountCount": 10,
|
||||
"correlationWarningCveThreshold": 1,
|
||||
"correlationCriticalCveThreshold": 1,
|
||||
"ransomwareBetaEnabled": false,
|
||||
"ransomwareLookbackMinutes": 15,
|
||||
"ransomwareWarningSignalCount": 2,
|
||||
"ransomwareCriticalSignalCount": 3,
|
||||
"ransomwareExcludedProcesses": [],
|
||||
"ftpRoots": [
|
||||
"C:\\inetpub\\logs\\LogFiles",
|
||||
"D:\\inetpub\\logs\\LogFiles"
|
||||
|
||||
46
docs/beta-deployment.md
Normal file
46
docs/beta-deployment.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# OCSentinel Beta Deployment
|
||||
|
||||
## Ziel
|
||||
|
||||
Beta-Pakete werden ausschliesslich an benannte Pilotgeraete verteilt. Der
|
||||
Stable-Kanal und die vorhandene Stable-NinjaOne-Aufgabe bleiben unveraendert.
|
||||
|
||||
## Beta-Aufgabe in NinjaOne
|
||||
|
||||
1. Die bestehende Aufgabe `OCSentinel - Installieren und aktualisieren`
|
||||
duplizieren und eindeutig als `OCSentinel - Beta Pilot` benennen.
|
||||
2. Das Script `scripts/bootstrap-ocsentinel-ninja.ps1` verwenden.
|
||||
3. Die Script-Variable `releasechannel` als Text mit dem Wert `beta` anlegen.
|
||||
4. Webhook und Secret bleiben identisch zum Stable-Task.
|
||||
5. Die Aufgabe nur einer Pilot-Richtlinie oder explizit ausgewaehlten Geraeten
|
||||
zuweisen.
|
||||
|
||||
Die Stable-Aufgabe verwendet keinen Kanalwert oder den Wert `stable`.
|
||||
|
||||
## Passive Ransomware-Beta
|
||||
|
||||
Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem
|
||||
Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json`
|
||||
der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die erste Auswertung
|
||||
liest nur die letzten 15 Minuten der vorhandenen Prozess- und PowerShell-
|
||||
Ereignisse; sie installiert weder Sysmon noch Windows-Dateiauditing.
|
||||
|
||||
Ein Hinweis wird nur im JSON-Report und Dashboard sichtbar. Warnung und
|
||||
kritisch werden erst nach dem kontrollierten Alarmierungs-Pilot an NinjaOne
|
||||
weitergegeben.
|
||||
|
||||
## Rueckfall
|
||||
|
||||
1. Die Beta-Richtlinie entfernen oder die Beta-Aufgabe nicht mehr ausfuehren.
|
||||
2. Auf den Pilotgeraeten die vorhandene Stable-Aufgabe ausfuehren.
|
||||
3. Die Ransomware-Beta in der lokalen Konfiguration auf `false` setzen, falls
|
||||
sie aktiviert wurde.
|
||||
|
||||
Der Client prueft weiterhin Paket-Hash und Authenticode-Signaturstatus, bevor
|
||||
eine Beta installiert wird.
|
||||
|
||||
## Pilotprotokoll
|
||||
|
||||
Vor dem Start festhalten: Organisation, Geraete, Aktivierungszeit, aktivierte
|
||||
Feature-Schalter, verantwortliche Person und geplantes Enddatum. Nach dem
|
||||
Pilot Laufzeit, Upload-Volumen, Hinweise und Fehlalarme bewerten.
|
||||
32
docs/code-quality.md
Normal file
32
docs/code-quality.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Code-Qualitaetsstandard
|
||||
|
||||
## Ziel
|
||||
|
||||
Der Client soll klein, pruefbar und wartbar bleiben. Kommentare sind keine
|
||||
zweite Dokumentation und keine Erklaerung fuer selbsterklaerenden Code.
|
||||
|
||||
## Kommentarregel
|
||||
|
||||
- Kommentare bleiben nur bei Sicherheitsgrenzen, externen API-Eigenheiten,
|
||||
nicht offensichtlichen Entscheidungen und bewusstem Fehlertoleranz-Verhalten.
|
||||
- Beschreibende Kommentare direkt neben selbsterklaerenden Anweisungen werden
|
||||
entfernt.
|
||||
- Veraltete Kommentare werden im selben Pull Request wie die Codeaenderung
|
||||
geloescht oder aktualisiert.
|
||||
- Architektur- und Betriebswissen gehoert in `docs`, nicht in lange
|
||||
Quellcodekommentare.
|
||||
|
||||
## Wiederkehrender Clean-up
|
||||
|
||||
Bei jeder Minor-Version wird ein kurzer Wartungsdurchlauf eingeplant:
|
||||
|
||||
1. Tote Konfiguration, nicht erreichbare Pfade und doppelte Hilfsfunktionen entfernen.
|
||||
2. Kommentare gegen den aktuellen Code pruefen und ueberfluessige entfernen.
|
||||
3. Formatierung und Benennung vereinheitlichen.
|
||||
4. Release-Build und die relevanten Scan-Szenarien erneut ausfuehren.
|
||||
|
||||
## Sicherheitsausnahme
|
||||
|
||||
Kommentare, die vor einer unsicheren Aenderung schuetzen, bleiben erhalten.
|
||||
Beispiele sind TLS-Kompatibilitaet, Secret-Schutz, Upload-Signaturpruefung und
|
||||
deterministische Lastverteilung.
|
||||
250
docs/roadmap.md
Normal file
250
docs/roadmap.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# OfficeCom Sentinel Roadmap
|
||||
|
||||
## Produktziel
|
||||
|
||||
OfficeCom Sentinel erkennt nachvollziehbare Sicherheitsmuster auf Windows-
|
||||
Endpunkten und Fileservern, ohne den Betrieb zu stoeren. NinjaOne ist fuer
|
||||
zeitnahe Alerts zustaendig. Die zentrale Plattform sammelt verdichtete
|
||||
Telemetrie, zeigt die Sicherheitslage je Organisation und erstellt Berichte.
|
||||
|
||||
Der Client ersetzt weder G DATA/MXDR noch ein EDR. Er ergaenzt diese Systeme mit
|
||||
lokaler Korrelation, organisationsuebergreifender Sicht und nachvollziehbaren
|
||||
Incident-Protokollen.
|
||||
|
||||
## Leitplanken
|
||||
|
||||
- Wenige aussagekraeftige Signale statt Alarmierung bei Einzelereignissen.
|
||||
- Die Bewertung muss im JSON-Report und in der Uebersicht nachvollziehbar sein.
|
||||
- Kein direkter Datenbankzugriff und keine internen Infrastrukturwerte im Client.
|
||||
- Standardmaessig minimale Last: keine Vollscans, kein globales Dateiauditing,
|
||||
keine dauerhafte Uebertragung von Rohereignissen.
|
||||
- Datenminimierung: zentrale Speicherung nur von verdichteten Ereignissen und
|
||||
Incident-Kontext, nicht von vollstaendigen Dateilisten.
|
||||
|
||||
## Beta- und Rollback-Modell
|
||||
|
||||
Jede neue Erkennung, Datenart und UI-Aenderung durchlaeuft denselben
|
||||
reversiblen Lieferweg. Eine Funktion wird nie erstmals auf dem gesamten Bestand
|
||||
aktiv geschaltet.
|
||||
|
||||
### Stufe 0: Spezifikation und lokale Tests
|
||||
|
||||
- Zweck, Datenfelder, Bewertung und erwartete Last werden vor dem Coding
|
||||
dokumentiert.
|
||||
- Beispielereignisse decken Normalfall, Hinweis, Warnung, kritisch und Fehler
|
||||
ab.
|
||||
- Der Client muss bei fehlender neuer Konfiguration das bisherige Verhalten
|
||||
unveraendert beibehalten.
|
||||
|
||||
### Stufe 1: Interne Beta
|
||||
|
||||
- Das Paket wird als separater Beta-Release veroeffentlicht; `stable` bleibt
|
||||
unveraendert.
|
||||
- Eine neue Funktion ist per Feature-Schalter standardmaessig deaktiviert.
|
||||
- Die Beta wird nur auf Testgeraeten bzw. einer internen Organisation verteilt.
|
||||
- Zentrale Auswertung prueft Laufzeit, Upload-Volumen, Fehler und Datenformate.
|
||||
|
||||
### Stufe 2: Passiver Kunden-Pilot
|
||||
|
||||
- Ausgewaehlte Geraete erhalten die Beta mit aktivierter Funktion im
|
||||
Beobachtungsmodus.
|
||||
- Signale erscheinen in Protokoll und Dashboard, loesen aber keine NinjaOne-
|
||||
Alarmbedingung aus.
|
||||
- Der Pilot laeuft mindestens eine realistische Arbeitswoche, bei Fileservern
|
||||
inklusive der normalen Spitzenzeiten.
|
||||
|
||||
### Stufe 3: Kontrollierte Alarmierung
|
||||
|
||||
- Erst nach Auswertung werden Warnungen fuer eine kleine, benannte Pilotgruppe
|
||||
an NinjaOne uebergeben.
|
||||
- Hinweise bleiben weiterhin rein informativ.
|
||||
- Schwellenwerte, Ausnahmen und Empfaenger werden pro Pilot dokumentiert.
|
||||
|
||||
### Stufe 4: Stable-Rollout
|
||||
|
||||
- Rollout zuerst je Organisation oder Richtlinie, nicht an alle Kunden zugleich.
|
||||
- Der Stable-Kanal wird erst nach erfolgreichem Pilot, Review der Datenqualitaet
|
||||
und Freigabe der Alarmbedingungen aktualisiert.
|
||||
- Die vorherige Stable-Version bleibt als signiertes Release verfuegbar.
|
||||
|
||||
### Rueckfall
|
||||
|
||||
- Sofort: Feature-Schalter in der Richtlinie deaktivieren. Der Client bleibt
|
||||
installiert, sammelt fuer diese Funktion aber nichts mehr.
|
||||
- Kurzfristig: Pilotgeraete ueber NinjaOne auf die vorherige Stable-Version
|
||||
zuruecksetzen.
|
||||
- Zentral: Die Auswertung kann das neue Feld ignorieren; neue JSON-Felder sind
|
||||
immer optional und muessen abwaertskompatibel bleiben.
|
||||
- Datenbankaenderungen werden nur additiv eingefuehrt. Loeschende oder nicht
|
||||
rueckgaengig zu machende Migrationen gehoeren nicht in eine Beta.
|
||||
|
||||
### Abbruchkriterien
|
||||
|
||||
Ein Pilot wird pausiert und zurueckgesetzt, wenn eines dieser Kriterien eintritt:
|
||||
|
||||
- spuerbare Last oder Beeintraechtigung auf einem Kundenserver,
|
||||
- unkontrolliertes Upload- oder Queue-Wachstum,
|
||||
- fehlerhafte Organisationszuordnung oder unerwartete personenbezogene Daten,
|
||||
- mehr als ein unbegruendeter NinjaOne-Alarm im Pilot ohne klare Korrektur,
|
||||
- fehlende oder nicht nachvollziehbare Incident-Protokolle.
|
||||
|
||||
## Ausgangslage: geliefert
|
||||
|
||||
- Endpoint-Client mit signiertem Upload und lokaler NinjaOne-Feldaktualisierung.
|
||||
- N8n- und PostgreSQL-Pipeline mit organisationsbezogener Zuordnung.
|
||||
- Interne Uebersicht, Empfaengerverwaltung und woechentliche HTML-Berichte.
|
||||
- Gestaffelte taegliche Uploads sowie Burst-Pruefung.
|
||||
- Version 1.4.0: Fehlanmeldungen werden in 15-Minuten-Fenstern korreliert.
|
||||
Einzelne Tippfehler erzeugen keinen Alarm; Anmelde-Bursts und Password
|
||||
Spraying werden als Warnung oder kritisch bewertet.
|
||||
|
||||
## Voraussetzung: 1.4.1 Beta-Auslieferung
|
||||
|
||||
- Eigener Beta-Manifest-Pfad neben `release/stable/version.json`.
|
||||
- Eigene NinjaOne-Aufgabe fuer Pilotgeraete, die ausschliesslich den
|
||||
Beta-Manifest-Pfad verwendet.
|
||||
- Stable-Aufgabe bleibt unveraendert und ist zugleich der schnelle Rollback auf
|
||||
die letzte freigegebene Version.
|
||||
- Beta-Releases werden in Gitea als Vorabversion markiert und erhalten dieselbe
|
||||
Paket-Hash-Pruefung wie Stable-Releases.
|
||||
- Jeder Pilot dokumentiert Geraete, Organisation, aktivierte Feature-Schalter,
|
||||
Startzeitpunkt und verantwortliche Person.
|
||||
|
||||
## Naechster Schwerpunkt: 1.5 Ransomware-Frueherkennung
|
||||
|
||||
### 1.5.0: Leichtgewichtiger Fileserver-Sensor
|
||||
|
||||
**Lieferumfang**
|
||||
|
||||
- Inkrementelle Auswertung statt Dateiscan: Nur neue Prozess- und
|
||||
Systemereignisse sowie Aenderungszaehler seit dem letzten Pruefpunkt.
|
||||
- Erkennung hochrelevanter Manipulationen wie Schattenkopie-, Recovery- und
|
||||
Backup-Loeschbefehle sowie verdaechtiger Verschluesselungswerkzeuge.
|
||||
- Lokaler Ringpuffer mit aggregierten Datei-Churn-Signalen, etwa ungewoehnliche
|
||||
Umbenennungen, Loeschungen und neue Erweiterungen.
|
||||
- Snapshot der SMB-Sitzungen und des Incident-Kontexts erst bei einer
|
||||
Auffaelligkeit.
|
||||
- Verdichtetes Ransomware-Incident-Protokoll fuer n8n und die Uebersicht.
|
||||
|
||||
**Bewertung**
|
||||
|
||||
- Hinweis: Ein schwaches, isoliertes Signal. Es erscheint in Protokoll,
|
||||
Uebersicht und Wochenbericht, aber nicht als NinjaOne-Alarm.
|
||||
- Warnung: Zwei unabhaengige Signale innerhalb eines kurzen Zeitfensters oder
|
||||
eine veraenderte Canary-Datei.
|
||||
- Kritisch: Mehrere korrelierte Signale oder eine bestaetigte Schutzmeldung von
|
||||
G DATA/MXDR zusammen mit auffaelligem Datei-Churn.
|
||||
|
||||
**Last- und Datenschutzgrenzen**
|
||||
|
||||
- Sensorpruefung hoechstens einmal pro Minute, ausschliesslich inkrementell.
|
||||
- Kein globales Windows-Dateiauditing und keine globale Sysmon-Dateierstellung.
|
||||
- Keine Datei-Hashes und keine rekursiven Share-Scans im Normalbetrieb.
|
||||
- Maximal ein verdichteter Incident-Upload je Fileserver und fuenf Minuten;
|
||||
gleiche Muster werden lokal zusammengefasst.
|
||||
- Keine Dateinamen im Standardprotokoll; optionale, begrenzte Detaildaten nur
|
||||
fuer explizit konfigurierte kritische Freigaben.
|
||||
|
||||
**Abnahme**
|
||||
|
||||
- Test auf einem produktionsnahen Fileserver mit normaler Benutzerlast.
|
||||
- Vergleich der Sensorlast vor und nach Aktivierung.
|
||||
- Nachweis, dass normale Dateiaktivitaet von vielen Benutzern keinen Alert
|
||||
erzeugt und ein simuliertes Mehrsignal-Szenario korrekt eskaliert.
|
||||
- Offline-Pufferung, Deduplizierung und Retry des Incident-Protokolls getestet.
|
||||
- Start als interne Beta gemaess dem Beta- und Rollback-Modell; der Sensor wird
|
||||
erst nach dem passiven Fileserver-Pilot als NinjaOne-Alarm aktiviert.
|
||||
|
||||
### 1.5.1: Tuning und kontrollierter Rollout
|
||||
|
||||
- Baseline je Fileserver und Zeitfenster aus mindestens einer Arbeitswoche.
|
||||
- Konfigurierbare Ausnahmen fuer bekannte Backup-, Scan- und Servicekonten.
|
||||
- Pilotgruppe mit wenigen Fileservern, Auswertung der Hinweise und Anpassung
|
||||
der Schwellenwerte vor breiter Verteilung.
|
||||
- Klare NinjaOne-Conditions fuer Warnung und kritisch; Hinweise bleiben ohne
|
||||
Ticket- oder Alarmflut.
|
||||
|
||||
## Danach: 1.6 Zusaetzliche Sensoren
|
||||
|
||||
- Neue lokale Administratoren und auffaellige Gruppenmitgliedschaften.
|
||||
- RDP- und SMB-Fehlanmeldungen mit Quell- und Konto-Korrelation.
|
||||
- Sicherheitsrelevante Aenderungen an Diensten, geplanten Aufgaben und
|
||||
Autostart-Mechanismen.
|
||||
- Optionaler Import von G DATA-/MXDR-relevanten lokalen Ereignissen, sofern
|
||||
diese verlaesslich und ohne proprietaere Nebenlast verfuegbar sind.
|
||||
|
||||
## Danach: 1.7 Modernes Web GUI und Visualisierung
|
||||
|
||||
Das interne Web GUI wird von einer Debug-Ansicht zu einer schnellen,
|
||||
arbeitsfaehigen Sicherheitsuebersicht weiterentwickelt. Es bleibt intern und
|
||||
benoetigt keine eigene Anmeldung, solange der Zugriff ueber das bestehende
|
||||
interne Netz und den Reverse Proxy abgesichert ist.
|
||||
|
||||
### Informationsarchitektur
|
||||
|
||||
- Startseite mit Sicherheitslage ueber alle Organisationen, aktiven Incidents,
|
||||
Datenabdeckung und Upload-Gesundheit.
|
||||
- Organisationsansicht mit Trend, betroffenen Geraeten, offenen Hinweisen und
|
||||
letzter erfolgreicher Datenerfassung.
|
||||
- Geraeteansicht mit klarer Risikozusammenfassung, Ereignis-Timeline,
|
||||
Ransomware-Incident-Protokollen und aufgeklapptem Rohdatenexport fuer die
|
||||
technische Analyse.
|
||||
- Berichtsbereich mit Vorschau, Versandstatus, Empfaengerregeln und erneutem
|
||||
Versand einer Organisation.
|
||||
|
||||
### Visualisierung
|
||||
|
||||
- Zeitreihe fuer Hinweise, Warnungen und kritische Signale je Organisation.
|
||||
- Gestapelte Tagesansicht fuer Login-, CVE-, Ransomware- und Sensor-Signale.
|
||||
- Heatmap fuer auffaellige Zeitfenster statt einer langen, schwer lesbaren
|
||||
Ereignisliste.
|
||||
- Abdeckungsansicht: aktive Clients, veraltete Scans, Upload-Fehler und
|
||||
Geraete ohne Organisationszuordnung.
|
||||
- Jede Grafik verweist auf die zugrundeliegenden Geraete und Ereignisse; es
|
||||
gibt keine rein dekorativen Kennzahlen ohne Drill-down.
|
||||
|
||||
### Technische Leitplanken
|
||||
|
||||
- Responsive fuer Notebook, Tablet und Mobilansicht; barrierearme Kontraste und
|
||||
klare Statusfarben.
|
||||
- Datenbankabfragen liefern aggregierte Zeitreihen. Rohdaten werden nur beim
|
||||
Oeffnen einer Geraete- oder Incident-Ansicht nachgeladen.
|
||||
- Begrenzte Zeitraeume und serverseitige Pagination verhindern langsame Seiten
|
||||
bei wachsendem Datenbestand.
|
||||
- HTML-E-Mails und Weboberflaeche teilen einen konsistenten visuellen Standard,
|
||||
aber keine fragilen, kopierten CSS-Regeln.
|
||||
|
||||
### Beta und Abnahme
|
||||
|
||||
- Neue GUI zunaechst unter separatem internen Beta-Pfad neben der bestehenden
|
||||
Uebersicht bereitstellen.
|
||||
- Vergleich der neuen Kennzahlen mit den bekannten Rohdaten und Wochenberichten.
|
||||
- Pilot mit realen Organisationen, insbesondere einer groesseren Fileserver-
|
||||
Umgebung, vor Umschalten der Standardansicht.
|
||||
- Zuruecksetzen erfolgt ueber den Reverse Proxy auf die bestehende GUI; Daten
|
||||
und Empfaengerregeln bleiben dabei unveraendert.
|
||||
|
||||
## Danach: 1.8 Betrieb und Auswertung
|
||||
|
||||
- Datenqualitaetspruefung fuer unbekannte Organisationen, fehlende Zuordnung
|
||||
und veraltete Clients.
|
||||
- Sensor- und Client-Gesundheit in der internen Uebersicht.
|
||||
- Berichtsvarianten je Empfaengergruppe und nachvollziehbare Versandhistorie.
|
||||
- Betriebsmetriken fuer Upload-Fehler, Queue-Alter und Incident-Volumen.
|
||||
|
||||
## Nicht Bestandteil
|
||||
|
||||
- Kein zweiter Antivirus- oder EDR-Agent.
|
||||
- Keine Blockierung oder automatische Wiederherstellung durch OCSentinel ohne
|
||||
explizite, separat freigegebene Schutzfunktion.
|
||||
- Kein zentraler Upload aller Dateioperationen oder kompletter Eventlogs.
|
||||
|
||||
## Qualitaet in jedem Release
|
||||
|
||||
- Keine neue Erkennung ohne Beispielereignisse, Regressionstest und dokumentierte
|
||||
Bewertungslogik.
|
||||
- Jede neue Datenart benoetigt Zweck, Aufbewahrungsregel und Datenschutzpruefung.
|
||||
- Vor jeder Minor-Version: Code-Clean-up, Abhaengigkeiten pruefen, tote Pfade
|
||||
entfernen, ueberfluessige Kommentare loeschen und Dokumentation aktualisieren.
|
||||
- Release erst nach Build, Paket-Hash-Pruefung und einem Test der Update- und
|
||||
Upload-Strecke.
|
||||
@@ -113,6 +113,44 @@ def overview():
|
||||
)
|
||||
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])
|
||||
@@ -142,6 +180,28 @@ def overview():
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -149,6 +209,39 @@ def overview():
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -171,11 +264,33 @@ def device(machine_name):
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:root { --ink:#17201d; --muted:#66736d; --paper:#f5f3eb; --panel:#fffdf7; --line:#d8d4c6; --green:#236342; --lime:#c7ee6b; --amber:#b86613; --red:#a8342b; }
|
||||
:root { --ink:#132a3d; --muted:#5f7180; --paper:#eaf1f7; --panel:#ffffff; --line:#d5e1eb; --green:#14735b; --lime:#b8e36a; --amber:#a55a0a; --red:#a52b31; }
|
||||
* { 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:'Roboto',sans-serif; }.app-shell:before { content:''; position:fixed; z-index:-1; inset:0; opacity:.28; background-image:linear-gradient(rgba(35,99,66,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(35,99,66,.06) 1px,transparent 1px); background-size:34px 34px; mask-image:linear-gradient(to bottom,black,transparent 62%); }
|
||||
.masthead { height:70px; padding:0 6vw; display:flex; align-items:center; justify-content:flex-end; border-bottom:1px solid var(--line); background:rgba(255,253,247,.88); box-shadow:0 4px 22px rgba(35,56,42,.06); backdrop-filter:blur(14px); }.header-links { display:flex; gap:8px; align-items:center; }.header-links a { padding:7px 9px; border-radius:7px; color:var(--muted); font:700 12px 'Roboto',sans-serif; text-decoration:none; transition:background .18s ease,color .18s ease; }.header-links a:hover,.header-links a.active { color:var(--green); background:#e6f1e9; }
|
||||
body { margin:0; color:var(--ink); background:radial-gradient(circle at 10% -12%, #d9e9f7 0, transparent 30rem),radial-gradient(circle at 95% 8%, #dff2ec 0, transparent 24rem),var(--paper); font-family:'Roboto',sans-serif; }.app-shell:before { content:''; position:fixed; z-index:-1; inset:0; opacity:.34; background-image:linear-gradient(rgba(26,73,111,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(26,73,111,.045) 1px,transparent 1px); background-size:36px 36px; mask-image:linear-gradient(to bottom,black,transparent 68%); }
|
||||
.masthead { height:70px; padding:0 6vw; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #21445f; background:#102a43; box-shadow:0 5px 24px rgba(16,42,67,.2); }.wordmark { color:#fff; font:700 19px 'Roboto',sans-serif; letter-spacing:-.04em; text-decoration:none; }.wordmark span { display:inline-grid; place-items:center; width:27px; height:27px; margin-right:7px; border-radius:7px; background:#b8e36a; color:#102a43; font-size:10px; letter-spacing:0; }.header-links { display:flex; gap:8px; align-items:center; }.header-links a { padding:8px 10px; border-radius:6px; color:#c8d6e1; font:700 12px 'Roboto',sans-serif; text-decoration:none; transition:background .18s ease,color .18s ease; }.header-links a:hover,.header-links a.active { color:#fff; background:#245a85; }
|
||||
.brand { color:var(--ink); font:700 20px/1 'Roboto',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 'Roboto',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:32px 6vw 80px; }.hero { max-width:760px; 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; }.hero-note { display:flex; align-items:center; gap:8px; margin-top:20px; color:var(--green); font:700 11px 'Roboto',sans-serif; letter-spacing:.03em; }.hero-note span { width:8px; height:8px; border-radius:50%; background:var(--lime); box-shadow:0 0 0 4px rgba(199,238,107,.25); }
|
||||
.metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin:25px 0 46px; background:transparent; }.metrics article { min-height:130px; padding:20px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 16px rgba(35,56,42,.035); transition:transform .18s ease,box-shadow .18s ease; }.metrics article:hover { transform:translateY(-3px); box-shadow:0 12px 24px rgba(35,56,42,.09); }.metrics span { display:block; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.09em; text-transform:uppercase; }.metrics strong { display:block; margin-top:16px; font:700 31px 'Roboto',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); }
|
||||
@@ -9,8 +9,22 @@ main { max-width:1280px; margin:auto; padding:32px 6vw 80px; }.hero { max-width:
|
||||
.panel { margin-top:26px; padding:26px; background:var(--panel); border:1px solid var(--line); border-radius:5px; box-shadow:0 6px 18px rgba(35,56,42,.035); }.panel-heading h2 { margin:8px 0 22px; font-size:28px; letter-spacing:-.04em; }.panel-heading h2 small { color:var(--muted); font-size:12px; font-weight:500; letter-spacing:0; }.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); border-radius:3px; background:#fff7e9; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.alert-card:hover { transform:translateY(-2px); box-shadow:0 9px 18px rgba(88,57,20,.12); }.alert-card.critical { border-color:var(--red); background:#fff0ed; }.alert-card span,.alert-card small { display:block; font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.alert-card strong { display:block; margin:10px 0; font:700 22px 'Roboto',sans-serif; letter-spacing:-.04em; }
|
||||
.coverage-panel { padding-bottom:22px; }.coverage-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; }.coverage-metrics article { padding:15px; border:1px solid var(--line); border-radius:4px; background:#faf9f4; }.coverage-metrics span { display:block; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.coverage-metrics strong { display:block; margin-top:8px; font-size:26px; }.ok { color:var(--green); }
|
||||
table { width:100%; border-collapse:collapse; font-family:'Roboto',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; margin:1px 3px 1px 0; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px 'Roboto',sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); }.state.current { background:#e2efe6; color:var(--green); }.state.historic { background:#ece9e1; color:#68736e; } 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); }
|
||||
.report-frame { background:#fff; border:1px solid var(--line); border-radius:8px; box-shadow:0 18px 44px rgba(31,68,99,.12); overflow:hidden; }
|
||||
.panel-heading p:last-child { max-width:720px; margin:-13px 0 20px; color:var(--muted); font-size:14px; }.calm-panel { border-color:#b9d8c2; background:#f4fbf5; }
|
||||
.recipient-form { display:grid; grid-template-columns:minmax(220px,1fr) minmax(260px,1fr) auto; gap:14px; align-items:end; }.recipient-form label { display:grid; gap:6px; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.recipient-form input,.recipient-form select { min-height:40px; padding:9px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--ink); font:14px 'Roboto',sans-serif; }.recipient-form button,.rule-actions button { min-height:40px; padding:9px 13px; border:1px solid var(--green); border-radius:4px; background:var(--green); color:#fff; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.rule-actions { display:flex; gap:8px; }.rule-actions form { margin:0; }.rule-actions .button-secondary { border-color:#d8d4c6; background:#fffdf7; color:var(--ink); }.rule-actions .button-danger { border-color:#e3afa7; background:#fff0ed; color:#8a2a20; }
|
||||
@media (max-width:850px) { .recipient-form { grid-template-columns:1fr; }.rule-actions { min-width:220px; } }
|
||||
@media (max-width:850px) { .metrics { grid-template-columns:repeat(2,1fr); }.metrics article:last-child { grid-column:span 2; }.masthead { height:auto; min-height:70px; padding:14px 5vw; align-items:flex-start; }.header-links { justify-content:flex-end; flex-wrap:wrap; }.badge { display:none; } main { padding:38px 5vw; }.situation { align-items:flex-start; flex-direction:column; } }
|
||||
|
||||
.panel { border-radius:8px; box-shadow:0 14px 34px rgba(31,68,99,.08); }
|
||||
.panel > .table-wrap { border:1px solid #dce6ee; border-radius:6px; background:#fbfdff; }
|
||||
.panel > .table-wrap table { margin:0; }
|
||||
.panel > .table-wrap th { padding:12px 10px; color:#456174; background:#f0f5f9; }
|
||||
.panel > .table-wrap td { padding:14px 10px; }
|
||||
.panel > .table-wrap tbody tr:hover { background:#f2f8fb; }
|
||||
.recipient-form { padding:18px; border:1px solid #dce6ee; border-radius:6px; background:#f8fbfd; }
|
||||
.recipient-form input,.recipient-form select { border-radius:5px; background:#fff; }
|
||||
.recipient-form input:focus,.recipient-form select:focus { outline:2px solid rgba(36,90,133,.25); border-color:#245a85; }
|
||||
.rule-actions .button-secondary { border-color:var(--line); background:#f8fbfd; }
|
||||
.recipient-intro { max-width:720px; margin:6px 0 28px; }.recipient-intro h1 { margin:9px 0 10px; font-size:46px; line-height:1; letter-spacing:-.055em; }.recipient-intro p { margin:0; color:var(--muted); font-size:16px; line-height:1.55; }.recipient-intro strong { color:var(--ink); }.recipient-create-panel { margin-top:0; border-color:#c8dbe8; }.recipient-create-panel .panel-heading h2,.recipient-rules-panel .panel-heading h2 { margin:7px 0 8px; }.recipient-create-panel .panel-heading p { margin:0 0 20px; }.recipient-form button { white-space:nowrap; }.recipient-rules-panel { padding-bottom:12px; }.recipient-rules-panel .panel-heading { display:flex; align-items:end; justify-content:space-between; gap:16px; }.recipient-rules-panel .panel-heading h2 { margin-bottom:20px; }.recipient-rules-panel .panel-heading small { display:inline-block; margin-left:7px; padding:4px 7px; border-radius:12px; background:#edf4f8; color:#4d687b; font-size:10px; font-weight:700; letter-spacing:.04em; vertical-align:middle; }.recipient-table td { height:64px; }.recipient-table tr:last-child td { border-bottom:0; }.recipient-email { color:#245a85; font-weight:500; }.actions-heading { text-align:right; }.recipient-table .rule-actions { justify-content:flex-end; }.empty-state { padding:30px 10px !important; color:var(--muted); text-align:center; }
|
||||
.compact-metrics { grid-template-columns:repeat(4,1fr); }.event-summary-panel { margin-top:8px; }.event-summary-panel .panel-heading h2,.raw-export-panel .panel-heading h2 { margin:7px 0 8px; }.event-summary-panel .panel-heading p,.raw-export-panel .panel-heading p { margin:0 0 20px; }.event-count { display:inline-grid; min-width:28px; min-height:28px; place-items:center; border-radius:14px; background:#fff0d7; color:var(--amber); font:700 12px 'Roboto',sans-serif; }.raw-export-panel { margin-top:8px; }.raw-json { margin-top:18px; border-top:1px solid var(--line); }.raw-json summary { padding:14px 0; color:#245a85; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.raw-json pre { margin-bottom:0; } @media (max-width:850px) { .compact-metrics { grid-template-columns:repeat(2,1fr); }.compact-metrics article:last-child { grid-column:span 2; } }
|
||||
.trend-panel { overflow:hidden; }.trend-chart { display:grid; grid-template-columns:repeat(auto-fit,minmax(48px,1fr)); align-items:end; min-height:210px; gap:10px; padding:18px 4px 0; border-bottom:1px solid var(--line); }.trend-day { display:grid; grid-template-rows:154px auto auto; gap:5px; min-width:0; text-align:center; }.trend-bar { position:relative; align-self:end; height:max(7px,var(--bar)); border-radius:5px 5px 0 0; background:#bfd9eb; transition:height .25s ease; }.trend-critical,.trend-warning { position:absolute; right:0; left:0; bottom:0; display:block; }.trend-critical { height:var(--critical); background:var(--red); }.trend-warning { bottom:var(--critical); height:var(--warning); background:var(--amber); }.trend-day strong { font-size:13px; }.trend-day small { color:var(--muted); font-size:10px; }.chart-note { margin:15px 0 0; color:var(--muted); font-size:11px; }.legend { display:inline-block; width:8px; height:8px; margin:0 4px 0 12px; border-radius:2px; }.legend:first-child { margin-left:0; }.legend.critical { background:var(--red); }.legend.warning { background:var(--amber); }.legend.neutral { background:#bfd9eb; }.organization-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(245px,1fr)); gap:12px; }.organization-card { display:grid; gap:11px; min-height:150px; padding:18px; border:1px solid #d7e3ec; border-radius:8px; background:linear-gradient(145deg,#fff,#f3f8fb); color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease; }.organization-card:hover { border-color:#8fb7d0; box-shadow:0 14px 26px rgba(24,59,89,.12); transform:translateY(-2px); }.organization-card strong { font-size:19px; letter-spacing:-.035em; }.organization-card div { display:flex; flex-wrap:wrap; align-items:center; gap:5px; color:var(--muted); font-size:12px; }.organization-card small { color:var(--muted); font-size:10px; }.ransomware-panel { border-left:5px solid #8aa3b4; }.ransomware-panel.warning { border-left-color:var(--amber); }.ransomware-panel.critical { border-left-color:var(--red); }.ransomware-panel .panel-heading p { margin:0 0 18px; color:var(--muted); }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}OC Sentinel{% endblock %}</title>
|
||||
<title>{% block title %}OfficeCom Sentinel{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
@@ -11,7 +11,8 @@
|
||||
</head>
|
||||
<body class="app-shell">
|
||||
<header class="masthead">
|
||||
<nav class="header-links"><a class="{{ 'active' if request.endpoint == 'overview' else '' }}" href="/">Uebersicht</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
|
||||
<a class="wordmark" href="{{ url_for('overview') }}"><span>OC</span>Sentinel</a>
|
||||
<nav class="header-links"><a class="{{ 'active' if request.endpoint in ('overview', 'organization') else '' }}" href="{{ url_for('overview') }}">Lagebild</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
|
||||
</header>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
</body>
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
{% block content %}
|
||||
<section class="panel"><div class="panel-heading"><h2>{{ report[0] }}</h2><span class="state {{ report[6] }}">{{ report[6] }}</span>{% if report[8] %}<span class="state {{ 'current' if event.is_current else 'historic' }}">{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}</span>{% endif %}</div></section>
|
||||
<section class="metrics compact-metrics"><article><span>Ereignisse</span><strong>{{ report[8] }}</strong></article><article><span>Quell-IPs</span><strong>{{ report[9] }}</strong></article><article><span>CVEs</span><strong>{{ report[10] }}</strong></article><article><span>Kritische CVEs</span><strong class="critical">{{ report[11] }}</strong></article></section>
|
||||
<section class="panel"><pre>{{ payload_pretty }}</pre></section>
|
||||
{% if ransomware_beta.enabled %}<section class="panel ransomware-panel {{ ransomware_beta.state }}"><div class="panel-heading"><span class="eyebrow">Passive Beta</span><h2>Ransomware-Frueherkennung <small>{{ ransomware_beta.state }}</small></h2><p>{{ ransomware_beta.reason }}</p></div><div class="table-wrap"><table><thead><tr><th>Zeitpunkt</th><th>Signal</th><th>Prozess</th><th>Quelle</th><th>Bewertung</th></tr></thead><tbody>{% for signal in ransomware_beta.signals %}<tr><td>{{ signal.timestamp }}</td><td>{{ signal.category }}</td><td>{{ signal.process }}</td><td>{{ signal.source }}</td><td><span class="state {{ 'critical' if signal.confidence == 'high' else 'warning' }}">{{ signal.confidence }}</span></td></tr>{% else %}<tr><td colspan="5" class="empty-state">Keine Signale im aktuellen Beta-Zeitfenster.</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
|
||||
<section class="panel event-summary-panel"><div class="panel-heading"><span class="eyebrow">Schnelluebersicht</span><h2>Erkannte Sicherheitsereignisse</h2><p>Fehlgeschlagene Anmeldungen und weitere Vorfaelle aus dem letzten Scan, nach Konto und Quell-IP zusammengefasst.</p></div><div class="table-wrap"><table><thead><tr><th>Vorfall</th><th>Konto</th><th>Quell-IP</th><th>Letzter Zeitpunkt</th><th>Anzahl</th></tr></thead><tbody>{% for entry in security_events %}<tr><td><strong>{{ entry.type }}</strong></td><td>{{ entry.account }}</td><td>{{ entry.source_ip }}</td><td>{{ entry.latest }}</td><td><span class="event-count">{{ entry.count }}</span></td></tr>{% else %}<tr><td colspan="5" class="empty-state">Keine sicherheitsrelevanten Ereignisse im letzten Scan.</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="panel raw-export-panel"><div class="panel-heading"><span class="eyebrow">Technische Daten</span><h2>Roh-Export</h2><p>Vollstaendige, unveraenderte Nutzlast des zuletzt eingegangenen Scans.</p></div><details class="raw-json" open><summary>JSON-Rohdaten</summary><pre>{{ payload_pretty }}</pre></details></section>
|
||||
{% endblock %}
|
||||
|
||||
7
infra/debug-dashboard/templates/organization.html
Normal file
7
infra/debug-dashboard/templates/organization.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ organization_name }} - OfficeCom Sentinel{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero compact"><span class="eyebrow">Organisation {{ organization_id }}</span><h1>{{ organization_name }}</h1><p>{{ critical_count }} kritisch, {{ warning_count }} Warnungen. Waehle ein Geraet fuer die technische Analyse.</p></section>
|
||||
<section class="metrics compact-metrics"><article><span>Geraete</span><strong>{{ devices|length }}</strong></article><article><span>Kritisch</span><strong class="critical">{{ critical_count }}</strong></article><article><span>Warnungen</span><strong class="warning">{{ warning_count }}</strong></article><article><span>Letzte Meldung</span><strong class="timestamp">{{ devices[0][1] or '-' }}</strong></article></section>
|
||||
<section class="panel"><div class="table-wrap"><table><thead><tr><th>Geraet</th><th>Status</th><th>Ereignisse</th><th>Quell-IPs</th><th>Kritische CVEs</th><th>Empfangen</th></tr></thead><tbody>{% for device in devices %}<tr><td><a href="{{ url_for('device', machine_name=device[0]) }}">{{ device[0] }}</a></td><td><span class="state {{ device[2] }}">{{ device[2] }}</span></td><td>{{ device[3] }}</td><td>{{ device[4] }}</td><td>{{ device[5] }}</td><td>{{ device[1] or '-' }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
{% endblock %}
|
||||
@@ -1,10 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="situation {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
|
||||
<div><strong>{% if summary[2] %}Kritische Ereignisse{% elif summary[1] %}Hinweise vorhanden{% else %}Keine kritischen Auffaelligkeiten{% endif %}</strong></div>
|
||||
<div><span class="eyebrow">OfficeCom Sentinel Uebersicht</span><strong>{% if summary[2] %}Kritische Ereignisse erfordern Aufmerksamkeit{% elif summary[1] %}Hinweise im Bestand pruefen{% else %}Sicherheitslage stabil{% endif %}</strong></div>
|
||||
<span>{% if summary[2] %}KRITISCH{% elif summary[1] %}PRUEFEN{% else %}STABIL{% endif %}</span>
|
||||
</section>
|
||||
|
||||
<section class="panel trend-panel">
|
||||
<div class="panel-heading"><span class="eyebrow">Letzte 14 Tage</span><h2>Signalverlauf <small>Verdichtete Scan-Ergebnisse pro Tag</small></h2></div>
|
||||
<div class="trend-chart" aria-label="Signalverlauf der letzten 14 Tage">{% for day in trend %}<article class="trend-day"><div class="trend-bar" style="--bar: {{ (day.event_count * 100 / trend_max)|round(0, 'floor') }}%"><span class="trend-critical" style="--critical: {{ (day.critical_count * 100 / trend_max)|round(0, 'floor') }}%"></span><span class="trend-warning" style="--warning: {{ (day.warning_count * 100 / trend_max)|round(0, 'floor') }}%"></span></div><strong>{{ day.event_count }}</strong><small>{{ day.day.strftime('%d.%m.') }}</small></article>{% else %}<p class="empty-state">Noch keine Trenddaten vorhanden.</p>{% endfor %}</div>
|
||||
<p class="chart-note"><span class="legend critical"></span>kritisch <span class="legend warning"></span>Warnungen <span class="legend neutral"></span>Ereignisvolumen</p>
|
||||
</section>
|
||||
|
||||
<section class="panel organization-panel">
|
||||
<div class="panel-heading"><span class="eyebrow">Mandanten</span><h2>Organisationen <small>Drill-down bis zum einzelnen Geraet</small></h2></div>
|
||||
<div class="organization-grid">{% for organization in organizations %}<a class="organization-card" href="{{ url_for('organization', organization_id=organization.id) }}"><span class="eyebrow">{{ organization.id }}</span><strong>{{ organization.name }}</strong><div><span>{{ organization.device_count }} Geraete</span><span class="state critical">{{ organization.critical_count }} kritisch</span><span class="state warning">{{ organization.warning_count }} Warnung</span></div><small>Letzte Meldung: {{ organization.last_received_at or '-' }}</small></a>{% endfor %}</div>
|
||||
</section>
|
||||
|
||||
<section class="metrics">
|
||||
<article><span>Geraete</span><strong>{{ summary[0] }}</strong></article>
|
||||
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong></article>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Empfaenger - OC Sentinel{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel"><div class="panel-heading"><h2>Empfaenger hinzufuegen</h2></div>
|
||||
<form class="recipient-form" method="post" action="{{ url_for('add_recipient') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Organisation<select name="organization_id" id="organization_id" required onchange="document.getElementById('organization_name').value=this.options[this.selectedIndex].dataset.name"><option value="*" data-name="Alle Organisationen">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}" data-name="{{ organization[1] }}">{{ organization[1] }}</option>{% endfor %}</select></label>
|
||||
<input type="hidden" name="organization_name" id="organization_name" value="Alle Organisationen">
|
||||
<label>E-Mail-Adresse<input type="email" name="recipient_email" placeholder="name@officecom.it" required></label>
|
||||
<button type="submit">Speichern</button>
|
||||
</form></section>
|
||||
<section class="recipient-intro">
|
||||
<span class="eyebrow">Wochenberichte</span>
|
||||
<h1>Empfaenger verwalten</h1>
|
||||
<p>Lege fest, welche Personen den Sicherheitsbericht einer Organisation erhalten. Regeln fuer <strong>Alle Organisationen</strong> gelten zusaetzlich zu den einzelnen Organisationen.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel"><div class="panel-heading"><h2>E-Mail-Verteiler</h2></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Organisation</th><th>E-Mail-Adresse</th><th>Status</th><th>Aktion</th></tr></thead><tbody>
|
||||
{% for rule in rules %}<tr><td>{{ rule[2] }}</td><td>{{ rule[3] }}</td><td><span class="state {{ 'ok' if rule[4] else 'warning' }}">{{ 'aktiv' if rule[4] else 'pausiert' }}</span></td><td class="rule-actions"><form method="post" action="{{ url_for('toggle_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-secondary" type="submit">{{ 'Pausieren' if rule[4] else 'Aktivieren' }}</button></form><form method="post" action="{{ url_for('delete_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-danger" type="submit">Loeschen</button></form></td></tr>{% else %}<tr><td colspan="4">Keine Empfaengerregeln.</td></tr>{% endfor %}
|
||||
</tbody></table></div></section>
|
||||
<section class="panel recipient-create-panel">
|
||||
<div class="panel-heading"><span class="eyebrow">Neue Regel</span><h2>Bericht zustellen</h2><p>Die Adresse wird beim naechsten Wochenbericht automatisch beruecksichtigt.</p></div>
|
||||
<form class="recipient-form" method="post" action="{{ url_for('add_recipient') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Organisation<select name="organization_id" id="organization_id" required onchange="document.getElementById('organization_name').value=this.options[this.selectedIndex].dataset.name"><option value="*" data-name="Alle Organisationen">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}" data-name="{{ organization[1] }}">{{ organization[1] }}</option>{% endfor %}</select></label>
|
||||
<input type="hidden" name="organization_name" id="organization_name" value="Alle Organisationen">
|
||||
<label>E-Mail-Adresse<input type="email" name="recipient_email" placeholder="name@officecom.it" required></label>
|
||||
<button type="submit">Empfaenger hinzufuegen</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel recipient-rules-panel">
|
||||
<div class="panel-heading"><span class="eyebrow">Aktive Konfiguration</span><h2>E-Mail-Verteiler <small>{{ rules|length }} Regel{{ '' if rules|length == 1 else 'n' }}</small></h2></div>
|
||||
<div class="table-wrap recipient-table"><table><thead><tr><th>Organisation</th><th>E-Mail-Adresse</th><th>Status</th><th class="actions-heading">Verwalten</th></tr></thead><tbody>
|
||||
{% for rule in rules %}<tr><td><strong>{{ rule[2] }}</strong></td><td><a class="recipient-email" href="mailto:{{ rule[3] }}">{{ rule[3] }}</a></td><td><span class="state {{ 'ok' if rule[4] else 'warning' }}">{{ 'aktiv' if rule[4] else 'pausiert' }}</span></td><td class="rule-actions"><form method="post" action="{{ url_for('toggle_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-secondary" type="submit">{{ 'Pausieren' if rule[4] else 'Aktivieren' }}</button></form><form method="post" action="{{ url_for('delete_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-danger" type="submit">Loeschen</button></form></td></tr>{% else %}<tr><td colspan="4" class="empty-state">Noch keine Empfaengerregeln angelegt.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
222
infra/n8n/ocsentinel-weekly-organization-reports-outlook.json
Normal file
222
infra/n8n/ocsentinel-weekly-organization-reports-outlook.json
Normal file
File diff suppressed because one or more lines are too long
@@ -20,26 +20,11 @@
|
||||
"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": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{ "id": "email-enabled", "name": "emailEnabled", "value": true, "type": "boolean" }
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "email-delivery-toggle",
|
||||
"name": "E-Mail-Versand aktiv",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [1330, 300]
|
||||
},
|
||||
{
|
||||
"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": "={{ [ $('Build Organization HTML Reports').item.json.organizationId ] }}" }
|
||||
"options": { "queryReplacement": "={{ [$json.organizationId] }}" }
|
||||
},
|
||||
"id": "load-report-recipients",
|
||||
"name": "Empfaenger aus zentraler Zuordnung laden",
|
||||
@@ -50,7 +35,8 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"jsCode": "const report = $('Build Organization HTML Reports').item.json;\nconst emailEnabled = $('E-Mail-Versand aktiv').item.json.emailEnabled === true;\nif (!emailEnabled) return [];\nconst recipients = Array.from(new Set($json.recipients || []));\nif (recipients.length === 0) throw new Error(`No weekly report recipients configured for ${report.organizationName}.`);\nreturn [{ json: { ...report, recipients } }];"
|
||||
"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",
|
||||
@@ -77,9 +63,7 @@
|
||||
"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 }]] },
|
||||
"Store Weekly Organization Reports": { "main": [[{ "node": "E-Mail-Versand aktiv", "type": "main", "index": 0 }]] },
|
||||
"E-Mail-Versand aktiv": { "main": [[{ "node": "Empfaenger aus zentraler Zuordnung laden", "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 }]] }
|
||||
},
|
||||
|
||||
40
infra/n8n/weekly-organization-report-code-compact.js
Normal file
40
infra/n8n/weekly-organization-report-code-compact.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const esc = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
|
||||
const fmt = (value) => new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' });
|
||||
const now = new Date();
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
end.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7));
|
||||
const start = new Date(end.getTime() - 7 * 86400000);
|
||||
const groups = new Map();
|
||||
for (const item of items) {
|
||||
const payload = item.json.payload || {}, ninja = payload.NinjaOne || payload.ninjaOne || {};
|
||||
const id = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
|
||||
if (!groups.has(id)) groups.set(id, { id, name: String(ninja.OrganizationName || ninja.organizationName || `Organisation ${id}`), devices: [] });
|
||||
groups.get(id).devices.push({ name: item.json.machine_name || payload.MachineName || 'Unbekannt', state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(), payload });
|
||||
}
|
||||
const output = [];
|
||||
for (const group of groups.values()) {
|
||||
let warnings = 0, criticals = 0, totalEvents = 0, cveCritical = 0;
|
||||
const ips = new Set(), rows = [];
|
||||
for (const device of group.devices.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (device.state === 'warning') warnings++;
|
||||
if (device.state === 'critical') criticals++;
|
||||
cveCritical += Number((device.payload.VulnerabilityCorrelation || {}).CriticalCount || 0);
|
||||
const events = (device.payload.Events || []).filter((event) => { const date = new Date(event.Timestamp); return !Number.isNaN(date) && date >= start && date < end; });
|
||||
totalEvents += events.length;
|
||||
if (!events.length) { rows.push(`<tr class="clean"><td>${esc(device.name)}</td><td colspan="5">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td><b class="ok">Sauber</b></td></tr>`); continue; }
|
||||
const grouped = new Map();
|
||||
for (const event of events) {
|
||||
const ip = event.SourceIp || '-', account = event.Username || '-', type = event.Target || 'Sicherheitsereignis', key = [type, account, ip].join('|');
|
||||
const entry = grouped.get(key) || { ip, account, type, count: 0, latest: event.Timestamp };
|
||||
entry.count++; if (new Date(event.Timestamp) > new Date(entry.latest)) entry.latest = event.Timestamp; grouped.set(key, entry); if (ip !== '-') ips.add(ip);
|
||||
}
|
||||
for (const event of grouped.values()) rows.push(`<tr class="alert"><td>${esc(device.name)}</td><td>${esc(event.type)}</td><td>${esc(event.account)}</td><td>${esc(fmt(event.latest))}</td><td>${event.count}</td><td>${esc(event.ip)}</td><td><b class="${device.state === 'critical' ? 'critical' : 'warning'}">${device.state === 'critical' ? 'Kritisch' : 'Pruefen'}</b></td></tr>`);
|
||||
}
|
||||
const risk = criticals ? ['Kritisch', 'critical'] : warnings ? ['Beobachten', 'warning'] : ['Unauffaellig', 'ok'];
|
||||
const html = `<div class="ocs"><style>.ocs{font:13px Segoe UI,Tahoma,sans-serif;color:#172033;max-width:1160px}.ocs .head{background:#102a43;color:#fff;padding:18px 20px;border-radius:8px 8px 0 0}.ocs h1{margin:0;font-size:20px}.ocs .meta{color:#d5e2ee;margin-top:5px}.ocs .risk{float:right;padding:4px 8px;border-radius:12px}.ocs .stats{width:100%;border-collapse:separate;border-spacing:8px;margin:8px -8px}.ocs .stats td{width:16%;padding:9px;background:#f4f8fc;border:1px solid #dbe5ef}.ocs .stats b{display:block;font-size:20px;color:#102a43}.ocs table{width:100%;border-collapse:collapse}.ocs th{background:#1d4e89;color:#fff;text-align:left;padding:8px;font-size:11px}.ocs td{padding:8px;border-bottom:1px solid #dbe5ef;vertical-align:top}.ocs .alert{background:#fff8f3}.ocs .clean{background:#f3fbf6}.ocs .ok,.ocs .warning,.ocs .critical{padding:3px 6px;border-radius:4px}.ocs .ok{background:#d1fae5;color:#065f46}.ocs .warning{background:#fef3c7;color:#92400e}.ocs .critical{background:#fee2e2;color:#991b1b}.ocs .foot{margin-top:12px;text-align:right;color:#64748b;font-size:10px}</style><div class="head"><b class="risk ${risk[1]}">${risk[0]}</b><h1>OfficeCom Sentinel Sicherheitsbericht</h1><div class="meta">${esc(group.name)} | ${esc(start.toLocaleDateString('de-DE'))} bis ${esc(end.toLocaleDateString('de-DE'))}</div></div><table class="stats"><tr><td><b>${group.devices.length}</b>Geräte</td><td><b>${criticals}</b>Kritisch</td><td><b>${warnings}</b>Warnungen</td><td><b>${totalEvents}</b>Ereignisse</td><td><b>${ips.size}</b>Quell-IP-Adressen</td><td><b>${cveCritical}</b>Kritische CVEs</td></tr></table><table><thead><tr><th>System</th><th>Vorfall</th><th>Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Quell-IP</th><th>Bewertung</th></tr></thead><tbody>${rows.join('')}</tbody></table><div class="foot">Automatisch erstellt am ${esc(fmt(now))} durch OfficeCom Sentinel.</div></div>`;
|
||||
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 }) } });
|
||||
}
|
||||
return output;
|
||||
129
infra/n8n/weekly-organization-report-code.js
Normal file
129
infra/n8n/weekly-organization-report-code.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const esc = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
// Numeric entities keep German text intact in every supported mail client.
|
||||
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
|
||||
|
||||
const formatDate = (value) => new Date(value).toLocaleString('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const periodEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
periodEnd.setUTCDate(periodEnd.getUTCDate() - ((periodEnd.getUTCDay() + 6) % 7));
|
||||
const periodStart = new Date(periodEnd.getTime() - 7 * 86400000);
|
||||
const groups = new Map();
|
||||
|
||||
for (const item of items) {
|
||||
const payload = item.json.payload || {};
|
||||
const ninja = payload.NinjaOne || payload.ninjaOne || {};
|
||||
const organizationId = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
|
||||
const organizationName = String(ninja.OrganizationName || ninja.organizationName || `Organisation ${organizationId}`);
|
||||
|
||||
if (!groups.has(organizationId)) {
|
||||
groups.set(organizationId, { organizationId, organizationName, devices: [] });
|
||||
}
|
||||
|
||||
groups.get(organizationId).devices.push({
|
||||
machineName: item.json.machine_name || payload.MachineName || 'Unbekannt',
|
||||
state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(),
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
const reportItems = [];
|
||||
|
||||
for (const group of groups.values()) {
|
||||
let warningCount = 0;
|
||||
let criticalCount = 0;
|
||||
let totalEvents = 0;
|
||||
let cveTotal = 0;
|
||||
let cveCritical = 0;
|
||||
const uniqueIps = new Set();
|
||||
const alertRows = [];
|
||||
const cleanRows = [];
|
||||
|
||||
for (const device of group.devices.sort((left, right) => left.machineName.localeCompare(right.machineName))) {
|
||||
if (device.state === 'warning') warningCount++;
|
||||
if (device.state === 'critical') criticalCount++;
|
||||
|
||||
const vulnerabilities = device.payload.VulnerabilityCorrelation || {};
|
||||
cveTotal += Number(vulnerabilities.TotalCount || 0);
|
||||
cveCritical += Number(vulnerabilities.CriticalCount || 0);
|
||||
|
||||
const events = (device.payload.Events || []).filter((event) => {
|
||||
const timestamp = new Date(event.Timestamp);
|
||||
return !Number.isNaN(timestamp) && timestamp >= periodStart && timestamp < periodEnd;
|
||||
});
|
||||
totalEvents += events.length;
|
||||
|
||||
if (events.length === 0) {
|
||||
cleanRows.push(`<tr class="clean"><td>${esc(device.machineName)}</td><td colspan="5">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td><span class="badge badge-ok">Sauber</span></td></tr>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupedEvents = new Map();
|
||||
for (const event of events) {
|
||||
const sourceIp = event.SourceIp || '-';
|
||||
const account = event.Username || '-';
|
||||
const type = event.Target || 'Sicherheitsereignis';
|
||||
const key = [type, account, sourceIp].join('|');
|
||||
const row = groupedEvents.get(key) || { type, account, sourceIp, count: 0, latest: event.Timestamp };
|
||||
row.count++;
|
||||
if (new Date(event.Timestamp) > new Date(row.latest)) row.latest = event.Timestamp;
|
||||
groupedEvents.set(key, row);
|
||||
if (sourceIp !== '-') uniqueIps.add(sourceIp);
|
||||
}
|
||||
|
||||
for (const event of groupedEvents.values()) {
|
||||
const severity = device.state === 'critical' ? 'Kritisch' : 'Pruefen';
|
||||
const badge = device.state === 'critical' ? 'badge-critical' : 'badge-warning';
|
||||
alertRows.push(`<tr class="alert"><td>${esc(device.machineName)}</td><td>${esc(event.type)}</td><td>${esc(event.account)}</td><td>${esc(formatDate(event.latest))}</td><td>${event.count}</td><td>${esc(event.sourceIp)}</td><td><span class="badge ${badge}">${severity}</span></td></tr>`);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
deviceCount: group.devices.length,
|
||||
warningCount,
|
||||
criticalCount,
|
||||
totalEvents,
|
||||
uniqueIps: uniqueIps.size,
|
||||
cveTotal,
|
||||
cveCritical
|
||||
};
|
||||
const riskLabel = criticalCount > 0 ? 'Kritisch' : warningCount > 0 ? 'Beobachten' : 'Unauffaellig';
|
||||
const riskClass = criticalCount > 0 ? 'risk-critical' : warningCount > 0 ? 'risk-warning' : 'risk-ok';
|
||||
const rows = alertRows.length > 0 ? `${alertRows.join('')}${cleanRows.join('')}` : cleanRows.join('');
|
||||
|
||||
const reportHtml = `<div class="ocsentinel-report">
|
||||
<style>
|
||||
.ocsentinel-report{max-width:1180px;margin:0 auto;font-family:Segoe UI,Tahoma,sans-serif;font-size:13px;line-height:1.35;color:#172033;background:#fff}
|
||||
.ocsentinel-report .header{padding:18px 20px;background:#102a43;color:#fff;border-radius:8px 8px 0 0}
|
||||
.ocsentinel-report h1{margin:0;font-size:20px;line-height:1.2}.ocsentinel-report .subtitle{margin-top:5px;color:#cbd5e1;font-size:12px}
|
||||
.ocsentinel-report .risk{float:right;padding:5px 9px;border-radius:999px;font-size:11px;font-weight:700}.ocsentinel-report .risk-ok{background:#d1fae5;color:#065f46}.ocsentinel-report .risk-warning{background:#fef3c7;color:#92400e}.ocsentinel-report .risk-critical{background:#fee2e2;color:#991b1b}
|
||||
.ocsentinel-report .body{padding:16px 20px 20px;border:1px solid #dbe5ef;border-top:0}.ocsentinel-report .metrics{width:100%;border-collapse:separate;border-spacing:8px 0;margin:0 -8px 15px}.ocsentinel-report .metrics td{width:16.66%;padding:10px;background:#f6f9fc;border:1px solid #dbe5ef;border-radius:5px}.ocsentinel-report .metric-value{display:block;font-size:20px;font-weight:700;color:#102a43}.ocsentinel-report .metric-label{display:block;font-size:10px;color:#526577;text-transform:uppercase;letter-spacing:.04em}
|
||||
.ocsentinel-report table{width:100%;border-collapse:collapse}.ocsentinel-report th{padding:8px;background:#1d4e89;color:#fff;text-align:left;font-size:11px}.ocsentinel-report td{padding:8px;border-bottom:1px solid #dbe5ef;vertical-align:top}.ocsentinel-report tr.alert{background:#fff8f3}.ocsentinel-report tr.clean{background:#f3fbf6;color:#275b3b}.ocsentinel-report .badge{display:inline-block;padding:3px 6px;border-radius:4px;font-size:10px;font-weight:700}.ocsentinel-report .badge-ok{background:#d1fae5;color:#065f46}.ocsentinel-report .badge-warning{background:#fef3c7;color:#92400e}.ocsentinel-report .badge-critical{background:#fee2e2;color:#991b1b}.ocsentinel-report .footer{margin-top:14px;color:#64748b;font-size:10px;text-align:right}
|
||||
</style>
|
||||
<div class="header"><span class="risk ${riskClass}">${riskLabel}</span><h1>OfficeCom Sentinel Sicherheitsbericht</h1><div class="subtitle">${esc(group.organizationName)} | ${esc(periodStart.toLocaleDateString('de-DE'))} bis ${esc(periodEnd.toLocaleDateString('de-DE'))}</div></div>
|
||||
<div class="body"><table class="metrics"><tr><td><span class="metric-value">${summary.deviceCount}</span><span class="metric-label">Geräte</span></td><td><span class="metric-value">${summary.criticalCount}</span><span class="metric-label">Kritisch</span></td><td><span class="metric-value">${summary.warningCount}</span><span class="metric-label">Warnungen</span></td><td><span class="metric-value">${summary.totalEvents}</span><span class="metric-label">Ereignisse</span></td><td><span class="metric-value">${summary.uniqueIps}</span><span class="metric-label">Quell-IP-Adressen</span></td><td><span class="metric-value">${summary.cveCritical}</span><span class="metric-label">Kritische CVEs</span></td></tr></table>
|
||||
<table><thead><tr><th>System</th><th>Vorfall</th><th>Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Quell-IP</th><th>Bewertung</th></tr></thead><tbody>${rows}</tbody></table><div class="footer">Automatisch erstellt am ${esc(formatDate(now))} durch OfficeCom Sentinel.</div></div></div>`;
|
||||
|
||||
reportItems.push({
|
||||
json: {
|
||||
organizationId: group.organizationId,
|
||||
organizationName: group.organizationName,
|
||||
periodStartUtc: periodStart.toISOString(),
|
||||
periodEndUtc: periodEnd.toISOString(),
|
||||
...summary,
|
||||
reportHtml,
|
||||
summaryJson: JSON.stringify(summary)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return reportItems;
|
||||
41
infra/n8n/weekly-organization-report-outlook.js
Normal file
41
infra/n8n/weekly-organization-report-outlook.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const esc = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
|
||||
const now = new Date();
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
end.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7));
|
||||
const start = new Date(end.getTime() - 7 * 86400000);
|
||||
const formatDate = (value) => new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' });
|
||||
const groups = new Map();
|
||||
for (const item of items) {
|
||||
const payload = item.json.payload || {}, ninja = payload.NinjaOne || payload.ninjaOne || {};
|
||||
const id = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
|
||||
if (!groups.has(id)) groups.set(id, { id, name: String(ninja.OrganizationName || ninja.organizationName || `Organisation ${id}`), devices: [] });
|
||||
groups.get(id).devices.push({ name: item.json.machine_name || payload.MachineName || 'Unbekannt', state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(), payload });
|
||||
}
|
||||
const output = [];
|
||||
for (const group of groups.values()) {
|
||||
let warnings = 0, criticals = 0, totalEvents = 0, cveCritical = 0;
|
||||
const ips = new Set(), rows = [];
|
||||
for (const device of group.devices.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (device.state === 'warning') warnings++;
|
||||
if (device.state === 'critical') criticals++;
|
||||
cveCritical += Number((device.payload.VulnerabilityCorrelation || {}).CriticalCount || 0);
|
||||
const events = (device.payload.Events || []).filter((event) => { const timestamp = new Date(event.Timestamp); return !Number.isNaN(timestamp) && timestamp >= start && timestamp < end; });
|
||||
totalEvents += events.length;
|
||||
if (!events.length) { rows.push(`<tr bgcolor="#f0fdf4"><td style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px">${esc(device.name)}</td><td colspan="5" style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px;color:#166534">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px;color:#166534"><b>Sauber</b></td></tr>`); continue; }
|
||||
const grouped = new Map();
|
||||
for (const event of events) {
|
||||
const ip = event.SourceIp || '-', account = event.Username || '-', type = event.Target || 'Sicherheitsereignis', key = [type, account, ip].join('|');
|
||||
const entry = grouped.get(key) || { ip, account, type, count: 0, latest: event.Timestamp };
|
||||
entry.count++; if (new Date(event.Timestamp) > new Date(entry.latest)) entry.latest = event.Timestamp; grouped.set(key, entry); if (ip !== '-') ips.add(ip);
|
||||
}
|
||||
for (const event of grouped.values()) { const critical = device.state === 'critical'; rows.push(`<tr bgcolor="#fff7ed"><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(device.name)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.type)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.account)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(formatDate(event.latest))}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${event.count}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.ip)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px;color:${critical ? '#991b1b' : '#92400e'}"><b>${critical ? 'Kritisch' : 'Pruefen'}</b></td></tr>`); }
|
||||
}
|
||||
const risk = criticals ? ['Kritisch', '#991b1b', '#fee2e2'] : warnings ? ['Beobachten', '#92400e', '#fef3c7'] : ['Unauffaellig', '#166534', '#dcfce7'];
|
||||
const metric = (value, label) => `<td width="16.66%" valign="top" style="padding:10px;background:#f8fafc;border:1px solid #dbe5ef;font-family:Arial,sans-serif"><b style="font-size:20px;color:#102a43">${value}</b><br><span style="font-size:10px;color:#526577">${label}</span></td>`;
|
||||
const html = `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:1100px;border-collapse:collapse"><tr><td style="padding:18px 20px;background:#102a43"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td valign="top" style="font-family:Arial,sans-serif;color:#ffffff"><h2 style="margin:0;font-size:20px;color:#ffffff">OfficeCom Sentinel Sicherheitsbericht</h2><p style="margin:6px 0 0;color:#d5e2ee;font-size:12px">${esc(group.name)} | ${esc(start.toLocaleDateString('de-DE'))} bis ${esc(end.toLocaleDateString('de-DE'))}</p></td><td width="100" align="right" valign="top" style="font-family:Arial,sans-serif"><span style="display:inline-block;padding:5px 9px;background:${risk[2]};color:${risk[1]};font-size:11px"><b>${risk[0]}</b></span></td></tr></table></td></tr><tr><td style="padding:16px 20px;border:1px solid #dbe5ef"><table role="presentation" width="100%" cellspacing="6" cellpadding="0" border="0"><tr>${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')}</tr></table><table width="100%" cellspacing="0" cellpadding="0" border="0" style="border-collapse:collapse"><thead><tr bgcolor="#1d4e89"><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">System</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Vorfall</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Konto</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Letzter Zeitpunkt</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Anzahl</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Quell-IP</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Bewertung</th></tr></thead><tbody>${rows.join('')}</tbody></table><p style="margin:14px 0 0;text-align:right;color:#64748b;font-family:Arial,sans-serif;font-size:10px">Automatisch erstellt am ${esc(formatDate(now))} durch OfficeCom Sentinel.</p></td></tr></table>`;
|
||||
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 }) } });
|
||||
}
|
||||
return output;
|
||||
@@ -96,14 +96,23 @@ function Compare-Version {
|
||||
[Parameter(Mandatory)][string]$Right
|
||||
)
|
||||
|
||||
try {
|
||||
$leftVersion = [System.Version]$Left
|
||||
$rightVersion = [System.Version]$Right
|
||||
return $leftVersion.CompareTo($rightVersion)
|
||||
}
|
||||
catch {
|
||||
return [string]::Compare($Left, $Right, $true)
|
||||
$pattern = '^(?<version>\d+(?:\.\d+){0,3})(?:-(?<prerelease>.+))?$'
|
||||
$leftMatch = [regex]::Match($Left, $pattern)
|
||||
$rightMatch = [regex]::Match($Right, $pattern)
|
||||
if ($leftMatch.Success -and $rightMatch.Success) {
|
||||
$numericComparison = ([System.Version]$leftMatch.Groups['version'].Value).CompareTo([System.Version]$rightMatch.Groups['version'].Value)
|
||||
if ($numericComparison -ne 0) {
|
||||
return $numericComparison
|
||||
}
|
||||
|
||||
$leftPrerelease = $leftMatch.Groups['prerelease'].Value
|
||||
$rightPrerelease = $rightMatch.Groups['prerelease'].Value
|
||||
if ([string]::IsNullOrWhiteSpace($leftPrerelease) -and -not [string]::IsNullOrWhiteSpace($rightPrerelease)) { return 1 }
|
||||
if (-not [string]::IsNullOrWhiteSpace($leftPrerelease) -and [string]::IsNullOrWhiteSpace($rightPrerelease)) { return -1 }
|
||||
return [string]::Compare($leftPrerelease, $rightPrerelease, $true)
|
||||
}
|
||||
|
||||
return [string]::Compare($Left, $Right, $true)
|
||||
}
|
||||
|
||||
function Get-Sha256Hex {
|
||||
|
||||
5
release/beta/README.md
Normal file
5
release/beta/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# OCSentinel Beta Channel
|
||||
|
||||
This directory contains the current beta `version.json` only after a tested
|
||||
pre-release has been published. Pilot devices use this channel; stable devices
|
||||
continue to use `release/stable/version.json`.
|
||||
8
release/beta/version.json
Normal file
8
release/beta/version.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"channel": "beta",
|
||||
"version": "1.5.0-beta.1",
|
||||
"publishedAtUtc": "2026-07-29T23:04:51.9334773Z",
|
||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.1/OCSentinelClient-win-x64.zip",
|
||||
"sha256": "2bd6db7261f7ca9b47741968b922aea5969251539530e58441e882f53686ae6b",
|
||||
"minUpdaterVersion": "1.0.0"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"channel": "stable",
|
||||
"version": "1.3.6",
|
||||
"publishedAtUtc": "2026-07-27T08:45:53.0419623Z",
|
||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.3.6/OCSentinelClient-win-x64.zip",
|
||||
"sha256": "48e9faa242a59797bed9732ae5b8914ab44843b9c949260a17bc15b47659a3d5",
|
||||
"version": "1.4.0",
|
||||
"publishedAtUtc": "2026-07-28T22:50:17.7779552Z",
|
||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.4.0/OCSentinelClient-win-x64.zip",
|
||||
"sha256": "ece66505c4146fec72ba12cb59d3d0a39bb91a3aee44e338d19c186e56e2fad7",
|
||||
"minUpdaterVersion": "1.0.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json",
|
||||
[string]$ManifestUrl = "",
|
||||
[ValidateSet("stable", "beta")]
|
||||
[string]$ReleaseChannel = "stable",
|
||||
[string]$WebhookUrl = "",
|
||||
[string]$SecretValue = "",
|
||||
[switch]$RunInitialStatusScan
|
||||
@@ -112,6 +114,20 @@ function Get-OCSentinelArtifact {
|
||||
|
||||
Initialize-OCSentinelTls
|
||||
|
||||
if ($ReleaseChannel -eq "stable" -and -not [string]::IsNullOrWhiteSpace($env:ReleaseChannel)) {
|
||||
$requestedChannel = $env:ReleaseChannel.Trim().ToLowerInvariant()
|
||||
if ($requestedChannel -notin @("stable", "beta")) {
|
||||
throw "ReleaseChannel must be stable or beta."
|
||||
}
|
||||
$ReleaseChannel = $requestedChannel
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
|
||||
$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/$ReleaseChannel/version.json"
|
||||
}
|
||||
|
||||
Write-Host "OCSentinel release channel: $ReleaseChannel"
|
||||
|
||||
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
|
||||
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||
|
||||
@@ -34,6 +34,7 @@ internal sealed class AttackScanner
|
||||
ScanExchangeLogons(attacks, errors, since);
|
||||
ScanIisFtpLogs(attacks, errors, since, configuration);
|
||||
ScanFileZillaLogs(attacks, errors, since, configuration);
|
||||
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
|
||||
|
||||
if (configuration.ExcludedIps.Count > 0)
|
||||
{
|
||||
@@ -53,8 +54,9 @@ internal sealed class AttackScanner
|
||||
.ToList();
|
||||
|
||||
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||
string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration);
|
||||
string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState);
|
||||
AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta);
|
||||
string baseAlertState = baseAssessment.State;
|
||||
string baseAlertReason = baseAssessment.Reason;
|
||||
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
|
||||
? VulnerabilityCorrelationSummary.Empty()
|
||||
: VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors);
|
||||
@@ -78,6 +80,7 @@ internal sealed class AttackScanner
|
||||
BaseAlertState = baseAlertState,
|
||||
BaseAlertReason = baseAlertReason,
|
||||
VulnerabilityCorrelation = vulnerabilityCorrelation,
|
||||
RansomwareBeta = ransomwareBeta,
|
||||
Runtime = new ScanRuntimeMetadata
|
||||
{
|
||||
StartedAtUtc = startedAtUtc,
|
||||
@@ -469,28 +472,111 @@ internal sealed class AttackScanner
|
||||
return IPAddress.TryParse(input, out _);
|
||||
}
|
||||
|
||||
private static string GetAlertState(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration)
|
||||
private static AlertAssessment AssessAttackActivity(IReadOnlyList<AttackEvent> attacks, int uniqueIpCount, ScannerConfiguration configuration)
|
||||
{
|
||||
if (totalEvents >= configuration.CriticalEventThreshold || uniqueIpCount >= configuration.CriticalUniqueIpThreshold)
|
||||
if (attacks.Count == 0)
|
||||
{
|
||||
return "critical";
|
||||
return new AlertAssessment("ok", "No failed login activity observed.");
|
||||
}
|
||||
|
||||
if (totalEvents >= configuration.WarningEventThreshold || uniqueIpCount >= configuration.WarningUniqueIpThreshold)
|
||||
TimeSpan window = TimeSpan.FromMinutes(configuration.LoginBurstWindowMinutes);
|
||||
int largestBurst = attacks
|
||||
.GroupBy(attack => (attack.SourceIp, attack.Username, attack.Target))
|
||||
.Select(group => GetPeakEventCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
int largestSpray = attacks
|
||||
.GroupBy(attack => attack.SourceIp)
|
||||
.Select(group => GetPeakDistinctAccountCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
|
||||
if (largestBurst >= configuration.CriticalLoginBurstCount || largestSpray >= configuration.CriticalSprayAccountCount)
|
||||
{
|
||||
return "warning";
|
||||
return new AlertAssessment("critical", $"High-confidence login attack pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
|
||||
}
|
||||
|
||||
return "ok";
|
||||
if (largestBurst >= configuration.WarningLoginBurstCount || largestSpray >= configuration.WarningSprayAccountCount)
|
||||
{
|
||||
return new AlertAssessment("warning", $"Suspicious login pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
|
||||
}
|
||||
|
||||
int criticalEventThreshold = Math.Max(configuration.CriticalEventThreshold, configuration.CriticalLoginBurstCount);
|
||||
int criticalIpThreshold = Math.Max(configuration.CriticalUniqueIpThreshold, configuration.CriticalSprayAccountCount);
|
||||
int warningEventThreshold = Math.Max(configuration.WarningEventThreshold, configuration.WarningLoginBurstCount * 2);
|
||||
int warningIpThreshold = Math.Max(configuration.WarningUniqueIpThreshold, configuration.WarningSprayAccountCount);
|
||||
|
||||
if (attacks.Count >= criticalEventThreshold || uniqueIpCount >= criticalIpThreshold)
|
||||
{
|
||||
return new AlertAssessment("critical", $"Critical volume threshold reached: events={attacks.Count}, unique IPs={uniqueIpCount}.");
|
||||
}
|
||||
|
||||
if (attacks.Count >= warningEventThreshold || uniqueIpCount >= warningIpThreshold)
|
||||
{
|
||||
return new AlertAssessment("warning", $"Elevated failed-login volume: events={attacks.Count}, unique IPs={uniqueIpCount}.");
|
||||
}
|
||||
|
||||
return new AlertAssessment("ok", $"Low-volume login errors observed: events={attacks.Count}, unique IPs={uniqueIpCount}; no burst or password-spraying pattern detected.");
|
||||
}
|
||||
|
||||
private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState)
|
||||
private static AlertAssessment MergeRansomwareAssessment(AlertAssessment loginAssessment, RansomwareBetaSummary ransomwareBeta)
|
||||
{
|
||||
return alertState switch
|
||||
if (ransomwareBeta.State is not ("warning" or "critical"))
|
||||
{
|
||||
"critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.",
|
||||
"warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.",
|
||||
_ => "No thresholds exceeded."
|
||||
};
|
||||
return loginAssessment;
|
||||
}
|
||||
|
||||
int loginPriority = AlertPriority(loginAssessment.State);
|
||||
int ransomwarePriority = AlertPriority(ransomwareBeta.State);
|
||||
string state = ransomwarePriority > loginPriority ? ransomwareBeta.State : loginAssessment.State;
|
||||
string reason = $"{loginAssessment.Reason} {ransomwareBeta.Reason}";
|
||||
return new AlertAssessment(state, reason);
|
||||
}
|
||||
|
||||
private static int AlertPriority(string state) => state switch
|
||||
{
|
||||
"critical" => 2,
|
||||
"warning" => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
private static int GetPeakEventCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
|
||||
{
|
||||
int start = 0;
|
||||
int peak = 0;
|
||||
for (int end = 0; end < events.Count; end++)
|
||||
{
|
||||
while (events[end].Timestamp - events[start].Timestamp > window)
|
||||
{
|
||||
start++;
|
||||
}
|
||||
peak = Math.Max(peak, end - start + 1);
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
private static int GetPeakDistinctAccountCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
|
||||
{
|
||||
var accounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
int start = 0;
|
||||
int peak = 0;
|
||||
for (int end = 0; end < events.Count; end++)
|
||||
{
|
||||
accounts[events[end].Username] = accounts.GetValueOrDefault(events[end].Username) + 1;
|
||||
while (events[end].Timestamp - events[start].Timestamp > window)
|
||||
{
|
||||
string account = events[start].Username;
|
||||
accounts[account]--;
|
||||
if (accounts[account] == 0)
|
||||
{
|
||||
accounts.Remove(account);
|
||||
}
|
||||
start++;
|
||||
}
|
||||
peak = Math.Max(peak, accounts.Count);
|
||||
}
|
||||
return peak;
|
||||
}
|
||||
|
||||
private sealed record AlertAssessment(string State, string Reason);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ internal static class ScanCommand
|
||||
Console.WriteLine($"Status: {result.AlertState}");
|
||||
Console.WriteLine($"Reason: {result.AlertReason}");
|
||||
Console.WriteLine($"Base status: {result.BaseAlertState}");
|
||||
Console.WriteLine($"Ransomware beta: {result.RansomwareBeta.State}");
|
||||
Console.WriteLine($"Ransomware beta signals: {result.RansomwareBeta.Signals.Count}");
|
||||
Console.WriteLine($"Scan errors: {result.Errors.Count}");
|
||||
Console.WriteLine();
|
||||
|
||||
|
||||
@@ -4,18 +4,38 @@ namespace OCSentinelCli;
|
||||
|
||||
internal sealed record ScannerConfiguration
|
||||
{
|
||||
public int WarningEventThreshold { get; init; } = 1;
|
||||
public int WarningEventThreshold { get; init; } = 10;
|
||||
|
||||
public int CriticalEventThreshold { get; init; } = 20;
|
||||
public int CriticalEventThreshold { get; init; } = 30;
|
||||
|
||||
public int WarningUniqueIpThreshold { get; init; } = 1;
|
||||
public int WarningUniqueIpThreshold { get; init; } = 5;
|
||||
|
||||
public int CriticalUniqueIpThreshold { get; init; } = 10;
|
||||
public int CriticalUniqueIpThreshold { get; init; } = 12;
|
||||
|
||||
public int LoginBurstWindowMinutes { get; init; } = 15;
|
||||
|
||||
public int WarningLoginBurstCount { get; init; } = 5;
|
||||
|
||||
public int CriticalLoginBurstCount { get; init; } = 20;
|
||||
|
||||
public int WarningSprayAccountCount { get; init; } = 5;
|
||||
|
||||
public int CriticalSprayAccountCount { get; init; } = 10;
|
||||
|
||||
public int CorrelationWarningCveThreshold { get; init; } = 1;
|
||||
|
||||
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
||||
|
||||
public bool RansomwareBetaEnabled { get; init; }
|
||||
|
||||
public int RansomwareLookbackMinutes { get; init; } = 15;
|
||||
|
||||
public int RansomwareWarningSignalCount { get; init; } = 2;
|
||||
|
||||
public int RansomwareCriticalSignalCount { get; init; } = 3;
|
||||
|
||||
public List<string> RansomwareExcludedProcesses { get; init; } = [];
|
||||
|
||||
public List<string> FtpRoots { get; init; } = [];
|
||||
|
||||
public List<string> FileZillaRoots { get; init; } = [];
|
||||
|
||||
@@ -80,7 +80,6 @@ internal sealed record ScanResult
|
||||
|
||||
public string MachineName { get; init; } = string.Empty;
|
||||
|
||||
// Populated only for runs launched by NinjaOne automation.
|
||||
public NinjaOneContext NinjaOne { get; init; } = new();
|
||||
|
||||
public DateTimeOffset GeneratedAtLocal { get; init; }
|
||||
@@ -105,6 +104,8 @@ internal sealed record ScanResult
|
||||
|
||||
public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new();
|
||||
|
||||
public RansomwareBetaSummary RansomwareBeta { get; init; } = new();
|
||||
|
||||
public ScanRuntimeMetadata Runtime { get; init; } = new();
|
||||
|
||||
public List<AttackEvent> Events { get; init; } = [];
|
||||
@@ -114,6 +115,36 @@ internal sealed record ScanResult
|
||||
public List<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record RansomwareBetaSummary
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string State { get; init; } = "disabled";
|
||||
|
||||
public string Reason { get; init; } = "Ransomware beta is disabled.";
|
||||
|
||||
public int LookbackMinutes { get; init; }
|
||||
|
||||
public List<RansomwareSignal> Signals { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record RansomwareSignal
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
public string Category { get; init; } = string.Empty;
|
||||
|
||||
public string Confidence { get; init; } = string.Empty;
|
||||
|
||||
public string Process { get; init; } = string.Empty;
|
||||
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
public long EventId { get; init; }
|
||||
|
||||
public string Evidence { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record NinjaOneContext
|
||||
{
|
||||
public string OrganizationId { get; init; } = string.Empty;
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<RootNamespace>OCSentinelCli</RootNamespace>
|
||||
<Product>OfficeCom Sentinel</Product>
|
||||
<Company>OfficeCom</Company>
|
||||
<Version>1.3.7</Version>
|
||||
<AssemblyVersion>1.3.7.0</AssemblyVersion>
|
||||
<FileVersion>1.3.7.0</FileVersion>
|
||||
<InformationalVersion>1.3.7</InformationalVersion>
|
||||
<Version>1.5.0-beta.1</Version>
|
||||
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
||||
<FileVersion>1.5.0.0</FileVersion>
|
||||
<InformationalVersion>1.5.0-beta.1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
172
src/OCSentinelCli/RansomwareBetaDetector.cs
Normal file
172
src/OCSentinelCli/RansomwareBetaDetector.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace OCSentinelCli;
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class RansomwareBetaDetector
|
||||
{
|
||||
public static RansomwareBetaSummary Scan(ScannerConfiguration configuration, List<string> errors)
|
||||
{
|
||||
if (!configuration.RansomwareBetaEnabled)
|
||||
{
|
||||
return new RansomwareBetaSummary();
|
||||
}
|
||||
|
||||
int lookbackMinutes = Math.Clamp(configuration.RansomwareLookbackMinutes, 1, 60);
|
||||
DateTimeOffset since = DateTimeOffset.UtcNow.AddMinutes(-lookbackMinutes);
|
||||
var signals = new List<RansomwareSignal>();
|
||||
|
||||
ScanSecurityProcesses(signals, errors, since, configuration);
|
||||
ScanPowerShellScriptBlocks(signals, errors, since, configuration);
|
||||
ScanSysmonProcesses(signals, errors, since, configuration);
|
||||
|
||||
List<RansomwareSignal> distinctSignals = signals
|
||||
.OrderBy(signal => signal.Timestamp)
|
||||
.GroupBy(signal => $"{signal.Category}\u001f{signal.Process}", StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.Take(20)
|
||||
.ToList();
|
||||
int strongSignals = distinctSignals.Count(signal => signal.Confidence == "high");
|
||||
|
||||
string state = distinctSignals.Count >= Math.Max(2, configuration.RansomwareCriticalSignalCount)
|
||||
? "critical"
|
||||
: strongSignals > 0 || distinctSignals.Count >= Math.Max(2, configuration.RansomwareWarningSignalCount)
|
||||
? "warning"
|
||||
: distinctSignals.Count > 0 ? "hint" : "ok";
|
||||
string reason = state switch
|
||||
{
|
||||
"critical" => $"Ransomware beta detected {distinctSignals.Count} independent high-risk signals within {lookbackMinutes} minutes.",
|
||||
"warning" => $"Ransomware beta detected {strongSignals} high-confidence and {distinctSignals.Count - strongSignals} low-confidence signals within {lookbackMinutes} minutes.",
|
||||
"hint" => $"Ransomware beta observed an isolated low-confidence signal within {lookbackMinutes} minutes.",
|
||||
_ => $"Ransomware beta found no suspicious process activity in the last {lookbackMinutes} minutes."
|
||||
};
|
||||
|
||||
return new RansomwareBetaSummary
|
||||
{
|
||||
Enabled = true,
|
||||
State = state,
|
||||
Reason = reason,
|
||||
LookbackMinutes = lookbackMinutes,
|
||||
Signals = distinctSignals
|
||||
};
|
||||
}
|
||||
|
||||
private static void ScanSecurityProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||
{
|
||||
TryScan("Security", 4688, since, errors, record => AddSignal(signals, record, ReadProperty(record, 5), ReadProperty(record, 8), "Security", configuration));
|
||||
}
|
||||
|
||||
private static void ScanPowerShellScriptBlocks(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||
{
|
||||
TryScan("Microsoft-Windows-PowerShell/Operational", 4104, since, errors, record => AddSignal(signals, record, "powershell", FormatDescription(record), "PowerShell", configuration));
|
||||
}
|
||||
|
||||
private static void ScanSysmonProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||
{
|
||||
TryScan("Microsoft-Windows-Sysmon/Operational", 1, since, errors, record => AddSignal(signals, record, "sysmon-process", FormatDescription(record), "Sysmon", configuration));
|
||||
}
|
||||
|
||||
private static void AddSignal(List<RansomwareSignal> signals, EventRecord record, string process, string commandLine, string source, ScannerConfiguration configuration)
|
||||
{
|
||||
if (!record.TimeCreated.HasValue || string.IsNullOrWhiteSpace(commandLine))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string processName = Path.GetFileName(process.Trim());
|
||||
if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, processName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RansomwareSignal? signal = Classify(record.TimeCreated.Value, record.Id, processName, commandLine, source);
|
||||
if (signal is not null)
|
||||
{
|
||||
signals.Add(signal);
|
||||
}
|
||||
}
|
||||
|
||||
private static RansomwareSignal? Classify(DateTime timestamp, int eventId, string process, string commandLine, string source)
|
||||
{
|
||||
string value = commandLine.ToLowerInvariant();
|
||||
string evidence = commandLine.Length > 512 ? commandLine[..512] : commandLine;
|
||||
if (ContainsAll(value, "vssadmin", "delete", "shadow") || ContainsAll(value, "wmic", "shadowcopy", "delete") || ContainsAll(value, "win32_shadowcopy", "delete"))
|
||||
{
|
||||
return CreateSignal(timestamp, eventId, "shadow-copy-deletion", "high", process, source, evidence);
|
||||
}
|
||||
|
||||
if (ContainsAll(value, "wbadmin", "delete") || ContainsAll(value, "catalog", "delete"))
|
||||
{
|
||||
return CreateSignal(timestamp, eventId, "backup-catalog-deletion", "high", process, source, evidence);
|
||||
}
|
||||
|
||||
if (ContainsAll(value, "bcdedit", "recoveryenabled", "no") || ContainsAll(value, "bcdedit", "bootstatuspolicy", "ignoreallfailures"))
|
||||
{
|
||||
return CreateSignal(timestamp, eventId, "recovery-disable", "high", process, source, evidence);
|
||||
}
|
||||
|
||||
if (ContainsAll(value, "wevtutil", " cl "))
|
||||
{
|
||||
return CreateSignal(timestamp, eventId, "event-log-clearing", "high", process, source, evidence);
|
||||
}
|
||||
|
||||
return value.Contains("win32_shadowcopy", StringComparison.Ordinal)
|
||||
? CreateSignal(timestamp, eventId, "shadow-copy-access", "low", process, source, evidence)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static RansomwareSignal CreateSignal(DateTime timestamp, int eventId, string category, string confidence, string process, string source, string evidence)
|
||||
{
|
||||
return new RansomwareSignal
|
||||
{
|
||||
Timestamp = new DateTimeOffset(timestamp).ToLocalTime(),
|
||||
Category = category,
|
||||
Confidence = confidence,
|
||||
Process = string.IsNullOrWhiteSpace(process) ? "[unknown]" : process,
|
||||
Source = source,
|
||||
EventId = eventId,
|
||||
Evidence = evidence
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ContainsAll(string value, params string[] needles) => needles.All(needle => value.Contains(needle, StringComparison.Ordinal));
|
||||
|
||||
private static void TryScan(string logName, int eventId, DateTimeOffset since, List<string> errors, Action<EventRecord> processRecord)
|
||||
{
|
||||
try
|
||||
{
|
||||
long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - since).TotalMilliseconds);
|
||||
string query = $"*[System[(EventID={eventId}) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]";
|
||||
using var reader = new EventLogReader(new EventLogQuery(logName, PathType.LogName, query));
|
||||
for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent())
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
processRecord(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
errors.Add($"Ransomware beta query failed for {logName}: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadProperty(EventRecord record, int index) => index >= 0 && index < record.Properties.Count ? record.Properties[index].Value?.ToString()?.Trim() ?? string.Empty : string.Empty;
|
||||
|
||||
private static string FormatDescription(EventRecord record)
|
||||
{
|
||||
try
|
||||
{
|
||||
return record.FormatDescription() ?? string.Empty;
|
||||
}
|
||||
catch (EventLogException)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user