Compare commits
8 Commits
v1.5.0-bet
...
v1.5.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8407d0c5b2 | ||
|
|
22be9689e2 | ||
|
|
f69f14e0b1 | ||
|
|
94f5be8953 | ||
|
|
b97f8819f6 | ||
|
|
0207d84775 | ||
|
|
c65001aa17 | ||
|
|
42b387f3ef |
@@ -11,9 +11,18 @@
|
|||||||
"correlationWarningCveThreshold": 1,
|
"correlationWarningCveThreshold": 1,
|
||||||
"correlationCriticalCveThreshold": 1,
|
"correlationCriticalCveThreshold": 1,
|
||||||
"ransomwareBetaEnabled": false,
|
"ransomwareBetaEnabled": false,
|
||||||
|
"ransomwareBetaAlertingEnabled": false,
|
||||||
"ransomwareLookbackMinutes": 15,
|
"ransomwareLookbackMinutes": 15,
|
||||||
"ransomwareWarningSignalCount": 2,
|
"ransomwareWarningSignalCount": 2,
|
||||||
"ransomwareCriticalSignalCount": 3,
|
"ransomwareCriticalSignalCount": 3,
|
||||||
|
"ransomwareCaptureSmbSessions": true,
|
||||||
|
"ransomwareFileChurnEnabled": false,
|
||||||
|
"ransomwareFileChurnWindowMinutes": 15,
|
||||||
|
"ransomwareFileChurnWarningDeleteCount": 50,
|
||||||
|
"ransomwareFileChurnWarningWriteCount": 250,
|
||||||
|
"ransomwareFileChurnCriticalDeleteCount": 200,
|
||||||
|
"ransomwareFileChurnCriticalWriteCount": 1000,
|
||||||
|
"ransomwareFileChurnMaxAuditEvents": 5000,
|
||||||
"ransomwareExcludedProcesses": [],
|
"ransomwareExcludedProcesses": [],
|
||||||
"ftpRoots": [
|
"ftpRoots": [
|
||||||
"C:\\inetpub\\logs\\LogFiles",
|
"C:\\inetpub\\logs\\LogFiles",
|
||||||
|
|||||||
@@ -21,13 +21,18 @@ Die Stable-Aufgabe verwendet keinen Kanalwert oder den Wert `stable`.
|
|||||||
|
|
||||||
Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem
|
Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem
|
||||||
Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json`
|
Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json`
|
||||||
der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die erste Auswertung
|
der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die Auswertung bleibt
|
||||||
liest nur die letzten 15 Minuten der vorhandenen Prozess- und PowerShell-
|
passiv, solange `ransomwareBetaAlertingEnabled` auf `false` steht: Hinweise,
|
||||||
Ereignisse; sie installiert weder Sysmon noch Windows-Dateiauditing.
|
Warnungen und kritische Beta-Signale erscheinen im JSON-Report und Dashboard,
|
||||||
|
veraendern aber keine NinjaOne-Alarmfelder.
|
||||||
|
|
||||||
Ein Hinweis wird nur im JSON-Report und Dashboard sichtbar. Warnung und
|
Der optionale Datei-Churn-Sensor wird nur mit
|
||||||
kritisch werden erst nach dem kontrollierten Alarmierungs-Pilot an NinjaOne
|
`ransomwareFileChurnEnabled: true` aktiviert. Er wertet ausschliesslich bereits
|
||||||
weitergegeben.
|
vorhandene Security-Ereignisse 4663 aus, setzt keine Audit-Richtlinie und
|
||||||
|
aendert keine SACLs. Es werden nur Zaehler sowie Prozessnamen gespeichert und
|
||||||
|
uebertragen, niemals Datei- oder Freigabenamen. Eine Auswertung ist auf 5.000
|
||||||
|
Audit-Ereignisse und ein 15-Minuten-Fenster begrenzt; ein gekappter Lauf erzeugt
|
||||||
|
kein Churn-Signal.
|
||||||
|
|
||||||
## Rueckfall
|
## Rueckfall
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,29 @@ Runtime:
|
|||||||
-OutputPath "..\reports\ocsentinel-summary.json"
|
-OutputPath "..\reports\ocsentinel-summary.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Repair Missing NinjaOne Context
|
||||||
|
|
||||||
|
NinjaOne exposes `NINJA_ORGANIZATION_ID`, `NINJA_ORGANIZATION_NAME`,
|
||||||
|
`NINJA_AGENT_MACHINE_ID`, and location values only while an automation runs.
|
||||||
|
The scheduled OCSentinel task runs later as `SYSTEM`, so those values must be
|
||||||
|
persisted during an actual NinjaOne automation.
|
||||||
|
|
||||||
|
If the console shows `Organisation unbekannt`, create a temporary NinjaOne
|
||||||
|
PowerShell automation named `OCSentinel - NinjaOne Kontext aktualisieren` and
|
||||||
|
copy `scripts/refresh-ocsentinel-ninja-context.ps1` into the editor. Run it as
|
||||||
|
`SYSTEM` in 64-bit PowerShell once against the affected devices or policy.
|
||||||
|
|
||||||
|
The script has no script variables and does the following safely:
|
||||||
|
|
||||||
|
1. updates the installed client through the stable, version-independent manifest;
|
||||||
|
2. stores the current NinjaOne organization, location, node, and machine values;
|
||||||
|
3. starts one signed status scan and upload using that stored context.
|
||||||
|
|
||||||
|
Expected output includes `OCSENTINEL_NINJA_CONTEXT=updated`; this is emitted
|
||||||
|
only after the immediate upload succeeds. Do not run this script from an
|
||||||
|
interactive PowerShell session, because NinjaOne does not expose the required
|
||||||
|
environment values there.
|
||||||
|
|
||||||
## Secret Bootstrap
|
## Secret Bootstrap
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -120,8 +120,9 @@ Ein Pilot wird pausiert und zurueckgesetzt, wenn eines dieser Kriterien eintritt
|
|||||||
Systemereignisse sowie Aenderungszaehler seit dem letzten Pruefpunkt.
|
Systemereignisse sowie Aenderungszaehler seit dem letzten Pruefpunkt.
|
||||||
- Erkennung hochrelevanter Manipulationen wie Schattenkopie-, Recovery- und
|
- Erkennung hochrelevanter Manipulationen wie Schattenkopie-, Recovery- und
|
||||||
Backup-Loeschbefehle sowie verdaechtiger Verschluesselungswerkzeuge.
|
Backup-Loeschbefehle sowie verdaechtiger Verschluesselungswerkzeuge.
|
||||||
- Lokaler Ringpuffer mit aggregierten Datei-Churn-Signalen, etwa ungewoehnliche
|
- Lokaler Ringpuffer mit aggregierten Datei-Churn-Signalen aus vorhandenen
|
||||||
Umbenennungen, Loeschungen und neue Erweiterungen.
|
Datei-Audit-Ereignissen. Die erste Beta wertet Loesch- und Schreibzugriffe
|
||||||
|
ohne Datei- oder Freigabenamen aus.
|
||||||
- Snapshot der SMB-Sitzungen und des Incident-Kontexts erst bei einer
|
- Snapshot der SMB-Sitzungen und des Incident-Kontexts erst bei einer
|
||||||
Auffaelligkeit.
|
Auffaelligkeit.
|
||||||
- Verdichtetes Ransomware-Incident-Protokoll fuer n8n und die Uebersicht.
|
- Verdichtetes Ransomware-Incident-Protokoll fuer n8n und die Uebersicht.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
from flask import Flask, abort, redirect, render_template, request, url_for
|
from flask import Flask, abort, jsonify, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -245,6 +245,109 @@ def organization(organization_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_network_flows(days=14):
|
||||||
|
days = max(1, min(days, 90))
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, received_at, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE received_at >= now() - (%s * interval '1 day')
|
||||||
|
""",
|
||||||
|
(days,),
|
||||||
|
)
|
||||||
|
reports = cursor.fetchall()
|
||||||
|
|
||||||
|
flows = {}
|
||||||
|
for machine_name, received_at, payload in reports:
|
||||||
|
ninja_context = (payload or {}).get("NinjaOne") or {}
|
||||||
|
organization_id = str(ninja_context.get("OrganizationId") or "unknown")
|
||||||
|
organization_name = ninja_context.get("OrganizationName") or "Organisation unbekannt"
|
||||||
|
for event in (payload or {}).get("Events", []):
|
||||||
|
source_ip = event.get("SourceIp") or ""
|
||||||
|
if not source_ip or source_ip in {"-", "127.0.0.1", "::1"}:
|
||||||
|
continue
|
||||||
|
account = event.get("Username") or "[unbekannt]"
|
||||||
|
target = event.get("Target") or "Anmeldung"
|
||||||
|
key = (source_ip, machine_name, account, target, organization_id)
|
||||||
|
entry = flows.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"source_ip": source_ip,
|
||||||
|
"machine_name": machine_name,
|
||||||
|
"account": account,
|
||||||
|
"target": target,
|
||||||
|
"organization_id": organization_id,
|
||||||
|
"organization_name": organization_name,
|
||||||
|
"count": 0,
|
||||||
|
"last_seen": received_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry["count"] += 1
|
||||||
|
timestamp = event.get("Timestamp")
|
||||||
|
if timestamp and (entry["last_seen"] is None or str(timestamp) > str(entry["last_seen"])):
|
||||||
|
entry["last_seen"] = timestamp
|
||||||
|
|
||||||
|
flow_rows = sorted(flows.values(), key=lambda entry: (entry["count"], str(entry["last_seen"])), reverse=True)[:60]
|
||||||
|
max_count = max([entry["count"] for entry in flow_rows] or [1])
|
||||||
|
source_count = len({entry["source_ip"] for entry in flow_rows})
|
||||||
|
target_count = len({entry["machine_name"] for entry in flow_rows})
|
||||||
|
return flow_rows, max_count, source_count, target_count
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/network")
|
||||||
|
def network():
|
||||||
|
days = request.args.get("days", 14, type=int)
|
||||||
|
flows, _, source_count, target_count = load_network_flows(days)
|
||||||
|
return render_template(
|
||||||
|
"network.html",
|
||||||
|
source_count=source_count,
|
||||||
|
target_count=target_count,
|
||||||
|
total_events=sum(entry["count"] for entry in flows),
|
||||||
|
path_count=len(flows),
|
||||||
|
days=max(1, min(days, 90)),
|
||||||
|
organizations=sorted({(entry["organization_id"], entry["organization_name"]) for entry in flows}, key=lambda item: item[1]),
|
||||||
|
event_types=sorted({entry["target"] for entry in flows}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/network")
|
||||||
|
def network_api():
|
||||||
|
days = request.args.get("days", 14, type=int)
|
||||||
|
flows, max_count, source_count, target_count = load_network_flows(days)
|
||||||
|
nodes = {}
|
||||||
|
edges = []
|
||||||
|
for index, flow in enumerate(flows):
|
||||||
|
source_id = f"source:{flow['source_ip']}"
|
||||||
|
target_id = f"target:{flow['machine_name']}"
|
||||||
|
nodes[source_id] = {"data": {"id": source_id, "label": flow["source_ip"], "kind": "source"}}
|
||||||
|
nodes[target_id] = {"data": {"id": target_id, "label": flow["machine_name"], "kind": "target"}}
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": f"flow:{index}",
|
||||||
|
"source": source_id,
|
||||||
|
"target": target_id,
|
||||||
|
"count": flow["count"],
|
||||||
|
"account": flow["account"],
|
||||||
|
"event_type": flow["target"],
|
||||||
|
"last_seen": str(flow["last_seen"] or "-"),
|
||||||
|
"machine_name": flow["machine_name"],
|
||||||
|
"organization_id": flow["organization_id"],
|
||||||
|
"organization_name": flow["organization_name"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"elements": {"nodes": list(nodes.values()), "edges": edges},
|
||||||
|
"max_count": max_count,
|
||||||
|
"source_count": source_count,
|
||||||
|
"target_count": target_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/device/<machine_name>")
|
@app.get("/device/<machine_name>")
|
||||||
def device(machine_name):
|
def device(machine_name):
|
||||||
with db_connection() as connection, connection.cursor() as cursor:
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
|||||||
@@ -28,3 +28,6 @@ table { width:100%; border-collapse:collapse; font-family:'Roboto',sans-serif; f
|
|||||||
.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); }
|
.trend-panel { overflow:hidden; }.trend-chart { display:grid; grid-template-columns:repeat(auto-fit,minmax(48px,1fr)); align-items:end; min-height:210px; gap:10px; padding:18px 4px 0; border-bottom:1px solid var(--line); }.trend-day { display:grid; grid-template-rows:154px auto auto; gap:5px; min-width:0; text-align:center; }.trend-bar { position:relative; align-self:end; height:max(7px,var(--bar)); border-radius:5px 5px 0 0; background:#bfd9eb; transition:height .25s ease; }.trend-critical,.trend-warning { position:absolute; right:0; left:0; bottom:0; display:block; }.trend-critical { height:var(--critical); background:var(--red); }.trend-warning { bottom:var(--critical); height:var(--warning); background:var(--amber); }.trend-day strong { font-size:13px; }.trend-day small { color:var(--muted); font-size:10px; }.chart-note { margin:15px 0 0; color:var(--muted); font-size:11px; }.legend { display:inline-block; width:8px; height:8px; margin:0 4px 0 12px; border-radius:2px; }.legend:first-child { margin-left:0; }.legend.critical { background:var(--red); }.legend.warning { background:var(--amber); }.legend.neutral { background:#bfd9eb; }.organization-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(245px,1fr)); gap:12px; }.organization-card { display:grid; gap:11px; min-height:150px; padding:18px; border:1px solid #d7e3ec; border-radius:8px; background:linear-gradient(145deg,#fff,#f3f8fb); color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease; }.organization-card:hover { border-color:#8fb7d0; box-shadow:0 14px 26px rgba(24,59,89,.12); transform:translateY(-2px); }.organization-card strong { font-size:19px; letter-spacing:-.035em; }.organization-card div { display:flex; flex-wrap:wrap; align-items:center; gap:5px; color:var(--muted); font-size:12px; }.organization-card small { color:var(--muted); font-size:10px; }.ransomware-panel { border-left:5px solid #8aa3b4; }.ransomware-panel.warning { border-left-color:var(--amber); }.ransomware-panel.critical { border-left-color:var(--red); }.ransomware-panel .panel-heading p { margin:0 0 18px; color:var(--muted); }
|
||||||
|
.network-panel { overflow:hidden; }.network-flows { display:grid; gap:8px; }.network-flow { display:grid; grid-template-columns:minmax(150px,.9fr) minmax(130px,1.25fr) minmax(210px,1.2fr); align-items:center; gap:16px; padding:13px 14px; border:1px solid #dce6ee; border-radius:7px; background:#fbfdff; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.network-flow:hover { transform:translateX(3px); box-shadow:0 8px 18px rgba(31,68,99,.1); }.flow-endpoint { display:grid; gap:3px; }.flow-endpoint span { color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.flow-endpoint strong { font-size:14px; }.flow-endpoint small { color:var(--muted); font-size:11px; }.flow-line { position:relative; display:flex; align-items:center; gap:7px; min-height:22px; }.flow-line:before { position:absolute; right:0; left:0; height:3px; background:#d7e4ed; content:''; }.flow-line i { z-index:1; width:max(5%,var(--flow)); height:7px; border-radius:6px; background:linear-gradient(90deg,#245a85,#b8e36a); }.flow-line small { z-index:1; margin-left:auto; padding:2px 5px; border-radius:8px; background:#fff; color:#456174; font:700 10px 'Roboto',sans-serif; } @media (max-width:850px) { .network-flow { grid-template-columns:1fr; gap:9px; }.flow-line { order:3; }.flow-endpoint.target { order:2; } }
|
||||||
|
.network-map-panel { overflow:hidden; }.map-toolbar { display:flex; flex-wrap:wrap; gap:8px; margin:0 0 16px; }.map-toolbar label { flex:1 1 240px; display:grid; gap:5px; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-toolbar input,.map-toolbar button { min-height:38px; padding:8px 10px; border:1px solid var(--line); border-radius:5px; background:#fff; color:var(--ink); font:700 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#f5f9fc; }.network-map-layout { display:grid; grid-template-columns:minmax(0,1fr) 260px; min-height:560px; overflow:hidden; border:1px solid #d7e4ed; border-radius:9px; background:radial-gradient(circle at 18% 12%,#f5fbff,transparent 28rem),#edf4f8; }.network-map-layout #network-map { min-height:560px; background-image:linear-gradient(rgba(36,90,133,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(36,90,133,.05) 1px,transparent 1px); background-size:32px 32px; }.network-map-layout aside { padding:22px; border-left:1px solid #d7e4ed; background:#fff; }.network-map-layout aside strong { display:block; margin:8px 0 12px; font-size:18px; line-height:1.15; letter-spacing:-.035em; }.network-map-layout aside p { color:var(--muted); font-size:13px; line-height:1.5; }.network-map-layout dl { display:grid; grid-template-columns:1fr; gap:4px; margin:18px 0 0; }.network-map-layout dt { color:var(--muted); font-size:10px; font-weight:700; text-transform:uppercase; }.network-map-layout dd { margin:0 0 10px; font-size:13px; overflow-wrap:anywhere; }.inspector-arrow { color:var(--green); font-size:13px; }.legend.source { background:#245a85; }.legend.target { background:#14735b; } @media (max-width:850px) { .network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid #d7e4ed; border-left:0; }.network-map-layout #network-map { min-height:460px; } }
|
||||||
|
.map-hero { display:flex; align-items:end; justify-content:space-between; gap:28px; margin:0 -2vw 26px; padding:38px 3vw 30px; border-radius:14px; color:#eaf2f8; background:radial-gradient(circle at 82% 10%,rgba(79,163,223,.25),transparent 20rem),linear-gradient(132deg,#102a43,#0c1c2a 70%); box-shadow:0 18px 50px rgba(13,30,44,.22); }.map-hero h1 { max-width:650px; margin:8px 0 14px; font-size:clamp(42px,6vw,76px); line-height:.87; letter-spacing:-.07em; }.map-hero h1 em { color:#b8e36a; font-style:normal; }.map-hero p { max-width:620px; margin:0; color:#b7cad9; font-size:15px; line-height:1.55; }.map-hero .eyebrow { color:#9cc8e8; }.map-hero-status { display:grid; min-width:145px; gap:4px; padding:16px 18px; border:1px solid rgba(184,227,106,.35); border-radius:10px; background:rgba(11,31,45,.55); }.map-hero-status span { color:#b8e36a; font:700 9px 'Roboto',sans-serif; letter-spacing:.13em; }.map-hero-status strong { font-size:37px; line-height:1; letter-spacing:-.06em; }.map-hero-status small { color:#b7cad9; }.map-stat-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; margin:-10px 2vw 28px; border:1px solid #d8e5ee; border-radius:9px; overflow:hidden; background:#d8e5ee; box-shadow:0 10px 22px rgba(31,68,99,.08); }.map-stat-strip article { padding:15px 18px; background:#fff; }.map-stat-strip span,.map-stat-strip small { display:block; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-stat-strip strong { display:block; margin:8px 0 4px; font-size:29px; letter-spacing:-.06em; }.network-map-panel { margin-top:0; padding:0; border:0; border-radius:12px; background:#102a43; box-shadow:0 20px 44px rgba(16,42,67,.2); }.map-header { display:flex; justify-content:space-between; align-items:end; gap:16px; padding:24px 26px 18px; color:#eef6fa; }.map-header .eyebrow { color:#9cc8e8; }.map-header h2 { margin:6px 0 0; font-size:30px; letter-spacing:-.05em; }.map-legend { display:flex; gap:12px; color:#b7cad9; font-size:11px; }.map-legend span { display:flex; align-items:center; gap:5px; }.map-legend i { width:8px; height:8px; border-radius:50%; background:#4fa3df; }.map-legend i.target { background:#3bca99; border-radius:2px; }.map-legend i.hot { background:#ffb454; }.map-toolbar { align-items:end; margin:0; padding:0 26px 18px; border-bottom:1px solid rgba(156,200,232,.16); }.map-toolbar label { flex:0 1 180px; color:#9cc8e8; }.map-toolbar .search-field { flex:1 1 240px; }.map-toolbar input,.map-toolbar select,.map-toolbar button { min-height:40px; border:1px solid rgba(156,200,232,.24); border-radius:6px; background:#17374e; color:#eef6fa; font:600 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#245a85; }.map-toolbar button:hover { background:#326f9f; }.map-actions { display:flex; gap:7px; }.network-map-layout { grid-template-columns:minmax(0,1fr) 280px; min-height:610px; border:0; border-radius:0; background:#0d1e2c; }.network-map-layout #network-map { min-height:610px; background-image:radial-gradient(circle at 50% 0,rgba(79,163,223,.1),transparent 28rem),linear-gradient(rgba(156,200,232,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(156,200,232,.045) 1px,transparent 1px); background-size:auto,36px 36px,36px 36px; }.network-map-layout aside { padding:24px; border-left:1px solid rgba(156,200,232,.16); background:#112b3d; color:#eef6fa; }.network-map-layout aside .eyebrow { color:#9cc8e8; }.network-map-layout aside p { color:#b7cad9; }.network-map-layout dt { color:#82b7dc; }.network-map-layout dd { color:#eef6fa; }.inspector-arrow { color:#b8e36a; }.legend.source { background:#4fa3df; }.legend.target { background:#3bca99; } @media (max-width:850px) { .map-hero { flex-direction:column; align-items:start; margin:0 0 20px; }.map-stat-strip { grid-template-columns:repeat(2,1fr); margin:0 0 20px; }.map-header { align-items:start; flex-direction:column; }.map-toolbar { padding:0 18px 18px; }.network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid rgba(156,200,232,.16); border-left:0; } }
|
||||||
|
|||||||
18
infra/debug-dashboard/static/vendor/cytoscape-LICENSE.txt
vendored
Normal file
18
infra/debug-dashboard/static/vendor/cytoscape-LICENSE.txt
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
Cytoscape.js 3.34.0
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2016-2026, The Cytoscape Consortium
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
31
infra/debug-dashboard/static/vendor/cytoscape.min.js
vendored
Normal file
31
infra/debug-dashboard/static/vendor/cytoscape.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
|||||||
<body class="app-shell">
|
<body class="app-shell">
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<a class="wordmark" href="{{ url_for('overview') }}"><span>OC</span>Sentinel</a>
|
<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>
|
<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 == 'network' else '' }}" href="{{ url_for('network') }}">Zugriffswege</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
|
||||||
</header>
|
</header>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<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 %}
|
{% if ransomware_beta.enabled %}<section class="panel ransomware-panel {{ ransomware_beta.state }}"><div class="panel-heading"><span class="eyebrow">Passive Beta</span><h2>Ransomware-Frueherkennung <small>{{ ransomware_beta.state }}</small></h2><p>{{ ransomware_beta.reason }}</p></div><div class="table-wrap"><table><thead><tr><th>Zeitpunkt</th><th>Signal</th><th>Prozess</th><th>Quelle</th><th>Bewertung</th></tr></thead><tbody>{% for signal in ransomware_beta.signals %}<tr><td>{{ signal.timestamp }}</td><td>{{ signal.category }}</td><td>{{ signal.process }}</td><td>{{ signal.source }}</td><td><span class="state {{ 'critical' if signal.confidence == 'high' else 'warning' }}">{{ signal.confidence }}</span></td></tr>{% else %}<tr><td colspan="5" class="empty-state">Keine Signale im aktuellen Beta-Zeitfenster.</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
|
||||||
|
{% if ransomware_beta.smbSessions %}<section class="panel smb-context-panel"><div class="panel-heading"><span class="eyebrow">Incident-Kontext</span><h2>Aktive SMB-Sitzungen <small>Nur bei Ransomware-Warnung oder kritisch erfasst</small></h2></div><div class="table-wrap"><table><thead><tr><th>Client</th><th>Benutzer</th><th>Offene Dateien</th><th>Sitzung</th></tr></thead><tbody>{% for session in ransomware_beta.smbSessions %}<tr><td>{{ session.clientComputerName }}</td><td>{{ session.clientUserName }}</td><td>{{ session.openFileCount }}</td><td>{{ session.sessionId }}</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
|
||||||
<section class="panel event-summary-panel"><div class="panel-heading"><span class="eyebrow">Schnelluebersicht</span><h2>Erkannte Sicherheitsereignisse</h2><p>Fehlgeschlagene Anmeldungen und weitere Vorfaelle aus dem letzten Scan, nach Konto und Quell-IP zusammengefasst.</p></div><div class="table-wrap"><table><thead><tr><th>Vorfall</th><th>Konto</th><th>Quell-IP</th><th>Letzter Zeitpunkt</th><th>Anzahl</th></tr></thead><tbody>{% for entry in security_events %}<tr><td><strong>{{ entry.type }}</strong></td><td>{{ entry.account }}</td><td>{{ entry.source_ip }}</td><td>{{ entry.latest }}</td><td><span class="event-count">{{ entry.count }}</span></td></tr>{% else %}<tr><td colspan="5" class="empty-state">Keine sicherheitsrelevanten Ereignisse im letzten Scan.</td></tr>{% endfor %}</tbody></table></div></section>
|
<section class="panel 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 %}
|
||||||
|
|||||||
52
infra/debug-dashboard/templates/network.html
Normal file
52
infra/debug-dashboard/templates/network.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Zugriffswege - OfficeCom Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="map-hero">
|
||||||
|
<div><span class="eyebrow">Security topology</span><h1>Zugriffswege<br><em>sichtbar machen.</em></h1><p>Verdichtete fehlgeschlagene Anmeldungen aus den letzten {{ days }} Tagen. Die Karte zeigt nur beobachtete Pfade, keinen vollstaendigen Netzwerkverkehr.</p></div>
|
||||||
|
<div class="map-hero-status"><span>LIVE DATASET</span><strong>{{ total_events }}</strong><small>beobachtete Versuche</small></div>
|
||||||
|
</section>
|
||||||
|
<section class="map-stat-strip"><article><span>Quellen</span><strong>{{ source_count }}</strong><small>externe IPs</small></article><article><span>Ziele</span><strong>{{ target_count }}</strong><small>Systeme</small></article><article><span>Pfade</span><strong>{{ path_count }}</strong><small>korrelierte Kanten</small></article><article><span>Zeitraum</span><strong>{{ days }}</strong><small>Tage Rueckblick</small></article></section>
|
||||||
|
<section class="network-map-panel">
|
||||||
|
<header class="map-header"><div><span class="eyebrow">Interaktive Analyse</span><h2>Access graph</h2></div><div class="map-legend"><span><i class="source"></i>Quell-IP</span><span><i class="target"></i>Zielgeraet</span><span><i class="hot"></i>Hohe Aktivitaet</span></div></header>
|
||||||
|
<div class="map-toolbar">
|
||||||
|
<label class="search-field"><span>Suchen</span><input id="map-filter" type="search" placeholder="IP, Geraet oder Konto"></label>
|
||||||
|
<label><span>Zeitraum</span><select id="map-range"><option value="1" {% if days == 1 %}selected{% endif %}>24 Stunden</option><option value="7" {% if days == 7 %}selected{% endif %}>7 Tage</option><option value="14" {% if days == 14 %}selected{% endif %}>14 Tage</option><option value="30" {% if days == 30 %}selected{% endif %}>30 Tage</option></select></label>
|
||||||
|
<label><span>Organisation</span><select id="map-organization"><option value="">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}">{{ organization[1] }}</option>{% endfor %}</select></label>
|
||||||
|
<label><span>Vorfall</span><select id="map-event"><option value="">Alle Vorfaelle</option>{% for event_type in event_types %}<option value="{{ event_type }}">{{ event_type }}</option>{% endfor %}</select></label>
|
||||||
|
<div class="map-actions"><button type="button" id="map-fit">Gesamtansicht</button><button type="button" id="map-export">JSON</button><button type="button" id="map-print">Drucken</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="network-map-layout"><div id="network-map" aria-label="Interaktive Netzwerk- und Zugriffskarte"></div><aside id="network-inspector"><span class="eyebrow">Inspector</span><strong>Kein Element ausgewaehlt</strong><p>Waehle einen Knoten oder einen Pfad. Zugehoerige Verbindungen werden hervorgehoben.</p></aside></div>
|
||||||
|
</section>
|
||||||
|
<script src="{{ url_for('static', filename='vendor/cytoscape.min.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const inspector = document.getElementById('network-inspector');
|
||||||
|
const filter = document.getElementById('map-filter');
|
||||||
|
const organization = document.getElementById('map-organization');
|
||||||
|
const eventType = document.getElementById('map-event');
|
||||||
|
const apiUrl = '{{ url_for("network_api") }}?days={{ days }}';
|
||||||
|
fetch(apiUrl).then(response => response.json()).then(graph => {
|
||||||
|
const cy = cytoscape({ container: document.getElementById('network-map'), elements: graph.elements, minZoom: .3, maxZoom: 2.4,
|
||||||
|
style: [
|
||||||
|
{ selector: 'node', style: { 'label': 'data(label)', 'font-family': 'Roboto', 'font-size': 11, 'font-weight': 700, 'color': '#eaf2f8', 'text-valign': 'bottom', 'text-margin-y': 8, 'text-outline-width': 3, 'text-outline-color': '#0d1e2c', 'width': 48, 'height': 48, 'border-width': 2, 'border-color': '#d9f5ed' } },
|
||||||
|
{ selector: 'node[kind = "source"]', style: { 'background-color': '#4fa3df', 'shape': 'ellipse' } },
|
||||||
|
{ selector: 'node[kind = "target"]', style: { 'background-color': '#3bca99', 'shape': 'round-rectangle' } },
|
||||||
|
{ selector: 'edge', style: { 'width': 'mapData(count, 1, ' + graph.max_count + ', 2, 10)', 'line-color': '#406b86', 'target-arrow-color': '#406b86', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'opacity': .75 } },
|
||||||
|
{ selector: 'edge[count >= 5]', style: { 'line-color': '#ffb454', 'target-arrow-color': '#ffb454' } },
|
||||||
|
{ selector: '.selected', style: { 'border-color': '#e7ff88', 'border-width': 6, 'line-color': '#e7ff88', 'target-arrow-color': '#e7ff88', 'opacity': 1, 'z-index': 20 } },
|
||||||
|
{ selector: '.hidden', style: { 'display': 'none' } }
|
||||||
|
], layout: { name: 'cose', animate: false, padding: 52, nodeRepulsion: 9000, idealEdgeLength: 145, gravity: .2 } });
|
||||||
|
const resetInspector = () => inspector.innerHTML = '<span class="eyebrow">Inspector</span><strong>Kein Element ausgewaehlt</strong><p>Waehle einen Knoten oder einen Pfad. Zugehoerige Verbindungen werden hervorgehoben.</p>';
|
||||||
|
const show = element => { const data = element.data(); if (element.isEdge()) { inspector.innerHTML = '<span class="eyebrow">Observed path</span><strong>' + data.source.replace('source:', '') + ' <span class="inspector-arrow">to</span> ' + data.machine_name + '</strong><dl><dt>Versuche</dt><dd>' + data.count + '</dd><dt>Organisation</dt><dd>' + data.organization_name + '</dd><dt>Konto</dt><dd>' + data.account + '</dd><dt>Vorfall</dt><dd>' + data.event_type + '</dd><dt>Letzter Scan</dt><dd>' + data.last_seen + '</dd></dl>'; } else { const connected = element.connectedEdges(':visible'); inspector.innerHTML = '<span class="eyebrow">' + (data.kind === 'source' ? 'Quell-IP' : 'Zielgeraet') + '</span><strong>' + data.label + '</strong><p>' + connected.length + ' sichtbare Zugriffswege im aktuellen Filter.</p>'; } };
|
||||||
|
const applyFilters = () => { const term = filter.value.trim().toLowerCase(); const org = organization.value; const type = eventType.value; cy.elements().addClass('hidden'); const visible = cy.edges().filter(edge => { const d = edge.data(); return (!term || [d.source, d.machine_name, d.account, d.event_type].join(' ').toLowerCase().includes(term)) && (!org || d.organization_id === org) && (!type || d.event_type === type); }); visible.removeClass('hidden'); visible.connectedNodes().removeClass('hidden'); cy.layout({ name:'cose', animate:false, padding:52, nodeRepulsion:9000, idealEdgeLength:145, gravity:.2 }).run(); resetInspector(); };
|
||||||
|
cy.on('tap', 'node, edge', event => { cy.elements().removeClass('selected'); event.target.addClass('selected'); if (event.target.isNode()) event.target.connectedEdges(':visible').addClass('selected'); show(event.target); });
|
||||||
|
cy.on('tap', event => { if (event.target === cy) { cy.elements().removeClass('selected'); resetInspector(); } });
|
||||||
|
filter.addEventListener('input', applyFilters); organization.addEventListener('change', applyFilters); eventType.addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('map-range').addEventListener('change', e => { window.location.search = 'days=' + e.target.value; });
|
||||||
|
document.getElementById('map-fit').addEventListener('click', () => cy.fit(cy.elements(':visible'), 48));
|
||||||
|
document.getElementById('map-export').addEventListener('click', () => { const data = cy.json().elements; const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'}); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'ocsentinel-access-map.json'; link.click(); URL.revokeObjectURL(link.href); });
|
||||||
|
document.getElementById('map-print').addEventListener('click', () => window.print());
|
||||||
|
}).catch(() => { document.getElementById('network-map').textContent = 'Die Netzwerkdaten konnten nicht geladen werden.'; });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"channel": "beta",
|
"channel": "beta",
|
||||||
"version": "1.5.0-beta.1",
|
"version": "1.5.0-beta.1",
|
||||||
"publishedAtUtc": "2026-07-29T23:04:51.9334773Z",
|
"publishedAtUtc": "2026-07-29T23:07:54.7923320Z",
|
||||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.1/OCSentinelClient-win-x64.zip",
|
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.1/OCSentinelClient-win-x64.zip",
|
||||||
"sha256": "2bd6db7261f7ca9b47741968b922aea5969251539530e58441e882f53686ae6b",
|
"sha256": "a712ee820dee2f6786a8297d892b3ae8844548ddc7030dcbe01435fbc89d924a",
|
||||||
"minUpdaterVersion": "1.0.0"
|
"minUpdaterVersion": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"publishedAtUtc": "2026-07-28T22:50:17.7779552Z",
|
"publishedAtUtc": "2026-07-28T22:50:17.7779552Z",
|
||||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.4.0/OCSentinelClient-win-x64.zip",
|
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.4.0/OCSentinelClient-win-x64.zip?asset-revision=ece66505c4146fec",
|
||||||
"sha256": "ece66505c4146fec72ba12cb59d3d0a39bb91a3aee44e338d19c186e56e2fad7",
|
"sha256": "ece66505c4146fec72ba12cb59d3d0a39bb91a3aee44e338d19c186e56e2fad7",
|
||||||
"minUpdaterVersion": "1.0.0"
|
"minUpdaterVersion": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ foreach ($path in @($configPath, $secretScript, $monitorScript)) {
|
|||||||
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
||||||
$config.n8nWebhookUrl = $WebhookUrl
|
$config.n8nWebhookUrl = $WebhookUrl
|
||||||
$config.environment = "production"
|
$config.environment = "production"
|
||||||
|
$ninjaContext = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
|
||||||
|
)
|
||||||
|
foreach ($entry in $ninjaContext) {
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
$config | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
||||||
Write-Host "OCSentinel upload endpoint configured."
|
Write-Host "OCSentinel upload endpoint configured."
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,20 @@ $secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
|||||||
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
||||||
$config.n8nWebhookUrl = $WebhookUrl
|
$config.n8nWebhookUrl = $WebhookUrl
|
||||||
$config.environment = "production"
|
$config.environment = "production"
|
||||||
|
$ninjaContext = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
|
||||||
|
)
|
||||||
|
foreach ($entry in $ninjaContext) {
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
$config | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
||||||
|
|
||||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue
|
||||||
|
|||||||
110
scripts/refresh-ocsentinel-ninja-context.ps1
Normal file
110
scripts/refresh-ocsentinel-ninja-context.ps1
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProgressPreference = "SilentlyContinue"
|
||||||
|
|
||||||
|
function Initialize-OCSentinelTls {
|
||||||
|
$protocols = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
|
||||||
|
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
|
||||||
|
}
|
||||||
|
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = $protocols
|
||||||
|
[Net.ServicePointManager]::Expect100Continue = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-NinjaEnvironmentValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
|
||||||
|
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||||
|
if ($null -eq $value) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
Initialize-OCSentinelTls
|
||||||
|
|
||||||
|
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||||
|
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
|
||||||
|
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||||
|
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||||
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
|
||||||
|
foreach ($path in @($updaterPath, $clientConfigPath, $monitorPath)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $path)) {
|
||||||
|
throw "OCSentinel installation is incomplete. Missing: $path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The NinjaOne context exists only during this script execution. Upgrade first so
|
||||||
|
# future scheduled scans restore the context from the local client configuration.
|
||||||
|
$escapedUpdaterPath = $updaterPath.Replace("'", "''")
|
||||||
|
$escapedManifestUrl = $ManifestUrl.Replace("'", "''")
|
||||||
|
$updateCommand = @"
|
||||||
|
`$protocols = [Net.SecurityProtocolType]::Tls12
|
||||||
|
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') { `$protocols = `$protocols -bor [Net.SecurityProtocolType]::Tls13 }
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = `$protocols
|
||||||
|
[Net.ServicePointManager]::Expect100Continue = `$false
|
||||||
|
& '$escapedUpdaterPath' -ManifestUrl '$escapedManifestUrl'
|
||||||
|
exit `$LASTEXITCODE
|
||||||
|
"@
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $updateCommand | ForEach-Object { Write-Host $_ }
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "OCSentinel updater exited with code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
$mappings = @(
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId"; Required = $true },
|
||||||
|
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName"; Required = $true },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId"; Required = $true },
|
||||||
|
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId"; Required = $false },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId"; Required = $false },
|
||||||
|
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName"; Required = $false }
|
||||||
|
)
|
||||||
|
|
||||||
|
$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json
|
||||||
|
$missing = @()
|
||||||
|
$captured = 0
|
||||||
|
foreach ($mapping in $mappings) {
|
||||||
|
$value = Read-NinjaEnvironmentValue -Name $mapping.EnvironmentName
|
||||||
|
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
if ($mapping.Required) { $missing += $mapping.EnvironmentName }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientConfig | Add-Member -NotePropertyName $mapping.PropertyName -NotePropertyValue $value -Force
|
||||||
|
$captured++
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing.Count -gt 0) {
|
||||||
|
throw "NinjaOne did not provide required context: $($missing -join ', '). Run this only from a NinjaOne automation, not from an interactive PowerShell session."
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8
|
||||||
|
Write-Host "OCSentinel NinjaOne context captured: $captured of $($mappings.Count) values."
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace([string]$clientConfig.n8nWebhookUrl)) {
|
||||||
|
throw "NinjaOne context was stored, but this client has no configured n8n webhook URL. Run the OCSentinel installation/configuration automation with its WebhookUrl variable first."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $secretPath)) {
|
||||||
|
throw "NinjaOne context was stored, but the protected upload secret is missing. Run the OCSentinel installation/configuration automation with its SecretValue variable first."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Running an immediate status scan and upload with the refreshed NinjaOne context."
|
||||||
|
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorPath `
|
||||||
|
-Mode status `
|
||||||
|
-ClientConfigPath $clientConfigPath `
|
||||||
|
-SecretPath $secretPath `
|
||||||
|
-UploadMode required `
|
||||||
|
-SuppressTriggerExit
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "OCSentinel context refresh scan exited with code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "OCSENTINEL_NINJA_CONTEXT=updated"
|
||||||
@@ -3,6 +3,7 @@ using System.Globalization;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using OCSentinelCli.Configuration;
|
||||||
|
|
||||||
namespace OCSentinelCli;
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
@@ -54,7 +55,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 = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta);
|
AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta, configuration.RansomwareBetaAlertingEnabled);
|
||||||
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)
|
||||||
@@ -68,7 +69,7 @@ internal sealed class AttackScanner
|
|||||||
{
|
{
|
||||||
SchemaVersion = "2.0",
|
SchemaVersion = "2.0",
|
||||||
MachineName = Environment.MachineName,
|
MachineName = Environment.MachineName,
|
||||||
NinjaOne = GetNinjaOneContext(),
|
NinjaOne = GetNinjaOneContext(options, errors),
|
||||||
GeneratedAtLocal = generatedAtLocal,
|
GeneratedAtLocal = generatedAtLocal,
|
||||||
GeneratedAtUtc = generatedAtUtc,
|
GeneratedAtUtc = generatedAtUtc,
|
||||||
ClientVersion = BuildMetadata.Version,
|
ClientVersion = BuildMetadata.Version,
|
||||||
@@ -93,19 +94,38 @@ internal sealed class AttackScanner
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NinjaOneContext GetNinjaOneContext()
|
private static NinjaOneContext GetNinjaOneContext(ScanOptions options, List<string> errors)
|
||||||
{
|
{
|
||||||
|
ClientConfiguration? clientConfiguration = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.ClientConfigPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
clientConfiguration = ClientConfiguration.Load(options.ClientConfigPath);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Could not load persisted NinjaOne context: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new NinjaOneContext
|
return new NinjaOneContext
|
||||||
{
|
{
|
||||||
OrganizationId = ReadEnvironmentVariable("NINJA_ORGANIZATION_ID"),
|
OrganizationId = ReadContextValue("NINJA_ORGANIZATION_ID", clientConfiguration?.NinjaOrganizationId),
|
||||||
OrganizationName = ReadEnvironmentVariable("NINJA_ORGANIZATION_NAME"),
|
OrganizationName = ReadContextValue("NINJA_ORGANIZATION_NAME", clientConfiguration?.NinjaOrganizationName),
|
||||||
MachineId = ReadEnvironmentVariable("NINJA_AGENT_MACHINE_ID"),
|
MachineId = ReadContextValue("NINJA_AGENT_MACHINE_ID", clientConfiguration?.NinjaMachineId),
|
||||||
NodeId = ReadEnvironmentVariable("NINJA_AGENT_NODE_ID"),
|
NodeId = ReadContextValue("NINJA_AGENT_NODE_ID", clientConfiguration?.NinjaNodeId),
|
||||||
LocationId = ReadEnvironmentVariable("NINJA_LOCATION_ID"),
|
LocationId = ReadContextValue("NINJA_LOCATION_ID", clientConfiguration?.NinjaLocationId),
|
||||||
LocationName = ReadEnvironmentVariable("NINJA_LOCATION_NAME")
|
LocationName = ReadContextValue("NINJA_LOCATION_NAME", clientConfiguration?.NinjaLocationName)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ReadContextValue(string environmentName, string? persistedValue)
|
||||||
|
{
|
||||||
|
string currentValue = ReadEnvironmentVariable(environmentName);
|
||||||
|
return string.IsNullOrWhiteSpace(currentValue) ? persistedValue?.Trim() ?? string.Empty : currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
private static string ReadEnvironmentVariable(string name)
|
private static string ReadEnvironmentVariable(string name)
|
||||||
{
|
{
|
||||||
return Environment.GetEnvironmentVariable(name)?.Trim() ?? string.Empty;
|
return Environment.GetEnvironmentVariable(name)?.Trim() ?? string.Empty;
|
||||||
@@ -519,9 +539,9 @@ 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)
|
private static AlertAssessment MergeRansomwareAssessment(AlertAssessment loginAssessment, RansomwareBetaSummary ransomwareBeta, bool ransomwareAlertingEnabled)
|
||||||
{
|
{
|
||||||
if (ransomwareBeta.State is not ("warning" or "critical"))
|
if (!RansomwareAlertPolicy.CanElevate(ransomwareBeta, ransomwareAlertingEnabled))
|
||||||
{
|
{
|
||||||
return loginAssessment;
|
return loginAssessment;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ internal static class ScanAndUploadCommand
|
|||||||
|
|
||||||
if (string.Equals(args[i], "--client-config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
if (string.Equals(args[i], "--client-config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||||
{
|
{
|
||||||
clientConfigPath = args[++i];
|
string configPathValue = args[++i];
|
||||||
|
clientConfigPath = configPathValue;
|
||||||
|
scanArgs.Add("--client-config");
|
||||||
|
scanArgs.Add(configPathValue);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,30 @@ internal sealed record ScannerConfiguration
|
|||||||
|
|
||||||
public bool RansomwareBetaEnabled { get; init; }
|
public bool RansomwareBetaEnabled { get; init; }
|
||||||
|
|
||||||
|
public bool RansomwareBetaAlertingEnabled { get; init; }
|
||||||
|
|
||||||
public int RansomwareLookbackMinutes { get; init; } = 15;
|
public int RansomwareLookbackMinutes { get; init; } = 15;
|
||||||
|
|
||||||
public int RansomwareWarningSignalCount { get; init; } = 2;
|
public int RansomwareWarningSignalCount { get; init; } = 2;
|
||||||
|
|
||||||
public int RansomwareCriticalSignalCount { get; init; } = 3;
|
public int RansomwareCriticalSignalCount { get; init; } = 3;
|
||||||
|
|
||||||
|
public bool RansomwareCaptureSmbSessions { get; init; } = true;
|
||||||
|
|
||||||
|
public bool RansomwareFileChurnEnabled { get; init; }
|
||||||
|
|
||||||
|
public int RansomwareFileChurnWindowMinutes { get; init; } = 15;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnWarningDeleteCount { get; init; } = 50;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnWarningWriteCount { get; init; } = 250;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnCriticalDeleteCount { get; init; } = 200;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnCriticalWriteCount { get; init; } = 1000;
|
||||||
|
|
||||||
|
public int RansomwareFileChurnMaxAuditEvents { get; init; } = 5000;
|
||||||
|
|
||||||
public List<string> RansomwareExcludedProcesses { get; init; } = [];
|
public List<string> RansomwareExcludedProcesses { get; init; } = [];
|
||||||
|
|
||||||
public List<string> FtpRoots { get; init; } = [];
|
public List<string> FtpRoots { get; init; } = [];
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ internal sealed record ClientConfiguration
|
|||||||
|
|
||||||
public string N8nWebhookUrl { get; init; } = string.Empty;
|
public string N8nWebhookUrl { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaOrganizationId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaOrganizationName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaMachineId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaNodeId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaLocationId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string NinjaLocationName { get; init; } = string.Empty;
|
||||||
|
|
||||||
public string DeviceIdentifierMode { get; init; } = "machineName";
|
public string DeviceIdentifierMode { get; init; } = "machineName";
|
||||||
|
|
||||||
public int UploadTimeoutSeconds { get; init; } = 30;
|
public int UploadTimeoutSeconds { get; init; } = 30;
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ internal sealed record RansomwareBetaSummary
|
|||||||
{
|
{
|
||||||
public bool Enabled { get; init; }
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public bool AlertingEnabled { get; init; }
|
||||||
|
|
||||||
public string State { get; init; } = "disabled";
|
public string State { get; init; } = "disabled";
|
||||||
|
|
||||||
public string Reason { get; init; } = "Ransomware beta is disabled.";
|
public string Reason { get; init; } = "Ransomware beta is disabled.";
|
||||||
@@ -126,6 +128,51 @@ internal sealed record RansomwareBetaSummary
|
|||||||
public int LookbackMinutes { get; init; }
|
public int LookbackMinutes { get; init; }
|
||||||
|
|
||||||
public List<RansomwareSignal> Signals { get; init; } = [];
|
public List<RansomwareSignal> Signals { get; init; } = [];
|
||||||
|
|
||||||
|
public List<RansomwareSmbSession> SmbSessions { get; init; } = [];
|
||||||
|
|
||||||
|
public RansomwareFileChurnSummary FileChurn { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public bool DataAvailable { get; init; }
|
||||||
|
|
||||||
|
public bool IsTruncated { get; init; }
|
||||||
|
|
||||||
|
public int WindowMinutes { get; init; }
|
||||||
|
|
||||||
|
public int FileOperationCount { get; init; }
|
||||||
|
|
||||||
|
public int DeleteOperationCount { get; init; }
|
||||||
|
|
||||||
|
public int WriteOperationCount { get; init; }
|
||||||
|
|
||||||
|
public int DistinctProcessCount { get; init; }
|
||||||
|
|
||||||
|
public List<RansomwareFileChurnProcess> TopProcesses { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareFileChurnProcess
|
||||||
|
{
|
||||||
|
public string Process { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int DeleteOperationCount { get; init; }
|
||||||
|
|
||||||
|
public int WriteOperationCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareSmbSession
|
||||||
|
{
|
||||||
|
public string ClientComputerName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string ClientUserName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public long SessionId { get; init; }
|
||||||
|
|
||||||
|
public long OpenFileCount { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record RansomwareSignal
|
internal sealed record RansomwareSignal
|
||||||
|
|||||||
@@ -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.5.0-beta.1</Version>
|
<Version>1.5.0-beta.2</Version>
|
||||||
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.5.0.0</FileVersion>
|
<FileVersion>1.5.0.0</FileVersion>
|
||||||
<InformationalVersion>1.5.0-beta.1</InformationalVersion>
|
<InformationalVersion>1.5.0-beta.2</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
3
src/OCSentinelCli/Properties/AssemblyInfo.cs
Normal file
3
src/OCSentinelCli/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("OCSentinelCli.Tests")]
|
||||||
9
src/OCSentinelCli/RansomwareAlertPolicy.cs
Normal file
9
src/OCSentinelCli/RansomwareAlertPolicy.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
|
internal static class RansomwareAlertPolicy
|
||||||
|
{
|
||||||
|
public static bool CanElevate(RansomwareBetaSummary summary, bool alertingEnabled)
|
||||||
|
{
|
||||||
|
return alertingEnabled && summary.Enabled && (summary.State is "warning" or "critical");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.Eventing.Reader;
|
using System.Diagnostics.Eventing.Reader;
|
||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace OCSentinelCli;
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
@@ -20,6 +22,12 @@ internal static class RansomwareBetaDetector
|
|||||||
ScanSecurityProcesses(signals, errors, since, configuration);
|
ScanSecurityProcesses(signals, errors, since, configuration);
|
||||||
ScanPowerShellScriptBlocks(signals, errors, since, configuration);
|
ScanPowerShellScriptBlocks(signals, errors, since, configuration);
|
||||||
ScanSysmonProcesses(signals, errors, since, configuration);
|
ScanSysmonProcesses(signals, errors, since, configuration);
|
||||||
|
RansomwareFileChurnSummary fileChurn = RansomwareFileChurnDetector.Scan(configuration, errors);
|
||||||
|
RansomwareSignal? fileChurnSignal = RansomwareFileChurnDetector.CreateSignal(fileChurn, configuration);
|
||||||
|
if (fileChurnSignal is not null)
|
||||||
|
{
|
||||||
|
signals.Add(fileChurnSignal);
|
||||||
|
}
|
||||||
|
|
||||||
List<RansomwareSignal> distinctSignals = signals
|
List<RansomwareSignal> distinctSignals = signals
|
||||||
.OrderBy(signal => signal.Timestamp)
|
.OrderBy(signal => signal.Timestamp)
|
||||||
@@ -42,13 +50,19 @@ internal static class RansomwareBetaDetector
|
|||||||
_ => $"Ransomware beta found no suspicious process activity in the last {lookbackMinutes} minutes."
|
_ => $"Ransomware beta found no suspicious process activity in the last {lookbackMinutes} minutes."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
List<RansomwareSmbSession> smbSessions = state is "warning" or "critical" && configuration.RansomwareCaptureSmbSessions
|
||||||
|
? CaptureSmbSessions(errors)
|
||||||
|
: [];
|
||||||
return new RansomwareBetaSummary
|
return new RansomwareBetaSummary
|
||||||
{
|
{
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
|
AlertingEnabled = configuration.RansomwareBetaAlertingEnabled,
|
||||||
State = state,
|
State = state,
|
||||||
Reason = reason,
|
Reason = reason,
|
||||||
LookbackMinutes = lookbackMinutes,
|
LookbackMinutes = lookbackMinutes,
|
||||||
Signals = distinctSignals
|
Signals = distinctSignals,
|
||||||
|
SmbSessions = smbSessions,
|
||||||
|
FileChurn = fileChurn
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,4 +183,49 @@ internal static class RansomwareBetaDetector
|
|||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<RansomwareSmbSession> CaptureSmbSessions(List<string> errors)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
Arguments = "-NoProfile -NonInteractive -Command \"Get-SmbSession | Select-Object ClientComputerName,ClientUserName,SessionId,NumOpens | ConvertTo-Json -Compress\"",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
});
|
||||||
|
if (process is null || !process.WaitForExit(5000) || process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
string json = process.StandardOutput.ReadToEnd();
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonElement root = JsonSerializer.Deserialize<JsonElement>(json, JsonOptions.Default);
|
||||||
|
IEnumerable<JsonElement> rows = root.ValueKind == JsonValueKind.Array ? root.EnumerateArray().ToArray() : [root];
|
||||||
|
return rows.Take(100).Select(row => new RansomwareSmbSession
|
||||||
|
{
|
||||||
|
ClientComputerName = GetJsonString(row, "ClientComputerName"),
|
||||||
|
ClientUserName = GetJsonString(row, "ClientUserName"),
|
||||||
|
SessionId = GetJsonLong(row, "SessionId"),
|
||||||
|
OpenFileCount = GetJsonLong(row, "NumOpens")
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware beta SMB snapshot failed: {exception.Message}");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetJsonString(JsonElement value, string name) => value.TryGetProperty(name, out JsonElement property) ? property.ToString() : string.Empty;
|
||||||
|
|
||||||
|
private static long GetJsonLong(JsonElement value, string name) => value.TryGetProperty(name, out JsonElement property) && property.TryGetInt64(out long result) ? result : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
296
src/OCSentinelCli/RansomwareFileChurnDetector.cs
Normal file
296
src/OCSentinelCli/RansomwareFileChurnDetector.cs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
using System.Diagnostics.Eventing.Reader;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
internal static class RansomwareFileChurnDetector
|
||||||
|
{
|
||||||
|
private const uint DeleteAccessMask = 0x00010000;
|
||||||
|
private const uint FileWriteAccessMask = 0x00000156;
|
||||||
|
private const string StateFileName = "ransomware-file-churn.json";
|
||||||
|
|
||||||
|
public static RansomwareFileChurnSummary Scan(ScannerConfiguration configuration, List<string> errors)
|
||||||
|
{
|
||||||
|
if (!configuration.RansomwareFileChurnEnabled)
|
||||||
|
{
|
||||||
|
return new RansomwareFileChurnSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
int windowMinutes = Math.Clamp(configuration.RansomwareFileChurnWindowMinutes, 1, 60);
|
||||||
|
int maximumAuditEvents = Math.Clamp(configuration.RansomwareFileChurnMaxAuditEvents, 100, 20000);
|
||||||
|
DateTimeOffset windowStart = DateTimeOffset.UtcNow.AddMinutes(-windowMinutes);
|
||||||
|
var observed = new List<FileAuditActivity>();
|
||||||
|
bool isTruncated = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - windowStart).TotalMilliseconds);
|
||||||
|
string query = $"*[System[(EventID=4663) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]";
|
||||||
|
using var reader = new EventLogReader(new EventLogQuery("Security", PathType.LogName, query));
|
||||||
|
int inspected = 0;
|
||||||
|
for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent())
|
||||||
|
{
|
||||||
|
using (record)
|
||||||
|
{
|
||||||
|
if (++inspected > maximumAuditEvents)
|
||||||
|
{
|
||||||
|
isTruncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryCreateActivity(record, configuration, out FileAuditActivity? activity) && activity is not null)
|
||||||
|
{
|
||||||
|
observed.Add(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware file churn cannot read the Security log: {exception.Message}");
|
||||||
|
return Unavailable(windowMinutes);
|
||||||
|
}
|
||||||
|
catch (EventLogNotFoundException)
|
||||||
|
{
|
||||||
|
return Unavailable(windowMinutes);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware file churn query failed: {exception.Message}");
|
||||||
|
return Unavailable(windowMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTruncated)
|
||||||
|
{
|
||||||
|
return BuildSummary(observed, windowMinutes, isTruncated: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FileAuditActivity> rollingActivities = MergeWithState(observed, windowStart, errors);
|
||||||
|
return BuildSummary(rollingActivities, windowMinutes, isTruncated: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RansomwareSignal? CreateSignal(RansomwareFileChurnSummary summary, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
if (!summary.Enabled || !summary.DataAvailable || summary.IsTruncated)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int warningDeletes = Math.Max(1, configuration.RansomwareFileChurnWarningDeleteCount);
|
||||||
|
int warningWrites = Math.Max(1, configuration.RansomwareFileChurnWarningWriteCount);
|
||||||
|
int criticalDeletes = Math.Max(warningDeletes, configuration.RansomwareFileChurnCriticalDeleteCount);
|
||||||
|
int criticalWrites = Math.Max(warningWrites, configuration.RansomwareFileChurnCriticalWriteCount);
|
||||||
|
bool critical = summary.DeleteOperationCount >= criticalDeletes && summary.WriteOperationCount >= criticalWrites;
|
||||||
|
bool warning = summary.DeleteOperationCount >= warningDeletes && summary.WriteOperationCount >= warningWrites;
|
||||||
|
if (!warning)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
RansomwareFileChurnProcess? topProcess = summary.TopProcesses.FirstOrDefault();
|
||||||
|
string evidence = $"delete={summary.DeleteOperationCount}; write={summary.WriteOperationCount}; processes={summary.DistinctProcessCount}; window={summary.WindowMinutes}m";
|
||||||
|
return new RansomwareSignal
|
||||||
|
{
|
||||||
|
Timestamp = DateTimeOffset.Now,
|
||||||
|
Category = critical ? "file-churn-critical" : "file-churn",
|
||||||
|
Confidence = critical ? "medium" : "low",
|
||||||
|
Process = topProcess?.Process ?? "[multiple]",
|
||||||
|
Source = "Security file audit",
|
||||||
|
EventId = 4663,
|
||||||
|
Evidence = evidence
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareFileChurnSummary Unavailable(int windowMinutes)
|
||||||
|
{
|
||||||
|
return new RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
WindowMinutes = windowMinutes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryCreateActivity(EventRecord record, ScannerConfiguration configuration, out FileAuditActivity? activity)
|
||||||
|
{
|
||||||
|
activity = null;
|
||||||
|
if (!record.TimeCreated.HasValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, string> data = ReadEventData(record);
|
||||||
|
if (!data.TryGetValue("ObjectType", out string? objectType) || !string.Equals(objectType, "File", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.TryGetValue("AccessMask", out string? accessMaskText) || !TryParseAccessMask(accessMaskText, out uint accessMask))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isDelete = (accessMask & DeleteAccessMask) != 0;
|
||||||
|
bool isWrite = (accessMask & FileWriteAccessMask) != 0;
|
||||||
|
if (!isDelete && !isWrite)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string process = data.TryGetValue("ProcessName", out string? processPath) ? Path.GetFileName(processPath.Trim()) : string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(process))
|
||||||
|
{
|
||||||
|
process = "[unknown]";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, process, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activity = new FileAuditActivity
|
||||||
|
{
|
||||||
|
Timestamp = new DateTimeOffset(record.TimeCreated.Value).ToUniversalTime(),
|
||||||
|
RecordId = record.RecordId ?? 0,
|
||||||
|
Process = process,
|
||||||
|
IsDelete = isDelete,
|
||||||
|
IsWrite = isWrite
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, string> ReadEventData(EventRecord record)
|
||||||
|
{
|
||||||
|
XDocument document = XDocument.Parse(record.ToXml());
|
||||||
|
return document.Descendants().Where(element => element.Name.LocalName == "Data")
|
||||||
|
.Where(element => element.Attribute("Name") is not null)
|
||||||
|
.ToDictionary(element => element.Attribute("Name")!.Value, element => element.Value.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseAccessMask(string value, out uint result)
|
||||||
|
{
|
||||||
|
string normalized = value.Trim();
|
||||||
|
NumberStyles style = NumberStyles.Integer;
|
||||||
|
if (normalized.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
normalized = normalized[2..];
|
||||||
|
style = NumberStyles.AllowHexSpecifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint.TryParse(normalized, style, CultureInfo.InvariantCulture, out result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FileAuditActivity> MergeWithState(List<FileAuditActivity> observed, DateTimeOffset windowStart, List<string> errors)
|
||||||
|
{
|
||||||
|
FileChurnState stored = LoadState(errors);
|
||||||
|
long highestObservedRecordId = observed.Count == 0 ? 0 : observed.Max(activity => activity.RecordId);
|
||||||
|
bool securityLogReset = stored.LastSecurityRecordId > 0 && highestObservedRecordId > 0 && highestObservedRecordId < stored.LastSecurityRecordId;
|
||||||
|
IEnumerable<FileAuditActivity> fresh = securityLogReset
|
||||||
|
? observed
|
||||||
|
: observed.Where(activity => activity.RecordId == 0 || activity.RecordId > stored.LastSecurityRecordId);
|
||||||
|
List<FileAuditActivity> rolling = (securityLogReset ? [] : stored.Activities)
|
||||||
|
.Concat(fresh)
|
||||||
|
.Where(activity => activity.Timestamp >= windowStart)
|
||||||
|
.GroupBy(activity => activity.RecordId > 0 ? activity.RecordId.ToString(CultureInfo.InvariantCulture) : $"{activity.Timestamp:O}|{activity.Process}|{activity.IsDelete}|{activity.IsWrite}")
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderBy(activity => activity.Timestamp)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
SaveState(new FileChurnState
|
||||||
|
{
|
||||||
|
LastSecurityRecordId = securityLogReset ? highestObservedRecordId : Math.Max(stored.LastSecurityRecordId, highestObservedRecordId),
|
||||||
|
Activities = rolling
|
||||||
|
}, errors);
|
||||||
|
return rolling;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RansomwareFileChurnSummary BuildSummary(List<FileAuditActivity> activities, int windowMinutes, bool isTruncated)
|
||||||
|
{
|
||||||
|
return new RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
DataAvailable = true,
|
||||||
|
IsTruncated = isTruncated,
|
||||||
|
WindowMinutes = windowMinutes,
|
||||||
|
FileOperationCount = activities.Count,
|
||||||
|
DeleteOperationCount = activities.Count(activity => activity.IsDelete),
|
||||||
|
WriteOperationCount = activities.Count(activity => activity.IsWrite),
|
||||||
|
DistinctProcessCount = activities.Select(activity => activity.Process).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||||
|
TopProcesses = activities.GroupBy(activity => activity.Process, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => new RansomwareFileChurnProcess
|
||||||
|
{
|
||||||
|
Process = group.Key,
|
||||||
|
DeleteOperationCount = group.Count(activity => activity.IsDelete),
|
||||||
|
WriteOperationCount = group.Count(activity => activity.IsWrite)
|
||||||
|
})
|
||||||
|
.OrderByDescending(process => process.DeleteOperationCount + process.WriteOperationCount)
|
||||||
|
.ThenBy(process => process.Process, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(5)
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FileChurnState LoadState(List<string> errors)
|
||||||
|
{
|
||||||
|
string path = GetStatePath();
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new FileChurnState();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<FileChurnState>(File.ReadAllText(path), JsonOptions.Default) ?? new FileChurnState();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware file churn state could not be read: {exception.Message}");
|
||||||
|
return new FileChurnState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SaveState(FileChurnState state, List<string> errors)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string path = GetStatePath();
|
||||||
|
string directory = Path.GetDirectoryName(path)!;
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string temporaryPath = path + ".tmp";
|
||||||
|
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(state, JsonOptions.Default));
|
||||||
|
File.Move(temporaryPath, path, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Ransomware file churn state could not be saved: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetStatePath()
|
||||||
|
{
|
||||||
|
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OCSentinel", "state", StateFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FileChurnState
|
||||||
|
{
|
||||||
|
public long LastSecurityRecordId { get; init; }
|
||||||
|
|
||||||
|
public List<FileAuditActivity> Activities { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FileAuditActivity
|
||||||
|
{
|
||||||
|
public DateTimeOffset Timestamp { get; init; }
|
||||||
|
|
||||||
|
public long RecordId { get; init; }
|
||||||
|
|
||||||
|
public string Process { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsDelete { get; init; }
|
||||||
|
|
||||||
|
public bool IsWrite { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ internal sealed record ScanOptions
|
|||||||
{
|
{
|
||||||
public const string Usage = """
|
public const string Usage = """
|
||||||
Usage:
|
Usage:
|
||||||
OCSentinelCli [--output <path>] [--lookback-days <n>] [--top <n>] [--config <path>] [--vulnerability-csv <path>] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help]
|
OCSentinelCli [--output <path>] [--lookback-days <n>] [--top <n>] [--config <path>] [--client-config <path>] [--vulnerability-csv <path>] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--output <path> Write the JSON report to the given file.
|
--output <path> Write the JSON report to the given file.
|
||||||
--lookback-days <n> Only include events newer than now minus n days. Default: 30
|
--lookback-days <n> Only include events newer than now minus n days. Default: 30
|
||||||
--top <n> Number of aggregated source IPs to show. Default: 10
|
--top <n> Number of aggregated source IPs to show. Default: 10
|
||||||
--config <path> Load thresholds, path overrides, and exclusions from JSON.
|
--config <path> Load thresholds, path overrides, and exclusions from JSON.
|
||||||
|
--client-config <path> Load persisted NinjaOne identity and upload settings from JSON.
|
||||||
--vulnerability-csv <path>
|
--vulnerability-csv <path>
|
||||||
Correlate local attack results with exported CVE data for this host.
|
Correlate local attack results with exported CVE data for this host.
|
||||||
--json-only Print only JSON to stdout.
|
--json-only Print only JSON to stdout.
|
||||||
@@ -36,6 +37,8 @@ Options:
|
|||||||
|
|
||||||
public string? ConfigPath { get; init; }
|
public string? ConfigPath { get; init; }
|
||||||
|
|
||||||
|
public string? ClientConfigPath { get; init; }
|
||||||
|
|
||||||
public string? VulnerabilityCsvPath { get; init; }
|
public string? VulnerabilityCsvPath { get; init; }
|
||||||
|
|
||||||
public bool ShowHelp { get; init; }
|
public bool ShowHelp { get; init; }
|
||||||
@@ -72,6 +75,9 @@ Options:
|
|||||||
case "--config":
|
case "--config":
|
||||||
options = options with { ConfigPath = ReadValue(args, ref i, arg) };
|
options = options with { ConfigPath = ReadValue(args, ref i, arg) };
|
||||||
break;
|
break;
|
||||||
|
case "--client-config":
|
||||||
|
options = options with { ClientConfigPath = ReadValue(args, ref i, arg) };
|
||||||
|
break;
|
||||||
case "--vulnerability-csv":
|
case "--vulnerability-csv":
|
||||||
options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) };
|
options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) };
|
||||||
break;
|
break;
|
||||||
|
|||||||
19
tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj
Normal file
19
tests/OCSentinelCli.Tests/OCSentinelCli.Tests.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\OCSentinelCli\OCSentinelCli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
55
tests/OCSentinelCli.Tests/RansomwareBetaTests.cs
Normal file
55
tests/OCSentinelCli.Tests/RansomwareBetaTests.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OCSentinelCli.Tests;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class RansomwareBetaTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FileChurnBelowBothThresholdsDoesNotCreateSignal()
|
||||||
|
{
|
||||||
|
var summary = new RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
DataAvailable = true,
|
||||||
|
DeleteOperationCount = 49,
|
||||||
|
WriteOperationCount = 500
|
||||||
|
};
|
||||||
|
|
||||||
|
RansomwareSignal? signal = RansomwareFileChurnDetector.CreateSignal(summary, new ScannerConfiguration());
|
||||||
|
|
||||||
|
Assert.Null(signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CriticalFileChurnCreatesOnlyMediumConfidenceSignal()
|
||||||
|
{
|
||||||
|
var summary = new RansomwareFileChurnSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
DataAvailable = true,
|
||||||
|
WindowMinutes = 15,
|
||||||
|
DeleteOperationCount = 200,
|
||||||
|
WriteOperationCount = 1000,
|
||||||
|
DistinctProcessCount = 1,
|
||||||
|
TopProcesses = [new RansomwareFileChurnProcess { Process = "encryptor.exe", DeleteOperationCount = 200, WriteOperationCount = 1000 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
RansomwareSignal? signal = RansomwareFileChurnDetector.CreateSignal(summary, new ScannerConfiguration());
|
||||||
|
|
||||||
|
Assert.NotNull(signal);
|
||||||
|
Assert.Equal("file-churn-critical", signal.Category);
|
||||||
|
Assert.Equal("medium", signal.Confidence);
|
||||||
|
Assert.Equal("encryptor.exe", signal.Process);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PassiveBetaCannotElevateNinjaAlertState()
|
||||||
|
{
|
||||||
|
var summary = new RansomwareBetaSummary { Enabled = true, State = "critical" };
|
||||||
|
|
||||||
|
Assert.False(RansomwareAlertPolicy.CanElevate(summary, alertingEnabled: false));
|
||||||
|
Assert.True(RansomwareAlertPolicy.CanElevate(summary, alertingEnabled: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user