diff --git a/.gitea/workflows/client-build.yml b/.gitea/workflows/client-build.yml index 5c64b42..aec1664 100644 --- a/.gitea/workflows/client-build.yml +++ b/.gitea/workflows/client-build.yml @@ -93,7 +93,8 @@ jobs: exit 0 } $artifactUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/$tag/OCSentinelClient-win-x64.zip" - ./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl + $channel = if ($tag -match '-beta(?:\.|$)') { 'beta' } else { 'stable' } + ./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl -Channel $channel - name: Publish Gitea release assets shell: pwsh diff --git a/.gitignore b/.gitignore index 3d1f906..15e2015 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ bin/ obj/ artifacts/ +__pycache__/ reports/ _extracted/ _tools/ diff --git a/README.md b/README.md index 9a85eff..4f49666 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ OfficeCom Sentinel is the hardened endpoint client for Windows event correlation - release checklist: `docs/release-checklist.md` - product roadmap: `docs/roadmap.md` - code quality standard: `docs/code-quality.md` +- beta deployment: `docs/beta-deployment.md` - internal server-side target example: `infra/postgres-target.example.json` ## Build diff --git a/config/ocsentinel-settings.example.json b/config/ocsentinel-settings.example.json index ca244db..4e5d786 100644 --- a/config/ocsentinel-settings.example.json +++ b/config/ocsentinel-settings.example.json @@ -10,6 +10,11 @@ "criticalSprayAccountCount": 10, "correlationWarningCveThreshold": 1, "correlationCriticalCveThreshold": 1, + "ransomwareBetaEnabled": false, + "ransomwareLookbackMinutes": 15, + "ransomwareWarningSignalCount": 2, + "ransomwareCriticalSignalCount": 3, + "ransomwareExcludedProcesses": [], "ftpRoots": [ "C:\\inetpub\\logs\\LogFiles", "D:\\inetpub\\logs\\LogFiles" diff --git a/docs/beta-deployment.md b/docs/beta-deployment.md new file mode 100644 index 0000000..a6821e5 --- /dev/null +++ b/docs/beta-deployment.md @@ -0,0 +1,46 @@ +# OCSentinel Beta Deployment + +## Ziel + +Beta-Pakete werden ausschliesslich an benannte Pilotgeraete verteilt. Der +Stable-Kanal und die vorhandene Stable-NinjaOne-Aufgabe bleiben unveraendert. + +## Beta-Aufgabe in NinjaOne + +1. Die bestehende Aufgabe `OCSentinel - Installieren und aktualisieren` + duplizieren und eindeutig als `OCSentinel - Beta Pilot` benennen. +2. Das Script `scripts/bootstrap-ocsentinel-ninja.ps1` verwenden. +3. Die Script-Variable `releasechannel` als Text mit dem Wert `beta` anlegen. +4. Webhook und Secret bleiben identisch zum Stable-Task. +5. Die Aufgabe nur einer Pilot-Richtlinie oder explizit ausgewaehlten Geraeten + zuweisen. + +Die Stable-Aufgabe verwendet keinen Kanalwert oder den Wert `stable`. + +## Passive Ransomware-Beta + +Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem +Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json` +der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die erste Auswertung +liest nur die letzten 15 Minuten der vorhandenen Prozess- und PowerShell- +Ereignisse; sie installiert weder Sysmon noch Windows-Dateiauditing. + +Ein Hinweis wird nur im JSON-Report und Dashboard sichtbar. Warnung und +kritisch werden erst nach dem kontrollierten Alarmierungs-Pilot an NinjaOne +weitergegeben. + +## Rueckfall + +1. Die Beta-Richtlinie entfernen oder die Beta-Aufgabe nicht mehr ausfuehren. +2. Auf den Pilotgeraeten die vorhandene Stable-Aufgabe ausfuehren. +3. Die Ransomware-Beta in der lokalen Konfiguration auf `false` setzen, falls + sie aktiviert wurde. + +Der Client prueft weiterhin Paket-Hash und Authenticode-Signaturstatus, bevor +eine Beta installiert wird. + +## Pilotprotokoll + +Vor dem Start festhalten: Organisation, Geraete, Aktivierungszeit, aktivierte +Feature-Schalter, verantwortliche Person und geplantes Enddatum. Nach dem +Pilot Laufzeit, Upload-Volumen, Hinweise und Fehlalarme bewerten. diff --git a/infra/debug-dashboard/app.py b/infra/debug-dashboard/app.py index 7d9545a..704caa0 100644 --- a/infra/debug-dashboard/app.py +++ b/infra/debug-dashboard/app.py @@ -113,6 +113,44 @@ def overview(): ) alerts = cursor.fetchall() + cursor.execute( + """ + WITH latest AS ( + SELECT DISTINCT ON (d.machine_name_key, date_trunc('day', r.received_at)) + date_trunc('day', r.received_at)::date AS day, + r.alert_state, + r.total_events + FROM ocsentinel.scan_report AS r + JOIN ocsentinel.device AS d ON d.id = r.device_id + WHERE r.received_at >= now() - interval '14 days' + ORDER BY d.machine_name_key, date_trunc('day', r.received_at), r.received_at DESC + ) + SELECT day, + count(*) FILTER (WHERE alert_state = 'warning') AS warning_count, + count(*) FILTER (WHERE alert_state = 'critical') AS critical_count, + coalesce(sum(total_events), 0) AS event_count + FROM latest + GROUP BY day + ORDER BY day + """ + ) + trend = cursor.fetchall() + + cursor.execute( + """ + SELECT coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') AS organization_id, + coalesce(nullif(payload #>> '{NinjaOne,OrganizationName}', ''), 'Organisation unbekannt') AS organization_name, + count(*) AS device_count, + count(*) FILTER (WHERE alert_state = 'warning') AS warning_count, + count(*) FILTER (WHERE alert_state = 'critical') AS critical_count, + max(received_at) AS last_received_at + FROM ocsentinel.current_device_status + GROUP BY 1, 2 + ORDER BY critical_count DESC, warning_count DESC, organization_name + """ + ) + organizations = cursor.fetchall() + report_rows = [] for row in reports: event = event_metadata(row[8]) @@ -142,6 +180,28 @@ def overview(): } ) + trend_rows = [ + { + "day": row[0], + "warning_count": row[1], + "critical_count": row[2], + "event_count": row[3], + } + for row in trend + ] + trend_max = max([row["event_count"] for row in trend_rows] or [1]) + organization_rows = [ + { + "id": row[0], + "name": row[1], + "device_count": row[2], + "warning_count": row[3], + "critical_count": row[4], + "last_received_at": row[5], + } + for row in organizations + ] + return render_template( "overview.html", summary=summary, @@ -149,6 +209,39 @@ def overview(): reports=report_rows, alerts=alert_rows, current_alert_count=sum(alert["event"]["is_current"] for alert in alert_rows), + trend=trend_rows, + trend_max=trend_max, + organizations=organization_rows, + ) + + +@app.get("/organizations/") +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), ) @@ -197,6 +290,7 @@ def device(machine_name): event=event_metadata(payload), payload=payload, security_events=security_events, + ransomware_beta=payload.get("RansomwareBeta") or payload.get("ransomwareBeta") or {}, payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False), ) diff --git a/infra/debug-dashboard/static/app.css b/infra/debug-dashboard/static/app.css index f1a71ae..6266941 100644 --- a/infra/debug-dashboard/static/app.css +++ b/infra/debug-dashboard/static/app.css @@ -1,7 +1,7 @@ :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:flex-end; border-bottom:1px solid #21445f; background:#102a43; box-shadow:0 5px 24px rgba(16,42,67,.2); }.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; } +.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); } @@ -27,3 +27,4 @@ table { width:100%; border-collapse:collapse; font-family:'Roboto',sans-serif; f .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); } diff --git a/infra/debug-dashboard/templates/base.html b/infra/debug-dashboard/templates/base.html index 3897bc7..12b40d9 100644 --- a/infra/debug-dashboard/templates/base.html +++ b/infra/debug-dashboard/templates/base.html @@ -3,7 +3,7 @@ - {% block title %}OC Sentinel{% endblock %} + {% block title %}OfficeCom Sentinel{% endblock %} @@ -11,7 +11,8 @@
- + OCSentinel +
{% block content %}{% endblock %}
diff --git a/infra/debug-dashboard/templates/device.html b/infra/debug-dashboard/templates/device.html index 7bd8961..417fe32 100644 --- a/infra/debug-dashboard/templates/device.html +++ b/infra/debug-dashboard/templates/device.html @@ -3,6 +3,7 @@ {% block content %}

