Implement reversible Sentinel beta foundation
This commit is contained in:
@@ -93,7 +93,8 @@ 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: Publish Gitea release assets
|
- name: Publish Gitea release assets
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
artifacts/
|
artifacts/
|
||||||
|
__pycache__/
|
||||||
reports/
|
reports/
|
||||||
_extracted/
|
_extracted/
|
||||||
_tools/
|
_tools/
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ OfficeCom Sentinel is the hardened endpoint client for Windows event correlation
|
|||||||
- release checklist: `docs/release-checklist.md`
|
- release checklist: `docs/release-checklist.md`
|
||||||
- product roadmap: `docs/roadmap.md`
|
- product roadmap: `docs/roadmap.md`
|
||||||
- code quality standard: `docs/code-quality.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
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
"criticalSprayAccountCount": 10,
|
"criticalSprayAccountCount": 10,
|
||||||
"correlationWarningCveThreshold": 1,
|
"correlationWarningCveThreshold": 1,
|
||||||
"correlationCriticalCveThreshold": 1,
|
"correlationCriticalCveThreshold": 1,
|
||||||
|
"ransomwareBetaEnabled": false,
|
||||||
|
"ransomwareLookbackMinutes": 15,
|
||||||
|
"ransomwareWarningSignalCount": 2,
|
||||||
|
"ransomwareCriticalSignalCount": 3,
|
||||||
|
"ransomwareExcludedProcesses": [],
|
||||||
"ftpRoots": [
|
"ftpRoots": [
|
||||||
"C:\\inetpub\\logs\\LogFiles",
|
"C:\\inetpub\\logs\\LogFiles",
|
||||||
"D:\\inetpub\\logs\\LogFiles"
|
"D:\\inetpub\\logs\\LogFiles"
|
||||||
|
|||||||
46
docs/beta-deployment.md
Normal file
46
docs/beta-deployment.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# OCSentinel Beta Deployment
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Beta-Pakete werden ausschliesslich an benannte Pilotgeraete verteilt. Der
|
||||||
|
Stable-Kanal und die vorhandene Stable-NinjaOne-Aufgabe bleiben unveraendert.
|
||||||
|
|
||||||
|
## Beta-Aufgabe in NinjaOne
|
||||||
|
|
||||||
|
1. Die bestehende Aufgabe `OCSentinel - Installieren und aktualisieren`
|
||||||
|
duplizieren und eindeutig als `OCSentinel - Beta Pilot` benennen.
|
||||||
|
2. Das Script `scripts/bootstrap-ocsentinel-ninja.ps1` verwenden.
|
||||||
|
3. Die Script-Variable `releasechannel` als Text mit dem Wert `beta` anlegen.
|
||||||
|
4. Webhook und Secret bleiben identisch zum Stable-Task.
|
||||||
|
5. Die Aufgabe nur einer Pilot-Richtlinie oder explizit ausgewaehlten Geraeten
|
||||||
|
zuweisen.
|
||||||
|
|
||||||
|
Die Stable-Aufgabe verwendet keinen Kanalwert oder den Wert `stable`.
|
||||||
|
|
||||||
|
## Passive Ransomware-Beta
|
||||||
|
|
||||||
|
Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem
|
||||||
|
Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json`
|
||||||
|
der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die erste Auswertung
|
||||||
|
liest nur die letzten 15 Minuten der vorhandenen Prozess- und PowerShell-
|
||||||
|
Ereignisse; sie installiert weder Sysmon noch Windows-Dateiauditing.
|
||||||
|
|
||||||
|
Ein Hinweis wird nur im JSON-Report und Dashboard sichtbar. Warnung und
|
||||||
|
kritisch werden erst nach dem kontrollierten Alarmierungs-Pilot an NinjaOne
|
||||||
|
weitergegeben.
|
||||||
|
|
||||||
|
## Rueckfall
|
||||||
|
|
||||||
|
1. Die Beta-Richtlinie entfernen oder die Beta-Aufgabe nicht mehr ausfuehren.
|
||||||
|
2. Auf den Pilotgeraeten die vorhandene Stable-Aufgabe ausfuehren.
|
||||||
|
3. Die Ransomware-Beta in der lokalen Konfiguration auf `false` setzen, falls
|
||||||
|
sie aktiviert wurde.
|
||||||
|
|
||||||
|
Der Client prueft weiterhin Paket-Hash und Authenticode-Signaturstatus, bevor
|
||||||
|
eine Beta installiert wird.
|
||||||
|
|
||||||
|
## Pilotprotokoll
|
||||||
|
|
||||||
|
Vor dem Start festhalten: Organisation, Geraete, Aktivierungszeit, aktivierte
|
||||||
|
Feature-Schalter, verantwortliche Person und geplantes Enddatum. Nach dem
|
||||||
|
Pilot Laufzeit, Upload-Volumen, Hinweise und Fehlalarme bewerten.
|
||||||
@@ -113,6 +113,44 @@ def overview():
|
|||||||
)
|
)
|
||||||
alerts = cursor.fetchall()
|
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 = []
|
report_rows = []
|
||||||
for row in reports:
|
for row in reports:
|
||||||
event = event_metadata(row[8])
|
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(
|
return render_template(
|
||||||
"overview.html",
|
"overview.html",
|
||||||
summary=summary,
|
summary=summary,
|
||||||
@@ -149,6 +209,39 @@ def overview():
|
|||||||
reports=report_rows,
|
reports=report_rows,
|
||||||
alerts=alert_rows,
|
alerts=alert_rows,
|
||||||
current_alert_count=sum(alert["event"]["is_current"] for alert in 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -197,6 +290,7 @@ def device(machine_name):
|
|||||||
event=event_metadata(payload),
|
event=event_metadata(payload),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
security_events=security_events,
|
security_events=security_events,
|
||||||
|
ransomware_beta=payload.get("RansomwareBeta") or payload.get("ransomwareBeta") or {},
|
||||||
payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False),
|
payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
:root { --ink:#132a3d; --muted:#5f7180; --paper:#eaf1f7; --panel:#ffffff; --line:#d5e1eb; --green:#14735b; --lime:#b8e36a; --amber:#a55a0a; --red:#a52b31; }
|
:root { --ink:#132a3d; --muted:#5f7180; --paper:#eaf1f7; --panel:#ffffff; --line:#d5e1eb; --green:#14735b; --lime:#b8e36a; --amber:#a55a0a; --red:#a52b31; }
|
||||||
* { box-sizing:border-box; }
|
* { 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%); }
|
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; }
|
.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); }
|
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); }
|
.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; }
|
.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; }
|
.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; } }
|
.compact-metrics { grid-template-columns:repeat(4,1fr); }.event-summary-panel { margin-top:8px; }.event-summary-panel .panel-heading h2,.raw-export-panel .panel-heading h2 { margin:7px 0 8px; }.event-summary-panel .panel-heading p,.raw-export-panel .panel-heading p { margin:0 0 20px; }.event-count { display:inline-grid; min-width:28px; min-height:28px; place-items:center; border-radius:14px; background:#fff0d7; color:var(--amber); font:700 12px 'Roboto',sans-serif; }.raw-export-panel { margin-top:8px; }.raw-json { margin-top:18px; border-top:1px solid var(--line); }.raw-json summary { padding:14px 0; color:#245a85; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.raw-json pre { margin-bottom:0; } @media (max-width:850px) { .compact-metrics { grid-template-columns:repeat(2,1fr); }.compact-metrics article:last-child { grid-column:span 2; } }
|
||||||
|
.trend-panel { overflow:hidden; }.trend-chart { display:grid; grid-template-columns:repeat(auto-fit,minmax(48px,1fr)); align-items:end; min-height:210px; gap:10px; padding:18px 4px 0; border-bottom:1px solid var(--line); }.trend-day { display:grid; grid-template-rows:154px auto auto; gap:5px; min-width:0; text-align:center; }.trend-bar { position:relative; align-self:end; height:max(7px,var(--bar)); border-radius:5px 5px 0 0; background:#bfd9eb; transition:height .25s ease; }.trend-critical,.trend-warning { position:absolute; right:0; left:0; bottom:0; display:block; }.trend-critical { height:var(--critical); background:var(--red); }.trend-warning { bottom:var(--critical); height:var(--warning); background:var(--amber); }.trend-day strong { font-size:13px; }.trend-day small { color:var(--muted); font-size:10px; }.chart-note { margin:15px 0 0; color:var(--muted); font-size:11px; }.legend { display:inline-block; width:8px; height:8px; margin:0 4px 0 12px; border-radius:2px; }.legend:first-child { margin-left:0; }.legend.critical { background:var(--red); }.legend.warning { background:var(--amber); }.legend.neutral { background:#bfd9eb; }.organization-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(245px,1fr)); gap:12px; }.organization-card { display:grid; gap:11px; min-height:150px; padding:18px; border:1px solid #d7e3ec; border-radius:8px; background:linear-gradient(145deg,#fff,#f3f8fb); color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease; }.organization-card:hover { border-color:#8fb7d0; box-shadow:0 14px 26px rgba(24,59,89,.12); transform:translateY(-2px); }.organization-card strong { font-size:19px; letter-spacing:-.035em; }.organization-card div { display:flex; flex-wrap:wrap; align-items:center; gap:5px; color:var(--muted); font-size:12px; }.organization-card small { color:var(--muted); font-size:10px; }.ransomware-panel { border-left:5px solid #8aa3b4; }.ransomware-panel.warning { border-left-color:var(--amber); }.ransomware-panel.critical { border-left-color:var(--red); }.ransomware-panel .panel-heading p { margin:0 0 18px; color:var(--muted); }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}OC Sentinel{% endblock %}</title>
|
<title>{% block title %}OfficeCom Sentinel{% endblock %}</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="app-shell">
|
<body class="app-shell">
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<nav class="header-links"><a class="{{ 'active' if request.endpoint == 'overview' else '' }}" href="/">Uebersicht</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
|
<a class="wordmark" href="{{ url_for('overview') }}"><span>OC</span>Sentinel</a>
|
||||||
|
<nav class="header-links"><a class="{{ 'active' if request.endpoint in ('overview', 'organization') else '' }}" href="{{ url_for('overview') }}">Lagebild</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
|
||||||
</header>
|
</header>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel"><div class="panel-heading"><h2>{{ report[0] }}</h2><span class="state {{ report[6] }}">{{ report[6] }}</span>{% if report[8] %}<span class="state {{ 'current' if event.is_current else 'historic' }}">{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}</span>{% endif %}</div></section>
|
<section class="panel"><div class="panel-heading"><h2>{{ report[0] }}</h2><span class="state {{ report[6] }}">{{ report[6] }}</span>{% if report[8] %}<span class="state {{ 'current' if event.is_current else 'historic' }}">{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}</span>{% endif %}</div></section>
|
||||||
<section class="metrics compact-metrics"><article><span>Ereignisse</span><strong>{{ report[8] }}</strong></article><article><span>Quell-IPs</span><strong>{{ report[9] }}</strong></article><article><span>CVEs</span><strong>{{ report[10] }}</strong></article><article><span>Kritische CVEs</span><strong class="critical">{{ report[11] }}</strong></article></section>
|
<section class="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 %}
|
||||||
<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 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>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
7
infra/debug-dashboard/templates/organization.html
Normal file
7
infra/debug-dashboard/templates/organization.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ organization_name }} - OfficeCom Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="hero compact"><span class="eyebrow">Organisation {{ organization_id }}</span><h1>{{ organization_name }}</h1><p>{{ critical_count }} kritisch, {{ warning_count }} Warnungen. Waehle ein Geraet fuer die technische Analyse.</p></section>
|
||||||
|
<section class="metrics compact-metrics"><article><span>Geraete</span><strong>{{ devices|length }}</strong></article><article><span>Kritisch</span><strong class="critical">{{ critical_count }}</strong></article><article><span>Warnungen</span><strong class="warning">{{ warning_count }}</strong></article><article><span>Letzte Meldung</span><strong class="timestamp">{{ devices[0][1] or '-' }}</strong></article></section>
|
||||||
|
<section class="panel"><div class="table-wrap"><table><thead><tr><th>Geraet</th><th>Status</th><th>Ereignisse</th><th>Quell-IPs</th><th>Kritische CVEs</th><th>Empfangen</th></tr></thead><tbody>{% for device in devices %}<tr><td><a href="{{ url_for('device', machine_name=device[0]) }}">{{ device[0] }}</a></td><td><span class="state {{ device[2] }}">{{ device[2] }}</span></td><td>{{ device[3] }}</td><td>{{ device[4] }}</td><td>{{ device[5] }}</td><td>{{ device[1] or '-' }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="situation {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
|
<section class="situation {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
|
||||||
<div><strong>{% if summary[2] %}Kritische Ereignisse{% elif summary[1] %}Hinweise vorhanden{% else %}Keine kritischen Auffaelligkeiten{% endif %}</strong></div>
|
<div><span class="eyebrow">OfficeCom Sentinel Uebersicht</span><strong>{% if summary[2] %}Kritische Ereignisse erfordern Aufmerksamkeit{% elif summary[1] %}Hinweise im Bestand pruefen{% else %}Sicherheitslage stabil{% endif %}</strong></div>
|
||||||
<span>{% if summary[2] %}KRITISCH{% elif summary[1] %}PRUEFEN{% else %}STABIL{% endif %}</span>
|
<span>{% if summary[2] %}KRITISCH{% elif summary[1] %}PRUEFEN{% else %}STABIL{% endif %}</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel trend-panel">
|
||||||
|
<div class="panel-heading"><span class="eyebrow">Letzte 14 Tage</span><h2>Signalverlauf <small>Verdichtete Scan-Ergebnisse pro Tag</small></h2></div>
|
||||||
|
<div class="trend-chart" aria-label="Signalverlauf der letzten 14 Tage">{% for day in trend %}<article class="trend-day"><div class="trend-bar" style="--bar: {{ (day.event_count * 100 / trend_max)|round(0, 'floor') }}%"><span class="trend-critical" style="--critical: {{ (day.critical_count * 100 / trend_max)|round(0, 'floor') }}%"></span><span class="trend-warning" style="--warning: {{ (day.warning_count * 100 / trend_max)|round(0, 'floor') }}%"></span></div><strong>{{ day.event_count }}</strong><small>{{ day.day.strftime('%d.%m.') }}</small></article>{% else %}<p class="empty-state">Noch keine Trenddaten vorhanden.</p>{% endfor %}</div>
|
||||||
|
<p class="chart-note"><span class="legend critical"></span>kritisch <span class="legend warning"></span>Warnungen <span class="legend neutral"></span>Ereignisvolumen</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel organization-panel">
|
||||||
|
<div class="panel-heading"><span class="eyebrow">Mandanten</span><h2>Organisationen <small>Drill-down bis zum einzelnen Geraet</small></h2></div>
|
||||||
|
<div class="organization-grid">{% for organization in organizations %}<a class="organization-card" href="{{ url_for('organization', organization_id=organization.id) }}"><span class="eyebrow">{{ organization.id }}</span><strong>{{ organization.name }}</strong><div><span>{{ organization.device_count }} Geraete</span><span class="state critical">{{ organization.critical_count }} kritisch</span><span class="state warning">{{ organization.warning_count }} Warnung</span></div><small>Letzte Meldung: {{ organization.last_received_at or '-' }}</small></a>{% endfor %}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="metrics">
|
<section class="metrics">
|
||||||
<article><span>Geraete</span><strong>{{ summary[0] }}</strong></article>
|
<article><span>Geraete</span><strong>{{ summary[0] }}</strong></article>
|
||||||
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong></article>
|
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong></article>
|
||||||
|
|||||||
@@ -96,15 +96,24 @@ 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 {
|
||||||
param([Parameter(Mandatory)][string]$Path)
|
param([Parameter(Mandatory)][string]$Path)
|
||||||
|
|||||||
5
release/beta/README.md
Normal file
5
release/beta/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# OCSentinel Beta Channel
|
||||||
|
|
||||||
|
This directory contains the current beta `version.json` only after a tested
|
||||||
|
pre-release has been published. Pilot devices use this channel; stable devices
|
||||||
|
continue to use `release/stable/version.json`.
|
||||||
8
release/beta/version.json
Normal file
8
release/beta/version.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"channel": "beta",
|
||||||
|
"version": "1.5.0-beta.1",
|
||||||
|
"publishedAtUtc": "2026-07-29T23:04:51.9334773Z",
|
||||||
|
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.1/OCSentinelClient-win-x64.zip",
|
||||||
|
"sha256": "2bd6db7261f7ca9b47741968b922aea5969251539530e58441e882f53686ae6b",
|
||||||
|
"minUpdaterVersion": "1.0.0"
|
||||||
|
}
|
||||||
@@ -1,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
|
||||||
@@ -112,6 +114,20 @@ function Get-OCSentinelArtifact {
|
|||||||
|
|
||||||
Initialize-OCSentinelTls
|
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"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ internal sealed class AttackScanner
|
|||||||
ScanExchangeLogons(attacks, errors, since);
|
ScanExchangeLogons(attacks, errors, since);
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -53,7 +54,7 @@ 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();
|
||||||
AlertAssessment baseAssessment = AssessAttackActivity(attacks, uniqueIpCount, configuration);
|
AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta);
|
||||||
string baseAlertState = baseAssessment.State;
|
string baseAlertState = baseAssessment.State;
|
||||||
string baseAlertReason = baseAssessment.Reason;
|
string baseAlertReason = baseAssessment.Reason;
|
||||||
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
|
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
|
||||||
@@ -79,6 +80,7 @@ internal sealed class AttackScanner
|
|||||||
BaseAlertState = baseAlertState,
|
BaseAlertState = baseAlertState,
|
||||||
BaseAlertReason = baseAlertReason,
|
BaseAlertReason = baseAlertReason,
|
||||||
VulnerabilityCorrelation = vulnerabilityCorrelation,
|
VulnerabilityCorrelation = vulnerabilityCorrelation,
|
||||||
|
RansomwareBeta = ransomwareBeta,
|
||||||
Runtime = new ScanRuntimeMetadata
|
Runtime = new ScanRuntimeMetadata
|
||||||
{
|
{
|
||||||
StartedAtUtc = startedAtUtc,
|
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.");
|
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<AttackEvent> events, TimeSpan window)
|
private static int GetPeakEventCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
|
||||||
{
|
{
|
||||||
int start = 0;
|
int start = 0;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ internal sealed record ScannerConfiguration
|
|||||||
|
|
||||||
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
||||||
|
|
||||||
|
public bool RansomwareBetaEnabled { get; init; }
|
||||||
|
|
||||||
|
public int RansomwareLookbackMinutes { get; init; } = 15;
|
||||||
|
|
||||||
|
public int RansomwareWarningSignalCount { get; init; } = 2;
|
||||||
|
|
||||||
|
public int RansomwareCriticalSignalCount { get; init; } = 3;
|
||||||
|
|
||||||
|
public List<string> RansomwareExcludedProcesses { get; init; } = [];
|
||||||
|
|
||||||
public List<string> FtpRoots { get; init; } = [];
|
public List<string> FtpRoots { get; init; } = [];
|
||||||
|
|
||||||
public List<string> FileZillaRoots { get; init; } = [];
|
public List<string> FileZillaRoots { get; init; } = [];
|
||||||
|
|||||||
@@ -104,6 +104,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; } = [];
|
||||||
@@ -113,6 +115,36 @@ 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 string State { get; init; } = "disabled";
|
||||||
|
|
||||||
|
public string Reason { get; init; } = "Ransomware beta is disabled.";
|
||||||
|
|
||||||
|
public int LookbackMinutes { get; init; }
|
||||||
|
|
||||||
|
public List<RansomwareSignal> Signals { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareSignal
|
||||||
|
{
|
||||||
|
public DateTimeOffset Timestamp { get; init; }
|
||||||
|
|
||||||
|
public string Category { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Confidence { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Process { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Source { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public long EventId { get; init; }
|
||||||
|
|
||||||
|
public string Evidence { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed record NinjaOneContext
|
internal sealed record NinjaOneContext
|
||||||
{
|
{
|
||||||
public string OrganizationId { get; init; } = string.Empty;
|
public string OrganizationId { get; init; } = string.Empty;
|
||||||
|
|||||||
@@ -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.4.0</Version>
|
<Version>1.5.0-beta.1</Version>
|
||||||
<AssemblyVersion>1.4.0.0</AssemblyVersion>
|
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.4.0.0</FileVersion>
|
<FileVersion>1.5.0.0</FileVersion>
|
||||||
<InformationalVersion>1.4.0</InformationalVersion>
|
<InformationalVersion>1.5.0-beta.1</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
172
src/OCSentinelCli/RansomwareBetaDetector.cs
Normal file
172
src/OCSentinelCli/RansomwareBetaDetector.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Diagnostics.Eventing.Reader;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
internal static class RansomwareBetaDetector
|
||||||
|
{
|
||||||
|
public static RansomwareBetaSummary Scan(ScannerConfiguration configuration, List<string> errors)
|
||||||
|
{
|
||||||
|
if (!configuration.RansomwareBetaEnabled)
|
||||||
|
{
|
||||||
|
return new RansomwareBetaSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
int lookbackMinutes = Math.Clamp(configuration.RansomwareLookbackMinutes, 1, 60);
|
||||||
|
DateTimeOffset since = DateTimeOffset.UtcNow.AddMinutes(-lookbackMinutes);
|
||||||
|
var signals = new List<RansomwareSignal>();
|
||||||
|
|
||||||
|
ScanSecurityProcesses(signals, errors, since, configuration);
|
||||||
|
ScanPowerShellScriptBlocks(signals, errors, since, configuration);
|
||||||
|
ScanSysmonProcesses(signals, errors, since, configuration);
|
||||||
|
|
||||||
|
List<RansomwareSignal> distinctSignals = signals
|
||||||
|
.OrderBy(signal => signal.Timestamp)
|
||||||
|
.GroupBy(signal => $"{signal.Category}\u001f{signal.Process}", StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.Take(20)
|
||||||
|
.ToList();
|
||||||
|
int strongSignals = distinctSignals.Count(signal => signal.Confidence == "high");
|
||||||
|
|
||||||
|
string state = distinctSignals.Count >= Math.Max(2, configuration.RansomwareCriticalSignalCount)
|
||||||
|
? "critical"
|
||||||
|
: strongSignals > 0 || distinctSignals.Count >= Math.Max(2, configuration.RansomwareWarningSignalCount)
|
||||||
|
? "warning"
|
||||||
|
: distinctSignals.Count > 0 ? "hint" : "ok";
|
||||||
|
string reason = state switch
|
||||||
|
{
|
||||||
|
"critical" => $"Ransomware beta detected {distinctSignals.Count} independent high-risk signals within {lookbackMinutes} minutes.",
|
||||||
|
"warning" => $"Ransomware beta detected {strongSignals} high-confidence and {distinctSignals.Count - strongSignals} low-confidence signals within {lookbackMinutes} minutes.",
|
||||||
|
"hint" => $"Ransomware beta observed an isolated low-confidence signal within {lookbackMinutes} minutes.",
|
||||||
|
_ => $"Ransomware beta found no suspicious process activity in the last {lookbackMinutes} minutes."
|
||||||
|
};
|
||||||
|
|
||||||
|
return new RansomwareBetaSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
State = state,
|
||||||
|
Reason = reason,
|
||||||
|
LookbackMinutes = lookbackMinutes,
|
||||||
|
Signals = distinctSignals
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ScanSecurityProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
TryScan("Security", 4688, since, errors, record => AddSignal(signals, record, ReadProperty(record, 5), ReadProperty(record, 8), "Security", configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ScanPowerShellScriptBlocks(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
TryScan("Microsoft-Windows-PowerShell/Operational", 4104, since, errors, record => AddSignal(signals, record, "powershell", FormatDescription(record), "PowerShell", configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ScanSysmonProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
TryScan("Microsoft-Windows-Sysmon/Operational", 1, since, errors, record => AddSignal(signals, record, "sysmon-process", FormatDescription(record), "Sysmon", configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSignal(List<RansomwareSignal> signals, EventRecord record, string process, string commandLine, string source, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
if (!record.TimeCreated.HasValue || string.IsNullOrWhiteSpace(commandLine))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string processName = Path.GetFileName(process.Trim());
|
||||||
|
if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, processName, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RansomwareSignal? signal = Classify(record.TimeCreated.Value, record.Id, processName, commandLine, source);
|
||||||
|
if (signal is not null)
|
||||||
|
{
|
||||||
|
signals.Add(signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareSignal? Classify(DateTime timestamp, int eventId, string process, string commandLine, string source)
|
||||||
|
{
|
||||||
|
string value = commandLine.ToLowerInvariant();
|
||||||
|
string evidence = commandLine.Length > 512 ? commandLine[..512] : commandLine;
|
||||||
|
if (ContainsAll(value, "vssadmin", "delete", "shadow") || ContainsAll(value, "wmic", "shadowcopy", "delete") || ContainsAll(value, "win32_shadowcopy", "delete"))
|
||||||
|
{
|
||||||
|
return CreateSignal(timestamp, eventId, "shadow-copy-deletion", "high", process, source, evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsAll(value, "wbadmin", "delete") || ContainsAll(value, "catalog", "delete"))
|
||||||
|
{
|
||||||
|
return CreateSignal(timestamp, eventId, "backup-catalog-deletion", "high", process, source, evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsAll(value, "bcdedit", "recoveryenabled", "no") || ContainsAll(value, "bcdedit", "bootstatuspolicy", "ignoreallfailures"))
|
||||||
|
{
|
||||||
|
return CreateSignal(timestamp, eventId, "recovery-disable", "high", process, source, evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ContainsAll(value, "wevtutil", " cl "))
|
||||||
|
{
|
||||||
|
return CreateSignal(timestamp, eventId, "event-log-clearing", "high", process, source, evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Contains("win32_shadowcopy", StringComparison.Ordinal)
|
||||||
|
? CreateSignal(timestamp, eventId, "shadow-copy-access", "low", process, source, evidence)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareSignal CreateSignal(DateTime timestamp, int eventId, string category, string confidence, string process, string source, string evidence)
|
||||||
|
{
|
||||||
|
return new RansomwareSignal
|
||||||
|
{
|
||||||
|
Timestamp = new DateTimeOffset(timestamp).ToLocalTime(),
|
||||||
|
Category = category,
|
||||||
|
Confidence = confidence,
|
||||||
|
Process = string.IsNullOrWhiteSpace(process) ? "[unknown]" : process,
|
||||||
|
Source = source,
|
||||||
|
EventId = eventId,
|
||||||
|
Evidence = evidence
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAll(string value, params string[] needles) => needles.All(needle => value.Contains(needle, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
private static void TryScan(string logName, int eventId, DateTimeOffset since, List<string> errors, Action<EventRecord> processRecord)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - since).TotalMilliseconds);
|
||||||
|
string query = $"*[System[(EventID={eventId}) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]";
|
||||||
|
using var reader = new EventLogReader(new EventLogQuery(logName, PathType.LogName, query));
|
||||||
|
for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent())
|
||||||
|
{
|
||||||
|
using (record)
|
||||||
|
{
|
||||||
|
processRecord(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (EventLogNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware beta query failed for {logName}: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadProperty(EventRecord record, int index) => index >= 0 && index < record.Properties.Count ? record.Properties[index].Value?.ToString()?.Trim() ?? string.Empty : string.Empty;
|
||||||
|
|
||||||
|
private static string FormatDescription(EventRecord record)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return record.FormatDescription() ?? string.Empty;
|
||||||
|
}
|
||||||
|
catch (EventLogException)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user