Compare commits
74 Commits
v1.2.8
...
v1.5.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ca50a4ee9 | ||
|
|
d1f78a38fd | ||
|
|
cc77c45a10 | ||
|
|
c73bab139b | ||
|
|
7009596efc | ||
|
|
a494bc4ba3 | ||
|
|
ddba660b1a | ||
|
|
517cfa6773 | ||
|
|
072a4257ed | ||
|
|
47fe9448b7 | ||
|
|
fd990b698f | ||
|
|
d1bbd8838f | ||
|
|
feeeeedcf1 | ||
|
|
ceefb7a5dc | ||
|
|
8407d0c5b2 | ||
|
|
22be9689e2 | ||
|
|
f69f14e0b1 | ||
|
|
94f5be8953 | ||
|
|
b97f8819f6 | ||
|
|
0207d84775 | ||
|
|
c65001aa17 | ||
|
|
42b387f3ef | ||
|
|
c3ca95dfa5 | ||
|
|
58ad77242f | ||
|
|
412178055f | ||
|
|
c52fea835c | ||
|
|
e711dc7029 | ||
|
|
a81d8b9830 | ||
|
|
64841d36e7 | ||
|
|
66dcfe09b6 | ||
|
|
c74d5582b0 | ||
|
|
4b9202b47c | ||
|
|
a5cea5ebad | ||
|
|
db0533a4cf | ||
|
|
f054702438 | ||
|
|
e357e6329d | ||
|
|
585b4f91b1 | ||
|
|
1eedac4a76 | ||
|
|
5d5828db49 | ||
|
|
2cf3281b4d | ||
|
|
b2da734c75 | ||
|
|
40c6daada8 | ||
|
|
5be4fb6c33 | ||
|
|
0fe8a96057 | ||
|
|
eb621ace5a | ||
|
|
e997e6b58c | ||
|
|
3f09805999 | ||
|
|
85f0682769 | ||
|
|
6722b00ee7 | ||
|
|
d3897c8e69 | ||
|
|
f9941b0807 | ||
|
|
c768e1be6b | ||
|
|
2a780cd52f | ||
|
|
daa494fade | ||
|
|
e7fd4e7ef5 | ||
|
|
dfdae7a532 | ||
|
|
854c99e3b2 | ||
|
|
2598de2ecc | ||
|
|
6c791cc417 | ||
|
|
0adac8a0d9 | ||
|
|
a45a811040 | ||
|
|
2f2a553fc2 | ||
|
|
6ceb29a07b | ||
|
|
1c2170090e | ||
|
|
91c5794502 | ||
|
|
f392057535 | ||
|
|
7431c656d3 | ||
|
|
49b0e3025d | ||
|
|
aefec51581 | ||
|
|
7555e92aac | ||
|
|
b97b554f84 | ||
|
|
77d7eaa0b5 | ||
|
|
f91f45ad92 | ||
|
|
c27b53ea0d |
@@ -45,6 +45,39 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: "10.0.x"
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Install PowerShell
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if command -v pwsh >/dev/null 2>&1; then
|
||||||
|
pwsh --version
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
SUDO=""
|
||||||
|
elif command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
echo "PowerShell is missing and this runner cannot install packages."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
case "$ID" in
|
||||||
|
ubuntu) MICROSOFT_REPO="https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" ;;
|
||||||
|
debian) MICROSOFT_REPO="https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" ;;
|
||||||
|
*) echo "Unsupported runner distribution: $ID"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y ca-certificates curl
|
||||||
|
curl -fsSL "$MICROSOFT_REPO" -o /tmp/packages-microsoft-prod.deb
|
||||||
|
$SUDO dpkg -i /tmp/packages-microsoft-prod.deb
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y powershell
|
||||||
|
pwsh --version
|
||||||
|
|
||||||
- name: Build client package
|
- name: Build client package
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -60,20 +93,13 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
$artifactUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/$tag/OCSentinelClient-win-x64.zip"
|
$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: Upload package artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ocsentinel-client-${{ github.sha }}
|
|
||||||
path: |
|
|
||||||
artifacts/OCSentinelClient-win-x64.zip
|
|
||||||
artifacts/OCSentinelClient-win-x64.zip.sha256
|
|
||||||
artifacts/version.json
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Publish Gitea release assets
|
- name: Publish Gitea release assets
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
$ref = if ($env:GITHUB_REF) { $env:GITHUB_REF } else { $env:GITEA_REF }
|
$ref = if ($env:GITHUB_REF) { $env:GITHUB_REF } else { $env:GITEA_REF }
|
||||||
$tag = if ($env:GITHUB_REF_NAME) { $env:GITHUB_REF_NAME } else { Split-Path -Leaf $ref }
|
$tag = if ($env:GITHUB_REF_NAME) { $env:GITHUB_REF_NAME } else { Split-Path -Leaf $ref }
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
artifacts/
|
artifacts/
|
||||||
|
__pycache__/
|
||||||
reports/
|
reports/
|
||||||
_extracted/
|
_extracted/
|
||||||
_tools/
|
_tools/
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ OfficeCom Sentinel is the hardened endpoint client for Windows event correlation
|
|||||||
- setup EXE builder: `build/build-client-installer.ps1`
|
- setup EXE builder: `build/build-client-installer.ps1`
|
||||||
- update manifest builder: `build/build-release-manifest.ps1`
|
- update manifest builder: `build/build-release-manifest.ps1`
|
||||||
- release checklist: `docs/release-checklist.md`
|
- 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`
|
- internal server-side target example: `infra/postgres-target.example.json`
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Copy-Item -Path (Join-Path $installerRoot "uninstall-ocsentinel.ps1") -Destinati
|
|||||||
Copy-Item -Path (Join-Path $installerRoot "update-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\update-ocsentinel.ps1") -Force
|
Copy-Item -Path (Join-Path $installerRoot "update-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\update-ocsentinel.ps1") -Force
|
||||||
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Force
|
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Force
|
||||||
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-monitor.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Force
|
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-monitor.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Force
|
||||||
|
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-scheduled.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-scheduled.ps1") -Force
|
||||||
Copy-Item -Path (Join-Path $repoRoot "scripts\protect-ocsentinel-secret.ps1") -Destination (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Force
|
Copy-Item -Path (Join-Path $repoRoot "scripts\protect-ocsentinel-secret.ps1") -Destination (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Force
|
||||||
|
|
||||||
Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-settings.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-settings.example.json") -Force
|
Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-settings.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-settings.example.json") -Force
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
"lookbackDays": 7,
|
"lookbackDays": 7,
|
||||||
"topFindings": 10,
|
"topFindings": 10,
|
||||||
"n8nWebhookUrl": "http://172.16.41.197:5678/webhook/ocsentinel-ingest",
|
"n8nWebhookUrl": "http://172.16.41.197:5678/webhook/ocsentinel-ingest",
|
||||||
|
"ninjaOrganizationId": "",
|
||||||
|
"ninjaOrganizationName": "",
|
||||||
|
"ninjaMachineId": "",
|
||||||
|
"ninjaNodeId": "",
|
||||||
|
"ninjaLocationId": "",
|
||||||
|
"ninjaLocationName": "",
|
||||||
"deviceIdentifierMode": "machineName",
|
"deviceIdentifierMode": "machineName",
|
||||||
"uploadTimeoutSeconds": 30,
|
"uploadTimeoutSeconds": 30,
|
||||||
|
"uploadQueueMaxReports": 100,
|
||||||
"enableVulnerabilityCorrelation": true,
|
"enableVulnerabilityCorrelation": true,
|
||||||
"vulnerabilityCsvPath": "",
|
"vulnerabilityCsvPath": "",
|
||||||
"secretReference": "device-default"
|
"secretReference": "device-default"
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
"lookbackDays": 7,
|
"lookbackDays": 7,
|
||||||
"topFindings": 10,
|
"topFindings": 10,
|
||||||
"n8nWebhookUrl": "https://n8n.example.com/webhook/ocsentinel-ingest",
|
"n8nWebhookUrl": "https://n8n.example.com/webhook/ocsentinel-ingest",
|
||||||
|
"ninjaOrganizationId": "",
|
||||||
|
"ninjaOrganizationName": "",
|
||||||
|
"ninjaMachineId": "",
|
||||||
|
"ninjaNodeId": "",
|
||||||
|
"ninjaLocationId": "",
|
||||||
|
"ninjaLocationName": "",
|
||||||
"deviceIdentifierMode": "machineName",
|
"deviceIdentifierMode": "machineName",
|
||||||
"uploadTimeoutSeconds": 30,
|
"uploadTimeoutSeconds": 30,
|
||||||
|
"uploadQueueMaxReports": 100,
|
||||||
"enableVulnerabilityCorrelation": true,
|
"enableVulnerabilityCorrelation": true,
|
||||||
"vulnerabilityCsvPath": "",
|
"vulnerabilityCsvPath": "",
|
||||||
"secretReference": "device-default"
|
"secretReference": "device-default"
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
{
|
{
|
||||||
"warningEventThreshold": 1,
|
"warningEventThreshold": 10,
|
||||||
"criticalEventThreshold": 20,
|
"maxReportedEvents": 1000,
|
||||||
"warningUniqueIpThreshold": 1,
|
"criticalEventThreshold": 30,
|
||||||
"criticalUniqueIpThreshold": 10,
|
"warningUniqueIpThreshold": 5,
|
||||||
|
"criticalUniqueIpThreshold": 12,
|
||||||
|
"loginBurstWindowMinutes": 15,
|
||||||
|
"warningLoginBurstCount": 5,
|
||||||
|
"criticalLoginBurstCount": 20,
|
||||||
|
"warningSprayAccountCount": 5,
|
||||||
|
"criticalSprayAccountCount": 10,
|
||||||
"correlationWarningCveThreshold": 1,
|
"correlationWarningCveThreshold": 1,
|
||||||
"correlationCriticalCveThreshold": 1,
|
"correlationCriticalCveThreshold": 1,
|
||||||
|
"ransomwareBetaEnabled": true,
|
||||||
|
"ransomwareBetaAlertingEnabled": false,
|
||||||
|
"ransomwareLookbackMinutes": 15,
|
||||||
|
"ransomwareWarningSignalCount": 2,
|
||||||
|
"ransomwareCriticalSignalCount": 3,
|
||||||
|
"ransomwareCaptureSmbSessions": true,
|
||||||
|
"ransomwareFileChurnEnabled": false,
|
||||||
|
"ransomwareFileChurnWindowMinutes": 15,
|
||||||
|
"ransomwareFileChurnWarningDeleteCount": 50,
|
||||||
|
"ransomwareFileChurnWarningWriteCount": 250,
|
||||||
|
"ransomwareFileChurnCriticalDeleteCount": 200,
|
||||||
|
"ransomwareFileChurnCriticalWriteCount": 1000,
|
||||||
|
"ransomwareFileChurnMaxAuditEvents": 5000,
|
||||||
|
"ransomwareExcludedProcesses": [],
|
||||||
"ftpRoots": [
|
"ftpRoots": [
|
||||||
"C:\\inetpub\\logs\\LogFiles",
|
"C:\\inetpub\\logs\\LogFiles",
|
||||||
"D:\\inetpub\\logs\\LogFiles"
|
"D:\\inetpub\\logs\\LogFiles"
|
||||||
],
|
],
|
||||||
|
"iisLogRoots": [
|
||||||
|
"C:\\inetpub\\logs\\LogFiles",
|
||||||
|
"D:\\inetpub\\logs\\LogFiles"
|
||||||
|
],
|
||||||
"fileZillaRoots": [
|
"fileZillaRoots": [
|
||||||
"C:\\Program Files (x86)\\FileZilla Server\\Logs",
|
"C:\\Program Files (x86)\\FileZilla Server\\Logs",
|
||||||
"D:\\Program Files (x86)\\FileZilla Server\\Logs"
|
"D:\\Program Files (x86)\\FileZilla Server\\Logs"
|
||||||
|
|||||||
58
docs/beta-deployment.md
Normal file
58
docs/beta-deployment.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 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 Ransomware-Beta ist im Beta-Kanal standardmaessig aktiviert. Die Auswertung
|
||||||
|
bleibt passiv, solange `ransomwareBetaAlertingEnabled` auf `false` steht:
|
||||||
|
Hinweise, Warnungen und kritische Beta-Signale erscheinen im JSON-Report und
|
||||||
|
Dashboard, veraendern aber keine NinjaOne-Alarmfelder. Fuer eine lokale
|
||||||
|
Ausnahme kann `ransomwareBetaEnabled` in
|
||||||
|
`C:\Program Files\OCSentinel\config\ocsentinel-settings.json` auf `false`
|
||||||
|
gesetzt werden.
|
||||||
|
|
||||||
|
Jeder Beta-Report enthaelt zudem eine datensparsame Sensorabdeckung fuer
|
||||||
|
Security-Ereignis 4688, PowerShell 4104, Sysmon 1 und das optionale
|
||||||
|
Datei-Auditing 4663. Damit bedeutet ein Status `ok` nicht mehr stillschweigend,
|
||||||
|
dass alle Datenquellen vorhanden waren. Es werden nur Verfuegbarkeit,
|
||||||
|
technischer Zustand und gezaehlte Ereignisse uebertragen.
|
||||||
|
|
||||||
|
Der optionale Datei-Churn-Sensor wird nur mit
|
||||||
|
`ransomwareFileChurnEnabled: true` aktiviert. Er wertet ausschliesslich bereits
|
||||||
|
vorhandene Security-Ereignisse 4663 aus, setzt keine Audit-Richtlinie und
|
||||||
|
aendert keine SACLs. Es werden nur Zaehler sowie Prozessnamen gespeichert und
|
||||||
|
uebertragen, niemals Datei- oder Freigabenamen. Eine Auswertung ist auf 5.000
|
||||||
|
Audit-Ereignisse und ein 15-Minuten-Fenster begrenzt; ein gekappter Lauf erzeugt
|
||||||
|
kein Churn-Signal.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -30,6 +30,53 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 `
|
|||||||
- `config\ocsentinel-settings.json`
|
- `config\ocsentinel-settings.json`
|
||||||
- `config\ocsentinel-client.json`
|
- `config\ocsentinel-client.json`
|
||||||
|
|
||||||
|
## Local Schedule And Burst Mode
|
||||||
|
|
||||||
|
During a NinjaOne installation or update, OCSentinel stores the device's
|
||||||
|
NinjaOne organization, location, and device identifiers in its local client
|
||||||
|
configuration. Scheduled `SYSTEM` scans restore that context before creating a
|
||||||
|
report, so their uploads remain assigned to the correct organization.
|
||||||
|
|
||||||
|
The installer creates two Windows Scheduled Tasks running as `SYSTEM`:
|
||||||
|
|
||||||
|
- `OCSentinel Daily Scan`: runs once per day and uploads one signed report.
|
||||||
|
The installer deterministically assigns each device a stable slot between
|
||||||
|
`04:00` and `06:59`, derived from its Windows `MachineGuid`. This distributes
|
||||||
|
a fleet rollout instead of sending all reports at the same time.
|
||||||
|
- `OCSentinel Burst Check`: runs every five minutes. It performs no scan unless
|
||||||
|
the NinjaOne device custom field `ocsentinelburst` is enabled. Once enabled,
|
||||||
|
it scans for two hours and then disables itself automatically.
|
||||||
|
|
||||||
|
Create `ocsentinelburst` as a device-level `Checkbox` custom field and allow
|
||||||
|
automation read and write access. Set it to `true` for a device to begin the
|
||||||
|
five-minute burst scans; clear it to stop them early. The normal daily scan
|
||||||
|
continues regardless of the checkbox.
|
||||||
|
|
||||||
|
Create these accompanying device custom fields and allow automation write
|
||||||
|
access:
|
||||||
|
|
||||||
|
| Field name | Type | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ocsentinelburstuntilutc` | Date/Time | UTC time at which the active burst ends |
|
||||||
|
| `ocsentinelburststatus` | Text | `idle`, `active until ...`, or `completed` |
|
||||||
|
|
||||||
|
## Upload Reliability And Client Health
|
||||||
|
|
||||||
|
If the upload endpoint is temporarily unavailable, the client stores up to 100
|
||||||
|
signed report payloads locally under `C:\ProgramData\OCSentinel\upload-queue`.
|
||||||
|
The next scheduled run sends queued payloads before its new report. The local
|
||||||
|
health state is stored under `C:\ProgramData\OCSentinel\state`.
|
||||||
|
|
||||||
|
Create these additional device custom fields in NinjaOne and allow automation
|
||||||
|
write access:
|
||||||
|
|
||||||
|
| Field name | Type | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ocsentineluploadstatus` | Text | `ok`, `queued`, or `unknown` upload state |
|
||||||
|
| `ocsentinelqueuedreports` | Integer | Reports waiting for delivery |
|
||||||
|
| `ocsentinellastuploadutc` | Date/Time | Last successful upload time |
|
||||||
|
| `ocsentinellasterror` | Text | Last upload error, if any |
|
||||||
|
|
||||||
## NinjaOne Tasks
|
## NinjaOne Tasks
|
||||||
|
|
||||||
Create a PowerShell script in NinjaOne named `OCSentinel - Installieren oder aktualisieren`.
|
Create a PowerShell script in NinjaOne named `OCSentinel - Installieren oder aktualisieren`.
|
||||||
@@ -64,6 +111,29 @@ Runtime:
|
|||||||
-OutputPath "..\reports\ocsentinel-summary.json"
|
-OutputPath "..\reports\ocsentinel-summary.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Repair Missing NinjaOne Context
|
||||||
|
|
||||||
|
NinjaOne exposes `NINJA_ORGANIZATION_ID`, `NINJA_ORGANIZATION_NAME`,
|
||||||
|
`NINJA_AGENT_MACHINE_ID`, and location values only while an automation runs.
|
||||||
|
The scheduled OCSentinel task runs later as `SYSTEM`, so those values must be
|
||||||
|
persisted during an actual NinjaOne automation.
|
||||||
|
|
||||||
|
If the console shows `Organisation unbekannt`, create a temporary NinjaOne
|
||||||
|
PowerShell automation named `OCSentinel - NinjaOne Kontext aktualisieren` and
|
||||||
|
copy `scripts/refresh-ocsentinel-ninja-context.ps1` into the editor. Run it as
|
||||||
|
`SYSTEM` in 64-bit PowerShell once against the affected devices or policy.
|
||||||
|
|
||||||
|
The script has no script variables and does the following safely:
|
||||||
|
|
||||||
|
1. updates the installed client through the stable, version-independent manifest;
|
||||||
|
2. stores the current NinjaOne organization, location, node, and machine values;
|
||||||
|
3. starts one signed status scan and upload using that stored context.
|
||||||
|
|
||||||
|
Expected output includes `OCSENTINEL_NINJA_CONTEXT=updated`; this is emitted
|
||||||
|
only after the immediate upload succeeds. Do not run this script from an
|
||||||
|
interactive PowerShell session, because NinjaOne does not expose the required
|
||||||
|
environment values there.
|
||||||
|
|
||||||
## Secret Bootstrap
|
## Secret Bootstrap
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ For the isolated development environment only, HTTP is permitted at
|
|||||||
`http://172.16.41.197:5678/webhook/ocsentinel-ingest`. Do not reuse this URL,
|
`http://172.16.41.197:5678/webhook/ocsentinel-ingest`. Do not reuse this URL,
|
||||||
the development shared secret, or a disabled-TLS configuration in production.
|
the development shared secret, or a disabled-TLS configuration in production.
|
||||||
|
|
||||||
1. `Webhook`: accept `POST` on the configured private URL.
|
1. `Webhook`: accept `POST` on the configured private URL and enable **Raw Body**.
|
||||||
2. `Code`: reject a request if `X-ATN-Device`, `X-ATN-Timestamp`,
|
2. `Code`: reject a request if `X-ATN-Device`, `X-ATN-Timestamp`,
|
||||||
`X-ATN-Nonce`, `X-ATN-Version`, `X-ATN-Payload-SHA256`, or
|
`X-ATN-Nonce`, `X-ATN-Version`, `X-ATN-Payload-SHA256`, or
|
||||||
`X-ATN-Signature` is missing; reject timestamps outside five minutes.
|
`X-ATN-Signature` is missing; reject timestamps outside five minutes.
|
||||||
@@ -49,6 +49,11 @@ the development shared secret, or a disabled-TLS configuration in production.
|
|||||||
<device>\n<timestamp>\n<nonce>\n<version>\n<payload-sha256>
|
<device>\n<timestamp>\n<nonce>\n<version>\n<payload-sha256>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In the current n8n Webhook node, the raw bytes are exposed as Base64 at
|
||||||
|
`$binary.data.data`. Decode this value before calculating the payload hash.
|
||||||
|
Do not hash `JSON.stringify($json.body)`: parsing and reserializing JSON
|
||||||
|
changes whitespace and can change the signed byte sequence.
|
||||||
|
|
||||||
4. `Postgres`: insert the nonce into `ocsentinel.ingest_nonce` with a short
|
4. `Postgres`: insert the nonce into `ocsentinel.ingest_nonce` with a short
|
||||||
expiry. If it already exists, return `409` and do not process the report.
|
expiry. If it already exists, return `409` and do not process the report.
|
||||||
5. `Postgres`: upsert the device, insert a row in `ocsentinel.scan_report`,
|
5. `Postgres`: upsert the device, insert a row in `ocsentinel.scan_report`,
|
||||||
|
|||||||
253
docs/roadmap.md
Normal file
253
docs/roadmap.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# 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 aus vorhandenen
|
||||||
|
Datei-Audit-Ereignissen. Die erste Beta wertet Loesch- und Schreibzugriffe
|
||||||
|
ohne Datei- oder Freigabenamen aus.
|
||||||
|
- Snapshot der SMB-Sitzungen und des Incident-Kontexts erst bei einer
|
||||||
|
Auffaelligkeit.
|
||||||
|
- Verdichtetes Ransomware-Incident-Protokoll fuer n8n und die Uebersicht.
|
||||||
|
- Strukturierte Sensorabdeckung im Report, damit fehlende Audit- oder Sysmon-
|
||||||
|
Quellen von einem echten unauffaelligen Zeitraum unterscheidbar sind.
|
||||||
|
|
||||||
|
**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.
|
||||||
6
infra/debug-dashboard/.env.example
Normal file
6
infra/debug-dashboard/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
DB_HOST=ocsentinel-postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=ocsentinel
|
||||||
|
DB_USER=ocsentinel_debug
|
||||||
|
DB_PASSWORD=replace-with-server-generated-password
|
||||||
|
DASHBOARD_CSRF_SECRET=replace-with-server-generated-secret
|
||||||
15
infra/debug-dashboard/Dockerfile
Normal file
15
infra/debug-dashboard/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
COPY templates ./templates
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
RUN addgroup -S ocsentinel && adduser -S ocsentinel -G ocsentinel
|
||||||
|
USER ocsentinel
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--threads", "4", "--timeout", "30", "app:app"]
|
||||||
570
infra/debug-dashboard/app.py
Normal file
570
infra/debug-dashboard/app.py
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from flask import Flask, abort, jsonify, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
CURRENT_EVENT_HOURS = 24
|
||||||
|
STALE_REPORT_HOURS = 36
|
||||||
|
|
||||||
|
|
||||||
|
def db_connection():
|
||||||
|
return psycopg.connect(
|
||||||
|
host=os.environ["DB_HOST"],
|
||||||
|
port=os.getenv("DB_PORT", "5432"),
|
||||||
|
dbname=os.environ["DB_NAME"],
|
||||||
|
user=os.environ["DB_USER"],
|
||||||
|
password=os.environ["DB_PASSWORD"],
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_token():
|
||||||
|
secret = os.environ["DASHBOARD_CSRF_SECRET"].encode("utf-8")
|
||||||
|
return hmac.new(secret, b"recipient-rules", hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def require_csrf():
|
||||||
|
supplied = request.form.get("csrf_token", "")
|
||||||
|
if not hmac.compare_digest(supplied, csrf_token()):
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
|
||||||
|
def event_metadata(payload):
|
||||||
|
latest_event = None
|
||||||
|
for event in (payload or {}).get("Events", []):
|
||||||
|
value = event.get("Timestamp")
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||||
|
if latest_event is None or timestamp > latest_event:
|
||||||
|
latest_event = timestamp
|
||||||
|
|
||||||
|
if latest_event is None:
|
||||||
|
return {"is_current": False, "label": "keine Ereignisse", "timestamp": None}
|
||||||
|
|
||||||
|
age_seconds = max(0, int((datetime.now(timezone.utc) - latest_event.astimezone(timezone.utc)).total_seconds()))
|
||||||
|
if age_seconds < 3600:
|
||||||
|
age_label = f"vor {max(1, age_seconds // 60)} Min."
|
||||||
|
elif age_seconds < 86400:
|
||||||
|
age_label = f"vor {age_seconds // 3600} Std."
|
||||||
|
else:
|
||||||
|
age_label = f"vor {age_seconds // 86400} Tg."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_current": age_seconds <= CURRENT_EVENT_HOURS * 3600,
|
||||||
|
"label": age_label,
|
||||||
|
"timestamp": latest_event,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def payload_value(payload, *names, default=None):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return default
|
||||||
|
for name in names:
|
||||||
|
if name in payload:
|
||||||
|
return payload[name]
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def overview():
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT * FROM ocsentinel.organization_summary")
|
||||||
|
summary = cursor.fetchone()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, organization_name, received_at, alert_state,
|
||||||
|
total_events, unique_ip_count, cve_total, cve_critical, payload
|
||||||
|
FROM (
|
||||||
|
SELECT machine_name, received_at, alert_state, total_events,
|
||||||
|
unique_ip_count, cve_total, cve_critical,
|
||||||
|
payload #>> '{NinjaOne,OrganizationName}' AS organization_name,
|
||||||
|
payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
) AS status
|
||||||
|
ORDER BY received_at DESC NULLS LAST
|
||||||
|
LIMIT 100
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
reports = cursor.fetchall()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS known_devices,
|
||||||
|
count(*) FILTER (WHERE received_at >= now() - interval '36 hours') AS reporting_devices,
|
||||||
|
count(*) FILTER (WHERE received_at IS NULL OR received_at < now() - interval '36 hours') AS stale_devices
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
coverage = cursor.fetchone()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, alert_state, total_events, unique_ip_count,
|
||||||
|
received_at, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE alert_state IN ('warning', 'critical')
|
||||||
|
ORDER BY CASE alert_state WHEN 'critical' THEN 0 ELSE 1 END, received_at DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
alerts = cursor.fetchall()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT DISTINCT ON (d.machine_name_key, date_trunc('day', r.received_at))
|
||||||
|
date_trunc('day', r.received_at)::date AS day,
|
||||||
|
r.alert_state,
|
||||||
|
r.total_events
|
||||||
|
FROM ocsentinel.scan_report AS r
|
||||||
|
JOIN ocsentinel.device AS d ON d.id = r.device_id
|
||||||
|
WHERE r.received_at >= now() - interval '14 days'
|
||||||
|
ORDER BY d.machine_name_key, date_trunc('day', r.received_at), r.received_at DESC
|
||||||
|
)
|
||||||
|
SELECT day,
|
||||||
|
count(*) FILTER (WHERE alert_state = 'warning') AS warning_count,
|
||||||
|
count(*) FILTER (WHERE alert_state = 'critical') AS critical_count,
|
||||||
|
coalesce(sum(total_events), 0) AS event_count
|
||||||
|
FROM latest
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
trend = cursor.fetchall()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') AS organization_id,
|
||||||
|
coalesce(nullif(payload #>> '{NinjaOne,OrganizationName}', ''), 'Organisation unbekannt') AS organization_name,
|
||||||
|
count(*) AS device_count,
|
||||||
|
count(*) FILTER (WHERE alert_state = 'warning') AS warning_count,
|
||||||
|
count(*) FILTER (WHERE alert_state = 'critical') AS critical_count,
|
||||||
|
max(received_at) AS last_received_at
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY critical_count DESC, warning_count DESC, organization_name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
organizations = cursor.fetchall()
|
||||||
|
|
||||||
|
report_rows = []
|
||||||
|
for row in reports:
|
||||||
|
event = event_metadata(row[8])
|
||||||
|
report_rows.append(
|
||||||
|
{
|
||||||
|
"machine_name": row[0],
|
||||||
|
"organization_name": row[1],
|
||||||
|
"received_at": row[2],
|
||||||
|
"alert_state": row[3],
|
||||||
|
"total_events": row[4],
|
||||||
|
"unique_ip_count": row[5],
|
||||||
|
"event": event,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
alert_rows = []
|
||||||
|
for row in alerts:
|
||||||
|
event = event_metadata(row[5])
|
||||||
|
alert_rows.append(
|
||||||
|
{
|
||||||
|
"machine_name": row[0],
|
||||||
|
"alert_state": row[1],
|
||||||
|
"total_events": row[2],
|
||||||
|
"unique_ip_count": row[3],
|
||||||
|
"received_at": row[4],
|
||||||
|
"event": event,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
trend_rows = [
|
||||||
|
{
|
||||||
|
"day": row[0],
|
||||||
|
"warning_count": row[1],
|
||||||
|
"critical_count": row[2],
|
||||||
|
"event_count": row[3],
|
||||||
|
}
|
||||||
|
for row in trend
|
||||||
|
]
|
||||||
|
trend_max = max([row["event_count"] for row in trend_rows] or [1])
|
||||||
|
organization_rows = [
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"name": row[1],
|
||||||
|
"device_count": row[2],
|
||||||
|
"warning_count": row[3],
|
||||||
|
"critical_count": row[4],
|
||||||
|
"last_received_at": row[5],
|
||||||
|
}
|
||||||
|
for row in organizations
|
||||||
|
]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"overview.html",
|
||||||
|
summary=summary,
|
||||||
|
coverage=coverage,
|
||||||
|
reports=report_rows,
|
||||||
|
alerts=alert_rows,
|
||||||
|
current_alert_count=sum(alert["event"]["is_current"] for alert in alert_rows),
|
||||||
|
trend=trend_rows,
|
||||||
|
trend_max=trend_max,
|
||||||
|
organizations=organization_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/organizations/<organization_id>")
|
||||||
|
def organization(organization_id):
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, received_at, alert_state, total_events, unique_ip_count,
|
||||||
|
cve_critical, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s
|
||||||
|
ORDER BY CASE alert_state WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
||||||
|
machine_name
|
||||||
|
""",
|
||||||
|
(organization_id,),
|
||||||
|
)
|
||||||
|
devices = cursor.fetchall()
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
organization_name = (devices[0][6] or {}).get("NinjaOne", {}).get("OrganizationName") or "Organisation unbekannt"
|
||||||
|
return render_template(
|
||||||
|
"organization.html",
|
||||||
|
organization_id=organization_id,
|
||||||
|
organization_name=organization_name,
|
||||||
|
devices=devices,
|
||||||
|
critical_count=sum(row[2] == "critical" for row in devices),
|
||||||
|
warning_count=sum(row[2] == "warning" for row in devices),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_network_flows(days=14):
|
||||||
|
days = max(1, min(days, 90))
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, received_at, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE received_at >= now() - (%s * interval '1 day')
|
||||||
|
""",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
reports = cursor.fetchall()
|
||||||
|
|
||||||
|
flows = {}
|
||||||
|
for machine_name, received_at, payload in reports:
|
||||||
|
ninja_context = (payload or {}).get("NinjaOne") or {}
|
||||||
|
organization_id = str(ninja_context.get("OrganizationId") or "unknown")
|
||||||
|
organization_name = ninja_context.get("OrganizationName") or "Organisation unbekannt"
|
||||||
|
for event in (payload or {}).get("Events", []):
|
||||||
|
source_ip = event.get("SourceIp") or ""
|
||||||
|
if not source_ip or source_ip in {"-", "127.0.0.1", "::1"}:
|
||||||
|
continue
|
||||||
|
account = event.get("Username") or "[unbekannt]"
|
||||||
|
target = event.get("Target") or "Anmeldung"
|
||||||
|
key = (source_ip, machine_name, account, target, organization_id)
|
||||||
|
entry = flows.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"source_ip": source_ip,
|
||||||
|
"machine_name": machine_name,
|
||||||
|
"account": account,
|
||||||
|
"target": target,
|
||||||
|
"organization_id": organization_id,
|
||||||
|
"organization_name": organization_name,
|
||||||
|
"count": 0,
|
||||||
|
"last_seen": received_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["count"] += 1
|
||||||
|
timestamp = event.get("Timestamp")
|
||||||
|
if timestamp and (entry["last_seen"] is None or str(timestamp) > str(entry["last_seen"])):
|
||||||
|
entry["last_seen"] = timestamp
|
||||||
|
|
||||||
|
flow_rows = sorted(flows.values(), key=lambda entry: (entry["count"], str(entry["last_seen"])), reverse=True)[:60]
|
||||||
|
max_count = max([entry["count"] for entry in flow_rows] or [1])
|
||||||
|
source_count = len({entry["source_ip"] for entry in flow_rows})
|
||||||
|
target_count = len({entry["machine_name"] for entry in flow_rows})
|
||||||
|
return flow_rows, max_count, source_count, target_count
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/network")
|
||||||
|
def network():
|
||||||
|
days = request.args.get("days", 14, type=int)
|
||||||
|
flows, _, source_count, target_count = load_network_flows(days)
|
||||||
|
return render_template(
|
||||||
|
"network.html",
|
||||||
|
source_count=source_count,
|
||||||
|
target_count=target_count,
|
||||||
|
total_events=sum(entry["count"] for entry in flows),
|
||||||
|
path_count=len(flows),
|
||||||
|
days=max(1, min(days, 90)),
|
||||||
|
organizations=sorted({(entry["organization_id"], entry["organization_name"]) for entry in flows}, key=lambda item: item[1]),
|
||||||
|
event_types=sorted({entry["target"] for entry in flows}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/network")
|
||||||
|
def network_api():
|
||||||
|
days = request.args.get("days", 14, type=int)
|
||||||
|
flows, max_count, source_count, target_count = load_network_flows(days)
|
||||||
|
nodes = {}
|
||||||
|
edges = []
|
||||||
|
for index, flow in enumerate(flows):
|
||||||
|
source_id = f"source:{flow['source_ip']}"
|
||||||
|
target_id = f"target:{flow['machine_name']}"
|
||||||
|
nodes[source_id] = {"data": {"id": source_id, "label": flow["source_ip"], "kind": "source"}}
|
||||||
|
nodes[target_id] = {"data": {"id": target_id, "label": flow["machine_name"], "kind": "target"}}
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": f"flow:{index}",
|
||||||
|
"source": source_id,
|
||||||
|
"target": target_id,
|
||||||
|
"count": flow["count"],
|
||||||
|
"account": flow["account"],
|
||||||
|
"event_type": flow["target"],
|
||||||
|
"last_seen": str(flow["last_seen"] or "-"),
|
||||||
|
"machine_name": flow["machine_name"],
|
||||||
|
"organization_id": flow["organization_id"],
|
||||||
|
"organization_name": flow["organization_name"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"elements": {"nodes": list(nodes.values()), "edges": edges},
|
||||||
|
"max_count": max_count,
|
||||||
|
"source_count": source_count,
|
||||||
|
"target_count": target_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/device/<machine_name>")
|
||||||
|
def device(machine_name):
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, first_seen_at, last_seen_at, last_client_version,
|
||||||
|
generated_at_utc, received_at, alert_state, base_alert_state,
|
||||||
|
total_events, unique_ip_count, cve_total, cve_critical, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE machine_name = %s
|
||||||
|
""",
|
||||||
|
(machine_name,),
|
||||||
|
)
|
||||||
|
report = cursor.fetchone()
|
||||||
|
|
||||||
|
if report is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
payload = report[12]
|
||||||
|
event_groups = {}
|
||||||
|
for entry in payload.get("Events") or payload.get("events") or []:
|
||||||
|
event_type = entry.get("Target") or entry.get("target") or "Sicherheitsereignis"
|
||||||
|
account = entry.get("Username") or entry.get("username") or "-"
|
||||||
|
source_ip = entry.get("SourceIp") or entry.get("sourceIp") or "-"
|
||||||
|
key = (event_type, account, source_ip)
|
||||||
|
group = event_groups.setdefault(
|
||||||
|
key,
|
||||||
|
{"type": event_type, "account": account, "source_ip": source_ip, "count": 0, "latest": "-"},
|
||||||
|
)
|
||||||
|
group["count"] += 1
|
||||||
|
timestamp = entry.get("Timestamp") or entry.get("timestamp") or "-"
|
||||||
|
if timestamp > group["latest"]:
|
||||||
|
group["latest"] = timestamp
|
||||||
|
|
||||||
|
security_events = sorted(
|
||||||
|
event_groups.values(),
|
||||||
|
key=lambda entry: (entry["latest"], entry["count"]),
|
||||||
|
reverse=True,
|
||||||
|
)[:25]
|
||||||
|
ransomware_raw = payload.get("RansomwareBeta") or payload.get("ransomwareBeta") or {}
|
||||||
|
sensor_labels = {
|
||||||
|
"security-process-4688": "Prozessstarts (Security 4688)",
|
||||||
|
"powershell-script-block-4104": "PowerShell-Skriptblöcke (4104)",
|
||||||
|
"sysmon-process-1": "Sysmon-Prozesse (1)",
|
||||||
|
"security-file-audit-4663": "Datei-Auditing (4663)",
|
||||||
|
}
|
||||||
|
ransomware_sensors = [
|
||||||
|
{
|
||||||
|
"label": sensor_labels.get(payload_value(sensor, "Name", "name"), payload_value(sensor, "Name", "name", default="Unbekannter Sensor")),
|
||||||
|
"enabled": payload_value(sensor, "Enabled", "enabled", default=False),
|
||||||
|
"available": payload_value(sensor, "Available", "available", default=False),
|
||||||
|
"state": payload_value(sensor, "State", "state", default="unknown"),
|
||||||
|
"event_count": payload_value(sensor, "EventCount", "eventCount", default=0),
|
||||||
|
}
|
||||||
|
for sensor in payload_value(ransomware_raw, "Sensors", "sensors", default=[])
|
||||||
|
]
|
||||||
|
ransomware_beta = {
|
||||||
|
"enabled": payload_value(ransomware_raw, "Enabled", "enabled", default=False),
|
||||||
|
"state": payload_value(ransomware_raw, "State", "state", default="disabled"),
|
||||||
|
"reason": payload_value(ransomware_raw, "Reason", "reason", default="Keine Ransomware-Beta-Daten verfuegbar."),
|
||||||
|
"signals": [
|
||||||
|
{
|
||||||
|
"timestamp": payload_value(signal, "Timestamp", "timestamp", default="-"),
|
||||||
|
"category": payload_value(signal, "Category", "category", default="Signal"),
|
||||||
|
"process": payload_value(signal, "Process", "process", default="-"),
|
||||||
|
"source": payload_value(signal, "Source", "source", default="-"),
|
||||||
|
"confidence": payload_value(signal, "Confidence", "confidence", default="low"),
|
||||||
|
}
|
||||||
|
for signal in payload_value(ransomware_raw, "Signals", "signals", default=[])
|
||||||
|
],
|
||||||
|
"smbSessions": [
|
||||||
|
{
|
||||||
|
"clientComputerName": payload_value(session, "ClientComputerName", "clientComputerName", default="-"),
|
||||||
|
"clientUserName": payload_value(session, "ClientUserName", "clientUserName", default="-"),
|
||||||
|
"openFileCount": payload_value(session, "OpenFileCount", "openFileCount", default=0),
|
||||||
|
"sessionId": payload_value(session, "SessionId", "sessionId", default="-"),
|
||||||
|
}
|
||||||
|
for session in payload_value(ransomware_raw, "SmbSessions", "smbSessions", default=[])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return render_template(
|
||||||
|
"device.html",
|
||||||
|
report=report,
|
||||||
|
event=event_metadata(payload),
|
||||||
|
payload=payload,
|
||||||
|
security_events=security_events,
|
||||||
|
ransomware_beta=ransomware_beta,
|
||||||
|
ransomware_sensors=ransomware_sensors,
|
||||||
|
payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reports")
|
||||||
|
def reports():
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, organization_name, period_start_utc, period_end_utc,
|
||||||
|
generated_at, device_count, warning_count, critical_count,
|
||||||
|
total_events
|
||||||
|
FROM ocsentinel.weekly_organization_report
|
||||||
|
ORDER BY period_end_utc DESC, organization_name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
weekly_reports = cursor.fetchall()
|
||||||
|
|
||||||
|
return render_template("reports.html", reports=weekly_reports)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reports/<int:report_id>")
|
||||||
|
def weekly_report(report_id):
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT organization_name, period_start_utc, period_end_utc,
|
||||||
|
generated_at, report_html
|
||||||
|
FROM ocsentinel.weekly_organization_report
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(report_id,),
|
||||||
|
)
|
||||||
|
report = cursor.fetchone()
|
||||||
|
|
||||||
|
if report is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return render_template("weekly_report.html", report=report)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/recipients")
|
||||||
|
def recipients():
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, organization_id, organization_name, recipient_email, enabled
|
||||||
|
FROM ocsentinel.organization_report_recipient
|
||||||
|
ORDER BY organization_id = '*', organization_name, recipient_email
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rules = cursor.fetchall()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT payload #>> '{NinjaOne,OrganizationId}',
|
||||||
|
payload #>> '{NinjaOne,OrganizationName}'
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', '') <> ''
|
||||||
|
ORDER BY 2
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
organizations = cursor.fetchall()
|
||||||
|
|
||||||
|
return render_template("recipients.html", rules=rules, organizations=organizations, csrf_token=csrf_token())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/recipients")
|
||||||
|
def add_recipient():
|
||||||
|
require_csrf()
|
||||||
|
organization_id = request.form.get("organization_id", "").strip()
|
||||||
|
organization_name = request.form.get("organization_name", "").strip()
|
||||||
|
recipient_email = request.form.get("recipient_email", "").strip().lower()
|
||||||
|
if not organization_id or not organization_name or "@" not in recipient_email:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ocsentinel.organization_report_recipient
|
||||||
|
(organization_id, organization_name, recipient_email)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (organization_id, recipient_email) DO NOTHING
|
||||||
|
""",
|
||||||
|
(organization_id, organization_name, recipient_email),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
return redirect(url_for("recipients"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/recipients/<int:rule_id>/toggle")
|
||||||
|
def toggle_recipient(rule_id):
|
||||||
|
require_csrf()
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE ocsentinel.organization_report_recipient SET enabled = NOT enabled WHERE id = %s",
|
||||||
|
(rule_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return redirect(url_for("recipients"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/recipients/<int:rule_id>/delete")
|
||||||
|
def delete_recipient(rule_id):
|
||||||
|
require_csrf()
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute("DELETE FROM ocsentinel.organization_report_recipient WHERE id = %s", (rule_id,))
|
||||||
|
connection.commit()
|
||||||
|
return redirect(url_for("recipients"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
try:
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
return {"status": "ok"}
|
||||||
|
except Exception:
|
||||||
|
return {"status": "unavailable"}, 503
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8080)
|
||||||
22
infra/debug-dashboard/compose.yml
Normal file
22
infra/debug-dashboard/compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
ocsentinel-debug:
|
||||||
|
build: .
|
||||||
|
container_name: ocsentinel-debug
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "172.16.41.197:8090:8080"
|
||||||
|
networks:
|
||||||
|
- ocsentinel-network
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ocsentinel-network:
|
||||||
|
external: true
|
||||||
|
name: n8n_n8n-network
|
||||||
3
infra/debug-dashboard/requirements.txt
Normal file
3
infra/debug-dashboard/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.1.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
78
infra/debug-dashboard/static/app.css
Normal file
78
infra/debug-dashboard/static/app.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
: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 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); }
|
||||||
|
.situation { display:flex; align-items:center; justify-content:space-between; gap:22px; margin:0 0 24px; padding:20px 22px; border:1px solid #b9d8c2; background:#edf8f0; color:#195235; }.situation.warning { border-color:#f2cf99; background:#fff6e8; color:#80450d; }.situation.critical { border-color:#edb4aa; background:#fff0ed; color:#8a2a20; }.situation strong { display:block; margin-top:7px; font:700 19px/1.15 'Roboto',sans-serif; letter-spacing:-.025em; }.situation > span { padding:7px 9px; border:1px solid currentColor; border-radius:20px; font:700 10px 'Roboto',sans-serif; letter-spacing:.1em; }
|
||||||
|
.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); 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); }
|
||||||
|
.network-panel { overflow:hidden; }.network-flows { display:grid; gap:8px; }.network-flow { display:grid; grid-template-columns:minmax(150px,.9fr) minmax(130px,1.25fr) minmax(210px,1.2fr); align-items:center; gap:16px; padding:13px 14px; border:1px solid #dce6ee; border-radius:7px; background:#fbfdff; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.network-flow:hover { transform:translateX(3px); box-shadow:0 8px 18px rgba(31,68,99,.1); }.flow-endpoint { display:grid; gap:3px; }.flow-endpoint span { color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.flow-endpoint strong { font-size:14px; }.flow-endpoint small { color:var(--muted); font-size:11px; }.flow-line { position:relative; display:flex; align-items:center; gap:7px; min-height:22px; }.flow-line:before { position:absolute; right:0; left:0; height:3px; background:#d7e4ed; content:''; }.flow-line i { z-index:1; width:max(5%,var(--flow)); height:7px; border-radius:6px; background:linear-gradient(90deg,#245a85,#b8e36a); }.flow-line small { z-index:1; margin-left:auto; padding:2px 5px; border-radius:8px; background:#fff; color:#456174; font:700 10px 'Roboto',sans-serif; } @media (max-width:850px) { .network-flow { grid-template-columns:1fr; gap:9px; }.flow-line { order:3; }.flow-endpoint.target { order:2; } }
|
||||||
|
.network-map-panel { overflow:hidden; }.map-toolbar { display:flex; flex-wrap:wrap; gap:8px; margin:0 0 16px; }.map-toolbar label { flex:1 1 240px; display:grid; gap:5px; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-toolbar input,.map-toolbar button { min-height:38px; padding:8px 10px; border:1px solid var(--line); border-radius:5px; background:#fff; color:var(--ink); font:700 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#f5f9fc; }.network-map-layout { display:grid; grid-template-columns:minmax(0,1fr) 260px; min-height:560px; overflow:hidden; border:1px solid #d7e4ed; border-radius:9px; background:radial-gradient(circle at 18% 12%,#f5fbff,transparent 28rem),#edf4f8; }.network-map-layout #network-map { min-height:560px; background-image:linear-gradient(rgba(36,90,133,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(36,90,133,.05) 1px,transparent 1px); background-size:32px 32px; }.network-map-layout aside { padding:22px; border-left:1px solid #d7e4ed; background:#fff; }.network-map-layout aside strong { display:block; margin:8px 0 12px; font-size:18px; line-height:1.15; letter-spacing:-.035em; }.network-map-layout aside p { color:var(--muted); font-size:13px; line-height:1.5; }.network-map-layout dl { display:grid; grid-template-columns:1fr; gap:4px; margin:18px 0 0; }.network-map-layout dt { color:var(--muted); font-size:10px; font-weight:700; text-transform:uppercase; }.network-map-layout dd { margin:0 0 10px; font-size:13px; overflow-wrap:anywhere; }.inspector-arrow { color:var(--green); font-size:13px; }.legend.source { background:#245a85; }.legend.target { background:#14735b; } @media (max-width:850px) { .network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid #d7e4ed; border-left:0; }.network-map-layout #network-map { min-height:460px; } }
|
||||||
|
.map-hero { display:flex; align-items:end; justify-content:space-between; gap:28px; margin:0 -2vw 26px; padding:38px 3vw 30px; border-radius:14px; color:#eaf2f8; background:radial-gradient(circle at 82% 10%,rgba(79,163,223,.25),transparent 20rem),linear-gradient(132deg,#102a43,#0c1c2a 70%); box-shadow:0 18px 50px rgba(13,30,44,.22); }.map-hero h1 { max-width:650px; margin:8px 0 14px; font-size:clamp(42px,6vw,76px); line-height:.87; letter-spacing:-.07em; }.map-hero h1 em { color:#b8e36a; font-style:normal; }.map-hero p { max-width:620px; margin:0; color:#b7cad9; font-size:15px; line-height:1.55; }.map-hero .eyebrow { color:#9cc8e8; }.map-hero-status { display:grid; min-width:145px; gap:4px; padding:16px 18px; border:1px solid rgba(184,227,106,.35); border-radius:10px; background:rgba(11,31,45,.55); }.map-hero-status span { color:#b8e36a; font:700 9px 'Roboto',sans-serif; letter-spacing:.13em; }.map-hero-status strong { font-size:37px; line-height:1; letter-spacing:-.06em; }.map-hero-status small { color:#b7cad9; }.map-stat-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; margin:-10px 2vw 28px; border:1px solid #d8e5ee; border-radius:9px; overflow:hidden; background:#d8e5ee; box-shadow:0 10px 22px rgba(31,68,99,.08); }.map-stat-strip article { padding:15px 18px; background:#fff; }.map-stat-strip span,.map-stat-strip small { display:block; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-stat-strip strong { display:block; margin:8px 0 4px; font-size:29px; letter-spacing:-.06em; }.network-map-panel { margin-top:0; padding:0; border:0; border-radius:12px; background:#102a43; box-shadow:0 20px 44px rgba(16,42,67,.2); }.map-header { display:flex; justify-content:space-between; align-items:end; gap:16px; padding:24px 26px 18px; color:#eef6fa; }.map-header .eyebrow { color:#9cc8e8; }.map-header h2 { margin:6px 0 0; font-size:30px; letter-spacing:-.05em; }.map-legend { display:flex; gap:12px; color:#b7cad9; font-size:11px; }.map-legend span { display:flex; align-items:center; gap:5px; }.map-legend i { width:8px; height:8px; border-radius:50%; background:#4fa3df; }.map-legend i.target { background:#3bca99; border-radius:2px; }.map-legend i.hot { background:#ffb454; }.map-toolbar { align-items:end; margin:0; padding:0 26px 18px; border-bottom:1px solid rgba(156,200,232,.16); }.map-toolbar label { flex:0 1 180px; color:#9cc8e8; }.map-toolbar .search-field { flex:1 1 240px; }.map-toolbar input,.map-toolbar select,.map-toolbar button { min-height:40px; border:1px solid rgba(156,200,232,.24); border-radius:6px; background:#17374e; color:#eef6fa; font:600 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#245a85; }.map-toolbar button:hover { background:#326f9f; }.map-actions { display:flex; gap:7px; }.network-map-layout { grid-template-columns:minmax(0,1fr) 280px; min-height:610px; border:0; border-radius:0; background:#0d1e2c; }.network-map-layout #network-map { min-height:610px; background-image:radial-gradient(circle at 50% 0,rgba(79,163,223,.1),transparent 28rem),linear-gradient(rgba(156,200,232,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(156,200,232,.045) 1px,transparent 1px); background-size:auto,36px 36px,36px 36px; }.network-map-layout aside { padding:24px; border-left:1px solid rgba(156,200,232,.16); background:#112b3d; color:#eef6fa; }.network-map-layout aside .eyebrow { color:#9cc8e8; }.network-map-layout aside p { color:#b7cad9; }.network-map-layout dt { color:#82b7dc; }.network-map-layout dd { color:#eef6fa; }.inspector-arrow { color:#b8e36a; }.legend.source { background:#4fa3df; }.legend.target { background:#3bca99; } @media (max-width:850px) { .map-hero { flex-direction:column; align-items:start; margin:0 0 20px; }.map-stat-strip { grid-template-columns:repeat(2,1fr); margin:0 0 20px; }.map-header { align-items:start; flex-direction:column; }.map-toolbar { padding:0 18px 18px; }.network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid rgba(156,200,232,.16); border-left:0; } }
|
||||||
|
|
||||||
|
:root { --font-sans:'Manrope','Segoe UI',sans-serif; --font-mono:'IBM Plex Mono','Cascadia Code',monospace; --surface:#f7fafc; --surface-strong:#edf4f8; --navy:#0c2438; --blue:#2e6b9a; }
|
||||||
|
body,body * { font-family:var(--font-sans); }
|
||||||
|
pre,code,.raw-json pre { font-family:var(--font-mono); }
|
||||||
|
body { background:radial-gradient(circle at 8% -10%,rgba(102,176,225,.24),transparent 31rem),radial-gradient(circle at 94% 6%,rgba(85,201,155,.16),transparent 24rem),linear-gradient(180deg,#edf4f8 0,#f8fafc 42%,#eef4f7 100%); }
|
||||||
|
.masthead { position:sticky; z-index:5; top:0; backdrop-filter:blur(16px); background:rgba(12,36,56,.94); }
|
||||||
|
.dashboard-hero { display:grid; grid-template-columns:minmax(0,1fr) 230px; gap:28px; min-height:276px; margin:0 0 18px; padding:38px; border:1px solid rgba(134,192,225,.24); border-radius:18px; color:#ecf5fb; background:radial-gradient(circle at 88% 8%,rgba(96,183,227,.28),transparent 19rem),linear-gradient(135deg,#102f49,#0a1d2c 72%); box-shadow:0 24px 50px rgba(18,51,75,.18); overflow:hidden; }
|
||||||
|
.dashboard-hero .eyebrow { color:#a6d2ec; }.dashboard-hero h1 { max-width:730px; margin:11px 0 15px; font-size:clamp(38px,5.2vw,66px); line-height:.94; letter-spacing:-.067em; }.dashboard-hero p { max-width:620px; margin:0; color:#bdd3e1; font-size:15px; line-height:1.65; }
|
||||||
|
.dashboard-status { align-self:end; display:grid; gap:5px; padding:19px; border:1px solid rgba(168,216,241,.26); border-radius:14px; background:rgba(4,22,35,.34); box-shadow:inset 0 1px rgba(255,255,255,.06); }.dashboard-status > span:not(.status-orb) { color:#a9c7d8; font-size:10px; font-weight:800; letter-spacing:.11em; text-transform:uppercase; }.dashboard-status strong { font-size:30px; letter-spacing:-.055em; }.dashboard-status small { color:#c5d9e5; font-size:11px; }.status-orb { width:10px; height:10px; margin-bottom:4px; border-radius:50%; background:#72d39c; box-shadow:0 0 0 6px rgba(114,211,156,.13); }.dashboard-hero.warning .status-orb { background:#ffbe62; box-shadow:0 0 0 6px rgba(255,190,98,.13); }.dashboard-hero.critical .status-orb { background:#f4796d; box-shadow:0 0 0 6px rgba(244,121,109,.13); }
|
||||||
|
.quick-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:0 0 35px; }.quick-metrics article { min-height:112px; padding:18px 20px; border:1px solid #d6e3ec; border-radius:12px; background:rgba(255,255,255,.86); box-shadow:0 10px 22px rgba(30,68,94,.055); }.quick-metrics span,.quick-metrics small { display:block; color:#607b8e; font-size:10px; font-weight:800; letter-spacing:.08em; text-transform:uppercase; }.quick-metrics strong { display:block; margin:10px 0 6px; font-size:30px; letter-spacing:-.06em; }.quick-metrics small { color:#7d94a4; font-size:9px; letter-spacing:.045em; text-transform:none; }
|
||||||
|
.page-intro { max-width:770px; margin:10px 0 30px; }.page-intro h1 { margin:10px 0 13px; color:var(--navy); font-size:clamp(38px,5vw,62px); line-height:.94; letter-spacing:-.07em; }.page-intro h1 em { color:var(--green); font-style:normal; }.page-intro p { max-width:600px; margin:0; color:var(--muted); font-size:15px; line-height:1.65; }.reports-panel { margin-top:0; }
|
||||||
|
.sensor-coverage-panel { margin-top:8px; }.sensor-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(215px,1fr)); gap:11px; }.sensor-card { min-height:160px; display:grid; align-content:start; gap:10px; padding:17px; border:1px solid #dbe7ee; border-radius:11px; background:linear-gradient(145deg,#fff,#f6fafc); }.sensor-card > div { display:flex; align-items:center; gap:7px; }.sensor-card strong { color:#17354a; font-size:14px; line-height:1.3; }.sensor-card p { margin:0; color:#607b8e; font-size:12px; line-height:1.55; }.sensor-dot { width:8px; height:8px; border-radius:50%; background:#6abf90; box-shadow:0 0 0 4px rgba(106,191,144,.13); }.sensor-state { color:#5d788a; font-size:9px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; }.sensor-card.disabled { background:#f4f6f7; }.sensor-card.disabled .sensor-dot,.sensor-card.not-installed .sensor-dot { background:#9caeba; box-shadow:0 0 0 4px rgba(156,174,186,.13); }.sensor-card.query-failed { border-color:#f2c59c; background:#fff8ef; }.sensor-card.query-failed .sensor-dot,.sensor-card.truncated .sensor-dot { background:#e9a64d; box-shadow:0 0 0 4px rgba(233,166,77,.13); }.sensor-empty { width:100%; padding:24px; border:1px dashed #b8cbd7; border-radius:10px; color:#607b8e; text-align:center; font-size:13px; }.ransomware-panel + .sensor-coverage-panel { margin-top:8px; }
|
||||||
|
@media (max-width:850px) { .dashboard-hero { grid-template-columns:1fr; min-height:0; padding:28px 24px; }.dashboard-status { align-self:auto; }.quick-metrics { grid-template-columns:repeat(2,1fr); }.quick-metrics article { min-height:102px; }.page-intro { margin-top:4px; }.sensor-grid { grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
@layer enhancements {
|
||||||
|
:where(a,button,input,select,summary):focus-visible { outline:3px solid var(--lime); outline:3px solid color-mix(in srgb,var(--lime) 72%,white); outline-offset:3px; }
|
||||||
|
:where(a,button) { -webkit-tap-highlight-color:transparent; }
|
||||||
|
.quick-metrics,.sensor-grid { container-type:inline-size; }
|
||||||
|
.dashboard-hero { isolation:isolate; }
|
||||||
|
.dashboard-hero::after { position:absolute; z-index:-1; inset:auto -8% -54% auto; width:300px; aspect-ratio:1; border-radius:999px; background:radial-gradient(circle,rgba(184,227,106,.15),transparent 68%); content:''; filter:blur(3px); }
|
||||||
|
.quick-metrics article,.organization-card,.sensor-card { position:relative; overflow:hidden; }
|
||||||
|
.quick-metrics article::before,.sensor-card::before { position:absolute; inset:0; opacity:0; background:linear-gradient(120deg,transparent 18%,rgba(255,255,255,.65),transparent 82%); content:''; transform:translateX(-110%); transition:transform .55s ease,opacity .2s ease; }
|
||||||
|
@media (hover:hover) { .quick-metrics article:hover::before,.sensor-card:hover::before { opacity:1; transform:translateX(110%); }.quick-metrics article:hover { border-color:color-mix(in srgb,var(--blue) 42%,var(--line)); transform:translateY(-3px); box-shadow:0 18px 34px rgba(30,68,94,.12); }.sensor-card:hover { border-color:color-mix(in srgb,var(--blue) 34%,var(--line)); transform:translateY(-2px); box-shadow:0 14px 28px rgba(30,68,94,.09); } }
|
||||||
|
.quick-metrics article,.sensor-card { transition:transform .22s cubic-bezier(.2,.8,.2,1),box-shadow .22s ease,border-color .22s ease; }
|
||||||
|
.dashboard-hero,.quick-metrics article,.panel { animation:sentinel-rise .55s cubic-bezier(.2,.8,.2,1) both; }
|
||||||
|
.quick-metrics article:nth-child(2),.panel:nth-of-type(2) { animation-delay:60ms; }.quick-metrics article:nth-child(3),.panel:nth-of-type(3) { animation-delay:120ms; }.quick-metrics article:nth-child(4),.panel:nth-of-type(4) { animation-delay:180ms; }
|
||||||
|
@container (max-width:520px) { .sensor-card { min-height:0; grid-template-columns:auto 1fr; column-gap:12px; }.sensor-card > div { grid-column:1 / -1; }.sensor-card p { grid-column:1 / -1; } }
|
||||||
|
@supports (backdrop-filter:blur(1px)) { .panel { background:color-mix(in srgb,var(--panel) 90%,transparent); backdrop-filter:blur(10px); }.quick-metrics article { background:color-mix(in srgb,white 82%,transparent); backdrop-filter:blur(12px); } }
|
||||||
|
@supports not (backdrop-filter:blur(1px)) { .masthead { background:#0c2438; } }
|
||||||
|
@supports selector(body:has(.dashboard-hero.critical)) { body:has(.dashboard-hero.critical) .masthead { border-bottom-color:color-mix(in srgb,var(--red) 52%,#21445f); } body:has(.dashboard-hero.warning) .masthead { border-bottom-color:color-mix(in srgb,var(--amber) 55%,#21445f); } }
|
||||||
|
@media (prefers-contrast:more) { .panel,.quick-metrics article,.sensor-card { border-width:2px; }.state { border:1px solid currentColor; } }
|
||||||
|
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sentinel-rise { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
|
||||||
|
/* Operational hierarchy and responsive chrome. */
|
||||||
|
.skip-link { position:fixed; z-index:20; top:10px; left:10px; padding:10px 13px; border-radius:8px; background:var(--lime); color:var(--navy); font-weight:800; text-decoration:none; transform:translateY(-160%); transition:transform .2s ease; }
|
||||||
|
.skip-link:focus { transform:translateY(0); }
|
||||||
|
.wordmark { display:flex; align-items:center; gap:8px; }.wordmark .wordmark-mark { display:grid; flex:0 0 auto; place-items:center; width:29px; height:29px; margin:0; border-radius:8px; background:var(--lime); color:#102a43; font:800 10px var(--font-sans); letter-spacing:0; }.wordmark .wordmark-name { display:grid; gap:1px; color:#fff; font:800 18px/1 var(--font-sans); letter-spacing:-.045em; }.wordmark .wordmark-name small { color:#9cc8e8; font:700 8px/1 var(--font-sans); letter-spacing:.12em; text-transform:uppercase; }
|
||||||
|
.app-footer { display:flex; justify-content:space-between; gap:16px; max-width:1280px; margin:0 auto; padding:0 6vw 32px; color:#6c8494; font-size:10px; letter-spacing:.04em; }.app-footer span:first-child { color:#426277; font-weight:800; text-transform:uppercase; }
|
||||||
|
.priority-board { display:grid; grid-template-columns:minmax(240px,.82fr) minmax(0,1.65fr); gap:1px; margin:0 0 30px; overflow:hidden; border:1px solid #193a53; border-radius:16px; background:#193a53; box-shadow:0 20px 42px rgba(16,42,67,.15); }.priority-intro { display:grid; align-content:space-between; min-height:272px; padding:27px; color:#eaf3f8; background:radial-gradient(circle at 15% 8%,rgba(79,163,223,.2),transparent 16rem),linear-gradient(145deg,#153b58,#0d2437); }.priority-intro .eyebrow { color:#a4cae4; }.priority-intro h2 { margin:10px 0; font-size:clamp(28px,3.2vw,43px); line-height:.95; letter-spacing:-.065em; }.priority-intro p { max-width:290px; margin:0; color:#b7ccda; font-size:13px; line-height:1.55; }.priority-sync { display:flex; align-items:center; gap:8px; margin-top:20px; color:#9fc0d2; font:700 10px var(--font-sans); }.priority-sync i { width:7px; height:7px; border-radius:50%; background:#72d39c; box-shadow:0 0 0 5px rgba(114,211,156,.12); }.priority-list { display:grid; align-content:center; gap:1px; background:#d7e4ec; }.priority-item { display:grid; grid-template-columns:34px minmax(0,1fr) 24px; align-items:center; gap:15px; min-height:90px; padding:16px 22px; background:rgba(255,255,255,.96); color:var(--ink); text-decoration:none; transition:background .2s ease,transform .2s ease; }.priority-item:hover { background:#f4faff; }.priority-item.critical:hover { background:#fff4f1; }.priority-index { align-self:start; color:#8aa0ae; font:700 11px var(--font-mono); }.priority-item strong,.priority-item small { display:block; }.priority-item strong { margin:6px 0 3px; font-size:18px; letter-spacing:-.035em; }.priority-item small { overflow:hidden; color:#657f90; font-size:11px; text-overflow:ellipsis; white-space:nowrap; }.priority-arrow { color:#3a7196; font-size:20px; transition:transform .2s ease; }.priority-item:hover .priority-arrow { transform:translate(2px,-2px); }.priority-calm { display:grid; align-content:center; justify-items:start; gap:9px; padding:30px; color:#eaf3f8; background:linear-gradient(145deg,#173d4b,#0e2a35); }.priority-calm .status-orb { margin:0 0 8px; }.priority-calm strong { font-size:22px; letter-spacing:-.04em; }.priority-calm p { margin:0; color:#b4cbd4; font-size:13px; }.priority-board.calm { grid-template-columns:minmax(240px,.82fr) minmax(0,1.65fr); }
|
||||||
|
.device-hero { display:grid; grid-template-columns:minmax(0,1fr) 240px; gap:28px; align-items:end; min-height:225px; margin:0 0 18px; padding:31px 33px; overflow:hidden; border:1px solid rgba(134,192,225,.24); border-radius:18px; color:#ecf5fb; background:radial-gradient(circle at 88% 10%,rgba(96,183,227,.25),transparent 17rem),linear-gradient(135deg,#102f49,#0a1d2c 72%); box-shadow:0 24px 50px rgba(18,51,75,.15); }.device-hero.warning { background:radial-gradient(circle at 88% 10%,rgba(247,181,91,.22),transparent 17rem),linear-gradient(135deg,#40301a,#20180e 72%); }.device-hero.critical { background:radial-gradient(circle at 88% 10%,rgba(240,110,100,.24),transparent 17rem),linear-gradient(135deg,#45252b,#1d1116 72%); }.device-hero .eyebrow { color:#a6d2ec; }.device-hero h1 { margin:9px 0 12px; font-size:clamp(37px,5vw,62px); line-height:.92; letter-spacing:-.07em; }.device-hero p { margin:0; color:#bdd3e1; font-size:13px; line-height:1.55; }.device-hero-state { display:grid; gap:5px; padding:18px; border:1px solid rgba(168,216,241,.26); border-radius:14px; background:rgba(4,22,35,.34); }.device-hero-state > span:not(.status-orb) { color:#a9c7d8; font-size:10px; font-weight:800; letter-spacing:.11em; text-transform:uppercase; }.device-hero-state strong { font-size:27px; letter-spacing:-.055em; text-transform:capitalize; }.device-hero-state small { color:#c5d9e5; font-size:11px; line-height:1.45; }
|
||||||
|
.header-links a,.state,.event-count,.recipient-form button,.rule-actions button,table,th,td,.map-toolbar input,.map-toolbar select,.map-toolbar button { font-family:var(--font-sans); }
|
||||||
|
@media (max-width:850px) { .masthead { gap:14px; align-items:center; }.header-links { gap:4px; overflow:auto; max-width:calc(100vw - 155px); flex-wrap:nowrap; }.header-links a { flex:0 0 auto; padding:7px 8px; font-size:11px; }.wordmark .wordmark-name small { display:none; }.app-footer { align-items:flex-start; flex-direction:column; padding-bottom:24px; }.priority-board,.priority-board.calm,.device-hero { grid-template-columns:1fr; }.priority-intro { min-height:215px; }.priority-list { gap:1px; }.priority-item { min-height:84px; padding:15px 17px; }.priority-item small { white-space:normal; }.device-hero { min-height:0; padding:28px 24px; }.device-hero-state { max-width:none; }.device-hero h1 { font-size:42px; } }
|
||||||
18
infra/debug-dashboard/static/vendor/cytoscape-LICENSE.txt
vendored
Normal file
18
infra/debug-dashboard/static/vendor/cytoscape-LICENSE.txt
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
Cytoscape.js 3.34.0
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2016-2026, The Cytoscape Consortium
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
31
infra/debug-dashboard/static/vendor/cytoscape.min.js
vendored
Normal file
31
infra/debug-dashboard/static/vendor/cytoscape.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
21
infra/debug-dashboard/templates/base.html
Normal file
21
infra/debug-dashboard/templates/base.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<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=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="app-shell">
|
||||||
|
<a class="skip-link" href="#main-content">Zum Inhalt springen</a>
|
||||||
|
<header class="masthead">
|
||||||
|
<a class="wordmark" href="{{ url_for('overview') }}"><span class="wordmark-mark">OC</span><span class="wordmark-name">Sentinel <small>Security console</small></span></a>
|
||||||
|
<nav class="header-links" aria-label="Hauptnavigation"><a class="{{ 'active' if request.endpoint in ('overview', 'organization') else '' }}" href="{{ url_for('overview') }}">Lagebild</a><a class="{{ 'active' if request.endpoint == 'network' else '' }}" href="{{ url_for('network') }}">Zugriffswege</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 id="main-content">{% block content %}{% endblock %}</main>
|
||||||
|
<footer class="app-footer"><span>OfficeCom Sentinel</span><span>Interne Sicherheitskonsole · Verdichtete Endpoint-Signale</span></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
infra/debug-dashboard/templates/device.html
Normal file
14
infra/debug-dashboard/templates/device.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="device-hero {{ report[6] }}">
|
||||||
|
<div><span class="eyebrow">Geraeteanalyse</span><h1>{{ report[0] }}</h1><p>Letzter Scan {{ report[5] or '-' }} · Client zuletzt gesehen {{ report[2] or '-' }}</p></div>
|
||||||
|
<div class="device-hero-state"><span class="status-orb"></span><span>Aktueller Status</span><strong>{{ report[6] }}</strong>{% if report[8] %}<small>{{ 'Aktuelles Signal' if event.is_current else 'Historisches Signal' }} · {{ event.label }}</small>{% else %}<small>Keine Ereignisse im letzten Scan</small>{% 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>
|
||||||
|
{% 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 %}
|
||||||
|
{% if ransomware_beta.enabled %}<section class="panel sensor-coverage-panel"><div class="panel-heading"><span class="eyebrow">Beta-Abdeckung</span><h2>Erkennungsquellen <small>Verfuegbarkeit im letzten Scan</small></h2><p>Ein unauffaelliger Zeitraum ist nur dann belastbar, wenn die benoetigten Datenquellen erreichbar waren.</p></div><div class="sensor-grid">{% for sensor in ransomware_sensors %}<article class="sensor-card {{ sensor.state }}"><div><span class="sensor-dot"></span><span class="sensor-state">{{ sensor.state }}</span></div><strong>{{ sensor.label }}</strong><p>{% if not sensor.enabled %}Nicht fuer diesen Client aktiviert.{% elif sensor.available %}{{ sensor.event_count }} Ereignisse im Zeitfenster verarbeitet.{% elif sensor.state == 'not-installed' %}Protokollquelle ist auf diesem System nicht installiert.{% else %}Quelle konnte im letzten Scan nicht verwendet werden.{% endif %}</p></article>{% else %}<div class="sensor-empty">Dieser Client sendet noch keine Abdeckungsdaten. Nach dem Update auf Beta 1.5.0-beta.5 erscheint die Sensoransicht automatisch.</div>{% endfor %}</div></section>{% endif %}
|
||||||
|
{% if ransomware_beta.smbSessions %}<section class="panel smb-context-panel"><div class="panel-heading"><span class="eyebrow">Incident-Kontext</span><h2>Aktive SMB-Sitzungen <small>Nur bei Ransomware-Warnung oder kritisch erfasst</small></h2></div><div class="table-wrap"><table><thead><tr><th>Client</th><th>Benutzer</th><th>Offene Dateien</th><th>Sitzung</th></tr></thead><tbody>{% for session in ransomware_beta.smbSessions %}<tr><td>{{ session.clientComputerName }}</td><td>{{ session.clientUserName }}</td><td>{{ session.openFileCount }}</td><td>{{ session.sessionId }}</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 %}
|
||||||
52
infra/debug-dashboard/templates/network.html
Normal file
52
infra/debug-dashboard/templates/network.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Zugriffswege - OfficeCom Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="map-hero">
|
||||||
|
<div><span class="eyebrow">Security topology</span><h1>Zugriffswege<br><em>sichtbar machen.</em></h1><p>Verdichtete fehlgeschlagene Anmeldungen aus den letzten {{ days }} Tagen. Die Karte zeigt nur beobachtete Pfade, keinen vollstaendigen Netzwerkverkehr.</p></div>
|
||||||
|
<div class="map-hero-status"><span>LIVE DATASET</span><strong>{{ total_events }}</strong><small>beobachtete Versuche</small></div>
|
||||||
|
</section>
|
||||||
|
<section class="map-stat-strip"><article><span>Quellen</span><strong>{{ source_count }}</strong><small>externe IPs</small></article><article><span>Ziele</span><strong>{{ target_count }}</strong><small>Systeme</small></article><article><span>Pfade</span><strong>{{ path_count }}</strong><small>korrelierte Kanten</small></article><article><span>Zeitraum</span><strong>{{ days }}</strong><small>Tage Rueckblick</small></article></section>
|
||||||
|
<section class="network-map-panel">
|
||||||
|
<header class="map-header"><div><span class="eyebrow">Interaktive Analyse</span><h2>Access graph</h2></div><div class="map-legend"><span><i class="source"></i>Quell-IP</span><span><i class="target"></i>Zielgeraet</span><span><i class="hot"></i>Hohe Aktivitaet</span></div></header>
|
||||||
|
<div class="map-toolbar">
|
||||||
|
<label class="search-field"><span>Suchen</span><input id="map-filter" type="search" placeholder="IP, Geraet oder Konto"></label>
|
||||||
|
<label><span>Zeitraum</span><select id="map-range"><option value="1" {% if days == 1 %}selected{% endif %}>24 Stunden</option><option value="7" {% if days == 7 %}selected{% endif %}>7 Tage</option><option value="14" {% if days == 14 %}selected{% endif %}>14 Tage</option><option value="30" {% if days == 30 %}selected{% endif %}>30 Tage</option></select></label>
|
||||||
|
<label><span>Organisation</span><select id="map-organization"><option value="">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}">{{ organization[1] }}</option>{% endfor %}</select></label>
|
||||||
|
<label><span>Vorfall</span><select id="map-event"><option value="">Alle Vorfaelle</option>{% for event_type in event_types %}<option value="{{ event_type }}">{{ event_type }}</option>{% endfor %}</select></label>
|
||||||
|
<div class="map-actions"><button type="button" id="map-fit">Gesamtansicht</button><button type="button" id="map-export">JSON</button><button type="button" id="map-print">Drucken</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="network-map-layout"><div id="network-map" aria-label="Interaktive Netzwerk- und Zugriffskarte"></div><aside id="network-inspector"><span class="eyebrow">Inspector</span><strong>Kein Element ausgewaehlt</strong><p>Waehle einen Knoten oder einen Pfad. Zugehoerige Verbindungen werden hervorgehoben.</p></aside></div>
|
||||||
|
</section>
|
||||||
|
<script src="{{ url_for('static', filename='vendor/cytoscape.min.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const inspector = document.getElementById('network-inspector');
|
||||||
|
const filter = document.getElementById('map-filter');
|
||||||
|
const organization = document.getElementById('map-organization');
|
||||||
|
const eventType = document.getElementById('map-event');
|
||||||
|
const apiUrl = '{{ url_for("network_api") }}?days={{ days }}';
|
||||||
|
fetch(apiUrl).then(response => response.json()).then(graph => {
|
||||||
|
const cy = cytoscape({ container: document.getElementById('network-map'), elements: graph.elements, minZoom: .3, maxZoom: 2.4,
|
||||||
|
style: [
|
||||||
|
{ selector: 'node', style: { 'label': 'data(label)', 'font-family': 'Roboto', 'font-size': 11, 'font-weight': 700, 'color': '#eaf2f8', 'text-valign': 'bottom', 'text-margin-y': 8, 'text-outline-width': 3, 'text-outline-color': '#0d1e2c', 'width': 48, 'height': 48, 'border-width': 2, 'border-color': '#d9f5ed' } },
|
||||||
|
{ selector: 'node[kind = "source"]', style: { 'background-color': '#4fa3df', 'shape': 'ellipse' } },
|
||||||
|
{ selector: 'node[kind = "target"]', style: { 'background-color': '#3bca99', 'shape': 'round-rectangle' } },
|
||||||
|
{ selector: 'edge', style: { 'width': 'mapData(count, 1, ' + graph.max_count + ', 2, 10)', 'line-color': '#406b86', 'target-arrow-color': '#406b86', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'opacity': .75 } },
|
||||||
|
{ selector: 'edge[count >= 5]', style: { 'line-color': '#ffb454', 'target-arrow-color': '#ffb454' } },
|
||||||
|
{ selector: '.selected', style: { 'border-color': '#e7ff88', 'border-width': 6, 'line-color': '#e7ff88', 'target-arrow-color': '#e7ff88', 'opacity': 1, 'z-index': 20 } },
|
||||||
|
{ selector: '.hidden', style: { 'display': 'none' } }
|
||||||
|
], layout: { name: 'cose', animate: false, padding: 52, nodeRepulsion: 9000, idealEdgeLength: 145, gravity: .2 } });
|
||||||
|
const resetInspector = () => inspector.innerHTML = '<span class="eyebrow">Inspector</span><strong>Kein Element ausgewaehlt</strong><p>Waehle einen Knoten oder einen Pfad. Zugehoerige Verbindungen werden hervorgehoben.</p>';
|
||||||
|
const show = element => { const data = element.data(); if (element.isEdge()) { inspector.innerHTML = '<span class="eyebrow">Observed path</span><strong>' + data.source.replace('source:', '') + ' <span class="inspector-arrow">to</span> ' + data.machine_name + '</strong><dl><dt>Versuche</dt><dd>' + data.count + '</dd><dt>Organisation</dt><dd>' + data.organization_name + '</dd><dt>Konto</dt><dd>' + data.account + '</dd><dt>Vorfall</dt><dd>' + data.event_type + '</dd><dt>Letzter Scan</dt><dd>' + data.last_seen + '</dd></dl>'; } else { const connected = element.connectedEdges(':visible'); inspector.innerHTML = '<span class="eyebrow">' + (data.kind === 'source' ? 'Quell-IP' : 'Zielgeraet') + '</span><strong>' + data.label + '</strong><p>' + connected.length + ' sichtbare Zugriffswege im aktuellen Filter.</p>'; } };
|
||||||
|
const applyFilters = () => { const term = filter.value.trim().toLowerCase(); const org = organization.value; const type = eventType.value; cy.elements().addClass('hidden'); const visible = cy.edges().filter(edge => { const d = edge.data(); return (!term || [d.source, d.machine_name, d.account, d.event_type].join(' ').toLowerCase().includes(term)) && (!org || d.organization_id === org) && (!type || d.event_type === type); }); visible.removeClass('hidden'); visible.connectedNodes().removeClass('hidden'); cy.layout({ name:'cose', animate:false, padding:52, nodeRepulsion:9000, idealEdgeLength:145, gravity:.2 }).run(); resetInspector(); };
|
||||||
|
cy.on('tap', 'node, edge', event => { cy.elements().removeClass('selected'); event.target.addClass('selected'); if (event.target.isNode()) event.target.connectedEdges(':visible').addClass('selected'); show(event.target); });
|
||||||
|
cy.on('tap', event => { if (event.target === cy) { cy.elements().removeClass('selected'); resetInspector(); } });
|
||||||
|
filter.addEventListener('input', applyFilters); organization.addEventListener('change', applyFilters); eventType.addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('map-range').addEventListener('change', e => { window.location.search = 'days=' + e.target.value; });
|
||||||
|
document.getElementById('map-fit').addEventListener('click', () => cy.fit(cy.elements(':visible'), 48));
|
||||||
|
document.getElementById('map-export').addEventListener('click', () => { const data = cy.json().elements; const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'}); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'ocsentinel-access-map.json'; link.click(); URL.revokeObjectURL(link.href); });
|
||||||
|
document.getElementById('map-print').addEventListener('click', () => window.print());
|
||||||
|
}).catch(() => { document.getElementById('network-map').textContent = 'Die Netzwerkdaten konnten nicht geladen werden.'; });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% 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 %}
|
||||||
78
infra/debug-dashboard/templates/overview.html
Normal file
78
infra/debug-dashboard/templates/overview.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="dashboard-hero {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">OfficeCom Sentinel Uebersicht</span>
|
||||||
|
<h1>{% if summary[2] %}Sicherheitslage<br>braucht Aufmerksamkeit.{% elif summary[1] %}Signale im Bestand<br>gezielt pruefen.{% else %}Sicherheitslage<br>unter Kontrolle.{% endif %}</h1>
|
||||||
|
<p>Verdichtete Endpoint-Signale, Upload-Gesundheit und organisationsweite Einordnung an einem Ort.</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-status"><span class="status-orb"></span><span>Aktueller Zustand</span><strong>{% if summary[2] %}Kritisch{% elif summary[1] %}Pruefen{% else %}Stabil{% endif %}</strong><small>{{ current_alert_count }} aktuelle Auffaelligkeit{{ '' if current_alert_count == 1 else 'en' }}</small></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="quick-metrics">
|
||||||
|
<article><span>Geraete</span><strong>{{ summary[0] }}</strong><small>{{ coverage[1] }} melden aktuell</small></article>
|
||||||
|
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong><small>im letzten Status</small></article>
|
||||||
|
<article><span>Kritisch</span><strong class="critical">{{ summary[2] }}</strong><small>sofort sichtbar</small></article>
|
||||||
|
<article><span>Abdeckung</span><strong class="{% if coverage[2] %}warning{% else %}ok{% endif %}">{{ coverage[1] }}/{{ coverage[0] }}</strong><small>{{ coverage[2] }} stumm > 36 Std.</small></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if alerts %}
|
||||||
|
<section class="priority-board">
|
||||||
|
<div class="priority-intro">
|
||||||
|
<span class="eyebrow">Einsatzfokus</span>
|
||||||
|
<h2>Was jetzt<br>Aufmerksamkeit braucht.</h2>
|
||||||
|
<p>Priorisiert nach Schweregrad und zuletzt gemeldetem Signal.</p>
|
||||||
|
<div class="priority-sync"><i></i><span>Letzter Datenstand: {{ summary[7] or '-' }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="priority-list">
|
||||||
|
{% for alert in alerts[:3] %}
|
||||||
|
<a class="priority-item {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
|
||||||
|
<span class="priority-index">0{{ loop.index }}</span>
|
||||||
|
<div><span class="state {{ alert.alert_state }}">{{ alert.alert_state }}</span><strong>{{ alert.machine_name }}</strong><small>{{ alert.total_events }} Ereignisse · {{ alert.unique_ip_count }} Quell-IPs · {{ alert.event.label }}</small></div>
|
||||||
|
<span class="priority-arrow" aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="priority-board calm">
|
||||||
|
<div class="priority-intro"><span class="eyebrow">Einsatzfokus</span><h2>Aktuell keine<br>offenen Signale.</h2><p>Die meldenden Systeme liefern derzeit keine auffaelligen Sicherheitsereignisse.</p><div class="priority-sync"><i></i><span>Letzter Datenstand: {{ summary[7] or '-' }}</span></div></div>
|
||||||
|
<div class="priority-calm"><span class="status-orb"></span><strong>Keine unmittelbare Aktion notwendig</strong><p>Behalte Abdeckung und Berichtsintervall im Blick.</p></div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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="panel coverage-panel">
|
||||||
|
<div class="panel-heading"><h2>Geraeteabdeckung</h2></div>
|
||||||
|
<div class="coverage-metrics"><article><span>Bekannt</span><strong>{{ coverage[0] }}</strong></article><article><span>Meldend < 36 Std.</span><strong class="ok">{{ coverage[1] }}</strong></article><article><span>Stumm > 36 Std.</span><strong class="{% if coverage[2] %}warning{% endif %}">{{ coverage[2] }}</strong></article></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if alerts %}
|
||||||
|
<section class="panel alert-panel">
|
||||||
|
<div class="panel-heading"><span class="eyebrow">Vollstaendige Liste</span><h2>Auffaellige Geraete <small>{{ current_alert_count }} aktuell, {{ alerts|length - current_alert_count }} historisch</small></h2></div>
|
||||||
|
<div class="alert-grid">
|
||||||
|
{% for alert in alerts %}
|
||||||
|
<a class="alert-card {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
|
||||||
|
<span>{{ alert.alert_state }} | {{ 'aktuell' if alert.event.is_current else 'historisch' }}</span><strong>{{ alert.machine_name }}</strong><small>{{ alert.total_events }} Ereignisse | {{ alert.unique_ip_count }} Quell-IPs | letztes Ereignis {{ alert.event.label }}</small>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading"><h2>Geraetestatus</h2></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>Geraet</th><th>Organisation</th><th>Status</th><th>Ereignisse</th><th>Quell-IPs</th><th>Empfangen</th></tr></thead>
|
||||||
|
<tbody>{% for row in reports %}<tr><td><a href="{{ url_for('device', machine_name=row.machine_name) }}">{{ row.machine_name }}</a></td><td>{{ row.organization_name or '-' }}</td><td><span class="state {{ row.alert_state }}">{{ row.alert_state }}</span>{% if row.total_events %}<span class="state {{ 'current' if row.event.is_current else 'historic' }}">{{ 'aktuell' if row.event.is_current else 'historisch' }}</span>{% endif %}</td><td>{{ row.total_events }}</td><td>{{ row.unique_ip_count }}</td><td>{{ row.received_at or '-' }}</td></tr>{% else %}<tr><td colspan="6">Keine Geraeteberichte.</td></tr>{% endfor %}</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
27
infra/debug-dashboard/templates/recipients.html
Normal file
27
infra/debug-dashboard/templates/recipients.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Empfaenger - OC Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<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 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 %}
|
||||||
8
infra/debug-dashboard/templates/reports.html
Normal file
8
infra/debug-dashboard/templates/reports.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Berichte - OC Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro"><span class="eyebrow">Wochenberichte</span><h1>Sicherheitsberichte<br><em>auf einen Blick.</em></h1><p>Alle automatisch erzeugten Organisationsberichte mit direktem Zugriff auf die finale HTML-Vorschau.</p></section>
|
||||||
|
<section class="panel reports-panel"><div class="panel-heading"><span class="eyebrow">Archiv</span><h2>Gesendete Berichte <small>{{ reports|length }} Eintraege</small></h2></div><div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Zeitraum</th><th>Geraete</th><th>Warnung</th><th>Kritisch</th><th>Events</th><th>Erstellt</th></tr></thead><tbody>
|
||||||
|
{% for row in reports %}<tr><td><a href="{{ url_for('weekly_report', report_id=row[0]) }}">{{ row[1] }}</a></td><td>{{ row[2] }} bis {{ row[3] }}</td><td>{{ row[5] }}</td><td>{{ row[6] }}</td><td>{{ row[7] }}</td><td>{{ row[8] }}</td><td>{{ row[4] }}</td></tr>{% else %}<tr><td colspan="7">Keine Wochenberichte.</td></tr>{% endfor %}
|
||||||
|
</tbody></table></div></section>
|
||||||
|
{% endblock %}
|
||||||
5
infra/debug-dashboard/templates/weekly_report.html
Normal file
5
infra/debug-dashboard/templates/weekly_report.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ report[0] }} - Wochenbericht{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="report-frame">{{ report[4] | safe }}</section>
|
||||||
|
{% endblock %}
|
||||||
13
infra/mcp-server/.env.example
Normal file
13
infra/mcp-server/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Dedicated, read-only PostgreSQL login. Do not reuse the n8n or ingest role.
|
||||||
|
DB_HOST=ocsentinel-postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=ocsentinel
|
||||||
|
DB_USER=ocsentinel_mcp
|
||||||
|
DB_PASSWORD=replace-with-a-long-random-password
|
||||||
|
|
||||||
|
# A long random bearer token for trusted MCP clients. Keep this file private.
|
||||||
|
MCP_AUTH_TOKEN=replace-with-a-second-long-random-token
|
||||||
|
|
||||||
|
# Validate browser origins and Host headers when they are present.
|
||||||
|
MCP_ALLOWED_ORIGINS=http://localhost:6274,http://127.0.0.1:6274
|
||||||
|
MCP_ALLOWED_HOSTS=localhost:8091,127.0.0.1:8091
|
||||||
2
infra/mcp-server/.gitignore
vendored
Normal file
2
infra/mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
16
infra/mcp-server/Dockerfile
Normal file
16
infra/mcp-server/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
RUN addgroup -S ocsentinel && adduser -S ocsentinel -G ocsentinel
|
||||||
|
USER ocsentinel
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--no-access-log"]
|
||||||
70
infra/mcp-server/README.md
Normal file
70
infra/mcp-server/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# OfficeCom Sentinel MCP
|
||||||
|
|
||||||
|
Dieser Container stellt sichere, **schreibgeschuetzte** Abfragen der OfficeCom-Sentinel-Daten fuer KI-Agenten bereit. Er nutzt das offizielle Python-MCP-SDK mit Streamable HTTP unter `/mcp`.
|
||||||
|
|
||||||
|
## Sicherheitsmodell
|
||||||
|
|
||||||
|
- Der Dienst wird im ersten Schritt nur auf `127.0.0.1:8091` des n8n-Hosts gebunden. Er wird nicht ueber `sentinel.officecom.biz` veroeffentlicht.
|
||||||
|
- Jeder MCP-Aufruf verlangt einen eigenen Bearer-Token, prueft `Host` sowie vorhandene `Origin`-Header und wird ohne Aufrufparameter protokolliert.
|
||||||
|
- Der PostgreSQL-Zugang ist ein dedizierter Login mit `default_transaction_read_only=on`, einem 5-Sekunden-Statement-Timeout und ausschliesslich `SELECT`-Rechten.
|
||||||
|
- Die Werkzeuge haben feste, parametrisierte Abfragen und feste Ergebnisgrenzen. Es gibt kein Werkzeug fuer SQL, Schreiboperationen, Rohbeweise, Befehlszeilen oder Zugangsdaten.
|
||||||
|
- Die Antwort auf `get_device_security` und `search_security_events` enthaelt standardmaessig keine Kontonamen. Konten werden nur auf ausdrueckliche Tool-Anforderung ergaenzt.
|
||||||
|
|
||||||
|
## Verfuegbare Tools
|
||||||
|
|
||||||
|
| Tool | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `security_overview` | Gesamtlage, Abdeckung und dringende Systeme |
|
||||||
|
| `get_organization_status` | Status eines NinjaOne-Organisations-IDs |
|
||||||
|
| `get_device_security` | Bereinigte Sicherheitslage eines Systems |
|
||||||
|
| `search_security_events` | Zeitlich und mengenmaessig begrenzte Ereigniszusammenfassungen |
|
||||||
|
| `get_network_paths` | Beobachtete Quell-IP-zu-System-Pfade |
|
||||||
|
| `get_weekly_report` | Letzte woechentliche Kennzahlen ohne Bericht-HTML |
|
||||||
|
|
||||||
|
Zusaetzlich gibt es die Resource `ocsentinel://read-only-policy` und den Prompt `incident_triage`.
|
||||||
|
|
||||||
|
## Einmalig: Datenbankrolle anlegen
|
||||||
|
|
||||||
|
Auf dem PostgreSQL-Container als Datenbankadministrator ausfuehren. Das Passwort in diesem Befehl durch ein langes, zufaelliges Kennwort ersetzen und danach nur in der lokalen `.env` hinterlegen.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE ROLE ocsentinel_mcp LOGIN PASSWORD 'replace-with-a-long-random-password'
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT;
|
||||||
|
GRANT CONNECT ON DATABASE ocsentinel TO ocsentinel_mcp;
|
||||||
|
GRANT USAGE ON SCHEMA ocsentinel TO ocsentinel_mcp;
|
||||||
|
GRANT SELECT ON ocsentinel.device, ocsentinel.scan_report,
|
||||||
|
ocsentinel.weekly_organization_report TO ocsentinel_mcp;
|
||||||
|
GRANT SELECT ON ocsentinel.current_device_status,
|
||||||
|
ocsentinel.organization_summary TO ocsentinel_mcp;
|
||||||
|
```
|
||||||
|
|
||||||
|
Pruefung:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SET ROLE ocsentinel_mcp;
|
||||||
|
SELECT * FROM ocsentinel.organization_summary;
|
||||||
|
INSERT INTO ocsentinel.device (machine_name, machine_name_key) VALUES ('must-fail', 'must-fail');
|
||||||
|
```
|
||||||
|
|
||||||
|
Die letzte Anweisung muss scheitern.
|
||||||
|
|
||||||
|
## Dockge-Bereitstellung
|
||||||
|
|
||||||
|
1. Den Ordner `infra/mcp-server` als neuen Dockge-Stack auf dem n8n-Host ablegen.
|
||||||
|
2. `.env.example` nach `.env` kopieren, Datenbankpasswort und einen zweiten langen Zufallstoken setzen.
|
||||||
|
3. In `MCP_ALLOWED_HOSTS` nur die echten, erlaubten Host-Header lassen. Fuer den SSH-Tunnel sind `localhost:8091` und `127.0.0.1:8091` korrekt.
|
||||||
|
4. Stack starten. Der Endpunkt ist lokal: `http://127.0.0.1:8091/mcp`.
|
||||||
|
|
||||||
|
Der Container hat keinen veroeffentlichten Zugriff auf das Internet. Fuer einen Arbeitsplatz wird ein Tunnel genutzt:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh -L 8091:127.0.0.1:8091 oc@172.16.41.197 -p 1022
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach ist der lokale MCP-Endpunkt `http://localhost:8091/mcp`. Der MCP-Client muss den Header `Authorization: Bearer <MCP_AUTH_TOKEN>` mitsenden.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- Logs: `docker logs ocsentinel-mcp --tail 100`.
|
||||||
|
- Niemals den Bearer-Token in einem Git-Repository, Screenshot oder Prompt speichern.
|
||||||
|
- Fuer einen spaeteren externen Zugriff wird ein separater OAuth-geschuetzter Reverse Proxy benoetigt. Der aktuelle Token-Modus ist ausschliesslich fuer den privaten Tunnel und vertrauenswuerdige Agenten gedacht.
|
||||||
23
infra/mcp-server/compose.yml
Normal file
23
infra/mcp-server/compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
ocsentinel-mcp:
|
||||||
|
build: .
|
||||||
|
container_name: ocsentinel-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
# The first version is intentionally only reachable through an SSH tunnel.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8091:8080"
|
||||||
|
networks:
|
||||||
|
- ocsentinel-network
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ocsentinel-network:
|
||||||
|
external: true
|
||||||
|
name: n8n_n8n-network
|
||||||
3
infra/mcp-server/requirements.txt
Normal file
3
infra/mcp-server/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mcp==1.26.0
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
|
uvicorn==0.35.0
|
||||||
478
infra/mcp-server/server.py
Normal file
478
infra/mcp-server/server.py
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
"""Read-only MCP access to curated OfficeCom Sentinel security data."""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("ocsentinel.mcp")
|
||||||
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
MAX_RESULT_LIMIT = 100
|
||||||
|
MAX_LOOKBACK_HOURS = 24 * 90
|
||||||
|
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||||
|
|
||||||
|
|
||||||
|
def required_setting(name: str) -> str:
|
||||||
|
value = os.getenv(name, "").strip()
|
||||||
|
if not value or value.startswith("replace-with-"):
|
||||||
|
raise RuntimeError(f"{name} must be configured before starting OCSentinel MCP.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_TOKEN = required_setting("MCP_AUTH_TOKEN")
|
||||||
|
ALLOWED_ORIGINS = {value.strip() for value in os.getenv("MCP_ALLOWED_ORIGINS", "").split(",") if value.strip()}
|
||||||
|
ALLOWED_HOSTS = {value.strip().lower() for value in os.getenv("MCP_ALLOWED_HOSTS", "").split(",") if value.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def db_connection() -> psycopg.Connection:
|
||||||
|
return psycopg.connect(
|
||||||
|
host=required_setting("DB_HOST"),
|
||||||
|
port=os.getenv("DB_PORT", "5432"),
|
||||||
|
dbname=required_setting("DB_NAME"),
|
||||||
|
user=required_setting("DB_USER"),
|
||||||
|
password=required_setting("DB_PASSWORD"),
|
||||||
|
connect_timeout=5,
|
||||||
|
row_factory=dict_row,
|
||||||
|
options="-c default_transaction_read_only=on -c statement_timeout=5000",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(sql: str, parameters: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, parameters)
|
||||||
|
return list(cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def row(sql: str, parameters: tuple[Any, ...] = ()) -> dict[str, Any] | None:
|
||||||
|
results = rows(sql, parameters)
|
||||||
|
return results[0] if results else None
|
||||||
|
|
||||||
|
|
||||||
|
def value(record: dict[str, Any] | None, *names: str, default: Any = None) -> Any:
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return default
|
||||||
|
for name in names:
|
||||||
|
if name in record:
|
||||||
|
return record[name]
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def json_safe(data: Any) -> Any:
|
||||||
|
return json.loads(json.dumps(data, default=lambda entry: entry.isoformat() if isinstance(entry, datetime) else str(entry)))
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_limit(limit: int) -> int:
|
||||||
|
if not isinstance(limit, int) or isinstance(limit, bool):
|
||||||
|
raise ValueError("limit must be a whole number.")
|
||||||
|
return max(1, min(limit, MAX_RESULT_LIMIT))
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_hours(hours: int) -> int:
|
||||||
|
if not isinstance(hours, int) or isinstance(hours, bool):
|
||||||
|
raise ValueError("hours must be a whole number.")
|
||||||
|
return max(1, min(hours, MAX_LOOKBACK_HOURS))
|
||||||
|
|
||||||
|
|
||||||
|
def checked_identifier(identifier: str, field_name: str) -> str:
|
||||||
|
value_to_check = (identifier or "").strip()
|
||||||
|
if value_to_check == "unknown" or IDENTIFIER_PATTERN.fullmatch(value_to_check):
|
||||||
|
return value_to_check
|
||||||
|
raise ValueError(f"{field_name} contains unsupported characters.")
|
||||||
|
|
||||||
|
|
||||||
|
def checked_machine_name(machine_name: str) -> str:
|
||||||
|
machine = (machine_name or "").strip()
|
||||||
|
if not machine or len(machine) > 255 or any(character in machine for character in "\r\n\x00"):
|
||||||
|
raise ValueError("machine_name must be a single device name of at most 255 characters.")
|
||||||
|
return machine
|
||||||
|
|
||||||
|
|
||||||
|
def organization_context(payload: dict[str, Any] | None) -> tuple[str, str]:
|
||||||
|
ninja = value(payload, "NinjaOne", "ninjaOne", default={}) or {}
|
||||||
|
return (
|
||||||
|
str(value(ninja, "OrganizationId", "organizationId", default="unknown") or "unknown"),
|
||||||
|
str(value(ninja, "OrganizationName", "organizationName", default="Organisation unbekannt") or "Organisation unbekannt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitized_ransomware(payload: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
ransomware = value(payload, "RansomwareBeta", "ransomwareBeta", default={}) or {}
|
||||||
|
sensors = []
|
||||||
|
for sensor in value(ransomware, "Sensors", "sensors", default=[]) or []:
|
||||||
|
sensors.append(
|
||||||
|
{
|
||||||
|
"name": value(sensor, "Name", "name", default="unknown"),
|
||||||
|
"enabled": bool(value(sensor, "Enabled", "enabled", default=False)),
|
||||||
|
"available": bool(value(sensor, "Available", "available", default=False)),
|
||||||
|
"state": value(sensor, "State", "state", default="unknown"),
|
||||||
|
"eventCount": value(sensor, "EventCount", "eventCount", default=0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
signals = []
|
||||||
|
for signal in value(ransomware, "Signals", "signals", default=[]) or []:
|
||||||
|
# Evidence and raw command lines intentionally never leave the MCP boundary.
|
||||||
|
signals.append(
|
||||||
|
{
|
||||||
|
"timestamp": value(signal, "Timestamp", "timestamp", default=None),
|
||||||
|
"category": value(signal, "Category", "category", default="signal"),
|
||||||
|
"process": value(signal, "Process", "process", default="-"),
|
||||||
|
"source": value(signal, "Source", "source", default="-"),
|
||||||
|
"confidence": value(signal, "Confidence", "confidence", default="low"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": bool(value(ransomware, "Enabled", "enabled", default=False)),
|
||||||
|
"state": value(ransomware, "State", "state", default="disabled"),
|
||||||
|
"reason": value(ransomware, "Reason", "reason", default="Keine Ransomware-Beta-Daten verfuegbar."),
|
||||||
|
"sensors": sensors,
|
||||||
|
"signals": signals[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarized_events(payload: dict[str, Any] | None, include_accounts: bool = False) -> list[dict[str, Any]]:
|
||||||
|
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||||
|
for event in value(payload, "Events", "events", default=[]) or []:
|
||||||
|
event_type = str(value(event, "Target", "target", default="Sicherheitsereignis"))
|
||||||
|
source_ip = str(value(event, "SourceIp", "sourceIp", default="-"))
|
||||||
|
account = str(value(event, "Username", "username", default="-")) if include_accounts else ""
|
||||||
|
key = (event_type, source_ip, account)
|
||||||
|
group = groups.setdefault(key, {"type": event_type, "sourceIp": source_ip, "count": 0, "latest": None})
|
||||||
|
group["count"] += 1
|
||||||
|
timestamp = value(event, "Timestamp", "timestamp", default=None)
|
||||||
|
if timestamp and (not group["latest"] or str(timestamp) > str(group["latest"])):
|
||||||
|
group["latest"] = timestamp
|
||||||
|
if include_accounts:
|
||||||
|
group["account"] = account
|
||||||
|
return sorted(groups.values(), key=lambda entry: (str(entry["latest"]), entry["count"]), reverse=True)[:25]
|
||||||
|
|
||||||
|
|
||||||
|
def audited(tool_name: str):
|
||||||
|
def decorator(function):
|
||||||
|
@functools.wraps(function)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
result = function(*args, **kwargs)
|
||||||
|
LOGGER.info("mcp_tool=%s outcome=ok duration_ms=%d", tool_name, (time.monotonic() - started) * 1000)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("mcp_tool=%s outcome=error duration_ms=%d", tool_name, (time.monotonic() - started) * 1000)
|
||||||
|
raise
|
||||||
|
return wrapped
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
"OfficeCom Sentinel",
|
||||||
|
instructions=(
|
||||||
|
"Read-only security context from OfficeCom Sentinel. Use this data to investigate, summarize, and prioritize. "
|
||||||
|
"Do not treat it as authorization to alter devices, NinjaOne, PostgreSQL, or security controls."
|
||||||
|
),
|
||||||
|
stateless_http=True,
|
||||||
|
json_response=True,
|
||||||
|
streamable_http_path="/mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ocsentinel://read-only-policy")
|
||||||
|
def read_only_policy() -> str:
|
||||||
|
"""Explain the data and safety boundary of this server."""
|
||||||
|
return (
|
||||||
|
"OfficeCom Sentinel MCP is read-only. It returns curated status, event summaries, ransomware sensor coverage, "
|
||||||
|
"network paths, and weekly-report metadata. Raw event evidence, command lines, SMB sessions, credentials, "
|
||||||
|
"and every write action are deliberately excluded."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
def incident_triage() -> str:
|
||||||
|
"""Provide a safe, evidence-oriented workflow for analyzing Sentinel findings."""
|
||||||
|
return (
|
||||||
|
"Start with security_overview or get_organization_status. For a flagged device, use get_device_security and "
|
||||||
|
"search_security_events. Separate observed facts from hypotheses, identify the next reversible validation step, "
|
||||||
|
"and recommend escalation to the responsible OfficeCom technician for any containment action."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("security_overview")
|
||||||
|
def security_overview() -> dict[str, Any]:
|
||||||
|
"""Return the current cross-organization security posture and the most urgent devices."""
|
||||||
|
summary = row("SELECT * FROM ocsentinel.organization_summary") or {}
|
||||||
|
coverage = row(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS known_devices,
|
||||||
|
count(*) FILTER (WHERE received_at >= now() - interval '36 hours') AS reporting_devices,
|
||||||
|
count(*) FILTER (WHERE received_at IS NULL OR received_at < now() - interval '36 hours') AS stale_devices
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
"""
|
||||||
|
) or {}
|
||||||
|
urgent = rows(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, alert_state, received_at, total_events, unique_ip_count,
|
||||||
|
payload #>> '{NinjaOne,OrganizationId}' AS organization_id,
|
||||||
|
payload #>> '{NinjaOne,OrganizationName}' AS organization_name
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE alert_state IN ('warning', 'critical')
|
||||||
|
ORDER BY CASE alert_state WHEN 'critical' THEN 0 ELSE 1 END, received_at DESC NULLS LAST
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return json_safe({"summary": summary, "coverage": coverage, "urgentDevices": urgent})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("get_organization_status")
|
||||||
|
def get_organization_status(organization_id: str) -> dict[str, Any]:
|
||||||
|
"""Return current coverage and alert state for one NinjaOne organization ID."""
|
||||||
|
organization_id = checked_identifier(organization_id, "organization_id")
|
||||||
|
devices = rows(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, received_at, alert_state, base_alert_state, total_events, unique_ip_count,
|
||||||
|
cve_total, 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,),
|
||||||
|
)
|
||||||
|
if not devices:
|
||||||
|
return {"organizationId": organization_id, "found": False, "devices": []}
|
||||||
|
_, organization_name = organization_context(devices[0]["payload"])
|
||||||
|
status_counts = {state: sum(device["alert_state"] == state for device in devices) for state in ("ok", "warning", "critical", "unknown")}
|
||||||
|
return json_safe(
|
||||||
|
{
|
||||||
|
"organizationId": organization_id,
|
||||||
|
"organizationName": organization_name,
|
||||||
|
"found": True,
|
||||||
|
"deviceCount": len(devices),
|
||||||
|
"statusCounts": status_counts,
|
||||||
|
"lastReceivedAt": max((device["received_at"] for device in devices if device["received_at"]), default=None),
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"machineName": device["machine_name"],
|
||||||
|
"alertState": device["alert_state"],
|
||||||
|
"receivedAt": device["received_at"],
|
||||||
|
"totalEvents": device["total_events"],
|
||||||
|
"uniqueIpCount": device["unique_ip_count"],
|
||||||
|
"criticalCves": device["cve_critical"],
|
||||||
|
}
|
||||||
|
for device in devices
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("get_device_security")
|
||||||
|
def get_device_security(machine_name: str, include_accounts: bool = False) -> dict[str, Any]:
|
||||||
|
"""Return the latest sanitized security summary for one device. Accounts are omitted by default."""
|
||||||
|
machine_name = checked_machine_name(machine_name)
|
||||||
|
device = row(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, first_seen_at, last_seen_at, last_client_version, generated_at_utc, received_at,
|
||||||
|
alert_state, base_alert_state, total_events, unique_ip_count, cve_total, cve_critical, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE machine_name = %s
|
||||||
|
""",
|
||||||
|
(machine_name,),
|
||||||
|
)
|
||||||
|
if not device:
|
||||||
|
return {"machineName": machine_name, "found": False}
|
||||||
|
organization_id, organization_name = organization_context(device["payload"])
|
||||||
|
return json_safe(
|
||||||
|
{
|
||||||
|
"found": True,
|
||||||
|
"machineName": device["machine_name"],
|
||||||
|
"organizationId": organization_id,
|
||||||
|
"organizationName": organization_name,
|
||||||
|
"receivedAt": device["received_at"],
|
||||||
|
"generatedAt": device["generated_at_utc"],
|
||||||
|
"alertState": device["alert_state"],
|
||||||
|
"baseAlertState": device["base_alert_state"],
|
||||||
|
"metrics": {
|
||||||
|
"totalEvents": device["total_events"],
|
||||||
|
"uniqueIpCount": device["unique_ip_count"],
|
||||||
|
"cveTotal": device["cve_total"],
|
||||||
|
"criticalCves": device["cve_critical"],
|
||||||
|
},
|
||||||
|
"events": summarized_events(device["payload"], include_accounts),
|
||||||
|
"ransomwareBeta": sanitized_ransomware(device["payload"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("search_security_events")
|
||||||
|
def search_security_events(
|
||||||
|
hours: int = 168,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
alert_state: str | None = None,
|
||||||
|
limit: int = 25,
|
||||||
|
include_accounts: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Find recent scanned reports with bounded filters. This returns event summaries, not raw evidence."""
|
||||||
|
hours = bounded_hours(hours)
|
||||||
|
limit = bounded_limit(limit)
|
||||||
|
if organization_id is not None:
|
||||||
|
organization_id = checked_identifier(organization_id, "organization_id")
|
||||||
|
if alert_state is not None and alert_state not in {"ok", "warning", "critical", "unknown"}:
|
||||||
|
raise ValueError("alert_state must be ok, warning, critical, or unknown.")
|
||||||
|
|
||||||
|
reports = rows(
|
||||||
|
"""
|
||||||
|
SELECT d.machine_name, r.received_at, r.generated_at_utc, r.alert_state, r.base_alert_state,
|
||||||
|
r.total_events, r.unique_ip_count, r.cve_total, r.cve_critical, r.payload
|
||||||
|
FROM ocsentinel.scan_report AS r
|
||||||
|
JOIN ocsentinel.device AS d ON d.id = r.device_id
|
||||||
|
WHERE r.received_at >= now() - (%s * interval '1 hour')
|
||||||
|
AND (%s::text IS NULL OR coalesce(r.payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s)
|
||||||
|
AND (%s::text IS NULL OR r.alert_state = %s)
|
||||||
|
ORDER BY r.received_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(hours, organization_id, organization_id, alert_state, alert_state, limit),
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
for report in reports:
|
||||||
|
org_id, org_name = organization_context(report["payload"])
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"machineName": report["machine_name"],
|
||||||
|
"organizationId": org_id,
|
||||||
|
"organizationName": org_name,
|
||||||
|
"receivedAt": report["received_at"],
|
||||||
|
"generatedAt": report["generated_at_utc"],
|
||||||
|
"alertState": report["alert_state"],
|
||||||
|
"baseAlertState": report["base_alert_state"],
|
||||||
|
"totalEvents": report["total_events"],
|
||||||
|
"uniqueIpCount": report["unique_ip_count"],
|
||||||
|
"criticalCves": report["cve_critical"],
|
||||||
|
"events": summarized_events(report["payload"], include_accounts),
|
||||||
|
"ransomwareBeta": sanitized_ransomware(report["payload"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return json_safe({"lookbackHours": hours, "resultCount": len(results), "reports": results})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("get_network_paths")
|
||||||
|
def get_network_paths(days: int = 14, organization_id: str | None = None, limit: int = 50) -> dict[str, Any]:
|
||||||
|
"""Return recent observed source-IP to device paths from summarized security events."""
|
||||||
|
days = max(1, min(bounded_hours(days * 24) // 24, 90))
|
||||||
|
limit = bounded_limit(limit)
|
||||||
|
if organization_id is not None:
|
||||||
|
organization_id = checked_identifier(organization_id, "organization_id")
|
||||||
|
|
||||||
|
reports = rows(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, received_at, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE received_at >= now() - (%s * interval '1 day')
|
||||||
|
AND (%s::text IS NULL OR coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s)
|
||||||
|
""",
|
||||||
|
(days, organization_id, organization_id),
|
||||||
|
)
|
||||||
|
flows: dict[tuple[str, str, str, str], dict[str, Any]] = {}
|
||||||
|
for report in reports:
|
||||||
|
org_id, org_name = organization_context(report["payload"])
|
||||||
|
for event in value(report["payload"], "Events", "events", default=[]) or []:
|
||||||
|
source_ip = str(value(event, "SourceIp", "sourceIp", default=""))
|
||||||
|
if not source_ip or source_ip in {"-", "127.0.0.1", "::1"}:
|
||||||
|
continue
|
||||||
|
event_type = str(value(event, "Target", "target", default="Sicherheitsereignis"))
|
||||||
|
key = (source_ip, report["machine_name"], event_type, org_id)
|
||||||
|
flow = flows.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"sourceIp": source_ip,
|
||||||
|
"machineName": report["machine_name"],
|
||||||
|
"eventType": event_type,
|
||||||
|
"organizationId": org_id,
|
||||||
|
"organizationName": org_name,
|
||||||
|
"count": 0,
|
||||||
|
"lastSeen": report["received_at"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
flow["count"] += 1
|
||||||
|
timestamp = value(event, "Timestamp", "timestamp", default=None)
|
||||||
|
if timestamp and (not flow["lastSeen"] or str(timestamp) > str(flow["lastSeen"])):
|
||||||
|
flow["lastSeen"] = timestamp
|
||||||
|
paths = sorted(flows.values(), key=lambda entry: (entry["count"], str(entry["lastSeen"])), reverse=True)[:limit]
|
||||||
|
return json_safe({"days": days, "pathCount": len(paths), "paths": paths})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("get_weekly_report")
|
||||||
|
def get_weekly_report(organization_id: str) -> dict[str, Any]:
|
||||||
|
"""Return the latest weekly report metadata and structured summary for one organization, without report HTML."""
|
||||||
|
organization_id = checked_identifier(organization_id, "organization_id")
|
||||||
|
report = row(
|
||||||
|
"""
|
||||||
|
SELECT organization_id, organization_name, period_start_utc, period_end_utc, generated_at,
|
||||||
|
device_count, warning_count, critical_count, total_events, unique_ips, cve_total,
|
||||||
|
cve_critical, summary
|
||||||
|
FROM ocsentinel.weekly_organization_report
|
||||||
|
WHERE organization_id = %s
|
||||||
|
ORDER BY period_end_utc DESC, generated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(organization_id,),
|
||||||
|
)
|
||||||
|
if not report:
|
||||||
|
return {"organizationId": organization_id, "found": False}
|
||||||
|
report["found"] = True
|
||||||
|
return json_safe(report)
|
||||||
|
|
||||||
|
|
||||||
|
class GuardedMcpApp:
|
||||||
|
"""Small ASGI guard without BaseHTTPMiddleware, which can disrupt MCP streaming."""
|
||||||
|
|
||||||
|
def __init__(self, wrapped_app):
|
||||||
|
self.wrapped_app = wrapped_app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.wrapped_app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {key.decode("latin-1").lower(): value.decode("latin-1") for key, value in scope.get("headers", [])}
|
||||||
|
host = headers.get("host", "").lower()
|
||||||
|
origin = headers.get("origin")
|
||||||
|
authorization = headers.get("authorization", "")
|
||||||
|
|
||||||
|
if ALLOWED_HOSTS and host not in ALLOWED_HOSTS:
|
||||||
|
await self.reject(send, 421, "Untrusted Host header.")
|
||||||
|
return
|
||||||
|
if origin and (not ALLOWED_ORIGINS or origin not in ALLOWED_ORIGINS):
|
||||||
|
await self.reject(send, 403, "Untrusted Origin header.")
|
||||||
|
return
|
||||||
|
if authorization != f"Bearer {AUTH_TOKEN}":
|
||||||
|
await self.reject(send, 401, "Bearer token required.", {b"www-authenticate": b"Bearer"})
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.wrapped_app(scope, receive, send)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def reject(send, status: int, message: str, extra_headers: dict[bytes, bytes] | None = None):
|
||||||
|
body = json.dumps({"error": message}).encode("utf-8")
|
||||||
|
headers = [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))]
|
||||||
|
if extra_headers:
|
||||||
|
headers.extend(extra_headers.items())
|
||||||
|
await send({"type": "http.response.start", "status": status, "headers": headers})
|
||||||
|
await send({"type": "http.response.body", "body": body})
|
||||||
|
|
||||||
|
|
||||||
|
app = GuardedMcpApp(mcp.streamable_http_app())
|
||||||
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
76
infra/n8n/ocsentinel-weekly-organization-reports.json
Normal file
76
infra/n8n/ocsentinel-weekly-organization-reports.json
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"id": "OCwRpt7eK3mQ2xL9",
|
||||||
|
"name": "OCSentinel - Weekly Organization Reports",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": { "rule": { "interval": [{ "field": "weeks", "weeksInterval": 1, "triggerAtDay": [1], "triggerAtHour": 7, "triggerAtMinute": 20 }] } },
|
||||||
|
"id": "schedule-weekly-reports", "name": "Every Monday 07:20", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [300, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": { "operation": "executeQuery", "query": "WITH latest AS (\n SELECT DISTINCT ON (d.id) d.machine_name, r.alert_state, r.payload\n FROM ocsentinel.scan_report AS r\n JOIN ocsentinel.device AS d ON d.id = r.device_id\n WHERE r.received_at >= now() - interval '8 days'\n ORDER BY d.id, r.generated_at_utc DESC, r.received_at DESC\n)\nSELECT machine_name, alert_state, payload\nFROM latest\nORDER BY payload #>> '{NinjaOne,OrganizationName}', machine_name;" },
|
||||||
|
"id": "load-weekly-data", "name": "Load Latest Device Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [560, 300],
|
||||||
|
"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": { "jsCode": "const esc=v=>String(v??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\\\"/g,'"').replace(/'/g,''');\nconst now=new Date(),end=new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate()));\nend.setUTCDate(end.getUTCDate()-((end.getUTCDay()+6)%7)); const start=new Date(end-7*86400000);\nconst groups=new Map();\nfor(const item of items){const p=item.json.payload||{},n=p.NinjaOne||p.ninjaOne||{},id=String(n.OrganizationId||n.organizationId||'unknown'),name=String(n.OrganizationName||n.organizationName||`Organisation ${id}`);if(!groups.has(id))groups.set(id,{id,name,devices:[]});groups.get(id).devices.push({machine:item.json.machine_name||p.MachineName||'Unbekannt',state:String(item.json.alert_state||p.AlertState||'unknown').toLowerCase(),p});}\nconst result=[];\nfor(const group of groups.values()){let warnings=0,criticals=0,total=0,cveTotal=0,cveCritical=0;const ips=new Set(),alerts=[],clean=[];for(const d of group.devices){if(d.state==='warning')warnings++;if(d.state==='critical')criticals++;const vc=d.p.VulnerabilityCorrelation||{};cveTotal+=Number(vc.TotalCount||0);cveCritical+=Number(vc.CriticalCount||0);const events=(d.p.Events||[]).filter(e=>{const t=new Date(e.Timestamp);return !Number.isNaN(t)&&t>=start&&t<end;});total+=events.length;if(!events.length){clean.push(`<tr class=\"clean\"><td>${esc(d.machine)}</td><td>Keine</td><td>-</td><td>-</td><td>0</td><td>-</td><td>Log sauber / Keine Angriffe</td></tr>`);continue;}const rows=new Map();for(const e of events){const ip=e.SourceIp||'-',account=e.Username||'-',type=e.Target||'Sicherheitsereignis',key=[type,account,ip].join('|'),row=rows.get(key)||{ip,account,type,count:0,last:e.Timestamp};row.count++;if(new Date(e.Timestamp)>new Date(row.last))row.last=e.Timestamp;rows.set(key,row);if(ip!=='-')ips.add(ip);}for(const r of rows.values()){alerts.push(`<tr class=\"alert\"><td>${esc(d.machine)}</td><td>${esc(r.type)}</td><td>${esc(r.account)}</td><td>${esc(new Date(r.last).toLocaleString('de-DE',{timeZone:'Europe/Berlin'}))}</td><td>${r.count}</td><td>${esc(r.ip)}</td><td>${d.state==='critical'?'Problem entdeckt (kritisch)':'Problem entdeckt'}</td></tr>`);}}const summary={deviceCount:group.devices.length,warningCount:warnings,criticalCount:criticals,totalEvents:total,uniqueIps:ips.size,cveTotal,cveCritical};const html=`<div class=\"ocsentinel-report\"><style>.ocsentinel-report{font-family:Arial,sans-serif;font-size:12px;color:#1f2937;max-width:1200px}.ocsentinel-report h2{font-size:16px;color:#183b79;margin:0 0 5px;border-bottom:1px solid #183b79;padding-bottom:5px}.ocsentinel-report .meta{font-size:11px;color:#4b5563;margin-bottom:12px}.ocsentinel-report .summary{margin:10px 0;padding:8px;background:#eef5ff;border:1px solid #bfd4f2;color:#183b79}.ocsentinel-report table{width:100%;border-collapse:collapse}.ocsentinel-report th{background:#1f4a99;color:#fff;text-align:left;padding:6px;font-size:11px}.ocsentinel-report td{border:1px solid #e5e7eb;padding:6px;vertical-align:top}.ocsentinel-report tr.alert{background:#fff4ed;color:#8a3d16}.ocsentinel-report tr.clean{background:#effcf4;color:#17643a}.ocsentinel-report .footer{margin-top:10px;font-size:10px;color:#6b7280;text-align:right}</style><h2>OCSentinel - Konsolidierter Sicherheitsbericht</h2><div class=\"meta\"><strong>${esc(group.name)}</strong><br>Berichtszeitraum: ${start.toLocaleDateString('de-DE')} bis ${end.toLocaleDateString('de-DE')}<br>Erstellt am: ${now.toLocaleString('de-DE',{timeZone:'Europe/Berlin'})}</div><div class=\"summary\"><strong>${summary.deviceCount} Geräte</strong> | <strong>${summary.criticalCount} kritisch</strong> | <strong>${summary.warningCount} Warnungen</strong> | <strong>${summary.totalEvents} Ereignisse</strong> | <strong>${summary.uniqueIps} IPs</strong> | <strong>${summary.cveCritical} kritische CVEs</strong></div><table><thead><tr><th>Server</th><th>Vorfall-Typ</th><th>Betroffenes Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Angreifer-IP</th><th>Status / Bemerkung</th></tr></thead><tbody>${alerts.join('')}${clean.join('')}</tbody></table><div class=\"footer\">Automatisch durch OCSentinel und n8n erzeugt.</div></div>`;result.push({json:{organizationId:group.id,organizationName:group.name,periodStartUtc:start.toISOString(),periodEndUtc:end.toISOString(),...summary,reportHtml:html,summaryJson:JSON.stringify(summary)}});}return result;" },
|
||||||
|
"id": "build-weekly-reports", "name": "Build Organization HTML Reports", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [820, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": { "operation": "executeQuery", "query": "INSERT INTO ocsentinel.weekly_organization_report (organization_id, organization_name, period_start_utc, period_end_utc, device_count, warning_count, critical_count, total_events, unique_ips, cve_total, cve_critical, report_html, summary)\nVALUES ($1, $2, $3::timestamptz, $4::timestamptz, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)\nON CONFLICT (organization_id, period_start_utc) DO UPDATE SET organization_name=EXCLUDED.organization_name, period_end_utc=EXCLUDED.period_end_utc, generated_at=now(), device_count=EXCLUDED.device_count, warning_count=EXCLUDED.warning_count, critical_count=EXCLUDED.critical_count, total_events=EXCLUDED.total_events, unique_ips=EXCLUDED.unique_ips, cve_total=EXCLUDED.cve_total, cve_critical=EXCLUDED.cve_critical, report_html=EXCLUDED.report_html, summary=EXCLUDED.summary\nRETURNING id;", "options": { "queryReplacement": "={{ [$json.organizationId, $json.organizationName, $json.periodStartUtc, $json.periodEndUtc, $json.deviceCount, $json.warningCount, $json.criticalCount, $json.totalEvents, $json.uniqueIps, $json.cveTotal, $json.cveCritical, $json.reportHtml, $json.summaryJson] }}" } },
|
||||||
|
"id": "store-weekly-reports", "name": "Store Weekly Organization Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [1080, 300],
|
||||||
|
"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"operation": "executeQuery",
|
||||||
|
"query": "SELECT coalesce(array_agg(recipient_email ORDER BY recipient_email), ARRAY[]::text[]) AS recipients\nFROM ocsentinel.organization_report_recipient\nWHERE enabled = TRUE AND (organization_id = '*' OR organization_id = $1);",
|
||||||
|
"options": { "queryReplacement": "={{ [$json.organizationId] }}" }
|
||||||
|
},
|
||||||
|
"id": "load-report-recipients",
|
||||||
|
"name": "Empfaenger aus zentraler Zuordnung laden",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [1560, 300]
|
||||||
|
,"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForEachItem",
|
||||||
|
"jsCode": "const report = $('Build Organization HTML Reports').item.json;\nconst recipients = Array.from(new Set($json.recipients || []));\nif (recipients.length === 0) return [];\nreturn { json: { ...report, recipients } };"
|
||||||
|
},
|
||||||
|
"id": "prepare-report-email",
|
||||||
|
"name": "E-Mail vorbereiten",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1800, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"fromEmail": "donotreply@officecom.biz",
|
||||||
|
"toEmail": "={{ $json.recipients.join(', ') }}",
|
||||||
|
"subject": "=OCSentinel Wochenbericht - {{ $json.organizationName }}",
|
||||||
|
"html": "={{ $json.reportHtml }}",
|
||||||
|
"options": { "appendAttribution": false }
|
||||||
|
},
|
||||||
|
"id": "send-weekly-report-email",
|
||||||
|
"name": "Send Weekly Organization Report",
|
||||||
|
"type": "n8n-nodes-base.emailSend",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [2040, 300],
|
||||||
|
"credentials": { "smtp": { "id": "vafGYgYzM9SbxQbW", "name": "SMTP account" } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Every Monday 07:20": { "main": [[{ "node": "Load Latest Device Reports", "type": "main", "index": 0 }]] },
|
||||||
|
"Load Latest Device Reports": { "main": [[{ "node": "Build Organization HTML Reports", "type": "main", "index": 0 }]] },
|
||||||
|
"Build Organization HTML Reports": { "main": [[{ "node": "Store Weekly Organization Reports", "type": "main", "index": 0 }, { "node": "Empfaenger aus zentraler Zuordnung laden", "type": "main", "index": 0 }]] },
|
||||||
|
"Empfaenger aus zentraler Zuordnung laden": { "main": [[{ "node": "E-Mail vorbereiten", "type": "main", "index": 0 }]] },
|
||||||
|
"E-Mail vorbereiten": { "main": [[{ "node": "Send Weekly Organization Report", "type": "main", "index": 0 }]] }
|
||||||
|
},
|
||||||
|
"settings": { "executionOrder": "v1", "timezone": "Europe/Berlin" },
|
||||||
|
"active": true,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "af98f45b-192e-49c8-9a1e-e7b1fc7e00b2",
|
||||||
|
"meta": { "templateCredsSetupCompleted": true },
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
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;
|
||||||
@@ -47,6 +47,28 @@ CREATE TABLE IF NOT EXISTS ocsentinel.ingest_nonce (
|
|||||||
CREATE INDEX IF NOT EXISTS ix_ocsentinel_ingest_nonce_expires
|
CREATE INDEX IF NOT EXISTS ix_ocsentinel_ingest_nonce_expires
|
||||||
ON ocsentinel.ingest_nonce (expires_at);
|
ON ocsentinel.ingest_nonce (expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ocsentinel.weekly_organization_report (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
organization_name TEXT NOT NULL,
|
||||||
|
period_start_utc TIMESTAMPTZ NOT NULL,
|
||||||
|
period_end_utc TIMESTAMPTZ NOT NULL,
|
||||||
|
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
device_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
warning_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
critical_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_events INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unique_ips INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cve_total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cve_critical INTEGER NOT NULL DEFAULT 0,
|
||||||
|
report_html TEXT NOT NULL,
|
||||||
|
summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (organization_id, period_start_utc)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ocsentinel_weekly_report_organization_generated
|
||||||
|
ON ocsentinel.weekly_organization_report (organization_id, generated_at DESC);
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW ocsentinel.current_device_status AS
|
CREATE OR REPLACE VIEW ocsentinel.current_device_status AS
|
||||||
SELECT DISTINCT ON (d.id)
|
SELECT DISTINCT ON (d.id)
|
||||||
d.machine_name,
|
d.machine_name,
|
||||||
|
|||||||
25
infra/postgres/002_organization_report_recipients.sql
Normal file
25
infra/postgres/002_organization_report_recipients.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Central recipient rules for OCSentinel weekly organization reports.
|
||||||
|
-- The '*' organization ID applies to every organization.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ocsentinel.organization_report_recipient (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
organization_name TEXT NOT NULL,
|
||||||
|
recipient_email TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (organization_id, recipient_email)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ocsentinel.organization_report_recipient
|
||||||
|
(organization_id, organization_name, recipient_email)
|
||||||
|
VALUES
|
||||||
|
('*', 'Alle Organisationen', 'lg@officecom.it'),
|
||||||
|
('*', 'Alle Organisationen', 'rk@officecom.biz'),
|
||||||
|
('9', 'Mildenberger Verlag', 'dd@officecom.it'),
|
||||||
|
('16', 'Kirsch GmbH', 'dd@officecom.it')
|
||||||
|
ON CONFLICT (organization_id, recipient_email) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -32,6 +32,7 @@ if (Test-Path (Join-Path $packageRoot "config\ocsentinel-client.dev.example.json
|
|||||||
Copy-Item -Path (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $samplesRoot "ninja-vulnerability-export.example.csv") -Force
|
Copy-Item -Path (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $samplesRoot "ninja-vulnerability-export.example.csv") -Force
|
||||||
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Destination $scriptRoot -Force
|
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Destination $scriptRoot -Force
|
||||||
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Destination $scriptRoot -Force
|
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Destination $scriptRoot -Force
|
||||||
|
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-scheduled.ps1") -Destination $scriptRoot -Force
|
||||||
if (Test-Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1")) {
|
if (Test-Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1")) {
|
||||||
Copy-Item -Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Destination $scriptRoot -Force
|
Copy-Item -Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Destination $scriptRoot -Force
|
||||||
}
|
}
|
||||||
@@ -69,8 +70,34 @@ Set-ItemProperty -Path $uninstallKey -Name "QuietUninstallString" -Value $uninst
|
|||||||
Set-ItemProperty -Path $uninstallKey -Name "NoModify" -Value 1 -Type DWord
|
Set-ItemProperty -Path $uninstallKey -Name "NoModify" -Value 1 -Type DWord
|
||||||
Set-ItemProperty -Path $uninstallKey -Name "NoRepair" -Value 1 -Type DWord
|
Set-ItemProperty -Path $uninstallKey -Name "NoRepair" -Value 1 -Type DWord
|
||||||
|
|
||||||
|
$scheduledScript = Join-Path $scriptRoot "run-ocsentinel-scheduled.ps1"
|
||||||
|
$taskPrincipal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||||
|
$taskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -MultipleInstances IgnoreNew
|
||||||
|
|
||||||
|
# Spread fleet uploads across the early-morning window while keeping each device's slot stable.
|
||||||
|
$machineGuid = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Cryptography" -Name "MachineGuid").MachineGuid
|
||||||
|
$guidBytes = [Text.Encoding]::UTF8.GetBytes([string]$machineGuid)
|
||||||
|
$sha256 = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
$slotHash = $sha256.ComputeHash($guidBytes)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$sha256.Dispose()
|
||||||
|
}
|
||||||
|
$dailySlotMinutes = [BitConverter]::ToUInt32($slotHash, 0) % 180
|
||||||
|
$dailyRunAt = (Get-Date -Hour 4 -Minute 0 -Second 0).AddMinutes($dailySlotMinutes)
|
||||||
|
|
||||||
|
$dailyAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scheduledScript`" -Kind daily" -WorkingDirectory $scriptRoot
|
||||||
|
$dailyTrigger = New-ScheduledTaskTrigger -Daily -At $dailyRunAt
|
||||||
|
Register-ScheduledTask -TaskName "OCSentinel Daily Scan" -Action $dailyAction -Trigger $dailyTrigger -Principal $taskPrincipal -Settings $taskSettings -Description "OfficeCom Sentinel daily signed scan and upload." -Force | Out-Null
|
||||||
|
|
||||||
|
$burstAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scheduledScript`" -Kind burst" -WorkingDirectory $scriptRoot
|
||||||
|
$burstTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
|
||||||
|
Register-ScheduledTask -TaskName "OCSentinel Burst Check" -Action $burstAction -Trigger $burstTrigger -Principal $taskPrincipal -Settings $taskSettings -Description "OfficeCom Sentinel burst check; scans only when Ninja field ocsentinelburst is enabled." -Force | Out-Null
|
||||||
|
|
||||||
Write-Host "Installation complete."
|
Write-Host "Installation complete."
|
||||||
Write-Host "Main path: $installRoot"
|
Write-Host "Main path: $installRoot"
|
||||||
Write-Host "Runner: $(Join-Path $scriptRoot 'run-ocsentinel.ps1')"
|
Write-Host "Runner: $(Join-Path $scriptRoot 'run-ocsentinel.ps1')"
|
||||||
Write-Host "Monitor: $(Join-Path $scriptRoot 'run-ocsentinel-monitor.ps1')"
|
Write-Host "Monitor: $(Join-Path $scriptRoot 'run-ocsentinel-monitor.ps1')"
|
||||||
Write-Host "Updater: $(Join-Path $scriptRoot 'update-ocsentinel.ps1')"
|
Write-Host "Updater: $(Join-Path $scriptRoot 'update-ocsentinel.ps1')"
|
||||||
|
Write-Host "Schedule: Daily scan at $($dailyRunAt.ToString('HH:mm')) (deterministic 04:00-06:59 slot); burst check every 5 minutes."
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ param(
|
|||||||
[string]$UploadMode = "auto",
|
[string]$UploadMode = "auto",
|
||||||
[string]$VulnerabilityCsvPath = "",
|
[string]$VulnerabilityCsvPath = "",
|
||||||
[string]$MirrorRoot = "",
|
[string]$MirrorRoot = "",
|
||||||
|
[switch]$SuppressTriggerExit,
|
||||||
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
||||||
[string]$Mode = "status"
|
[string]$Mode = "status"
|
||||||
)
|
)
|
||||||
@@ -46,8 +47,13 @@ function Initialize-NinjaFieldWriter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
$script:NinjaFieldBackend = "powershell-modern"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||||
$script:NinjaFieldBackend = "powershell"
|
$script:NinjaFieldBackend = "powershell-legacy"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +70,20 @@ function Set-NinjaCustomFieldValue {
|
|||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$Name,
|
[string]$Name,
|
||||||
[AllowEmptyString()]
|
[AllowEmptyString()]
|
||||||
[string]$Value
|
[object]$Value,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Type
|
||||||
)
|
)
|
||||||
|
|
||||||
Initialize-NinjaFieldWriter
|
Initialize-NinjaFieldWriter
|
||||||
|
|
||||||
switch ($script:NinjaFieldBackend) {
|
switch ($script:NinjaFieldBackend) {
|
||||||
"powershell" {
|
"powershell-modern" {
|
||||||
Ninja-Property-Set $Name $Value | Out-Null
|
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
"powershell-legacy" {
|
||||||
|
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
"cli" {
|
"cli" {
|
||||||
@@ -112,28 +124,42 @@ function Publish-NinjaCustomFields {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValues = [ordered]@{
|
$uploadStatus = [string]$Report.Runtime.UploadStatus
|
||||||
"ocsentinelstatus" = [string]$Report.AlertState
|
if ([string]::IsNullOrWhiteSpace($uploadStatus)) { $uploadStatus = "unknown" }
|
||||||
"ocsentinelreason" = $Reason
|
$queuedReports = [int]$Report.Runtime.QueuedReportCount
|
||||||
"ocsentinelbasestatus" = [string]$Report.BaseAlertState
|
$lastUploadUtc = ""
|
||||||
"ocsentinelevents" = [string]([int]$Report.TotalEvents)
|
if ($Report.Runtime.LastSuccessfulUploadUtc) {
|
||||||
"ocsentineluniqueips" = [string]([int]$Report.UniqueIpCount)
|
try { $lastUploadUtc = ([DateTimeOffset]$Report.Runtime.LastSuccessfulUploadUtc).ToUniversalTime().ToString("o") } catch { $lastUploadUtc = [string]$Report.Runtime.LastSuccessfulUploadUtc }
|
||||||
"ocsentinelcvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount)
|
|
||||||
"ocsentinelcvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount)
|
|
||||||
"ocsentinelmode" = $Mode
|
|
||||||
"ocsentineltriggered" = $Triggered.ToString().ToLowerInvariant()
|
|
||||||
"ocsentinellastscanutc" = $generatedAtUtc
|
|
||||||
}
|
}
|
||||||
|
$lastUploadError = [string]$Report.Runtime.LastUploadError
|
||||||
|
if ($lastUploadError.Length -gt 900) { $lastUploadError = $lastUploadError.Substring(0, 900) }
|
||||||
|
|
||||||
|
$fieldValues = @(
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelstatus"; Type = "Text"; Value = [string]$Report.AlertState }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelreason"; Type = "Text"; Value = $Reason }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelbasestatus"; Type = "Text"; Value = [string]$Report.BaseAlertState }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelevents"; Type = "Integer"; Value = [int]$Report.TotalEvents }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineluniqueips"; Type = "Integer"; Value = [int]$Report.UniqueIpCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelcvecritical"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.CriticalCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelcvetotal"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.TotalCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelmode"; Type = "Text"; Value = $Mode }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineltriggered"; Type = "Checkbox"; Value = $Triggered }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellastscanutc"; Type = "DateTime"; Value = $generatedAtUtc }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineluploadstatus"; Type = "Text"; Value = $uploadStatus }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelqueuedreports"; Type = "Integer"; Value = $queuedReports }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellastuploadutc"; Type = "DateTime"; Value = $lastUploadUtc }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellasterror"; Type = "Text"; Value = $lastUploadError }
|
||||||
|
)
|
||||||
|
|
||||||
$updated = 0
|
$updated = 0
|
||||||
foreach ($entry in $fieldValues.GetEnumerator()) {
|
foreach ($entry in $fieldValues) {
|
||||||
try {
|
try {
|
||||||
if (Set-NinjaCustomFieldValue -Name $entry.Key -Value $entry.Value) {
|
if (Set-NinjaCustomFieldValue -Name $entry.Name -Value $entry.Value -Type $entry.Type) {
|
||||||
$updated++
|
$updated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Warning "Failed to set Ninja custom field '$($entry.Key)': $($_.Exception.Message)"
|
Write-Warning "Failed to set Ninja custom field '$($entry.Name)': $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +204,8 @@ $events = [int]$report.TotalEvents
|
|||||||
$uniqueIps = [int]$report.UniqueIpCount
|
$uniqueIps = [int]$report.UniqueIpCount
|
||||||
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
||||||
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
||||||
|
$uploadStatus = [string]$report.Runtime.UploadStatus
|
||||||
|
$queuedReports = [int]$report.Runtime.QueuedReportCount
|
||||||
|
|
||||||
$monitorTriggered = $false
|
$monitorTriggered = $false
|
||||||
$monitorReason = ""
|
$monitorReason = ""
|
||||||
@@ -213,11 +241,13 @@ Write-Host "Events: $events"
|
|||||||
Write-Host "Unique IPs: $uniqueIps"
|
Write-Host "Unique IPs: $uniqueIps"
|
||||||
Write-Host "Critical/High CVEs: $criticalCves"
|
Write-Host "Critical/High CVEs: $criticalCves"
|
||||||
Write-Host "Total CVEs: $totalCves"
|
Write-Host "Total CVEs: $totalCves"
|
||||||
|
Write-Host "Upload status: $uploadStatus"
|
||||||
|
Write-Host "Queued reports: $queuedReports"
|
||||||
Write-Host "Report: $outputFullPath"
|
Write-Host "Report: $outputFullPath"
|
||||||
Write-Host "Runner exit code: $runnerExitCode"
|
Write-Host "Runner exit code: $runnerExitCode"
|
||||||
|
|
||||||
if ($monitorTriggered) {
|
if ($monitorTriggered -and -not $SuppressTriggerExit) {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
exit 0
|
exit $runnerExitCode
|
||||||
|
|||||||
116
installer/runtime-run-ocsentinel-scheduled.ps1
Normal file
116
installer/runtime-run-ocsentinel-scheduled.ps1
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("daily", "burst")]
|
||||||
|
[string]$Kind = "daily",
|
||||||
|
[ValidateRange(15, 480)]
|
||||||
|
[int]$BurstDurationMinutes = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$monitorScript = Join-Path $scriptDir "run-ocsentinel-monitor.ps1"
|
||||||
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
$mutexName = "Global\OfficeComSentinelScan"
|
||||||
|
|
||||||
|
function Get-NinjaBurstEnabled {
|
||||||
|
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
return [bool](Get-NinjaProperty -Name "ocsentinelburst" -Type "Checkbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
|
||||||
|
$value = Ninja-Property-Get -Name "ocsentinelburst"
|
||||||
|
return [string]$value -match "^(1|true|yes)$"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warning "Ninja custom-field reader is unavailable; burst scan skipped."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Type)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
return Get-NinjaProperty -Name $Name -Type $Type
|
||||||
|
}
|
||||||
|
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
|
||||||
|
return Ninja-Property-Get -Name $Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not read Ninja field '$Name': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Value, [Parameter(Mandatory)][string]$Type)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||||
|
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not update Ninja field '$Name': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Kind -eq "burst") {
|
||||||
|
if (-not (Get-NinjaBurstEnabled)) {
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "idle" -Type "Text" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst check: disabled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = [DateTimeOffset]::UtcNow
|
||||||
|
$untilValue = Get-NinjaValue -Name "ocsentinelburstuntilutc" -Type "DateTime"
|
||||||
|
$until = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$untilValue)) {
|
||||||
|
try { $until = [DateTimeOffset]$untilValue } catch { Write-Warning "Burst end time is invalid and will be restarted." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $until) {
|
||||||
|
$until = $now.AddMinutes($BurstDurationMinutes)
|
||||||
|
Set-NinjaValue -Name "ocsentinelburstuntilutc" -Value $until.ToString("o") -Type "DateTime" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst window started until $($until.ToString('u'))."
|
||||||
|
}
|
||||||
|
elseif ($until -le $now) {
|
||||||
|
Set-NinjaValue -Name "ocsentinelburst" -Value "false" -Type "Checkbox" | Out-Null
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "completed" -Type "Text" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst window completed and was disabled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "active until $($until.ToUniversalTime().ToString('o'))" -Type "Text" | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdNew = $false
|
||||||
|
$mutex = [Threading.Mutex]::new($false, $mutexName, [ref]$createdNew)
|
||||||
|
try {
|
||||||
|
if (-not $mutex.WaitOne(0)) {
|
||||||
|
Write-Host "OfficeCom Sentinel scan skipped: another scan is already running."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "OfficeCom Sentinel scheduled $Kind scan started."
|
||||||
|
$monitorArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $monitorScript, "-Mode", "status", "-UploadMode", "required", "-SecretPath", $secretPath, "-SuppressTriggerExit")
|
||||||
|
if ($Kind -eq "burst") {
|
||||||
|
$monitorArgs += @("-LookbackDays", "1", "-TopCount", "25")
|
||||||
|
}
|
||||||
|
& powershell.exe @monitorArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $mutex) {
|
||||||
|
try { $mutex.ReleaseMutex() } catch { }
|
||||||
|
$mutex.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,40 @@ function Resolve-PathLike {
|
|||||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
|
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Restore-NinjaContextFromClientConfiguration {
|
||||||
|
param([Parameter(Mandatory)][string]$Path)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$clientConfiguration = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||||
|
$mappings = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($mapping in $mappings) {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($mapping.EnvironmentName, "Process"))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = [string]$clientConfiguration.($mapping.PropertyName)
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
[Environment]::SetEnvironmentVariable($mapping.EnvironmentName, $value, "Process")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not restore stored NinjaOne context: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (-not (Test-Path $appExe)) {
|
if (-not (Test-Path $appExe)) {
|
||||||
throw "Application executable not found: $appExe"
|
throw "Application executable not found: $appExe"
|
||||||
}
|
}
|
||||||
@@ -54,6 +88,8 @@ else {
|
|||||||
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $scriptDir }
|
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $scriptDir }
|
||||||
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
|
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
|
||||||
|
|
||||||
|
Restore-NinjaContextFromClientConfiguration -Path $clientConfigFullPath
|
||||||
|
|
||||||
if ($UploadMode -eq "required" -and -not $canUpload) {
|
if ($UploadMode -eq "required" -and -not $canUpload) {
|
||||||
throw "UploadMode 'required' was set, but client config or protected secret is missing."
|
throw "UploadMode 'required' was set, but client config or protected secret is missing."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ $ErrorActionPreference = "Stop"
|
|||||||
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
||||||
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OCSentinel"
|
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OCSentinel"
|
||||||
|
|
||||||
|
foreach ($taskName in @("OCSentinel Daily Scan", "OCSentinel Burst Check")) {
|
||||||
|
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Test-Path $uninstallKey) {
|
if (Test-Path $uninstallKey) {
|
||||||
Remove-Item -Path $uninstallKey -Force -Recurse
|
Remove-Item -Path $uninstallKey -Force -Recurse
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,72 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Initialize-OCSentinelTls {
|
||||||
|
$protocols = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
|
||||||
|
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
|
||||||
|
}
|
||||||
|
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = $protocols
|
||||||
|
[Net.ServicePointManager]::Expect100Continue = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-OCSentinelManifest {
|
||||||
|
param([Parameter(Mandatory)][string]$Uri)
|
||||||
|
|
||||||
|
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
|
||||||
|
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
|
||||||
|
$parameters.UseBasicParsing = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||||
|
try {
|
||||||
|
return Invoke-RestMethod @parameters
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($attempt -eq 3) {
|
||||||
|
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds (3 * $attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-OCSentinelArtifact {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Uri,
|
||||||
|
[Parameter(Mandatory)][string]$DestinationPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$parameters = @{ Uri = $Uri; OutFile = $DestinationPath; TimeoutSec = 300 }
|
||||||
|
if ((Get-Command Invoke-WebRequest).Parameters.ContainsKey("UseBasicParsing")) {
|
||||||
|
$parameters.UseBasicParsing = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||||
|
try {
|
||||||
|
Remove-Item -LiteralPath $DestinationPath -Force -ErrorAction SilentlyContinue
|
||||||
|
Invoke-WebRequest @parameters
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $DestinationPath) -or (Get-Item -LiteralPath $DestinationPath).Length -eq 0) {
|
||||||
|
throw "The downloaded artifact is empty."
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($attempt -eq 3) {
|
||||||
|
throw "Could not download the OCSentinel package after 3 attempts. Last error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warning "Package download attempt $attempt failed. Retrying."
|
||||||
|
Start-Sleep -Seconds (5 * $attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Initialize-OCSentinelTls
|
||||||
|
|
||||||
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
||||||
$appExe = Join-Path $installRoot "app\OCSentinelCli.exe"
|
$appExe = Join-Path $installRoot "app\OCSentinelCli.exe"
|
||||||
$installScript = Join-Path $installRoot "scripts\install-ocsentinel.ps1"
|
$installScript = Join-Path $installRoot "scripts\install-ocsentinel.ps1"
|
||||||
@@ -30,14 +96,23 @@ function Compare-Version {
|
|||||||
[Parameter(Mandatory)][string]$Right
|
[Parameter(Mandatory)][string]$Right
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
$pattern = '^(?<version>\d+(?:\.\d+){0,3})(?:-(?<prerelease>.+))?$'
|
||||||
$leftVersion = [System.Version]$Left
|
$leftMatch = [regex]::Match($Left, $pattern)
|
||||||
$rightVersion = [System.Version]$Right
|
$rightMatch = [regex]::Match($Right, $pattern)
|
||||||
return $leftVersion.CompareTo($rightVersion)
|
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
|
||||||
}
|
}
|
||||||
catch {
|
|
||||||
|
$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)
|
return [string]::Compare($Left, $Right, $true)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-Sha256Hex {
|
function Get-Sha256Hex {
|
||||||
@@ -66,7 +141,7 @@ if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
|
|||||||
$resolvedManifestUrl = Resolve-ManifestUrl -ManifestUrl $ManifestUrl -Channel $Channel
|
$resolvedManifestUrl = Resolve-ManifestUrl -ManifestUrl $ManifestUrl -Channel $Channel
|
||||||
Write-Host "Checking update manifest: $resolvedManifestUrl"
|
Write-Host "Checking update manifest: $resolvedManifestUrl"
|
||||||
|
|
||||||
$manifest = Invoke-RestMethod -Method Get -Uri $resolvedManifestUrl -TimeoutSec 60
|
$manifest = Get-OCSentinelManifest -Uri $resolvedManifestUrl
|
||||||
if (-not $manifest.version -or -not $manifest.artifactUrl -or -not $manifest.sha256) {
|
if (-not $manifest.version -or -not $manifest.artifactUrl -or -not $manifest.sha256) {
|
||||||
throw "Update manifest is missing required fields: version, artifactUrl, sha256."
|
throw "Update manifest is missing required fields: version, artifactUrl, sha256."
|
||||||
}
|
}
|
||||||
@@ -89,7 +164,7 @@ $extractRoot = Join-Path $downloadRoot "payload"
|
|||||||
New-Item -ItemType Directory -Force -Path $downloadRoot, $extractRoot | Out-Null
|
New-Item -ItemType Directory -Force -Path $downloadRoot, $extractRoot | Out-Null
|
||||||
|
|
||||||
Write-Host "Downloading artifact: $($manifest.artifactUrl)"
|
Write-Host "Downloading artifact: $($manifest.artifactUrl)"
|
||||||
Invoke-WebRequest -Uri ([string]$manifest.artifactUrl) -OutFile $zipPath -TimeoutSec 300
|
Get-OCSentinelArtifact -Uri ([string]$manifest.artifactUrl) -DestinationPath $zipPath
|
||||||
|
|
||||||
$actualHash = Get-Sha256Hex -Path $zipPath
|
$actualHash = Get-Sha256Hex -Path $zipPath
|
||||||
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant()
|
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant()
|
||||||
|
|||||||
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.6",
|
||||||
|
"publishedAtUtc": "2026-08-02T21:52:46.1209296Z",
|
||||||
|
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.6/OCSentinelClient-win-x64.zip",
|
||||||
|
"sha256": "341f56e69f0e5e9836af6d87d86851b0d63638a5e902d459af88ee68f94010f5",
|
||||||
|
"minUpdaterVersion": "1.0.0"
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"version": "1.2.6",
|
"version": "1.4.0",
|
||||||
"publishedAtUtc": "2026-07-25T00:00:00Z",
|
"publishedAtUtc": "2026-07-28T22:50:17.7779552Z",
|
||||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.2.6/OCSentinelClient-win-x64.zip",
|
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.4.0/OCSentinelClient-win-x64.zip?asset-revision=ece66505c4146fec",
|
||||||
"sha256": "d6256f3e376701bff18d9dfd8d5825498e85fb6d94c15227828a50678693d097",
|
"sha256": "ece66505c4146fec72ba12cb59d3d0a39bb91a3aee44e338d19c186e56e2fad7",
|
||||||
"minUpdaterVersion": "1.0.0"
|
"minUpdaterVersion": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
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]$WebhookUrl = "",
|
||||||
[string]$SecretValue = "",
|
[string]$SecretValue = "",
|
||||||
[switch]$RunInitialStatusScan
|
[switch]$RunInitialStatusScan
|
||||||
@@ -8,16 +10,171 @@ param(
|
|||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$ProgressPreference = "SilentlyContinue"
|
$ProgressPreference = "SilentlyContinue"
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
||||||
|
function Initialize-OCSentinelTls {
|
||||||
|
$protocols = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
|
||||||
|
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
|
||||||
|
}
|
||||||
|
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = $protocols
|
||||||
|
[Net.ServicePointManager]::Expect100Continue = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-OCSentinelManifest {
|
||||||
|
param([Parameter(Mandatory)][string]$Uri)
|
||||||
|
|
||||||
|
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
|
||||||
|
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
|
||||||
|
$parameters.UseBasicParsing = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||||
|
try {
|
||||||
|
return Invoke-RestMethod @parameters
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($attempt -eq 3) {
|
||||||
|
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds (3 * $attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-OCSentinelUpdater {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$UpdaterPath,
|
||||||
|
[Parameter(Mandatory)][string]$ManifestUri
|
||||||
|
)
|
||||||
|
|
||||||
|
# Existing clients can still contain an older updater without TLS setup.
|
||||||
|
# Start it in a prepared child process so it can download the current package.
|
||||||
|
$escapedUpdaterPath = $UpdaterPath.Replace("'", "''")
|
||||||
|
$escapedManifestUri = $ManifestUri.Replace("'", "''")
|
||||||
|
$command = @"
|
||||||
|
`$protocols = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') {
|
||||||
|
`$protocols = `$protocols -bor [Net.SecurityProtocolType]::Tls13
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = `$protocols
|
||||||
|
[Net.ServicePointManager]::Expect100Continue = `$false
|
||||||
|
& '$escapedUpdaterPath' -ManifestUrl '$escapedManifestUri'
|
||||||
|
exit `$LASTEXITCODE
|
||||||
|
"@
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $command | ForEach-Object { Write-Host $_ }
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
if ($exitCode -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attempt -lt 3) {
|
||||||
|
Write-Warning "OCSentinel update attempt $attempt failed. Retrying."
|
||||||
|
Start-Sleep -Seconds (5 * $attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "OCSentinel updater exited with code $exitCode after 3 attempts."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-OCSentinelArtifact {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Uri,
|
||||||
|
[Parameter(Mandatory)][string]$DestinationPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$parameters = @{ Uri = $Uri; OutFile = $DestinationPath; TimeoutSec = 300 }
|
||||||
|
if ((Get-Command Invoke-WebRequest).Parameters.ContainsKey("UseBasicParsing")) {
|
||||||
|
$parameters.UseBasicParsing = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||||
|
try {
|
||||||
|
Remove-Item -LiteralPath $DestinationPath -Force -ErrorAction SilentlyContinue
|
||||||
|
Invoke-WebRequest @parameters
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $DestinationPath) -or (Get-Item -LiteralPath $DestinationPath).Length -eq 0) {
|
||||||
|
throw "The downloaded artifact is empty."
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($attempt -eq 3) {
|
||||||
|
throw "Could not download the OCSentinel package after 3 attempts. Last error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warning "Package download attempt $attempt failed. Retrying."
|
||||||
|
Start-Sleep -Seconds (5 * $attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Enable-OCSentinelBetaDefaults {
|
||||||
|
param([Parameter(Mandatory)][string]$SettingsPath)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $SettingsPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = Get-Content -LiteralPath $SettingsPath -Raw | ConvertFrom-Json
|
||||||
|
if ($null -ne $settings.PSObject.Properties["ransomwareBetaDefaultApplied"]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $settings.PSObject.Properties["ransomwareBetaEnabled"]) {
|
||||||
|
$settings | Add-Member -NotePropertyName "ransomwareBetaEnabled" -NotePropertyValue $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$settings.ransomwareBetaEnabled = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings | Add-Member -NotePropertyName "ransomwareBetaDefaultApplied" -NotePropertyValue "1.5.0-beta.4"
|
||||||
|
$settings | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsPath -Encoding UTF8
|
||||||
|
Write-Host "Enabled passive ransomware beta defaults."
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||||
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
|
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
|
||||||
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||||
$appPath = Join-Path $installRoot "app\OCSentinelCli.exe"
|
$appPath = Join-Path $installRoot "app\OCSentinelCli.exe"
|
||||||
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||||
|
$settingsPath = Join-Path $installRoot "config\ocsentinel-settings.json"
|
||||||
$secretScriptPath = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
$secretScriptPath = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
||||||
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
|
||||||
|
# NinjaOne script variables are exposed as process environment variables.
|
||||||
|
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
||||||
|
$WebhookUrl = $env:WebhookUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||||
|
$SecretValue = $env:SecretValue
|
||||||
|
}
|
||||||
|
|
||||||
|
$runInitialScan = $RunInitialStatusScan.IsPresent
|
||||||
|
if (-not $runInitialScan -and -not [string]::IsNullOrWhiteSpace($env:RunInitialStatusScan)) {
|
||||||
|
$runInitialScan = $env:RunInitialStatusScan -match '^(1|true|yes|on)$'
|
||||||
|
}
|
||||||
|
|
||||||
function Assert-ArtifactSignature {
|
function Assert-ArtifactSignature {
|
||||||
param([Parameter(Mandatory)][string]$ExecutablePath)
|
param([Parameter(Mandatory)][string]$ExecutablePath)
|
||||||
|
|
||||||
@@ -33,14 +190,11 @@ function Assert-ArtifactSignature {
|
|||||||
|
|
||||||
if (Test-Path -LiteralPath $updaterPath) {
|
if (Test-Path -LiteralPath $updaterPath) {
|
||||||
Write-Host "Existing OCSentinel installation found. Checking for updates."
|
Write-Host "Existing OCSentinel installation found. Checking for updates."
|
||||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $updaterPath -ManifestUrl $ManifestUrl
|
Invoke-OCSentinelUpdater -UpdaterPath $updaterPath -ManifestUri $ManifestUrl
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "OCSentinel updater exited with code $LASTEXITCODE"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
Write-Host "Reading OCSentinel release manifest: $ManifestUrl"
|
Write-Host "Reading OCSentinel release manifest: $ManifestUrl"
|
||||||
$manifest = Invoke-RestMethod -Method Get -Uri $ManifestUrl -TimeoutSec 60
|
$manifest = Get-OCSentinelManifest -Uri $ManifestUrl
|
||||||
if ([string]::IsNullOrWhiteSpace($manifest.version) -or [string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
|
if ([string]::IsNullOrWhiteSpace($manifest.version) -or [string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
|
||||||
throw "Release manifest is missing version, artifactUrl, or sha256."
|
throw "Release manifest is missing version, artifactUrl, or sha256."
|
||||||
}
|
}
|
||||||
@@ -52,7 +206,7 @@ else {
|
|||||||
try {
|
try {
|
||||||
New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null
|
New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null
|
||||||
Write-Host "Downloading OCSentinel $($manifest.version)"
|
Write-Host "Downloading OCSentinel $($manifest.version)"
|
||||||
Invoke-WebRequest -Uri ([string]$manifest.artifactUrl) -OutFile $zipPath -TimeoutSec 300
|
Get-OCSentinelArtifact -Uri ([string]$manifest.artifactUrl) -DestinationPath $zipPath
|
||||||
|
|
||||||
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant()
|
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant()
|
||||||
@@ -85,6 +239,10 @@ if (-not (Test-Path -LiteralPath $appPath)) {
|
|||||||
throw "OCSentinel installation completed, but the client executable was not found."
|
throw "OCSentinel installation completed, but the client executable was not found."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($ReleaseChannel -eq "beta") {
|
||||||
|
Enable-OCSentinelBetaDefaults -SettingsPath $settingsPath
|
||||||
|
}
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
if (-not [string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
||||||
if (-not (Test-Path -LiteralPath $clientConfigPath)) {
|
if (-not (Test-Path -LiteralPath $clientConfigPath)) {
|
||||||
throw "OCSentinel client configuration was not found: $clientConfigPath"
|
throw "OCSentinel client configuration was not found: $clientConfigPath"
|
||||||
@@ -93,8 +251,22 @@ if (-not [string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
|||||||
$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json
|
$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json
|
||||||
$clientConfig.n8nWebhookUrl = $WebhookUrl
|
$clientConfig.n8nWebhookUrl = $WebhookUrl
|
||||||
$clientConfig.environment = "production"
|
$clientConfig.environment = "production"
|
||||||
|
$ninjaContext = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
|
||||||
|
)
|
||||||
|
foreach ($entry in $ninjaContext) {
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
$clientConfig | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8
|
$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8
|
||||||
Write-Host "Configured OCSentinel upload endpoint."
|
Write-Host "Configured OCSentinel upload endpoint and NinjaOne context."
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($SecretValue)) {
|
if (-not [string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||||
@@ -108,7 +280,7 @@ if (-not [string]::IsNullOrWhiteSpace($SecretValue)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($RunInitialStatusScan) {
|
if ($runInitialScan) {
|
||||||
if (-not (Test-Path -LiteralPath $monitorPath)) {
|
if (-not (Test-Path -LiteralPath $monitorPath)) {
|
||||||
throw "OCSentinel was installed, but the monitor script is missing."
|
throw "OCSentinel was installed, but the monitor script is missing."
|
||||||
}
|
}
|
||||||
|
|||||||
76
scripts/configure-ocsentinel-ninja.ps1
Normal file
76
scripts/configure-ocsentinel-ninja.ps1
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$WebhookUrl = "",
|
||||||
|
[string]$SecretValue = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Get-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||||
|
if ($null -eq $value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
||||||
|
$WebhookUrl = Get-NinjaValue -Name "webhookurl"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||||
|
$SecretValue = Get-NinjaValue -Name "secretvalue"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or [string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||||
|
throw "WebhookUrl and SecretValue must be supplied as NinjaOne script variables."
|
||||||
|
}
|
||||||
|
|
||||||
|
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||||
|
$configPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||||
|
$secretScript = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
||||||
|
$monitorScript = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||||
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
|
||||||
|
foreach ($path in @($configPath, $secretScript, $monitorScript)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $path)) {
|
||||||
|
throw "OCSentinel installation is incomplete. Missing: $path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
||||||
|
$config.n8nWebhookUrl = $WebhookUrl
|
||||||
|
$config.environment = "production"
|
||||||
|
$ninjaContext = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
|
||||||
|
)
|
||||||
|
foreach ($entry in $ninjaContext) {
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
$config | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
||||||
|
Write-Host "OCSentinel upload endpoint configured."
|
||||||
|
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Writing the protected upload secret failed with code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Running signed OCSentinel test scan and upload."
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorScript `
|
||||||
|
-Mode status `
|
||||||
|
-ClientConfigPath $configPath `
|
||||||
|
-SecretPath $secretPath `
|
||||||
|
-UploadMode required
|
||||||
|
|
||||||
|
exit $LASTEXITCODE
|
||||||
120
scripts/install-ocsentinel-ninja-once.ps1
Normal file
120
scripts/install-ocsentinel-ninja-once.ps1
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json",
|
||||||
|
[string]$WebhookUrl = "",
|
||||||
|
[string]$SecretValue = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
|
||||||
|
function Initialize-OCSentinelTls {
|
||||||
|
$protocols = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
|
||||||
|
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
|
||||||
|
}
|
||||||
|
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = $protocols
|
||||||
|
[Net.ServicePointManager]::Expect100Continue = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-OCSentinelManifest {
|
||||||
|
param([Parameter(Mandatory)][string]$Uri)
|
||||||
|
|
||||||
|
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
|
||||||
|
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
|
||||||
|
$parameters.UseBasicParsing = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||||
|
try {
|
||||||
|
return Invoke-RestMethod @parameters
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($attempt -eq 3) {
|
||||||
|
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds (3 * $attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Initialize-OCSentinelTls
|
||||||
|
|
||||||
|
function Get-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||||
|
if ($null -eq $value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) { $WebhookUrl = Get-NinjaValue -Name "webhookurl" }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($SecretValue)) { $SecretValue = Get-NinjaValue -Name "secretvalue" }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or [string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||||
|
throw "WebhookUrl and SecretValue must be set as NinjaOne script variables."
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = Get-OCSentinelManifest -Uri $ManifestUrl
|
||||||
|
if ([string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
|
||||||
|
throw "The release manifest is incomplete."
|
||||||
|
}
|
||||||
|
|
||||||
|
$downloadRoot = Join-Path $env:ProgramData ("OCSentinel\\install-" + [Guid]::NewGuid().ToString("N"))
|
||||||
|
$zipPath = Join-Path $downloadRoot "OCSentinelClient.zip"
|
||||||
|
$extractPath = Join-Path $downloadRoot "payload"
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Force -Path $extractPath | Out-Null
|
||||||
|
Write-Host "Downloading OCSentinel $($manifest.version)."
|
||||||
|
Invoke-WebRequest -Uri $manifest.artifactUrl -OutFile $zipPath -TimeoutSec 300
|
||||||
|
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($actualHash -ne ([string]$manifest.sha256).ToLowerInvariant()) {
|
||||||
|
throw "Release package SHA-256 validation failed."
|
||||||
|
}
|
||||||
|
|
||||||
|
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractPath -Force
|
||||||
|
$installer = Get-ChildItem -Path $extractPath -Recurse -Filter "install-ocsentinel.ps1" | Select-Object -First 1
|
||||||
|
if ($null -eq $installer) { throw "The release package does not contain the installer." }
|
||||||
|
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer.FullName
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Installer failed with code $LASTEXITCODE" }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path -LiteralPath $downloadRoot) { Remove-Item -LiteralPath $downloadRoot -Recurse -Force }
|
||||||
|
}
|
||||||
|
|
||||||
|
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||||
|
$configPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||||
|
$secretScript = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
||||||
|
$monitorScript = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||||
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
|
||||||
|
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
||||||
|
$config.n8nWebhookUrl = $WebhookUrl
|
||||||
|
$config.environment = "production"
|
||||||
|
$ninjaContext = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
|
||||||
|
)
|
||||||
|
foreach ($entry in $ninjaContext) {
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
$config | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
||||||
|
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Writing the protected upload secret failed with code $LASTEXITCODE" }
|
||||||
|
|
||||||
|
Write-Host "Running initial signed scan and upload."
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorScript -Mode status -ClientConfigPath $configPath -SecretPath $secretPath -UploadMode required
|
||||||
|
exit $LASTEXITCODE
|
||||||
110
scripts/refresh-ocsentinel-ninja-context.ps1
Normal file
110
scripts/refresh-ocsentinel-ninja-context.ps1
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
|
||||||
|
function Initialize-OCSentinelTls {
|
||||||
|
$protocols = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
|
||||||
|
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
|
||||||
|
}
|
||||||
|
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = $protocols
|
||||||
|
[Net.ServicePointManager]::Expect100Continue = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-NinjaEnvironmentValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||||
|
if ($null -eq $value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
Initialize-OCSentinelTls
|
||||||
|
|
||||||
|
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||||
|
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
|
||||||
|
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||||
|
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||||
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
|
||||||
|
foreach ($path in @($updaterPath, $clientConfigPath, $monitorPath)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $path)) {
|
||||||
|
throw "OCSentinel installation is incomplete. Missing: $path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The NinjaOne context exists only during this script execution. Upgrade first so
|
||||||
|
# future scheduled scans restore the context from the local client configuration.
|
||||||
|
$escapedUpdaterPath = $updaterPath.Replace("'", "''")
|
||||||
|
$escapedManifestUrl = $ManifestUrl.Replace("'", "''")
|
||||||
|
$updateCommand = @"
|
||||||
|
`$protocols = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') { `$protocols = `$protocols -bor [Net.SecurityProtocolType]::Tls13 }
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = `$protocols
|
||||||
|
[Net.ServicePointManager]::Expect100Continue = `$false
|
||||||
|
& '$escapedUpdaterPath' -ManifestUrl '$escapedManifestUrl'
|
||||||
|
exit `$LASTEXITCODE
|
||||||
|
"@
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $updateCommand | ForEach-Object { Write-Host $_ }
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "OCSentinel updater exited with code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappings = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId"; Required = $true },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName"; Required = $true },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId"; Required = $true },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId"; Required = $false },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId"; Required = $false },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName"; Required = $false }
|
||||||
|
)
|
||||||
|
|
||||||
|
$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json
|
||||||
|
$missing = @()
|
||||||
|
$captured = 0
|
||||||
|
foreach ($mapping in $mappings) {
|
||||||
|
$value = Read-NinjaEnvironmentValue -Name $mapping.EnvironmentName
|
||||||
|
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
if ($mapping.Required) { $missing += $mapping.EnvironmentName }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientConfig | Add-Member -NotePropertyName $mapping.PropertyName -NotePropertyValue $value -Force
|
||||||
|
$captured++
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing.Count -gt 0) {
|
||||||
|
throw "NinjaOne did not provide required context: $($missing -join ', '). Run this only from a NinjaOne automation, not from an interactive PowerShell session."
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8
|
||||||
|
Write-Host "OCSentinel NinjaOne context captured: $captured of $($mappings.Count) values."
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace([string]$clientConfig.n8nWebhookUrl)) {
|
||||||
|
throw "NinjaOne context was stored, but this client has no configured n8n webhook URL. Run the OCSentinel installation/configuration automation with its WebhookUrl variable first."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $secretPath)) {
|
||||||
|
throw "NinjaOne context was stored, but the protected upload secret is missing. Run the OCSentinel installation/configuration automation with its SecretValue variable first."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Running an immediate status scan and upload with the refreshed NinjaOne context."
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorPath `
|
||||||
|
-Mode status `
|
||||||
|
-ClientConfigPath $clientConfigPath `
|
||||||
|
-SecretPath $secretPath `
|
||||||
|
-UploadMode required `
|
||||||
|
-SuppressTriggerExit
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "OCSentinel context refresh scan exited with code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "OCSENTINEL_NINJA_CONTEXT=updated"
|
||||||
@@ -9,6 +9,7 @@ param(
|
|||||||
[string]$UploadMode = "auto",
|
[string]$UploadMode = "auto",
|
||||||
[string]$VulnerabilityCsvPath = "",
|
[string]$VulnerabilityCsvPath = "",
|
||||||
[string]$MirrorRoot = "",
|
[string]$MirrorRoot = "",
|
||||||
|
[switch]$SuppressTriggerExit,
|
||||||
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
||||||
[string]$Mode = "status"
|
[string]$Mode = "status"
|
||||||
)
|
)
|
||||||
@@ -46,8 +47,13 @@ function Initialize-NinjaFieldWriter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
$script:NinjaFieldBackend = "powershell-modern"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||||
$script:NinjaFieldBackend = "powershell"
|
$script:NinjaFieldBackend = "powershell-legacy"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +70,20 @@ function Set-NinjaCustomFieldValue {
|
|||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$Name,
|
[string]$Name,
|
||||||
[AllowEmptyString()]
|
[AllowEmptyString()]
|
||||||
[string]$Value
|
[object]$Value,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Type
|
||||||
)
|
)
|
||||||
|
|
||||||
Initialize-NinjaFieldWriter
|
Initialize-NinjaFieldWriter
|
||||||
|
|
||||||
switch ($script:NinjaFieldBackend) {
|
switch ($script:NinjaFieldBackend) {
|
||||||
"powershell" {
|
"powershell-modern" {
|
||||||
Ninja-Property-Set $Name $Value | Out-Null
|
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
"powershell-legacy" {
|
||||||
|
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
"cli" {
|
"cli" {
|
||||||
@@ -112,28 +124,42 @@ function Publish-NinjaCustomFields {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValues = [ordered]@{
|
$uploadStatus = [string]$Report.Runtime.UploadStatus
|
||||||
"ocsentinelstatus" = [string]$Report.AlertState
|
if ([string]::IsNullOrWhiteSpace($uploadStatus)) { $uploadStatus = "unknown" }
|
||||||
"ocsentinelreason" = $Reason
|
$queuedReports = [int]$Report.Runtime.QueuedReportCount
|
||||||
"ocsentinelbasestatus" = [string]$Report.BaseAlertState
|
$lastUploadUtc = ""
|
||||||
"ocsentinelevents" = [string]([int]$Report.TotalEvents)
|
if ($Report.Runtime.LastSuccessfulUploadUtc) {
|
||||||
"ocsentineluniqueips" = [string]([int]$Report.UniqueIpCount)
|
try { $lastUploadUtc = ([DateTimeOffset]$Report.Runtime.LastSuccessfulUploadUtc).ToUniversalTime().ToString("o") } catch { $lastUploadUtc = [string]$Report.Runtime.LastSuccessfulUploadUtc }
|
||||||
"ocsentinelcvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount)
|
|
||||||
"ocsentinelcvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount)
|
|
||||||
"ocsentinelmode" = $Mode
|
|
||||||
"ocsentineltriggered" = $Triggered.ToString().ToLowerInvariant()
|
|
||||||
"ocsentinellastscanutc" = $generatedAtUtc
|
|
||||||
}
|
}
|
||||||
|
$lastUploadError = [string]$Report.Runtime.LastUploadError
|
||||||
|
if ($lastUploadError.Length -gt 900) { $lastUploadError = $lastUploadError.Substring(0, 900) }
|
||||||
|
|
||||||
|
$fieldValues = @(
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelstatus"; Type = "Text"; Value = [string]$Report.AlertState }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelreason"; Type = "Text"; Value = $Reason }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelbasestatus"; Type = "Text"; Value = [string]$Report.BaseAlertState }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelevents"; Type = "Integer"; Value = [int]$Report.TotalEvents }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineluniqueips"; Type = "Integer"; Value = [int]$Report.UniqueIpCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelcvecritical"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.CriticalCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelcvetotal"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.TotalCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelmode"; Type = "Text"; Value = $Mode }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineltriggered"; Type = "Checkbox"; Value = $Triggered }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellastscanutc"; Type = "DateTime"; Value = $generatedAtUtc }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineluploadstatus"; Type = "Text"; Value = $uploadStatus }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelqueuedreports"; Type = "Integer"; Value = $queuedReports }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellastuploadutc"; Type = "DateTime"; Value = $lastUploadUtc }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellasterror"; Type = "Text"; Value = $lastUploadError }
|
||||||
|
)
|
||||||
|
|
||||||
$updated = 0
|
$updated = 0
|
||||||
foreach ($entry in $fieldValues.GetEnumerator()) {
|
foreach ($entry in $fieldValues) {
|
||||||
try {
|
try {
|
||||||
if (Set-NinjaCustomFieldValue -Name $entry.Key -Value $entry.Value) {
|
if (Set-NinjaCustomFieldValue -Name $entry.Name -Value $entry.Value -Type $entry.Type) {
|
||||||
$updated++
|
$updated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Warning "Failed to set Ninja custom field '$($entry.Key)': $($_.Exception.Message)"
|
Write-Warning "Failed to set Ninja custom field '$($entry.Name)': $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +204,8 @@ $events = [int]$report.TotalEvents
|
|||||||
$uniqueIps = [int]$report.UniqueIpCount
|
$uniqueIps = [int]$report.UniqueIpCount
|
||||||
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
||||||
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
||||||
|
$uploadStatus = [string]$report.Runtime.UploadStatus
|
||||||
|
$queuedReports = [int]$report.Runtime.QueuedReportCount
|
||||||
|
|
||||||
$monitorTriggered = $false
|
$monitorTriggered = $false
|
||||||
$monitorReason = ""
|
$monitorReason = ""
|
||||||
@@ -213,11 +241,13 @@ Write-Host "Events: $events"
|
|||||||
Write-Host "Unique IPs: $uniqueIps"
|
Write-Host "Unique IPs: $uniqueIps"
|
||||||
Write-Host "Critical/High CVEs: $criticalCves"
|
Write-Host "Critical/High CVEs: $criticalCves"
|
||||||
Write-Host "Total CVEs: $totalCves"
|
Write-Host "Total CVEs: $totalCves"
|
||||||
|
Write-Host "Upload status: $uploadStatus"
|
||||||
|
Write-Host "Queued reports: $queuedReports"
|
||||||
Write-Host "Report: $outputFullPath"
|
Write-Host "Report: $outputFullPath"
|
||||||
Write-Host "Runner exit code: $runnerExitCode"
|
Write-Host "Runner exit code: $runnerExitCode"
|
||||||
|
|
||||||
if ($monitorTriggered) {
|
if ($monitorTriggered -and -not $SuppressTriggerExit) {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
exit 0
|
exit $runnerExitCode
|
||||||
|
|||||||
116
scripts/run-ocsentinel-scheduled.ps1
Normal file
116
scripts/run-ocsentinel-scheduled.ps1
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("daily", "burst")]
|
||||||
|
[string]$Kind = "daily",
|
||||||
|
[ValidateRange(15, 480)]
|
||||||
|
[int]$BurstDurationMinutes = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$monitorScript = Join-Path $scriptDir "run-ocsentinel-monitor.ps1"
|
||||||
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
$mutexName = "Global\OfficeComSentinelScan"
|
||||||
|
|
||||||
|
function Get-NinjaBurstEnabled {
|
||||||
|
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
return [bool](Get-NinjaProperty -Name "ocsentinelburst" -Type "Checkbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
|
||||||
|
$value = Ninja-Property-Get -Name "ocsentinelburst"
|
||||||
|
return [string]$value -match "^(1|true|yes)$"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warning "Ninja custom-field reader is unavailable; burst scan skipped."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Type)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
return Get-NinjaProperty -Name $Name -Type $Type
|
||||||
|
}
|
||||||
|
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
|
||||||
|
return Ninja-Property-Get -Name $Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not read Ninja field '$Name': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Value, [Parameter(Mandatory)][string]$Type)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||||
|
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not update Ninja field '$Name': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Kind -eq "burst") {
|
||||||
|
if (-not (Get-NinjaBurstEnabled)) {
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "idle" -Type "Text" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst check: disabled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = [DateTimeOffset]::UtcNow
|
||||||
|
$untilValue = Get-NinjaValue -Name "ocsentinelburstuntilutc" -Type "DateTime"
|
||||||
|
$until = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$untilValue)) {
|
||||||
|
try { $until = [DateTimeOffset]$untilValue } catch { Write-Warning "Burst end time is invalid and will be restarted." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $until) {
|
||||||
|
$until = $now.AddMinutes($BurstDurationMinutes)
|
||||||
|
Set-NinjaValue -Name "ocsentinelburstuntilutc" -Value $until.ToString("o") -Type "DateTime" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst window started until $($until.ToString('u'))."
|
||||||
|
}
|
||||||
|
elseif ($until -le $now) {
|
||||||
|
Set-NinjaValue -Name "ocsentinelburst" -Value "false" -Type "Checkbox" | Out-Null
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "completed" -Type "Text" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst window completed and was disabled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "active until $($until.ToUniversalTime().ToString('o'))" -Type "Text" | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdNew = $false
|
||||||
|
$mutex = [Threading.Mutex]::new($false, $mutexName, [ref]$createdNew)
|
||||||
|
try {
|
||||||
|
if (-not $mutex.WaitOne(0)) {
|
||||||
|
Write-Host "OfficeCom Sentinel scan skipped: another scan is already running."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "OfficeCom Sentinel scheduled $Kind scan started."
|
||||||
|
$monitorArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $monitorScript, "-Mode", "status", "-UploadMode", "required", "-SecretPath", $secretPath, "-SuppressTriggerExit")
|
||||||
|
if ($Kind -eq "burst") {
|
||||||
|
$monitorArgs += @("-LookbackDays", "1", "-TopCount", "25")
|
||||||
|
}
|
||||||
|
& powershell.exe @monitorArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $mutex) {
|
||||||
|
try { $mutex.ReleaseMutex() } catch { }
|
||||||
|
$mutex.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,40 @@ function Resolve-PathLike {
|
|||||||
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
|
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Restore-NinjaContextFromClientConfiguration {
|
||||||
|
param([Parameter(Mandatory)][string]$Path)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$clientConfiguration = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||||
|
$mappings = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($mapping in $mappings) {
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($mapping.EnvironmentName, "Process"))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = [string]$clientConfiguration.($mapping.PropertyName)
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
[Environment]::SetEnvironmentVariable($mapping.EnvironmentName, $value, "Process")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not restore stored NinjaOne context: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$arguments = @(
|
$arguments = @(
|
||||||
$dllPath
|
$dllPath
|
||||||
)
|
)
|
||||||
@@ -53,6 +87,8 @@ else {
|
|||||||
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $repoRoot }
|
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $repoRoot }
|
||||||
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
|
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
|
||||||
|
|
||||||
|
Restore-NinjaContextFromClientConfiguration -Path $clientConfigFullPath
|
||||||
|
|
||||||
if ($UploadMode -eq "required" -and -not $canUpload) {
|
if ($UploadMode -eq "required" -and -not $canUpload) {
|
||||||
throw "UploadMode 'required' was set, but client config or protected secret is missing."
|
throw "UploadMode 'required' was set, but client config or protected secret is missing."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Globalization;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using OCSentinelCli.Configuration;
|
||||||
|
|
||||||
namespace OCSentinelCli;
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
@@ -15,6 +16,12 @@ internal sealed class AttackScanner
|
|||||||
@"D:\inetpub\logs\LogFiles"
|
@"D:\inetpub\logs\LogFiles"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] DefaultIisLogRoots =
|
||||||
|
[
|
||||||
|
@"C:\inetpub\logs\LogFiles",
|
||||||
|
@"D:\inetpub\logs\LogFiles"
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] DefaultFileZillaRoots =
|
private static readonly string[] DefaultFileZillaRoots =
|
||||||
[
|
[
|
||||||
@"C:\Program Files (x86)\FileZilla Server\Logs",
|
@"C:\Program Files (x86)\FileZilla Server\Logs",
|
||||||
@@ -32,8 +39,10 @@ internal sealed class AttackScanner
|
|||||||
ScanWindowsLogons(attacks, errors, since);
|
ScanWindowsLogons(attacks, errors, since);
|
||||||
ScanSqlLogons(attacks, errors, since);
|
ScanSqlLogons(attacks, errors, since);
|
||||||
ScanExchangeLogons(attacks, errors, since);
|
ScanExchangeLogons(attacks, errors, since);
|
||||||
|
ScanExchangeIisLogons(attacks, errors, since, configuration);
|
||||||
ScanIisFtpLogs(attacks, errors, since, configuration);
|
ScanIisFtpLogs(attacks, errors, since, configuration);
|
||||||
ScanFileZillaLogs(attacks, errors, since, configuration);
|
ScanFileZillaLogs(attacks, errors, since, configuration);
|
||||||
|
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
|
||||||
|
|
||||||
if (configuration.ExcludedIps.Count > 0)
|
if (configuration.ExcludedIps.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -44,6 +53,14 @@ internal sealed class AttackScanner
|
|||||||
|
|
||||||
attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp));
|
attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp));
|
||||||
|
|
||||||
|
int totalEventCount = attacks.Count;
|
||||||
|
int maxReportedEvents = Math.Clamp(configuration.MaxReportedEvents, 100, 5000);
|
||||||
|
List<AttackEvent> reportedEvents = attacks
|
||||||
|
.OrderByDescending(static attack => attack.Timestamp)
|
||||||
|
.Take(maxReportedEvents)
|
||||||
|
.OrderBy(static attack => attack.Timestamp)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
List<AggregatedAttack> topSources = attacks
|
List<AggregatedAttack> topSources = attacks
|
||||||
.GroupBy(static attack => attack.SourceIp)
|
.GroupBy(static attack => attack.SourceIp)
|
||||||
.Select(group => AggregatedAttack.FromGroup(group))
|
.Select(group => AggregatedAttack.FromGroup(group))
|
||||||
@@ -53,8 +70,9 @@ internal sealed class AttackScanner
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
|
||||||
string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration);
|
AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta, configuration.RansomwareBetaAlertingEnabled);
|
||||||
string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState);
|
string baseAlertState = baseAssessment.State;
|
||||||
|
string baseAlertReason = baseAssessment.Reason;
|
||||||
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
|
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
|
||||||
? VulnerabilityCorrelationSummary.Empty()
|
? VulnerabilityCorrelationSummary.Empty()
|
||||||
: VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors);
|
: VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors);
|
||||||
@@ -66,43 +84,65 @@ internal sealed class AttackScanner
|
|||||||
{
|
{
|
||||||
SchemaVersion = "2.0",
|
SchemaVersion = "2.0",
|
||||||
MachineName = Environment.MachineName,
|
MachineName = Environment.MachineName,
|
||||||
NinjaOne = GetNinjaOneContext(),
|
NinjaOne = GetNinjaOneContext(options, errors),
|
||||||
GeneratedAtLocal = generatedAtLocal,
|
GeneratedAtLocal = generatedAtLocal,
|
||||||
GeneratedAtUtc = generatedAtUtc,
|
GeneratedAtUtc = generatedAtUtc,
|
||||||
ClientVersion = BuildMetadata.Version,
|
ClientVersion = BuildMetadata.Version,
|
||||||
LookbackDays = options.LookbackDays,
|
LookbackDays = options.LookbackDays,
|
||||||
TotalEvents = attacks.Count,
|
TotalEvents = totalEventCount,
|
||||||
|
ReportedEventCount = reportedEvents.Count,
|
||||||
|
EventsTruncated = reportedEvents.Count < totalEventCount,
|
||||||
UniqueIpCount = uniqueIpCount,
|
UniqueIpCount = uniqueIpCount,
|
||||||
AlertState = correlationAssessment.FinalAlertState,
|
AlertState = correlationAssessment.FinalAlertState,
|
||||||
AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason,
|
AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason,
|
||||||
BaseAlertState = baseAlertState,
|
BaseAlertState = baseAlertState,
|
||||||
BaseAlertReason = baseAlertReason,
|
BaseAlertReason = baseAlertReason,
|
||||||
VulnerabilityCorrelation = vulnerabilityCorrelation,
|
VulnerabilityCorrelation = vulnerabilityCorrelation,
|
||||||
|
RansomwareBeta = ransomwareBeta,
|
||||||
Runtime = new ScanRuntimeMetadata
|
Runtime = new ScanRuntimeMetadata
|
||||||
{
|
{
|
||||||
StartedAtUtc = startedAtUtc,
|
StartedAtUtc = startedAtUtc,
|
||||||
FinishedAtUtc = generatedAtUtc,
|
FinishedAtUtc = generatedAtUtc,
|
||||||
UploadAttempted = false
|
UploadAttempted = false
|
||||||
},
|
},
|
||||||
Events = attacks,
|
Events = reportedEvents,
|
||||||
TopSources = topSources,
|
TopSources = topSources,
|
||||||
Errors = errors
|
Errors = errors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NinjaOneContext GetNinjaOneContext()
|
private static NinjaOneContext GetNinjaOneContext(ScanOptions options, List<string> errors)
|
||||||
{
|
{
|
||||||
|
ClientConfiguration? clientConfiguration = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.ClientConfigPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
clientConfiguration = ClientConfiguration.Load(options.ClientConfigPath);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Could not load persisted NinjaOne context: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new NinjaOneContext
|
return new NinjaOneContext
|
||||||
{
|
{
|
||||||
OrganizationId = ReadEnvironmentVariable("NINJA_ORGANIZATION_ID"),
|
OrganizationId = ReadContextValue("NINJA_ORGANIZATION_ID", clientConfiguration?.NinjaOrganizationId),
|
||||||
OrganizationName = ReadEnvironmentVariable("NINJA_ORGANIZATION_NAME"),
|
OrganizationName = ReadContextValue("NINJA_ORGANIZATION_NAME", clientConfiguration?.NinjaOrganizationName),
|
||||||
MachineId = ReadEnvironmentVariable("NINJA_AGENT_MACHINE_ID"),
|
MachineId = ReadContextValue("NINJA_AGENT_MACHINE_ID", clientConfiguration?.NinjaMachineId),
|
||||||
NodeId = ReadEnvironmentVariable("NINJA_AGENT_NODE_ID"),
|
NodeId = ReadContextValue("NINJA_AGENT_NODE_ID", clientConfiguration?.NinjaNodeId),
|
||||||
LocationId = ReadEnvironmentVariable("NINJA_LOCATION_ID"),
|
LocationId = ReadContextValue("NINJA_LOCATION_ID", clientConfiguration?.NinjaLocationId),
|
||||||
LocationName = ReadEnvironmentVariable("NINJA_LOCATION_NAME")
|
LocationName = ReadContextValue("NINJA_LOCATION_NAME", clientConfiguration?.NinjaLocationName)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ReadContextValue(string environmentName, string? persistedValue)
|
||||||
|
{
|
||||||
|
string currentValue = ReadEnvironmentVariable(environmentName);
|
||||||
|
return string.IsNullOrWhiteSpace(currentValue) ? persistedValue?.Trim() ?? string.Empty : currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
private static string ReadEnvironmentVariable(string name)
|
private static string ReadEnvironmentVariable(string name)
|
||||||
{
|
{
|
||||||
return Environment.GetEnvironmentVariable(name)?.Trim() ?? string.Empty;
|
return Environment.GetEnvironmentVariable(name)?.Trim() ?? string.Empty;
|
||||||
@@ -202,6 +242,40 @@ internal sealed class AttackScanner
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ScanExchangeIisLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
IEnumerable<string> roots = configuration.IisLogRoots.Count > 0 ? configuration.IisLogRoots : DefaultIisLogRoots;
|
||||||
|
foreach (string root in roots)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(root))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string directory in Directory.GetDirectories(root, "W3SVC*"))
|
||||||
|
{
|
||||||
|
foreach (string file in Directory.GetFiles(directory, "*.log").Where(path => File.GetLastWriteTime(path) >= since.LocalDateTime.Date))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
attacks.AddRange(ExchangeIisLogParser.ParseLines(File.ReadLines(file), since));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Exchange IIS log parse failed for {file}: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Exchange IIS log scan failed for {root}: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
{
|
{
|
||||||
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;
|
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;
|
||||||
@@ -469,28 +543,111 @@ internal sealed class AttackScanner
|
|||||||
return IPAddress.TryParse(input, out _);
|
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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState)
|
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 alertState switch
|
return new AlertAssessment("critical", $"Critical volume threshold reached: events={attacks.Count}, unique IPs={uniqueIpCount}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attacks.Count >= warningEventThreshold || uniqueIpCount >= warningIpThreshold)
|
||||||
{
|
{
|
||||||
"critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.",
|
return new AlertAssessment("warning", $"Elevated failed-login volume: events={attacks.Count}, unique IPs={uniqueIpCount}.");
|
||||||
"warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.",
|
}
|
||||||
_ => "No thresholds exceeded."
|
|
||||||
|
return new AlertAssessment("ok", $"Low-volume login errors observed: events={attacks.Count}, unique IPs={uniqueIpCount}; no burst or password-spraying pattern detected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AlertAssessment MergeRansomwareAssessment(AlertAssessment loginAssessment, RansomwareBetaSummary ransomwareBeta, bool ransomwareAlertingEnabled)
|
||||||
|
{
|
||||||
|
if (!RansomwareAlertPolicy.CanElevate(ransomwareBeta, ransomwareAlertingEnabled))
|
||||||
|
{
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ internal static class ScanAndUploadCommand
|
|||||||
|
|
||||||
if (string.Equals(args[i], "--client-config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
if (string.Equals(args[i], "--client-config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||||
{
|
{
|
||||||
clientConfigPath = args[++i];
|
string configPathValue = args[++i];
|
||||||
|
clientConfigPath = configPathValue;
|
||||||
|
scanArgs.Add("--client-config");
|
||||||
|
scanArgs.Add(configPathValue);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ internal static class ScanCommand
|
|||||||
Console.WriteLine($"Status: {result.AlertState}");
|
Console.WriteLine($"Status: {result.AlertState}");
|
||||||
Console.WriteLine($"Reason: {result.AlertReason}");
|
Console.WriteLine($"Reason: {result.AlertReason}");
|
||||||
Console.WriteLine($"Base status: {result.BaseAlertState}");
|
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($"Scan errors: {result.Errors.Count}");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OCSentinelCli.Configuration;
|
using OCSentinelCli.Configuration;
|
||||||
|
using OCSentinelCli.Models;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using OCSentinelCli.Security;
|
using OCSentinelCli.Security;
|
||||||
using OCSentinelCli.Transport;
|
using OCSentinelCli.Transport;
|
||||||
@@ -56,30 +57,52 @@ internal static class UploadCommand
|
|||||||
|
|
||||||
string reportFullPath = Path.GetFullPath(reportPath);
|
string reportFullPath = Path.GetFullPath(reportPath);
|
||||||
string json = File.ReadAllText(reportFullPath);
|
string json = File.ReadAllText(reportFullPath);
|
||||||
|
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath);
|
||||||
|
var client = new N8nUploadClient();
|
||||||
|
var queue = new UploadQueue(config.UploadQueueMaxReports);
|
||||||
|
UploadHealth health = queue.LoadHealth();
|
||||||
|
DateTimeOffset attemptTime = DateTimeOffset.UtcNow;
|
||||||
ScanResult? parsedReport = JsonSerializer.Deserialize<ScanResult>(json, JsonOptions.Default);
|
ScanResult? parsedReport = JsonSerializer.Deserialize<ScanResult>(json, JsonOptions.Default);
|
||||||
|
|
||||||
if (parsedReport is not null)
|
if (parsedReport is not null)
|
||||||
{
|
{
|
||||||
parsedReport = parsedReport with
|
parsedReport = parsedReport with
|
||||||
{
|
{
|
||||||
Runtime = parsedReport.Runtime with
|
Runtime = parsedReport.Runtime with
|
||||||
{
|
{
|
||||||
UploadAttempted = true
|
UploadAttempted = true,
|
||||||
|
UploadStatus = "attempting",
|
||||||
|
QueuedReportCount = health.QueuedReportCount,
|
||||||
|
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default);
|
json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default);
|
||||||
File.WriteAllText(reportFullPath, json);
|
File.WriteAllText(reportFullPath, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath);
|
UploadResult? deferredFailure = queue.Drain(
|
||||||
var client = new N8nUploadClient();
|
client,
|
||||||
var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds);
|
config.N8nWebhookUrl,
|
||||||
|
Environment.MachineName,
|
||||||
|
BuildMetadata.Version,
|
||||||
|
secret,
|
||||||
|
config.UploadTimeoutSeconds);
|
||||||
|
|
||||||
if (!result.Success)
|
if (deferredFailure is null)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"Upload failed ({result.StatusCode}): {result.Message}");
|
var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds);
|
||||||
return 1;
|
if (result.Success)
|
||||||
}
|
{
|
||||||
|
UploadHealth successHealth = new()
|
||||||
|
{
|
||||||
|
LastUploadAttemptUtc = attemptTime,
|
||||||
|
LastSuccessfulUploadUtc = DateTimeOffset.UtcNow,
|
||||||
|
LastUploadStatus = "ok",
|
||||||
|
LastUploadError = string.Empty,
|
||||||
|
QueuedReportCount = queue.GetQueueDepth()
|
||||||
|
};
|
||||||
|
queue.SaveHealth(successHealth);
|
||||||
|
WriteReportRuntime(reportFullPath, parsedReport, successHealth, true);
|
||||||
|
|
||||||
Console.WriteLine($"Upload succeeded ({result.StatusCode})");
|
Console.WriteLine($"Upload succeeded ({result.StatusCode})");
|
||||||
Console.WriteLine($"Nonce: {result.Nonce}");
|
Console.WriteLine($"Nonce: {result.Nonce}");
|
||||||
@@ -87,6 +110,50 @@ internal static class UploadCommand
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deferredFailure = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadResult failure = deferredFailure ?? throw new InvalidOperationException("Upload failed without a result.");
|
||||||
|
int queuedCount = queue.Enqueue(json);
|
||||||
|
UploadHealth queuedHealth = new()
|
||||||
|
{
|
||||||
|
LastUploadAttemptUtc = attemptTime,
|
||||||
|
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc,
|
||||||
|
LastUploadStatus = "queued",
|
||||||
|
LastUploadError = failure.Message,
|
||||||
|
QueuedReportCount = queuedCount
|
||||||
|
};
|
||||||
|
queue.SaveHealth(queuedHealth);
|
||||||
|
WriteReportRuntime(reportFullPath, parsedReport, queuedHealth, true);
|
||||||
|
|
||||||
|
Console.WriteLine($"Upload deferred ({failure.StatusCode}): {failure.Message}");
|
||||||
|
Console.WriteLine($"Queued reports: {queuedCount}");
|
||||||
|
Console.WriteLine("The report will be retried automatically on the next scheduled run.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteReportRuntime(string reportPath, ScanResult? report, UploadHealth health, bool attempted)
|
||||||
|
{
|
||||||
|
if (report is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanResult updated = report with
|
||||||
|
{
|
||||||
|
Runtime = report.Runtime with
|
||||||
|
{
|
||||||
|
UploadAttempted = attempted,
|
||||||
|
UploadSucceeded = string.Equals(health.LastUploadStatus, "ok", StringComparison.Ordinal),
|
||||||
|
UploadStatus = health.LastUploadStatus,
|
||||||
|
QueuedReportCount = health.QueuedReportCount,
|
||||||
|
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc,
|
||||||
|
LastUploadError = health.LastUploadError
|
||||||
|
}
|
||||||
|
};
|
||||||
|
File.WriteAllText(reportPath, JsonSerializer.Serialize(updated, JsonOptions.Default));
|
||||||
|
}
|
||||||
|
|
||||||
private static string ReadValue(string[] args, ref int index, string argName)
|
private static string ReadValue(string[] args, ref int index, string argName)
|
||||||
{
|
{
|
||||||
if (index + 1 >= args.Length)
|
if (index + 1 >= args.Length)
|
||||||
|
|||||||
@@ -4,20 +4,62 @@ namespace OCSentinelCli;
|
|||||||
|
|
||||||
internal sealed record ScannerConfiguration
|
internal sealed record ScannerConfiguration
|
||||||
{
|
{
|
||||||
public int WarningEventThreshold { get; init; } = 1;
|
public int WarningEventThreshold { get; init; } = 10;
|
||||||
|
|
||||||
public int CriticalEventThreshold { get; init; } = 20;
|
public int MaxReportedEvents { get; init; } = 1000;
|
||||||
|
|
||||||
public int WarningUniqueIpThreshold { get; init; } = 1;
|
public int CriticalEventThreshold { get; init; } = 30;
|
||||||
|
|
||||||
public int CriticalUniqueIpThreshold { get; init; } = 10;
|
public int WarningUniqueIpThreshold { get; init; } = 5;
|
||||||
|
|
||||||
|
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 CorrelationWarningCveThreshold { get; init; } = 1;
|
||||||
|
|
||||||
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
||||||
|
|
||||||
|
public bool RansomwareBetaEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
public bool RansomwareBetaAlertingEnabled { get; init; }
|
||||||
|
|
||||||
|
public int RansomwareLookbackMinutes { get; init; } = 15;
|
||||||
|
|
||||||
|
public int RansomwareWarningSignalCount { get; init; } = 2;
|
||||||
|
|
||||||
|
public int RansomwareCriticalSignalCount { get; init; } = 3;
|
||||||
|
|
||||||
|
public bool RansomwareCaptureSmbSessions { get; init; } = true;
|
||||||
|
|
||||||
|
public bool RansomwareFileChurnEnabled { get; init; }
|
||||||
|
|
||||||
|
public int RansomwareFileChurnWindowMinutes { get; init; } = 15;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnWarningDeleteCount { get; init; } = 50;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnWarningWriteCount { get; init; } = 250;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnCriticalDeleteCount { get; init; } = 200;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnCriticalWriteCount { get; init; } = 1000;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnMaxAuditEvents { get; init; } = 5000;
|
||||||
|
|
||||||
|
public List<string> RansomwareExcludedProcesses { get; init; } = [];
|
||||||
|
|
||||||
public List<string> FtpRoots { get; init; } = [];
|
public List<string> FtpRoots { get; init; } = [];
|
||||||
|
|
||||||
|
public List<string> IisLogRoots { get; init; } = [];
|
||||||
|
|
||||||
public List<string> FileZillaRoots { get; init; } = [];
|
public List<string> FileZillaRoots { get; init; } = [];
|
||||||
|
|
||||||
public List<string> ExcludedIps { get; init; } = [];
|
public List<string> ExcludedIps { get; init; } = [];
|
||||||
|
|||||||
@@ -14,10 +14,24 @@ internal sealed record ClientConfiguration
|
|||||||
|
|
||||||
public string N8nWebhookUrl { get; init; } = string.Empty;
|
public string N8nWebhookUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaOrganizationId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaOrganizationName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaMachineId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaNodeId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaLocationId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaLocationName { get; init; } = string.Empty;
|
||||||
|
|
||||||
public string DeviceIdentifierMode { get; init; } = "machineName";
|
public string DeviceIdentifierMode { get; init; } = "machineName";
|
||||||
|
|
||||||
public int UploadTimeoutSeconds { get; init; } = 30;
|
public int UploadTimeoutSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
public int UploadQueueMaxReports { get; init; } = 100;
|
||||||
|
|
||||||
public bool EnableVulnerabilityCorrelation { get; init; } = true;
|
public bool EnableVulnerabilityCorrelation { get; init; } = true;
|
||||||
|
|
||||||
public string VulnerabilityCsvPath { get; init; } = string.Empty;
|
public string VulnerabilityCsvPath { get; init; } = string.Empty;
|
||||||
|
|||||||
97
src/OCSentinelCli/ExchangeIisLogParser.cs
Normal file
97
src/OCSentinelCli/ExchangeIisLogParser.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
|
internal static class ExchangeIisLogParser
|
||||||
|
{
|
||||||
|
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
|
||||||
|
{
|
||||||
|
Dictionary<string, int>? fields = null;
|
||||||
|
|
||||||
|
foreach (string line in lines)
|
||||||
|
{
|
||||||
|
if (line.StartsWith("#Fields:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
fields = line[8..].Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select((field, index) => new { Field = field, Index = index })
|
||||||
|
.ToDictionary(item => item.Field, item => item.Index, StringComparer.OrdinalIgnoreCase);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (!TryValue(fields, values, "date", out string date) || !TryValue(fields, values, "time", out string time)
|
||||||
|
|| !TryValue(fields, values, "c-ip", out string sourceIp) || !TryValue(fields, values, "cs-uri-stem", out string path)
|
||||||
|
|| !TryValue(fields, values, "sc-status", out string statusText) || !int.TryParse(statusText, out int status))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status is not 401 and not 403 || !TryClassify(path, out string service))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero);
|
||||||
|
if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? destinationPort = TryValue(fields, values, "s-port", out string portText) && int.TryParse(portText, out int parsedPort) ? parsedPort : null;
|
||||||
|
string username = TryValue(fields, values, "cs-username", out string loggedUser) && loggedUser != "-" ? loggedUser : "[not logged]";
|
||||||
|
|
||||||
|
yield return new AttackEvent
|
||||||
|
{
|
||||||
|
Timestamp = timestamp.ToLocalTime(),
|
||||||
|
SourceIp = sourceIp,
|
||||||
|
Target = $"Exchange {service} login",
|
||||||
|
Username = username,
|
||||||
|
Source = "IIS W3C",
|
||||||
|
Service = service,
|
||||||
|
DestinationPort = destinationPort,
|
||||||
|
Endpoint = path,
|
||||||
|
InstanceId = status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryValue(IReadOnlyDictionary<string, int> fields, IReadOnlyList<string> values, string field, out string value)
|
||||||
|
{
|
||||||
|
value = string.Empty;
|
||||||
|
if (!fields.TryGetValue(field, out int index) || index >= values.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = values[index];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryClassify(string path, out string service)
|
||||||
|
{
|
||||||
|
string normalized = path.Trim().ToLowerInvariant();
|
||||||
|
service = normalized switch
|
||||||
|
{
|
||||||
|
var value when value.StartsWith("/owa/") => "OWA",
|
||||||
|
var value when value.StartsWith("/ecp/") => "ECP",
|
||||||
|
var value when value.StartsWith("/mapi/") => "MAPI/HTTP",
|
||||||
|
var value when value.StartsWith("/ews/") => "EWS",
|
||||||
|
var value when value.StartsWith("/microsoft-server-activesync") => "ActiveSync",
|
||||||
|
var value when value.StartsWith("/autodiscover/") => "Autodiscover",
|
||||||
|
var value when value.StartsWith("/rpc/") => "Outlook Anywhere",
|
||||||
|
var value when value.StartsWith("/powershell") => "Exchange PowerShell",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
return service.Length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ internal static class JsonOptions
|
|||||||
public static readonly JsonSerializerOptions Default = new()
|
public static readonly JsonSerializerOptions Default = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
|
// Client configuration is also written by PowerShell/NinjaOne scripts.
|
||||||
|
// Accept their conventional camelCase names (for example n8nWebhookUrl).
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ internal sealed record AttackEvent
|
|||||||
public string Username { get; init; } = string.Empty;
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
public string Source { get; init; } = string.Empty;
|
public string Source { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Service { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int? DestinationPort { get; init; }
|
||||||
|
|
||||||
|
public string Endpoint { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record AggregatedAttack
|
internal sealed record AggregatedAttack
|
||||||
@@ -33,6 +39,12 @@ internal sealed record AggregatedAttack
|
|||||||
|
|
||||||
public List<string> Sources { get; init; } = [];
|
public List<string> Sources { get; init; } = [];
|
||||||
|
|
||||||
|
public List<string> Services { get; init; } = [];
|
||||||
|
|
||||||
|
public List<int> DestinationPorts { get; init; } = [];
|
||||||
|
|
||||||
|
public List<string> Endpoints { get; init; } = [];
|
||||||
|
|
||||||
public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group)
|
public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group)
|
||||||
{
|
{
|
||||||
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
|
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
|
||||||
@@ -48,7 +60,10 @@ internal sealed record AggregatedAttack
|
|||||||
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
|
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
|
||||||
Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||||
Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||||
Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList()
|
Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||||
|
Services = ordered.Select(static attack => attack.Service).Where(static service => !string.IsNullOrWhiteSpace(service)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||||
|
DestinationPorts = ordered.Select(static attack => attack.DestinationPort).Where(static port => port.HasValue).Select(static port => port!.Value).Distinct().Order().ToList(),
|
||||||
|
Endpoints = ordered.Select(static attack => attack.Endpoint).Where(static endpoint => !string.IsNullOrWhiteSpace(endpoint)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +95,6 @@ internal sealed record ScanResult
|
|||||||
|
|
||||||
public string MachineName { get; init; } = string.Empty;
|
public string MachineName { get; init; } = string.Empty;
|
||||||
|
|
||||||
// Populated only for runs launched by NinjaOne automation.
|
|
||||||
public NinjaOneContext NinjaOne { get; init; } = new();
|
public NinjaOneContext NinjaOne { get; init; } = new();
|
||||||
|
|
||||||
public DateTimeOffset GeneratedAtLocal { get; init; }
|
public DateTimeOffset GeneratedAtLocal { get; init; }
|
||||||
@@ -93,6 +107,10 @@ internal sealed record ScanResult
|
|||||||
|
|
||||||
public int TotalEvents { get; init; }
|
public int TotalEvents { get; init; }
|
||||||
|
|
||||||
|
public int ReportedEventCount { get; init; }
|
||||||
|
|
||||||
|
public bool EventsTruncated { get; init; }
|
||||||
|
|
||||||
public int UniqueIpCount { get; init; }
|
public int UniqueIpCount { get; init; }
|
||||||
|
|
||||||
public string AlertState { get; init; } = "ok";
|
public string AlertState { get; init; } = "ok";
|
||||||
@@ -105,6 +123,8 @@ internal sealed record ScanResult
|
|||||||
|
|
||||||
public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new();
|
public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new();
|
||||||
|
|
||||||
|
public RansomwareBetaSummary RansomwareBeta { get; init; } = new();
|
||||||
|
|
||||||
public ScanRuntimeMetadata Runtime { get; init; } = new();
|
public ScanRuntimeMetadata Runtime { get; init; } = new();
|
||||||
|
|
||||||
public List<AttackEvent> Events { get; init; } = [];
|
public List<AttackEvent> Events { get; init; } = [];
|
||||||
@@ -114,6 +134,98 @@ internal sealed record ScanResult
|
|||||||
public List<string> Errors { get; init; } = [];
|
public List<string> Errors { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareBetaSummary
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public bool AlertingEnabled { 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; } = [];
|
||||||
|
|
||||||
|
public List<RansomwareSensorStatus> Sensors { get; init; } = [];
|
||||||
|
|
||||||
|
public List<RansomwareSmbSession> SmbSessions { get; init; } = [];
|
||||||
|
|
||||||
|
public RansomwareFileChurnSummary FileChurn { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public bool Available { get; init; }
|
||||||
|
|
||||||
|
public string State { get; init; } = "unknown";
|
||||||
|
|
||||||
|
public int EventCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public bool DataAvailable { get; init; }
|
||||||
|
|
||||||
|
public bool IsTruncated { get; init; }
|
||||||
|
|
||||||
|
public int WindowMinutes { get; init; }
|
||||||
|
|
||||||
|
public int FileOperationCount { get; init; }
|
||||||
|
|
||||||
|
public int DeleteOperationCount { get; init; }
|
||||||
|
|
||||||
|
public int WriteOperationCount { get; init; }
|
||||||
|
|
||||||
|
public int DistinctProcessCount { get; init; }
|
||||||
|
|
||||||
|
public List<RansomwareFileChurnProcess> TopProcesses { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareFileChurnProcess
|
||||||
|
{
|
||||||
|
public string Process { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int DeleteOperationCount { get; init; }
|
||||||
|
|
||||||
|
public int WriteOperationCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareSmbSession
|
||||||
|
{
|
||||||
|
public string ClientComputerName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ClientUserName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public long SessionId { get; init; }
|
||||||
|
|
||||||
|
public long OpenFileCount { 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
|
internal sealed record NinjaOneContext
|
||||||
{
|
{
|
||||||
public string OrganizationId { get; init; } = string.Empty;
|
public string OrganizationId { get; init; } = string.Empty;
|
||||||
@@ -136,6 +248,16 @@ internal sealed record ScanRuntimeMetadata
|
|||||||
public DateTimeOffset FinishedAtUtc { get; init; }
|
public DateTimeOffset FinishedAtUtc { get; init; }
|
||||||
|
|
||||||
public bool UploadAttempted { get; init; }
|
public bool UploadAttempted { get; init; }
|
||||||
|
|
||||||
|
public bool UploadSucceeded { get; init; }
|
||||||
|
|
||||||
|
public string UploadStatus { get; init; } = "not-attempted";
|
||||||
|
|
||||||
|
public int QueuedReportCount { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastSuccessfulUploadUtc { get; init; }
|
||||||
|
|
||||||
|
public string LastUploadError { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record VulnerabilityFinding
|
internal sealed record VulnerabilityFinding
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
<RootNamespace>OCSentinelCli</RootNamespace>
|
<RootNamespace>OCSentinelCli</RootNamespace>
|
||||||
<Product>OfficeCom Sentinel</Product>
|
<Product>OfficeCom Sentinel</Product>
|
||||||
<Company>OfficeCom</Company>
|
<Company>OfficeCom</Company>
|
||||||
<Version>1.2.8</Version>
|
<Version>1.5.0-beta.7</Version>
|
||||||
<AssemblyVersion>1.2.3.0</AssemblyVersion>
|
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.2.3.0</FileVersion>
|
<FileVersion>1.5.0.0</FileVersion>
|
||||||
<InformationalVersion>1.2.3</InformationalVersion>
|
<InformationalVersion>1.5.0-beta.7</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
3
src/OCSentinelCli/Properties/AssemblyInfo.cs
Normal file
3
src/OCSentinelCli/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("OCSentinelCli.Tests")]
|
||||||
9
src/OCSentinelCli/RansomwareAlertPolicy.cs
Normal file
9
src/OCSentinelCli/RansomwareAlertPolicy.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
|
internal static class RansomwareAlertPolicy
|
||||||
|
{
|
||||||
|
public static bool CanElevate(RansomwareBetaSummary summary, bool alertingEnabled)
|
||||||
|
{
|
||||||
|
return alertingEnabled && summary.Enabled && (summary.State is "warning" or "critical");
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/OCSentinelCli/RansomwareBetaDetector.cs
Normal file
267
src/OCSentinelCli/RansomwareBetaDetector.cs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.Eventing.Reader;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
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>();
|
||||||
|
var sensors = new List<RansomwareSensorStatus>
|
||||||
|
{
|
||||||
|
ScanSecurityProcesses(signals, errors, since, configuration),
|
||||||
|
ScanPowerShellScriptBlocks(signals, errors, since, configuration),
|
||||||
|
ScanSysmonProcesses(signals, errors, since, configuration)
|
||||||
|
};
|
||||||
|
|
||||||
|
RansomwareFileChurnSummary fileChurn = RansomwareFileChurnDetector.Scan(configuration, errors);
|
||||||
|
sensors.Add(new RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
Name = "security-file-audit-4663",
|
||||||
|
Enabled = fileChurn.Enabled,
|
||||||
|
Available = fileChurn.DataAvailable,
|
||||||
|
State = !fileChurn.Enabled ? "disabled" : fileChurn.IsTruncated ? "truncated" : fileChurn.DataAvailable ? "available" : "unavailable",
|
||||||
|
EventCount = fileChurn.FileOperationCount
|
||||||
|
});
|
||||||
|
RansomwareSignal? fileChurnSignal = RansomwareFileChurnDetector.CreateSignal(fileChurn, configuration);
|
||||||
|
if (fileChurnSignal is not null)
|
||||||
|
{
|
||||||
|
signals.Add(fileChurnSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
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."
|
||||||
|
};
|
||||||
|
|
||||||
|
List<RansomwareSmbSession> smbSessions = state is "warning" or "critical" && configuration.RansomwareCaptureSmbSessions
|
||||||
|
? CaptureSmbSessions(errors)
|
||||||
|
: [];
|
||||||
|
return new RansomwareBetaSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
AlertingEnabled = configuration.RansomwareBetaAlertingEnabled,
|
||||||
|
State = state,
|
||||||
|
Reason = reason,
|
||||||
|
LookbackMinutes = lookbackMinutes,
|
||||||
|
Signals = distinctSignals,
|
||||||
|
Sensors = sensors,
|
||||||
|
SmbSessions = smbSessions,
|
||||||
|
FileChurn = fileChurn
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareSensorStatus ScanSecurityProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
return TryScan("security-process-4688", "Security", 4688, since, errors, record => AddSignal(signals, record, ReadProperty(record, 5), ReadProperty(record, 8), "Security", configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareSensorStatus ScanPowerShellScriptBlocks(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
return TryScan("powershell-script-block-4104", "Microsoft-Windows-PowerShell/Operational", 4104, since, errors, record => AddSignal(signals, record, "powershell", FormatDescription(record), "PowerShell", configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareSensorStatus ScanSysmonProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
return TryScan("sysmon-process-1", "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 RansomwareSensorStatus TryScan(string sensorName, string logName, int eventId, DateTimeOffset since, List<string> errors, Action<EventRecord> processRecord)
|
||||||
|
{
|
||||||
|
int eventCount = 0;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
eventCount++;
|
||||||
|
processRecord(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
Name = sensorName,
|
||||||
|
Enabled = true,
|
||||||
|
Available = true,
|
||||||
|
State = "available",
|
||||||
|
EventCount = eventCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (EventLogNotFoundException)
|
||||||
|
{
|
||||||
|
return new RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
Name = sensorName,
|
||||||
|
Enabled = true,
|
||||||
|
State = "not-installed"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware beta query failed for {logName}: {exception.Message}");
|
||||||
|
return new RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
Name = sensorName,
|
||||||
|
Enabled = true,
|
||||||
|
State = "query-failed",
|
||||||
|
EventCount = eventCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RansomwareSmbSession> CaptureSmbSessions(List<string> errors)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
Arguments = "-NoProfile -NonInteractive -Command \"Get-SmbSession | Select-Object ClientComputerName,ClientUserName,SessionId,NumOpens | ConvertTo-Json -Compress\"",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
});
|
||||||
|
if (process is null || !process.WaitForExit(5000) || process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
string json = process.StandardOutput.ReadToEnd();
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonElement root = JsonSerializer.Deserialize<JsonElement>(json, JsonOptions.Default);
|
||||||
|
IEnumerable<JsonElement> rows = root.ValueKind == JsonValueKind.Array ? root.EnumerateArray().ToArray() : [root];
|
||||||
|
return rows.Take(100).Select(row => new RansomwareSmbSession
|
||||||
|
{
|
||||||
|
ClientComputerName = GetJsonString(row, "ClientComputerName"),
|
||||||
|
ClientUserName = GetJsonString(row, "ClientUserName"),
|
||||||
|
SessionId = GetJsonLong(row, "SessionId"),
|
||||||
|
OpenFileCount = GetJsonLong(row, "NumOpens")
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware beta SMB snapshot failed: {exception.Message}");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetJsonString(JsonElement value, string name) => value.TryGetProperty(name, out JsonElement property) ? property.ToString() : string.Empty;
|
||||||
|
|
||||||
|
private static long GetJsonLong(JsonElement value, string name) => value.TryGetProperty(name, out JsonElement property) && property.TryGetInt64(out long result) ? result : 0;
|
||||||
|
}
|
||||||
296
src/OCSentinelCli/RansomwareFileChurnDetector.cs
Normal file
296
src/OCSentinelCli/RansomwareFileChurnDetector.cs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
using System.Diagnostics.Eventing.Reader;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
internal static class RansomwareFileChurnDetector
|
||||||
|
{
|
||||||
|
private const uint DeleteAccessMask = 0x00010000;
|
||||||
|
private const uint FileWriteAccessMask = 0x00000156;
|
||||||
|
private const string StateFileName = "ransomware-file-churn.json";
|
||||||
|
|
||||||
|
public static RansomwareFileChurnSummary Scan(ScannerConfiguration configuration, List<string> errors)
|
||||||
|
{
|
||||||
|
if (!configuration.RansomwareFileChurnEnabled)
|
||||||
|
{
|
||||||
|
return new RansomwareFileChurnSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
int windowMinutes = Math.Clamp(configuration.RansomwareFileChurnWindowMinutes, 1, 60);
|
||||||
|
int maximumAuditEvents = Math.Clamp(configuration.RansomwareFileChurnMaxAuditEvents, 100, 20000);
|
||||||
|
DateTimeOffset windowStart = DateTimeOffset.UtcNow.AddMinutes(-windowMinutes);
|
||||||
|
var observed = new List<FileAuditActivity>();
|
||||||
|
bool isTruncated = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - windowStart).TotalMilliseconds);
|
||||||
|
string query = $"*[System[(EventID=4663) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]";
|
||||||
|
using var reader = new EventLogReader(new EventLogQuery("Security", PathType.LogName, query));
|
||||||
|
int inspected = 0;
|
||||||
|
for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent())
|
||||||
|
{
|
||||||
|
using (record)
|
||||||
|
{
|
||||||
|
if (++inspected > maximumAuditEvents)
|
||||||
|
{
|
||||||
|
isTruncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryCreateActivity(record, configuration, out FileAuditActivity? activity) && activity is not null)
|
||||||
|
{
|
||||||
|
observed.Add(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware file churn cannot read the Security log: {exception.Message}");
|
||||||
|
return Unavailable(windowMinutes);
|
||||||
|
}
|
||||||
|
catch (EventLogNotFoundException)
|
||||||
|
{
|
||||||
|
return Unavailable(windowMinutes);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware file churn query failed: {exception.Message}");
|
||||||
|
return Unavailable(windowMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTruncated)
|
||||||
|
{
|
||||||
|
return BuildSummary(observed, windowMinutes, isTruncated: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FileAuditActivity> rollingActivities = MergeWithState(observed, windowStart, errors);
|
||||||
|
return BuildSummary(rollingActivities, windowMinutes, isTruncated: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RansomwareSignal? CreateSignal(RansomwareFileChurnSummary summary, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
if (!summary.Enabled || !summary.DataAvailable || summary.IsTruncated)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int warningDeletes = Math.Max(1, configuration.RansomwareFileChurnWarningDeleteCount);
|
||||||
|
int warningWrites = Math.Max(1, configuration.RansomwareFileChurnWarningWriteCount);
|
||||||
|
int criticalDeletes = Math.Max(warningDeletes, configuration.RansomwareFileChurnCriticalDeleteCount);
|
||||||
|
int criticalWrites = Math.Max(warningWrites, configuration.RansomwareFileChurnCriticalWriteCount);
|
||||||
|
bool critical = summary.DeleteOperationCount >= criticalDeletes && summary.WriteOperationCount >= criticalWrites;
|
||||||
|
bool warning = summary.DeleteOperationCount >= warningDeletes && summary.WriteOperationCount >= warningWrites;
|
||||||
|
if (!warning)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
RansomwareFileChurnProcess? topProcess = summary.TopProcesses.FirstOrDefault();
|
||||||
|
string evidence = $"delete={summary.DeleteOperationCount}; write={summary.WriteOperationCount}; processes={summary.DistinctProcessCount}; window={summary.WindowMinutes}m";
|
||||||
|
return new RansomwareSignal
|
||||||
|
{
|
||||||
|
Timestamp = DateTimeOffset.Now,
|
||||||
|
Category = critical ? "file-churn-critical" : "file-churn",
|
||||||
|
Confidence = critical ? "medium" : "low",
|
||||||
|
Process = topProcess?.Process ?? "[multiple]",
|
||||||
|
Source = "Security file audit",
|
||||||
|
EventId = 4663,
|
||||||
|
Evidence = evidence
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareFileChurnSummary Unavailable(int windowMinutes)
|
||||||
|
{
|
||||||
|
return new RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
WindowMinutes = windowMinutes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreateActivity(EventRecord record, ScannerConfiguration configuration, out FileAuditActivity? activity)
|
||||||
|
{
|
||||||
|
activity = null;
|
||||||
|
if (!record.TimeCreated.HasValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, string> data = ReadEventData(record);
|
||||||
|
if (!data.TryGetValue("ObjectType", out string? objectType) || !string.Equals(objectType, "File", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.TryGetValue("AccessMask", out string? accessMaskText) || !TryParseAccessMask(accessMaskText, out uint accessMask))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isDelete = (accessMask & DeleteAccessMask) != 0;
|
||||||
|
bool isWrite = (accessMask & FileWriteAccessMask) != 0;
|
||||||
|
if (!isDelete && !isWrite)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string process = data.TryGetValue("ProcessName", out string? processPath) ? Path.GetFileName(processPath.Trim()) : string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(process))
|
||||||
|
{
|
||||||
|
process = "[unknown]";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, process, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activity = new FileAuditActivity
|
||||||
|
{
|
||||||
|
Timestamp = new DateTimeOffset(record.TimeCreated.Value).ToUniversalTime(),
|
||||||
|
RecordId = record.RecordId ?? 0,
|
||||||
|
Process = process,
|
||||||
|
IsDelete = isDelete,
|
||||||
|
IsWrite = isWrite
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> ReadEventData(EventRecord record)
|
||||||
|
{
|
||||||
|
XDocument document = XDocument.Parse(record.ToXml());
|
||||||
|
return document.Descendants().Where(element => element.Name.LocalName == "Data")
|
||||||
|
.Where(element => element.Attribute("Name") is not null)
|
||||||
|
.ToDictionary(element => element.Attribute("Name")!.Value, element => element.Value.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseAccessMask(string value, out uint result)
|
||||||
|
{
|
||||||
|
string normalized = value.Trim();
|
||||||
|
NumberStyles style = NumberStyles.Integer;
|
||||||
|
if (normalized.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
normalized = normalized[2..];
|
||||||
|
style = NumberStyles.AllowHexSpecifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint.TryParse(normalized, style, CultureInfo.InvariantCulture, out result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FileAuditActivity> MergeWithState(List<FileAuditActivity> observed, DateTimeOffset windowStart, List<string> errors)
|
||||||
|
{
|
||||||
|
FileChurnState stored = LoadState(errors);
|
||||||
|
long highestObservedRecordId = observed.Count == 0 ? 0 : observed.Max(activity => activity.RecordId);
|
||||||
|
bool securityLogReset = stored.LastSecurityRecordId > 0 && highestObservedRecordId > 0 && highestObservedRecordId < stored.LastSecurityRecordId;
|
||||||
|
IEnumerable<FileAuditActivity> fresh = securityLogReset
|
||||||
|
? observed
|
||||||
|
: observed.Where(activity => activity.RecordId == 0 || activity.RecordId > stored.LastSecurityRecordId);
|
||||||
|
List<FileAuditActivity> rolling = (securityLogReset ? [] : stored.Activities)
|
||||||
|
.Concat(fresh)
|
||||||
|
.Where(activity => activity.Timestamp >= windowStart)
|
||||||
|
.GroupBy(activity => activity.RecordId > 0 ? activity.RecordId.ToString(CultureInfo.InvariantCulture) : $"{activity.Timestamp:O}|{activity.Process}|{activity.IsDelete}|{activity.IsWrite}")
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderBy(activity => activity.Timestamp)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
SaveState(new FileChurnState
|
||||||
|
{
|
||||||
|
LastSecurityRecordId = securityLogReset ? highestObservedRecordId : Math.Max(stored.LastSecurityRecordId, highestObservedRecordId),
|
||||||
|
Activities = rolling
|
||||||
|
}, errors);
|
||||||
|
return rolling;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareFileChurnSummary BuildSummary(List<FileAuditActivity> activities, int windowMinutes, bool isTruncated)
|
||||||
|
{
|
||||||
|
return new RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
DataAvailable = true,
|
||||||
|
IsTruncated = isTruncated,
|
||||||
|
WindowMinutes = windowMinutes,
|
||||||
|
FileOperationCount = activities.Count,
|
||||||
|
DeleteOperationCount = activities.Count(activity => activity.IsDelete),
|
||||||
|
WriteOperationCount = activities.Count(activity => activity.IsWrite),
|
||||||
|
DistinctProcessCount = activities.Select(activity => activity.Process).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||||
|
TopProcesses = activities.GroupBy(activity => activity.Process, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => new RansomwareFileChurnProcess
|
||||||
|
{
|
||||||
|
Process = group.Key,
|
||||||
|
DeleteOperationCount = group.Count(activity => activity.IsDelete),
|
||||||
|
WriteOperationCount = group.Count(activity => activity.IsWrite)
|
||||||
|
})
|
||||||
|
.OrderByDescending(process => process.DeleteOperationCount + process.WriteOperationCount)
|
||||||
|
.ThenBy(process => process.Process, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(5)
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FileChurnState LoadState(List<string> errors)
|
||||||
|
{
|
||||||
|
string path = GetStatePath();
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new FileChurnState();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<FileChurnState>(File.ReadAllText(path), JsonOptions.Default) ?? new FileChurnState();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware file churn state could not be read: {exception.Message}");
|
||||||
|
return new FileChurnState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveState(FileChurnState state, List<string> errors)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string path = GetStatePath();
|
||||||
|
string directory = Path.GetDirectoryName(path)!;
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string temporaryPath = path + ".tmp";
|
||||||
|
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(state, JsonOptions.Default));
|
||||||
|
File.Move(temporaryPath, path, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware file churn state could not be saved: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetStatePath()
|
||||||
|
{
|
||||||
|
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OCSentinel", "state", StateFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FileChurnState
|
||||||
|
{
|
||||||
|
public long LastSecurityRecordId { get; init; }
|
||||||
|
|
||||||
|
public List<FileAuditActivity> Activities { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FileAuditActivity
|
||||||
|
{
|
||||||
|
public DateTimeOffset Timestamp { get; init; }
|
||||||
|
|
||||||
|
public long RecordId { get; init; }
|
||||||
|
|
||||||
|
public string Process { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsDelete { get; init; }
|
||||||
|
|
||||||
|
public bool IsWrite { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ internal sealed record ScanOptions
|
|||||||
{
|
{
|
||||||
public const string Usage = """
|
public const string Usage = """
|
||||||
Usage:
|
Usage:
|
||||||
OCSentinelCli [--output <path>] [--lookback-days <n>] [--top <n>] [--config <path>] [--vulnerability-csv <path>] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help]
|
OCSentinelCli [--output <path>] [--lookback-days <n>] [--top <n>] [--config <path>] [--client-config <path>] [--vulnerability-csv <path>] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--output <path> Write the JSON report to the given file.
|
--output <path> Write the JSON report to the given file.
|
||||||
--lookback-days <n> Only include events newer than now minus n days. Default: 30
|
--lookback-days <n> Only include events newer than now minus n days. Default: 30
|
||||||
--top <n> Number of aggregated source IPs to show. Default: 10
|
--top <n> Number of aggregated source IPs to show. Default: 10
|
||||||
--config <path> Load thresholds, path overrides, and exclusions from JSON.
|
--config <path> Load thresholds, path overrides, and exclusions from JSON.
|
||||||
|
--client-config <path> Load persisted NinjaOne identity and upload settings from JSON.
|
||||||
--vulnerability-csv <path>
|
--vulnerability-csv <path>
|
||||||
Correlate local attack results with exported CVE data for this host.
|
Correlate local attack results with exported CVE data for this host.
|
||||||
--json-only Print only JSON to stdout.
|
--json-only Print only JSON to stdout.
|
||||||
@@ -36,6 +37,8 @@ Options:
|
|||||||
|
|
||||||
public string? ConfigPath { get; init; }
|
public string? ConfigPath { get; init; }
|
||||||
|
|
||||||
|
public string? ClientConfigPath { get; init; }
|
||||||
|
|
||||||
public string? VulnerabilityCsvPath { get; init; }
|
public string? VulnerabilityCsvPath { get; init; }
|
||||||
|
|
||||||
public bool ShowHelp { get; init; }
|
public bool ShowHelp { get; init; }
|
||||||
@@ -72,6 +75,9 @@ Options:
|
|||||||
case "--config":
|
case "--config":
|
||||||
options = options with { ConfigPath = ReadValue(args, ref i, arg) };
|
options = options with { ConfigPath = ReadValue(args, ref i, arg) };
|
||||||
break;
|
break;
|
||||||
|
case "--client-config":
|
||||||
|
options = options with { ClientConfigPath = ReadValue(args, ref i, arg) };
|
||||||
|
break;
|
||||||
case "--vulnerability-csv":
|
case "--vulnerability-csv":
|
||||||
options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) };
|
options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) };
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ internal sealed class N8nUploadClient
|
|||||||
request.Headers.Add("X-ATN-Payload-SHA256", payloadHash);
|
request.Headers.Add("X-ATN-Payload-SHA256", payloadHash);
|
||||||
request.Headers.Add("X-ATN-Signature", signature);
|
request.Headers.Add("X-ATN-Signature", signature);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
using HttpResponseMessage response = httpClient.Send(request);
|
using HttpResponseMessage response = httpClient.Send(request);
|
||||||
string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
@@ -50,6 +52,18 @@ internal sealed class N8nUploadClient
|
|||||||
PayloadSha256 = payloadHash
|
PayloadSha256 = payloadHash
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new UploadResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
StatusCode = 0,
|
||||||
|
Message = exception.Message,
|
||||||
|
Nonce = nonce,
|
||||||
|
PayloadSha256 = payloadHash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string ComputeSha256(string value)
|
private static string ComputeSha256(string value)
|
||||||
{
|
{
|
||||||
|
|||||||
106
src/OCSentinelCli/Transport/UploadQueue.cs
Normal file
106
src/OCSentinelCli/Transport/UploadQueue.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using OCSentinelCli.Models;
|
||||||
|
|
||||||
|
namespace OCSentinelCli.Transport;
|
||||||
|
|
||||||
|
internal sealed record UploadHealth
|
||||||
|
{
|
||||||
|
public DateTimeOffset? LastUploadAttemptUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastSuccessfulUploadUtc { get; init; }
|
||||||
|
|
||||||
|
public string LastUploadStatus { get; init; } = "not-attempted";
|
||||||
|
|
||||||
|
public string LastUploadError { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int QueuedReportCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UploadQueue
|
||||||
|
{
|
||||||
|
private readonly string queueDirectory;
|
||||||
|
private readonly string healthPath;
|
||||||
|
private readonly int maxReports;
|
||||||
|
|
||||||
|
public UploadQueue(int maxReports)
|
||||||
|
{
|
||||||
|
string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OCSentinel");
|
||||||
|
queueDirectory = Path.Combine(root, "upload-queue");
|
||||||
|
healthPath = Path.Combine(root, "state", "upload-health.json");
|
||||||
|
this.maxReports = Math.Clamp(maxReports, 10, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UploadHealth LoadHealth()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(healthPath))
|
||||||
|
{
|
||||||
|
return new UploadHealth { QueuedReportCount = GetQueueDepth() };
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadHealth? health = JsonSerializer.Deserialize<UploadHealth>(File.ReadAllText(healthPath), JsonOptions.Default);
|
||||||
|
return (health ?? new UploadHealth()) with { QueuedReportCount = GetQueueDepth() };
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return new UploadHealth { QueuedReportCount = GetQueueDepth() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveHealth(UploadHealth health)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(healthPath)!);
|
||||||
|
WriteAtomically(healthPath, JsonSerializer.Serialize(health, JsonOptions.Default));
|
||||||
|
}
|
||||||
|
|
||||||
|
public UploadResult? Drain(N8nUploadClient client, string webhookUrl, string machineName, string clientVersion, string secret, int timeoutSeconds)
|
||||||
|
{
|
||||||
|
foreach (string path in GetQueuedPaths())
|
||||||
|
{
|
||||||
|
string payload = File.ReadAllText(path);
|
||||||
|
UploadResult result = client.UploadJson(webhookUrl, machineName, clientVersion, payload, secret, timeoutSeconds);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Enqueue(string payloadJson)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(queueDirectory);
|
||||||
|
string fileName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.json";
|
||||||
|
WriteAtomically(Path.Combine(queueDirectory, fileName), payloadJson);
|
||||||
|
|
||||||
|
foreach (string stalePath in GetQueuedPaths().Take(Math.Max(0, GetQueueDepth() - maxReports)))
|
||||||
|
{
|
||||||
|
File.Delete(stalePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetQueueDepth();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetQueueDepth()
|
||||||
|
{
|
||||||
|
return Directory.Exists(queueDirectory) ? Directory.EnumerateFiles(queueDirectory, "*.json").Count() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetQueuedPaths()
|
||||||
|
{
|
||||||
|
return Directory.Exists(queueDirectory)
|
||||||
|
? Directory.EnumerateFiles(queueDirectory, "*.json").OrderBy(static path => path, StringComparer.Ordinal)
|
||||||
|
: Enumerable.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteAtomically(string path, string content)
|
||||||
|
{
|
||||||
|
string temporaryPath = path + ".tmp";
|
||||||
|
File.WriteAllText(temporaryPath, content);
|
||||||
|
File.Move(temporaryPath, path, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs
Normal file
42
tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using OCSentinelCli;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OCSentinelCli.Tests;
|
||||||
|
|
||||||
|
public sealed class ExchangeIisLogParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParsesFailedOwaLoginWithActualIisPort()
|
||||||
|
{
|
||||||
|
string[] lines =
|
||||||
|
[
|
||||||
|
"#Fields: date time s-ip cs-method cs-uri-stem cs-username c-ip s-port sc-status",
|
||||||
|
"2026-08-02 04:15:00 10.0.0.10 POST /owa/auth.owa - 203.0.113.20 443 401"
|
||||||
|
];
|
||||||
|
|
||||||
|
AttackEvent attack = Assert.Single(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
|
||||||
|
|
||||||
|
Assert.Equal("Exchange OWA login", attack.Target);
|
||||||
|
Assert.Equal("OWA", attack.Service);
|
||||||
|
Assert.Equal(443, attack.DestinationPort);
|
||||||
|
Assert.Equal("/owa/auth.owa", attack.Endpoint);
|
||||||
|
Assert.Equal("203.0.113.20", attack.SourceIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesMapiAndIgnoresSuccessfulRequests()
|
||||||
|
{
|
||||||
|
string[] lines =
|
||||||
|
[
|
||||||
|
"#Fields: date time cs-uri-stem cs-username c-ip s-port sc-status",
|
||||||
|
"2026-08-02 04:15:00 /mapi/emsmdb/ user@example.test 198.51.100.8 444 403",
|
||||||
|
"2026-08-02 04:16:00 /ecp/ user@example.test 198.51.100.9 443 200"
|
||||||
|
];
|
||||||
|
|
||||||
|
AttackEvent attack = Assert.Single(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
|
||||||
|
|
||||||
|
Assert.Equal("MAPI/HTTP", attack.Service);
|
||||||
|
Assert.Equal(444, attack.DestinationPort);
|
||||||
|
Assert.Equal("user@example.test", attack.Username);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj
Normal file
19
tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\OCSentinelCli\OCSentinelCli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
87
tests/OCSentinelCli.Tests/RansomwareBetaTests.cs
Normal file
87
tests/OCSentinelCli.Tests/RansomwareBetaTests.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OCSentinelCli.Tests;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class RansomwareBetaTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RansomwareBetaIsEnabledByDefault()
|
||||||
|
{
|
||||||
|
Assert.True(new ScannerConfiguration().RansomwareBetaEnabled);
|
||||||
|
Assert.False(new ScannerConfiguration().RansomwareBetaAlertingEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DisabledRansomwareBetaDoesNotReportSensorCoverage()
|
||||||
|
{
|
||||||
|
var summary = RansomwareBetaDetector.Scan(new ScannerConfiguration { RansomwareBetaEnabled = false }, []);
|
||||||
|
|
||||||
|
Assert.False(summary.Enabled);
|
||||||
|
Assert.Empty(summary.Sensors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SensorCoverageIsIncludedInTheJsonReport()
|
||||||
|
{
|
||||||
|
var summary = new RansomwareBetaSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Sensors = [new RansomwareSensorStatus { Name = "security-process-4688", Enabled = true, Available = true, State = "available", EventCount = 4 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
string json = JsonSerializer.Serialize(summary, JsonOptions.Default);
|
||||||
|
|
||||||
|
Assert.Contains("\"Sensors\"", json);
|
||||||
|
Assert.Contains("security-process-4688", json);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FileChurnBelowBothThresholdsDoesNotCreateSignal()
|
||||||
|
{
|
||||||
|
var summary = new RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
DataAvailable = true,
|
||||||
|
DeleteOperationCount = 49,
|
||||||
|
WriteOperationCount = 500
|
||||||
|
};
|
||||||
|
|
||||||
|
RansomwareSignal? signal = RansomwareFileChurnDetector.CreateSignal(summary, new ScannerConfiguration());
|
||||||
|
|
||||||
|
Assert.Null(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CriticalFileChurnCreatesOnlyMediumConfidenceSignal()
|
||||||
|
{
|
||||||
|
var summary = new RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
DataAvailable = true,
|
||||||
|
WindowMinutes = 15,
|
||||||
|
DeleteOperationCount = 200,
|
||||||
|
WriteOperationCount = 1000,
|
||||||
|
DistinctProcessCount = 1,
|
||||||
|
TopProcesses = [new RansomwareFileChurnProcess { Process = "encryptor.exe", DeleteOperationCount = 200, WriteOperationCount = 1000 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
RansomwareSignal? signal = RansomwareFileChurnDetector.CreateSignal(summary, new ScannerConfiguration());
|
||||||
|
|
||||||
|
Assert.NotNull(signal);
|
||||||
|
Assert.Equal("file-churn-critical", signal.Category);
|
||||||
|
Assert.Equal("medium", signal.Confidence);
|
||||||
|
Assert.Equal("encryptor.exe", signal.Process);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PassiveBetaCannotElevateNinjaAlertState()
|
||||||
|
{
|
||||||
|
var summary = new RansomwareBetaSummary { Enabled = true, State = "critical" };
|
||||||
|
|
||||||
|
Assert.False(RansomwareAlertPolicy.CanElevate(summary, alertingEnabled: false));
|
||||||
|
Assert.True(RansomwareAlertPolicy.CanElevate(summary, alertingEnabled: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user