{{ report[0] }}

{{ report[6] }}{% if report[8] %}{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}{% endif %}
Ereignisse{{ report[8] }}
Quell-IPs{{ report[9] }}
CVEs{{ report[10] }}
Kritische CVEs{{ report[11] }}
+{% if ransomware_beta.enabled %}
Passive Beta

Ransomware-Frueherkennung {{ ransomware_beta.state }}

{{ ransomware_beta.reason }}

{% for signal in ransomware_beta.signals %}{% else %}{% endfor %}
ZeitpunktSignalProzessQuelleBewertung
{{ signal.timestamp }}{{ signal.category }}{{ signal.process }}{{ signal.source }}{{ signal.confidence }}
Keine Signale im aktuellen Beta-Zeitfenster.
{% endif %}
Schnelluebersicht

Erkannte Sicherheitsereignisse

Fehlgeschlagene Anmeldungen und weitere Vorfaelle aus dem letzten Scan, nach Konto und Quell-IP zusammengefasst.

{% for entry in security_events %}{% else %}{% endfor %}
VorfallKontoQuell-IPLetzter ZeitpunktAnzahl
{{ entry.type }}{{ entry.account }}{{ entry.source_ip }}{{ entry.latest }}{{ entry.count }}
Keine sicherheitsrelevanten Ereignisse im letzten Scan.
Technische Daten

Roh-Export

Vollstaendige, unveraenderte Nutzlast des zuletzt eingegangenen Scans.

JSON-Rohdaten
{{ payload_pretty }}
{% endblock %} diff --git a/infra/debug-dashboard/templates/organization.html b/infra/debug-dashboard/templates/organization.html new file mode 100644 index 0000000..7025b55 --- /dev/null +++ b/infra/debug-dashboard/templates/organization.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block title %}{{ organization_name }} - OfficeCom Sentinel{% endblock %} +{% block content %} +
Organisation {{ organization_id }}

{{ organization_name }}

{{ critical_count }} kritisch, {{ warning_count }} Warnungen. Waehle ein Geraet fuer die technische Analyse.

+
Geraete{{ devices|length }}
Kritisch{{ critical_count }}
Warnungen{{ warning_count }}
Letzte Meldung{{ devices[0][1] or '-' }}
+
{% for device in devices %}{% endfor %}
GeraetStatusEreignisseQuell-IPsKritische CVEsEmpfangen
{{ device[0] }}{{ device[2] }}{{ device[3] }}{{ device[4] }}{{ device[5] }}{{ device[1] or '-' }}
+{% endblock %} diff --git a/infra/debug-dashboard/templates/overview.html b/infra/debug-dashboard/templates/overview.html index e8a9c28..e3eb04a 100644 --- a/infra/debug-dashboard/templates/overview.html +++ b/infra/debug-dashboard/templates/overview.html @@ -1,10 +1,21 @@ {% extends "base.html" %} {% block content %}
-
{% if summary[2] %}Kritische Ereignisse{% elif summary[1] %}Hinweise vorhanden{% else %}Keine kritischen Auffaelligkeiten{% endif %}
+
OfficeCom Sentinel Uebersicht{% if summary[2] %}Kritische Ereignisse erfordern Aufmerksamkeit{% elif summary[1] %}Hinweise im Bestand pruefen{% else %}Sicherheitslage stabil{% endif %}
{% if summary[2] %}KRITISCH{% elif summary[1] %}PRUEFEN{% else %}STABIL{% endif %}
+
+
Letzte 14 Tage

Signalverlauf Verdichtete Scan-Ergebnisse pro Tag

+
{% for day in trend %}
{{ day.event_count }}{{ day.day.strftime('%d.%m.') }}
{% else %}

Noch keine Trenddaten vorhanden.

{% endfor %}
+

kritisch Warnungen Ereignisvolumen

+
+ +
+
Mandanten

Organisationen Drill-down bis zum einzelnen Geraet

+ +
+
Geraete{{ summary[0] }}
Warnungen{{ summary[1] }}
diff --git a/installer/update-ocsentinel.ps1 b/installer/update-ocsentinel.ps1 index 84db752..39dd338 100644 --- a/installer/update-ocsentinel.ps1 +++ b/installer/update-ocsentinel.ps1 @@ -96,14 +96,23 @@ function Compare-Version { [Parameter(Mandatory)][string]$Right ) - try { - $leftVersion = [System.Version]$Left - $rightVersion = [System.Version]$Right - return $leftVersion.CompareTo($rightVersion) - } - catch { - return [string]::Compare($Left, $Right, $true) + $pattern = '^(?\d+(?:\.\d+){0,3})(?:-(?.+))?$' + $leftMatch = [regex]::Match($Left, $pattern) + $rightMatch = [regex]::Match($Right, $pattern) + if ($leftMatch.Success -and $rightMatch.Success) { + $numericComparison = ([System.Version]$leftMatch.Groups['version'].Value).CompareTo([System.Version]$rightMatch.Groups['version'].Value) + if ($numericComparison -ne 0) { + return $numericComparison + } + + $leftPrerelease = $leftMatch.Groups['prerelease'].Value + $rightPrerelease = $rightMatch.Groups['prerelease'].Value + if ([string]::IsNullOrWhiteSpace($leftPrerelease) -and -not [string]::IsNullOrWhiteSpace($rightPrerelease)) { return 1 } + if (-not [string]::IsNullOrWhiteSpace($leftPrerelease) -and [string]::IsNullOrWhiteSpace($rightPrerelease)) { return -1 } + return [string]::Compare($leftPrerelease, $rightPrerelease, $true) } + + return [string]::Compare($Left, $Right, $true) } function Get-Sha256Hex { diff --git a/release/beta/README.md b/release/beta/README.md new file mode 100644 index 0000000..527ae77 --- /dev/null +++ b/release/beta/README.md @@ -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`. diff --git a/release/beta/version.json b/release/beta/version.json new file mode 100644 index 0000000..dfb2541 --- /dev/null +++ b/release/beta/version.json @@ -0,0 +1,8 @@ +{ + "channel": "beta", + "version": "1.5.0-beta.1", + "publishedAtUtc": "2026-07-29T23:04:51.9334773Z", + "artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.1/OCSentinelClient-win-x64.zip", + "sha256": "2bd6db7261f7ca9b47741968b922aea5969251539530e58441e882f53686ae6b", + "minUpdaterVersion": "1.0.0" +} diff --git a/scripts/bootstrap-ocsentinel-ninja.ps1 b/scripts/bootstrap-ocsentinel-ninja.ps1 index bec84e8..1327430 100644 --- a/scripts/bootstrap-ocsentinel-ninja.ps1 +++ b/scripts/bootstrap-ocsentinel-ninja.ps1 @@ -1,6 +1,8 @@ [CmdletBinding()] param( - [string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json", + [string]$ManifestUrl = "", + [ValidateSet("stable", "beta")] + [string]$ReleaseChannel = "stable", [string]$WebhookUrl = "", [string]$SecretValue = "", [switch]$RunInitialStatusScan @@ -112,6 +114,20 @@ function Get-OCSentinelArtifact { Initialize-OCSentinelTls +if ($ReleaseChannel -eq "stable" -and -not [string]::IsNullOrWhiteSpace($env:ReleaseChannel)) { + $requestedChannel = $env:ReleaseChannel.Trim().ToLowerInvariant() + if ($requestedChannel -notin @("stable", "beta")) { + throw "ReleaseChannel must be stable or beta." + } + $ReleaseChannel = $requestedChannel +} + +if ([string]::IsNullOrWhiteSpace($ManifestUrl)) { + $ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/$ReleaseChannel/version.json" +} + +Write-Host "OCSentinel release channel: $ReleaseChannel" + $installRoot = Join-Path $env:ProgramFiles "OCSentinel" $updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1" $monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1" diff --git a/src/OCSentinelCli/AttackScanner.cs b/src/OCSentinelCli/AttackScanner.cs index 2d4abda..5c0edf3 100644 --- a/src/OCSentinelCli/AttackScanner.cs +++ b/src/OCSentinelCli/AttackScanner.cs @@ -34,6 +34,7 @@ internal sealed class AttackScanner ScanExchangeLogons(attacks, errors, since); ScanIisFtpLogs(attacks, errors, since, configuration); ScanFileZillaLogs(attacks, errors, since, configuration); + RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors); if (configuration.ExcludedIps.Count > 0) { @@ -53,7 +54,7 @@ internal sealed class AttackScanner .ToList(); int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count(); - AlertAssessment baseAssessment = AssessAttackActivity(attacks, uniqueIpCount, configuration); + AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta); string baseAlertState = baseAssessment.State; string baseAlertReason = baseAssessment.Reason; VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath) @@ -79,6 +80,7 @@ internal sealed class AttackScanner BaseAlertState = baseAlertState, BaseAlertReason = baseAlertReason, VulnerabilityCorrelation = vulnerabilityCorrelation, + RansomwareBeta = ransomwareBeta, Runtime = new ScanRuntimeMetadata { StartedAtUtc = startedAtUtc, @@ -517,6 +519,27 @@ internal sealed class AttackScanner 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) + { + if (ransomwareBeta.State is not ("warning" or "critical")) + { + 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 events, TimeSpan window) { int start = 0; diff --git a/src/OCSentinelCli/Commands/ScanCommand.cs b/src/OCSentinelCli/Commands/ScanCommand.cs index 2047b89..7351e29 100644 --- a/src/OCSentinelCli/Commands/ScanCommand.cs +++ b/src/OCSentinelCli/Commands/ScanCommand.cs @@ -73,6 +73,8 @@ internal static class ScanCommand Console.WriteLine($"Status: {result.AlertState}"); Console.WriteLine($"Reason: {result.AlertReason}"); Console.WriteLine($"Base status: {result.BaseAlertState}"); + Console.WriteLine($"Ransomware beta: {result.RansomwareBeta.State}"); + Console.WriteLine($"Ransomware beta signals: {result.RansomwareBeta.Signals.Count}"); Console.WriteLine($"Scan errors: {result.Errors.Count}"); Console.WriteLine(); diff --git a/src/OCSentinelCli/Configuration.cs b/src/OCSentinelCli/Configuration.cs index 829d3a7..576d980 100644 --- a/src/OCSentinelCli/Configuration.cs +++ b/src/OCSentinelCli/Configuration.cs @@ -26,6 +26,16 @@ internal sealed record ScannerConfiguration public int CorrelationCriticalCveThreshold { get; init; } = 1; + public bool RansomwareBetaEnabled { get; init; } + + public int RansomwareLookbackMinutes { get; init; } = 15; + + public int RansomwareWarningSignalCount { get; init; } = 2; + + public int RansomwareCriticalSignalCount { get; init; } = 3; + + public List RansomwareExcludedProcesses { get; init; } = []; + public List FtpRoots { get; init; } = []; public List FileZillaRoots { get; init; } = []; diff --git a/src/OCSentinelCli/Models.cs b/src/OCSentinelCli/Models.cs index b3500c3..e9ba721 100644 --- a/src/OCSentinelCli/Models.cs +++ b/src/OCSentinelCli/Models.cs @@ -104,6 +104,8 @@ internal sealed record ScanResult public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new(); + public RansomwareBetaSummary RansomwareBeta { get; init; } = new(); + public ScanRuntimeMetadata Runtime { get; init; } = new(); public List Events { get; init; } = []; @@ -113,6 +115,36 @@ internal sealed record ScanResult public List Errors { get; init; } = []; } +internal sealed record RansomwareBetaSummary +{ + public bool Enabled { get; init; } + + public string State { get; init; } = "disabled"; + + public string Reason { get; init; } = "Ransomware beta is disabled."; + + public int LookbackMinutes { get; init; } + + public List Signals { get; init; } = []; +} + +internal sealed record RansomwareSignal +{ + public DateTimeOffset Timestamp { get; init; } + + public string Category { get; init; } = string.Empty; + + public string Confidence { get; init; } = string.Empty; + + public string Process { get; init; } = string.Empty; + + public string Source { get; init; } = string.Empty; + + public long EventId { get; init; } + + public string Evidence { get; init; } = string.Empty; +} + internal sealed record NinjaOneContext { public string OrganizationId { get; init; } = string.Empty; diff --git a/src/OCSentinelCli/OCSentinelCli.csproj b/src/OCSentinelCli/OCSentinelCli.csproj index 158c621..42749bc 100644 --- a/src/OCSentinelCli/OCSentinelCli.csproj +++ b/src/OCSentinelCli/OCSentinelCli.csproj @@ -9,10 +9,10 @@ OCSentinelCli OfficeCom Sentinel OfficeCom - 1.4.0 - 1.4.0.0 - 1.4.0.0 - 1.4.0 + 1.5.0-beta.1 + 1.5.0.0 + 1.5.0.0 + 1.5.0-beta.1 diff --git a/src/OCSentinelCli/RansomwareBetaDetector.cs b/src/OCSentinelCli/RansomwareBetaDetector.cs new file mode 100644 index 0000000..ef84bf6 --- /dev/null +++ b/src/OCSentinelCli/RansomwareBetaDetector.cs @@ -0,0 +1,172 @@ +using System.Diagnostics.Eventing.Reader; +using System.Runtime.Versioning; + +namespace OCSentinelCli; + +[SupportedOSPlatform("windows")] +internal static class RansomwareBetaDetector +{ + public static RansomwareBetaSummary Scan(ScannerConfiguration configuration, List 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(); + + ScanSecurityProcesses(signals, errors, since, configuration); + ScanPowerShellScriptBlocks(signals, errors, since, configuration); + ScanSysmonProcesses(signals, errors, since, configuration); + + List distinctSignals = signals + .OrderBy(signal => signal.Timestamp) + .GroupBy(signal => $"{signal.Category}\u001f{signal.Process}", StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .Take(20) + .ToList(); + int strongSignals = distinctSignals.Count(signal => signal.Confidence == "high"); + + string state = distinctSignals.Count >= Math.Max(2, configuration.RansomwareCriticalSignalCount) + ? "critical" + : strongSignals > 0 || distinctSignals.Count >= Math.Max(2, configuration.RansomwareWarningSignalCount) + ? "warning" + : distinctSignals.Count > 0 ? "hint" : "ok"; + string reason = state switch + { + "critical" => $"Ransomware beta detected {distinctSignals.Count} independent high-risk signals within {lookbackMinutes} minutes.", + "warning" => $"Ransomware beta detected {strongSignals} high-confidence and {distinctSignals.Count - strongSignals} low-confidence signals within {lookbackMinutes} minutes.", + "hint" => $"Ransomware beta observed an isolated low-confidence signal within {lookbackMinutes} minutes.", + _ => $"Ransomware beta found no suspicious process activity in the last {lookbackMinutes} minutes." + }; + + return new RansomwareBetaSummary + { + Enabled = true, + State = state, + Reason = reason, + LookbackMinutes = lookbackMinutes, + Signals = distinctSignals + }; + } + + private static void ScanSecurityProcesses(List signals, List errors, DateTimeOffset since, ScannerConfiguration configuration) + { + TryScan("Security", 4688, since, errors, record => AddSignal(signals, record, ReadProperty(record, 5), ReadProperty(record, 8), "Security", configuration)); + } + + private static void ScanPowerShellScriptBlocks(List signals, List errors, DateTimeOffset since, ScannerConfiguration configuration) + { + TryScan("Microsoft-Windows-PowerShell/Operational", 4104, since, errors, record => AddSignal(signals, record, "powershell", FormatDescription(record), "PowerShell", configuration)); + } + + private static void ScanSysmonProcesses(List signals, List errors, DateTimeOffset since, ScannerConfiguration configuration) + { + TryScan("Microsoft-Windows-Sysmon/Operational", 1, since, errors, record => AddSignal(signals, record, "sysmon-process", FormatDescription(record), "Sysmon", configuration)); + } + + private static void AddSignal(List signals, EventRecord record, string process, string commandLine, string source, ScannerConfiguration configuration) + { + if (!record.TimeCreated.HasValue || string.IsNullOrWhiteSpace(commandLine)) + { + return; + } + + string processName = Path.GetFileName(process.Trim()); + if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, processName, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + RansomwareSignal? signal = Classify(record.TimeCreated.Value, record.Id, processName, commandLine, source); + if (signal is not null) + { + signals.Add(signal); + } + } + + private static RansomwareSignal? Classify(DateTime timestamp, int eventId, string process, string commandLine, string source) + { + string value = commandLine.ToLowerInvariant(); + string evidence = commandLine.Length > 512 ? commandLine[..512] : commandLine; + if (ContainsAll(value, "vssadmin", "delete", "shadow") || ContainsAll(value, "wmic", "shadowcopy", "delete") || ContainsAll(value, "win32_shadowcopy", "delete")) + { + return CreateSignal(timestamp, eventId, "shadow-copy-deletion", "high", process, source, evidence); + } + + if (ContainsAll(value, "wbadmin", "delete") || ContainsAll(value, "catalog", "delete")) + { + return CreateSignal(timestamp, eventId, "backup-catalog-deletion", "high", process, source, evidence); + } + + if (ContainsAll(value, "bcdedit", "recoveryenabled", "no") || ContainsAll(value, "bcdedit", "bootstatuspolicy", "ignoreallfailures")) + { + return CreateSignal(timestamp, eventId, "recovery-disable", "high", process, source, evidence); + } + + if (ContainsAll(value, "wevtutil", " cl ")) + { + return CreateSignal(timestamp, eventId, "event-log-clearing", "high", process, source, evidence); + } + + return value.Contains("win32_shadowcopy", StringComparison.Ordinal) + ? CreateSignal(timestamp, eventId, "shadow-copy-access", "low", process, source, evidence) + : null; + } + + private static RansomwareSignal CreateSignal(DateTime timestamp, int eventId, string category, string confidence, string process, string source, string evidence) + { + return new RansomwareSignal + { + Timestamp = new DateTimeOffset(timestamp).ToLocalTime(), + Category = category, + Confidence = confidence, + Process = string.IsNullOrWhiteSpace(process) ? "[unknown]" : process, + Source = source, + EventId = eventId, + Evidence = evidence + }; + } + + private static bool ContainsAll(string value, params string[] needles) => needles.All(needle => value.Contains(needle, StringComparison.Ordinal)); + + private static void TryScan(string logName, int eventId, DateTimeOffset since, List errors, Action processRecord) + { + try + { + long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - since).TotalMilliseconds); + string query = $"*[System[(EventID={eventId}) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]"; + using var reader = new EventLogReader(new EventLogQuery(logName, PathType.LogName, query)); + for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent()) + { + using (record) + { + processRecord(record); + } + } + } + catch (EventLogNotFoundException) + { + } + catch (Exception exception) + { + errors.Add($"Ransomware beta query failed for {logName}: {exception.Message}"); + } + } + + private static string ReadProperty(EventRecord record, int index) => index >= 0 && index < record.Properties.Count ? record.Properties[index].Value?.ToString()?.Trim() ?? string.Empty : string.Empty; + + private static string FormatDescription(EventRecord record) + { + try + { + return record.FormatDescription() ?? string.Empty; + } + catch (EventLogException) + { + return string.Empty; + } + } +}