Compare commits
10 Commits
v1.5.0-bet
...
v1.5.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc77c45a10 | ||
|
|
c73bab139b | ||
|
|
7009596efc | ||
|
|
a494bc4ba3 | ||
|
|
ddba660b1a | ||
|
|
517cfa6773 | ||
|
|
072a4257ed | ||
|
|
47fe9448b7 | ||
|
|
fd990b698f | ||
|
|
d1bbd8838f |
@@ -10,7 +10,7 @@
|
|||||||
"criticalSprayAccountCount": 10,
|
"criticalSprayAccountCount": 10,
|
||||||
"correlationWarningCveThreshold": 1,
|
"correlationWarningCveThreshold": 1,
|
||||||
"correlationCriticalCveThreshold": 1,
|
"correlationCriticalCveThreshold": 1,
|
||||||
"ransomwareBetaEnabled": false,
|
"ransomwareBetaEnabled": true,
|
||||||
"ransomwareBetaAlertingEnabled": false,
|
"ransomwareBetaAlertingEnabled": false,
|
||||||
"ransomwareLookbackMinutes": 15,
|
"ransomwareLookbackMinutes": 15,
|
||||||
"ransomwareWarningSignalCount": 2,
|
"ransomwareWarningSignalCount": 2,
|
||||||
@@ -28,6 +28,10 @@
|
|||||||
"C:\\inetpub\\logs\\LogFiles",
|
"C:\\inetpub\\logs\\LogFiles",
|
||||||
"D:\\inetpub\\logs\\LogFiles"
|
"D:\\inetpub\\logs\\LogFiles"
|
||||||
],
|
],
|
||||||
|
"iisLogRoots": [
|
||||||
|
"C:\\inetpub\\logs\\LogFiles",
|
||||||
|
"D:\\inetpub\\logs\\LogFiles"
|
||||||
|
],
|
||||||
"fileZillaRoots": [
|
"fileZillaRoots": [
|
||||||
"C:\\Program Files (x86)\\FileZilla Server\\Logs",
|
"C:\\Program Files (x86)\\FileZilla Server\\Logs",
|
||||||
"D:\\Program Files (x86)\\FileZilla Server\\Logs"
|
"D:\\Program Files (x86)\\FileZilla Server\\Logs"
|
||||||
|
|||||||
@@ -19,12 +19,19 @@ Die Stable-Aufgabe verwendet keinen Kanalwert oder den Wert `stable`.
|
|||||||
|
|
||||||
## Passive Ransomware-Beta
|
## Passive Ransomware-Beta
|
||||||
|
|
||||||
Die Beta ist nach der Installation weiterhin deaktiviert. Auf einem
|
Die Ransomware-Beta ist im Beta-Kanal standardmaessig aktiviert. Die Auswertung
|
||||||
Pilotgeraet wird in `C:\Program Files\OCSentinel\config\ocsentinel-settings.json`
|
bleibt passiv, solange `ransomwareBetaAlertingEnabled` auf `false` steht:
|
||||||
der Wert `ransomwareBetaEnabled` auf `true` gesetzt. Die Auswertung bleibt
|
Hinweise, Warnungen und kritische Beta-Signale erscheinen im JSON-Report und
|
||||||
passiv, solange `ransomwareBetaAlertingEnabled` auf `false` steht: Hinweise,
|
Dashboard, veraendern aber keine NinjaOne-Alarmfelder. Fuer eine lokale
|
||||||
Warnungen und kritische Beta-Signale erscheinen im JSON-Report und Dashboard,
|
Ausnahme kann `ransomwareBetaEnabled` in
|
||||||
veraendern aber keine NinjaOne-Alarmfelder.
|
`C:\Program Files\OCSentinel\config\ocsentinel-settings.json` auf `false`
|
||||||
|
gesetzt werden.
|
||||||
|
|
||||||
|
Jeder Beta-Report enthaelt zudem eine datensparsame Sensorabdeckung fuer
|
||||||
|
Security-Ereignis 4688, PowerShell 4104, Sysmon 1 und das optionale
|
||||||
|
Datei-Auditing 4663. Damit bedeutet ein Status `ok` nicht mehr stillschweigend,
|
||||||
|
dass alle Datenquellen vorhanden waren. Es werden nur Verfuegbarkeit,
|
||||||
|
technischer Zustand und gezaehlte Ereignisse uebertragen.
|
||||||
|
|
||||||
Der optionale Datei-Churn-Sensor wird nur mit
|
Der optionale Datei-Churn-Sensor wird nur mit
|
||||||
`ransomwareFileChurnEnabled: true` aktiviert. Er wertet ausschliesslich bereits
|
`ransomwareFileChurnEnabled: true` aktiviert. Er wertet ausschliesslich bereits
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ Ein Pilot wird pausiert und zurueckgesetzt, wenn eines dieser Kriterien eintritt
|
|||||||
- 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.
|
||||||
|
- Strukturierte Sensorabdeckung im Report, damit fehlende Audit- oder Sysmon-
|
||||||
|
Quellen von einem echten unauffaelligen Zeitraum unterscheidbar sind.
|
||||||
|
|
||||||
**Bewertung**
|
**Bewertung**
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ def event_metadata(payload):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def payload_value(payload, *names, default=None):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return default
|
||||||
|
for name in names:
|
||||||
|
if name in payload:
|
||||||
|
return payload[name]
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def overview():
|
def overview():
|
||||||
with db_connection() as connection, connection.cursor() as cursor:
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
@@ -387,13 +396,55 @@ def device(machine_name):
|
|||||||
key=lambda entry: (entry["latest"], entry["count"]),
|
key=lambda entry: (entry["latest"], entry["count"]),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)[:25]
|
)[:25]
|
||||||
|
ransomware_raw = payload.get("RansomwareBeta") or payload.get("ransomwareBeta") or {}
|
||||||
|
sensor_labels = {
|
||||||
|
"security-process-4688": "Prozessstarts (Security 4688)",
|
||||||
|
"powershell-script-block-4104": "PowerShell-Skriptblöcke (4104)",
|
||||||
|
"sysmon-process-1": "Sysmon-Prozesse (1)",
|
||||||
|
"security-file-audit-4663": "Datei-Auditing (4663)",
|
||||||
|
}
|
||||||
|
ransomware_sensors = [
|
||||||
|
{
|
||||||
|
"label": sensor_labels.get(payload_value(sensor, "Name", "name"), payload_value(sensor, "Name", "name", default="Unbekannter Sensor")),
|
||||||
|
"enabled": payload_value(sensor, "Enabled", "enabled", default=False),
|
||||||
|
"available": payload_value(sensor, "Available", "available", default=False),
|
||||||
|
"state": payload_value(sensor, "State", "state", default="unknown"),
|
||||||
|
"event_count": payload_value(sensor, "EventCount", "eventCount", default=0),
|
||||||
|
}
|
||||||
|
for sensor in payload_value(ransomware_raw, "Sensors", "sensors", default=[])
|
||||||
|
]
|
||||||
|
ransomware_beta = {
|
||||||
|
"enabled": payload_value(ransomware_raw, "Enabled", "enabled", default=False),
|
||||||
|
"state": payload_value(ransomware_raw, "State", "state", default="disabled"),
|
||||||
|
"reason": payload_value(ransomware_raw, "Reason", "reason", default="Keine Ransomware-Beta-Daten verfuegbar."),
|
||||||
|
"signals": [
|
||||||
|
{
|
||||||
|
"timestamp": payload_value(signal, "Timestamp", "timestamp", default="-"),
|
||||||
|
"category": payload_value(signal, "Category", "category", default="Signal"),
|
||||||
|
"process": payload_value(signal, "Process", "process", default="-"),
|
||||||
|
"source": payload_value(signal, "Source", "source", default="-"),
|
||||||
|
"confidence": payload_value(signal, "Confidence", "confidence", default="low"),
|
||||||
|
}
|
||||||
|
for signal in payload_value(ransomware_raw, "Signals", "signals", default=[])
|
||||||
|
],
|
||||||
|
"smbSessions": [
|
||||||
|
{
|
||||||
|
"clientComputerName": payload_value(session, "ClientComputerName", "clientComputerName", default="-"),
|
||||||
|
"clientUserName": payload_value(session, "ClientUserName", "clientUserName", default="-"),
|
||||||
|
"openFileCount": payload_value(session, "OpenFileCount", "openFileCount", default=0),
|
||||||
|
"sessionId": payload_value(session, "SessionId", "sessionId", default="-"),
|
||||||
|
}
|
||||||
|
for session in payload_value(ransomware_raw, "SmbSessions", "smbSessions", default=[])
|
||||||
|
],
|
||||||
|
}
|
||||||
return render_template(
|
return render_template(
|
||||||
"device.html",
|
"device.html",
|
||||||
report=report,
|
report=report,
|
||||||
event=event_metadata(payload),
|
event=event_metadata(payload),
|
||||||
payload=payload,
|
payload=payload,
|
||||||
security_events=security_events,
|
security_events=security_events,
|
||||||
ransomware_beta=payload.get("RansomwareBeta") or payload.get("ransomwareBeta") or {},
|
ransomware_beta=ransomware_beta,
|
||||||
|
ransomware_sensors=ransomware_sensors,
|
||||||
payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False),
|
payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,48 @@ table { width:100%; border-collapse:collapse; font-family:'Roboto',sans-serif; f
|
|||||||
.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-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; } }
|
.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; } }
|
.map-hero { display:flex; align-items:end; justify-content:space-between; gap:28px; margin:0 -2vw 26px; padding:38px 3vw 30px; border-radius:14px; color:#eaf2f8; background:radial-gradient(circle at 82% 10%,rgba(79,163,223,.25),transparent 20rem),linear-gradient(132deg,#102a43,#0c1c2a 70%); box-shadow:0 18px 50px rgba(13,30,44,.22); }.map-hero h1 { max-width:650px; margin:8px 0 14px; font-size:clamp(42px,6vw,76px); line-height:.87; letter-spacing:-.07em; }.map-hero h1 em { color:#b8e36a; font-style:normal; }.map-hero p { max-width:620px; margin:0; color:#b7cad9; font-size:15px; line-height:1.55; }.map-hero .eyebrow { color:#9cc8e8; }.map-hero-status { display:grid; min-width:145px; gap:4px; padding:16px 18px; border:1px solid rgba(184,227,106,.35); border-radius:10px; background:rgba(11,31,45,.55); }.map-hero-status span { color:#b8e36a; font:700 9px 'Roboto',sans-serif; letter-spacing:.13em; }.map-hero-status strong { font-size:37px; line-height:1; letter-spacing:-.06em; }.map-hero-status small { color:#b7cad9; }.map-stat-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; margin:-10px 2vw 28px; border:1px solid #d8e5ee; border-radius:9px; overflow:hidden; background:#d8e5ee; box-shadow:0 10px 22px rgba(31,68,99,.08); }.map-stat-strip article { padding:15px 18px; background:#fff; }.map-stat-strip span,.map-stat-strip small { display:block; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-stat-strip strong { display:block; margin:8px 0 4px; font-size:29px; letter-spacing:-.06em; }.network-map-panel { margin-top:0; padding:0; border:0; border-radius:12px; background:#102a43; box-shadow:0 20px 44px rgba(16,42,67,.2); }.map-header { display:flex; justify-content:space-between; align-items:end; gap:16px; padding:24px 26px 18px; color:#eef6fa; }.map-header .eyebrow { color:#9cc8e8; }.map-header h2 { margin:6px 0 0; font-size:30px; letter-spacing:-.05em; }.map-legend { display:flex; gap:12px; color:#b7cad9; font-size:11px; }.map-legend span { display:flex; align-items:center; gap:5px; }.map-legend i { width:8px; height:8px; border-radius:50%; background:#4fa3df; }.map-legend i.target { background:#3bca99; border-radius:2px; }.map-legend i.hot { background:#ffb454; }.map-toolbar { align-items:end; margin:0; padding:0 26px 18px; border-bottom:1px solid rgba(156,200,232,.16); }.map-toolbar label { flex:0 1 180px; color:#9cc8e8; }.map-toolbar .search-field { flex:1 1 240px; }.map-toolbar input,.map-toolbar select,.map-toolbar button { min-height:40px; border:1px solid rgba(156,200,232,.24); border-radius:6px; background:#17374e; color:#eef6fa; font:600 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#245a85; }.map-toolbar button:hover { background:#326f9f; }.map-actions { display:flex; gap:7px; }.network-map-layout { grid-template-columns:minmax(0,1fr) 280px; min-height:610px; border:0; border-radius:0; background:#0d1e2c; }.network-map-layout #network-map { min-height:610px; background-image:radial-gradient(circle at 50% 0,rgba(79,163,223,.1),transparent 28rem),linear-gradient(rgba(156,200,232,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(156,200,232,.045) 1px,transparent 1px); background-size:auto,36px 36px,36px 36px; }.network-map-layout aside { padding:24px; border-left:1px solid rgba(156,200,232,.16); background:#112b3d; color:#eef6fa; }.network-map-layout aside .eyebrow { color:#9cc8e8; }.network-map-layout aside p { color:#b7cad9; }.network-map-layout dt { color:#82b7dc; }.network-map-layout dd { color:#eef6fa; }.inspector-arrow { color:#b8e36a; }.legend.source { background:#4fa3df; }.legend.target { background:#3bca99; } @media (max-width:850px) { .map-hero { flex-direction:column; align-items:start; margin:0 0 20px; }.map-stat-strip { grid-template-columns:repeat(2,1fr); margin:0 0 20px; }.map-header { align-items:start; flex-direction:column; }.map-toolbar { padding:0 18px 18px; }.network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid rgba(156,200,232,.16); border-left:0; } }
|
||||||
|
|
||||||
|
:root { --font-sans:'Manrope','Segoe UI',sans-serif; --font-mono:'IBM Plex Mono','Cascadia Code',monospace; --surface:#f7fafc; --surface-strong:#edf4f8; --navy:#0c2438; --blue:#2e6b9a; }
|
||||||
|
body,body * { font-family:var(--font-sans); }
|
||||||
|
pre,code,.raw-json pre { font-family:var(--font-mono); }
|
||||||
|
body { background:radial-gradient(circle at 8% -10%,rgba(102,176,225,.24),transparent 31rem),radial-gradient(circle at 94% 6%,rgba(85,201,155,.16),transparent 24rem),linear-gradient(180deg,#edf4f8 0,#f8fafc 42%,#eef4f7 100%); }
|
||||||
|
.masthead { position:sticky; z-index:5; top:0; backdrop-filter:blur(16px); background:rgba(12,36,56,.94); }
|
||||||
|
.dashboard-hero { display:grid; grid-template-columns:minmax(0,1fr) 230px; gap:28px; min-height:276px; margin:0 0 18px; padding:38px; border:1px solid rgba(134,192,225,.24); border-radius:18px; color:#ecf5fb; background:radial-gradient(circle at 88% 8%,rgba(96,183,227,.28),transparent 19rem),linear-gradient(135deg,#102f49,#0a1d2c 72%); box-shadow:0 24px 50px rgba(18,51,75,.18); overflow:hidden; }
|
||||||
|
.dashboard-hero .eyebrow { color:#a6d2ec; }.dashboard-hero h1 { max-width:730px; margin:11px 0 15px; font-size:clamp(38px,5.2vw,66px); line-height:.94; letter-spacing:-.067em; }.dashboard-hero p { max-width:620px; margin:0; color:#bdd3e1; font-size:15px; line-height:1.65; }
|
||||||
|
.dashboard-status { align-self:end; display:grid; gap:5px; padding:19px; border:1px solid rgba(168,216,241,.26); border-radius:14px; background:rgba(4,22,35,.34); box-shadow:inset 0 1px rgba(255,255,255,.06); }.dashboard-status > span:not(.status-orb) { color:#a9c7d8; font-size:10px; font-weight:800; letter-spacing:.11em; text-transform:uppercase; }.dashboard-status strong { font-size:30px; letter-spacing:-.055em; }.dashboard-status small { color:#c5d9e5; font-size:11px; }.status-orb { width:10px; height:10px; margin-bottom:4px; border-radius:50%; background:#72d39c; box-shadow:0 0 0 6px rgba(114,211,156,.13); }.dashboard-hero.warning .status-orb { background:#ffbe62; box-shadow:0 0 0 6px rgba(255,190,98,.13); }.dashboard-hero.critical .status-orb { background:#f4796d; box-shadow:0 0 0 6px rgba(244,121,109,.13); }
|
||||||
|
.quick-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:0 0 35px; }.quick-metrics article { min-height:112px; padding:18px 20px; border:1px solid #d6e3ec; border-radius:12px; background:rgba(255,255,255,.86); box-shadow:0 10px 22px rgba(30,68,94,.055); }.quick-metrics span,.quick-metrics small { display:block; color:#607b8e; font-size:10px; font-weight:800; letter-spacing:.08em; text-transform:uppercase; }.quick-metrics strong { display:block; margin:10px 0 6px; font-size:30px; letter-spacing:-.06em; }.quick-metrics small { color:#7d94a4; font-size:9px; letter-spacing:.045em; text-transform:none; }
|
||||||
|
.page-intro { max-width:770px; margin:10px 0 30px; }.page-intro h1 { margin:10px 0 13px; color:var(--navy); font-size:clamp(38px,5vw,62px); line-height:.94; letter-spacing:-.07em; }.page-intro h1 em { color:var(--green); font-style:normal; }.page-intro p { max-width:600px; margin:0; color:var(--muted); font-size:15px; line-height:1.65; }.reports-panel { margin-top:0; }
|
||||||
|
.sensor-coverage-panel { margin-top:8px; }.sensor-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(215px,1fr)); gap:11px; }.sensor-card { min-height:160px; display:grid; align-content:start; gap:10px; padding:17px; border:1px solid #dbe7ee; border-radius:11px; background:linear-gradient(145deg,#fff,#f6fafc); }.sensor-card > div { display:flex; align-items:center; gap:7px; }.sensor-card strong { color:#17354a; font-size:14px; line-height:1.3; }.sensor-card p { margin:0; color:#607b8e; font-size:12px; line-height:1.55; }.sensor-dot { width:8px; height:8px; border-radius:50%; background:#6abf90; box-shadow:0 0 0 4px rgba(106,191,144,.13); }.sensor-state { color:#5d788a; font-size:9px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; }.sensor-card.disabled { background:#f4f6f7; }.sensor-card.disabled .sensor-dot,.sensor-card.not-installed .sensor-dot { background:#9caeba; box-shadow:0 0 0 4px rgba(156,174,186,.13); }.sensor-card.query-failed { border-color:#f2c59c; background:#fff8ef; }.sensor-card.query-failed .sensor-dot,.sensor-card.truncated .sensor-dot { background:#e9a64d; box-shadow:0 0 0 4px rgba(233,166,77,.13); }.sensor-empty { width:100%; padding:24px; border:1px dashed #b8cbd7; border-radius:10px; color:#607b8e; text-align:center; font-size:13px; }.ransomware-panel + .sensor-coverage-panel { margin-top:8px; }
|
||||||
|
@media (max-width:850px) { .dashboard-hero { grid-template-columns:1fr; min-height:0; padding:28px 24px; }.dashboard-status { align-self:auto; }.quick-metrics { grid-template-columns:repeat(2,1fr); }.quick-metrics article { min-height:102px; }.page-intro { margin-top:4px; }.sensor-grid { grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
@layer enhancements {
|
||||||
|
:where(a,button,input,select,summary):focus-visible { outline:3px solid var(--lime); outline:3px solid color-mix(in srgb,var(--lime) 72%,white); outline-offset:3px; }
|
||||||
|
:where(a,button) { -webkit-tap-highlight-color:transparent; }
|
||||||
|
.quick-metrics,.sensor-grid { container-type:inline-size; }
|
||||||
|
.dashboard-hero { isolation:isolate; }
|
||||||
|
.dashboard-hero::after { position:absolute; z-index:-1; inset:auto -8% -54% auto; width:300px; aspect-ratio:1; border-radius:999px; background:radial-gradient(circle,rgba(184,227,106,.15),transparent 68%); content:''; filter:blur(3px); }
|
||||||
|
.quick-metrics article,.organization-card,.sensor-card { position:relative; overflow:hidden; }
|
||||||
|
.quick-metrics article::before,.sensor-card::before { position:absolute; inset:0; opacity:0; background:linear-gradient(120deg,transparent 18%,rgba(255,255,255,.65),transparent 82%); content:''; transform:translateX(-110%); transition:transform .55s ease,opacity .2s ease; }
|
||||||
|
@media (hover:hover) { .quick-metrics article:hover::before,.sensor-card:hover::before { opacity:1; transform:translateX(110%); }.quick-metrics article:hover { border-color:color-mix(in srgb,var(--blue) 42%,var(--line)); transform:translateY(-3px); box-shadow:0 18px 34px rgba(30,68,94,.12); }.sensor-card:hover { border-color:color-mix(in srgb,var(--blue) 34%,var(--line)); transform:translateY(-2px); box-shadow:0 14px 28px rgba(30,68,94,.09); } }
|
||||||
|
.quick-metrics article,.sensor-card { transition:transform .22s cubic-bezier(.2,.8,.2,1),box-shadow .22s ease,border-color .22s ease; }
|
||||||
|
.dashboard-hero,.quick-metrics article,.panel { animation:sentinel-rise .55s cubic-bezier(.2,.8,.2,1) both; }
|
||||||
|
.quick-metrics article:nth-child(2),.panel:nth-of-type(2) { animation-delay:60ms; }.quick-metrics article:nth-child(3),.panel:nth-of-type(3) { animation-delay:120ms; }.quick-metrics article:nth-child(4),.panel:nth-of-type(4) { animation-delay:180ms; }
|
||||||
|
@container (max-width:520px) { .sensor-card { min-height:0; grid-template-columns:auto 1fr; column-gap:12px; }.sensor-card > div { grid-column:1 / -1; }.sensor-card p { grid-column:1 / -1; } }
|
||||||
|
@supports (backdrop-filter:blur(1px)) { .panel { background:color-mix(in srgb,var(--panel) 90%,transparent); backdrop-filter:blur(10px); }.quick-metrics article { background:color-mix(in srgb,white 82%,transparent); backdrop-filter:blur(12px); } }
|
||||||
|
@supports not (backdrop-filter:blur(1px)) { .masthead { background:#0c2438; } }
|
||||||
|
@supports selector(body:has(.dashboard-hero.critical)) { body:has(.dashboard-hero.critical) .masthead { border-bottom-color:color-mix(in srgb,var(--red) 52%,#21445f); } body:has(.dashboard-hero.warning) .masthead { border-bottom-color:color-mix(in srgb,var(--amber) 55%,#21445f); } }
|
||||||
|
@media (prefers-contrast:more) { .panel,.quick-metrics article,.sensor-card { border-width:2px; }.state { border:1px solid currentColor; } }
|
||||||
|
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sentinel-rise { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
|
||||||
|
/* Operational hierarchy and responsive chrome. */
|
||||||
|
.skip-link { position:fixed; z-index:20; top:10px; left:10px; padding:10px 13px; border-radius:8px; background:var(--lime); color:var(--navy); font-weight:800; text-decoration:none; transform:translateY(-160%); transition:transform .2s ease; }
|
||||||
|
.skip-link:focus { transform:translateY(0); }
|
||||||
|
.wordmark { display:flex; align-items:center; gap:8px; }.wordmark .wordmark-mark { display:grid; flex:0 0 auto; place-items:center; width:29px; height:29px; margin:0; border-radius:8px; background:var(--lime); color:#102a43; font:800 10px var(--font-sans); letter-spacing:0; }.wordmark .wordmark-name { display:grid; gap:1px; color:#fff; font:800 18px/1 var(--font-sans); letter-spacing:-.045em; }.wordmark .wordmark-name small { color:#9cc8e8; font:700 8px/1 var(--font-sans); letter-spacing:.12em; text-transform:uppercase; }
|
||||||
|
.app-footer { display:flex; justify-content:space-between; gap:16px; max-width:1280px; margin:0 auto; padding:0 6vw 32px; color:#6c8494; font-size:10px; letter-spacing:.04em; }.app-footer span:first-child { color:#426277; font-weight:800; text-transform:uppercase; }
|
||||||
|
.priority-board { display:grid; grid-template-columns:minmax(240px,.82fr) minmax(0,1.65fr); gap:1px; margin:0 0 30px; overflow:hidden; border:1px solid #193a53; border-radius:16px; background:#193a53; box-shadow:0 20px 42px rgba(16,42,67,.15); }.priority-intro { display:grid; align-content:space-between; min-height:272px; padding:27px; color:#eaf3f8; background:radial-gradient(circle at 15% 8%,rgba(79,163,223,.2),transparent 16rem),linear-gradient(145deg,#153b58,#0d2437); }.priority-intro .eyebrow { color:#a4cae4; }.priority-intro h2 { margin:10px 0; font-size:clamp(28px,3.2vw,43px); line-height:.95; letter-spacing:-.065em; }.priority-intro p { max-width:290px; margin:0; color:#b7ccda; font-size:13px; line-height:1.55; }.priority-sync { display:flex; align-items:center; gap:8px; margin-top:20px; color:#9fc0d2; font:700 10px var(--font-sans); }.priority-sync i { width:7px; height:7px; border-radius:50%; background:#72d39c; box-shadow:0 0 0 5px rgba(114,211,156,.12); }.priority-list { display:grid; align-content:center; gap:1px; background:#d7e4ec; }.priority-item { display:grid; grid-template-columns:34px minmax(0,1fr) 24px; align-items:center; gap:15px; min-height:90px; padding:16px 22px; background:rgba(255,255,255,.96); color:var(--ink); text-decoration:none; transition:background .2s ease,transform .2s ease; }.priority-item:hover { background:#f4faff; }.priority-item.critical:hover { background:#fff4f1; }.priority-index { align-self:start; color:#8aa0ae; font:700 11px var(--font-mono); }.priority-item strong,.priority-item small { display:block; }.priority-item strong { margin:6px 0 3px; font-size:18px; letter-spacing:-.035em; }.priority-item small { overflow:hidden; color:#657f90; font-size:11px; text-overflow:ellipsis; white-space:nowrap; }.priority-arrow { color:#3a7196; font-size:20px; transition:transform .2s ease; }.priority-item:hover .priority-arrow { transform:translate(2px,-2px); }.priority-calm { display:grid; align-content:center; justify-items:start; gap:9px; padding:30px; color:#eaf3f8; background:linear-gradient(145deg,#173d4b,#0e2a35); }.priority-calm .status-orb { margin:0 0 8px; }.priority-calm strong { font-size:22px; letter-spacing:-.04em; }.priority-calm p { margin:0; color:#b4cbd4; font-size:13px; }.priority-board.calm { grid-template-columns:minmax(240px,.82fr) minmax(0,1.65fr); }
|
||||||
|
.device-hero { display:grid; grid-template-columns:minmax(0,1fr) 240px; gap:28px; align-items:end; min-height:225px; margin:0 0 18px; padding:31px 33px; overflow:hidden; border:1px solid rgba(134,192,225,.24); border-radius:18px; color:#ecf5fb; background:radial-gradient(circle at 88% 10%,rgba(96,183,227,.25),transparent 17rem),linear-gradient(135deg,#102f49,#0a1d2c 72%); box-shadow:0 24px 50px rgba(18,51,75,.15); }.device-hero.warning { background:radial-gradient(circle at 88% 10%,rgba(247,181,91,.22),transparent 17rem),linear-gradient(135deg,#40301a,#20180e 72%); }.device-hero.critical { background:radial-gradient(circle at 88% 10%,rgba(240,110,100,.24),transparent 17rem),linear-gradient(135deg,#45252b,#1d1116 72%); }.device-hero .eyebrow { color:#a6d2ec; }.device-hero h1 { margin:9px 0 12px; font-size:clamp(37px,5vw,62px); line-height:.92; letter-spacing:-.07em; }.device-hero p { margin:0; color:#bdd3e1; font-size:13px; line-height:1.55; }.device-hero-state { display:grid; gap:5px; padding:18px; border:1px solid rgba(168,216,241,.26); border-radius:14px; background:rgba(4,22,35,.34); }.device-hero-state > span:not(.status-orb) { color:#a9c7d8; font-size:10px; font-weight:800; letter-spacing:.11em; text-transform:uppercase; }.device-hero-state strong { font-size:27px; letter-spacing:-.055em; text-transform:capitalize; }.device-hero-state small { color:#c5d9e5; font-size:11px; line-height:1.45; }
|
||||||
|
.header-links a,.state,.event-count,.recipient-form button,.rule-actions button,table,th,td,.map-toolbar input,.map-toolbar select,.map-toolbar button { font-family:var(--font-sans); }
|
||||||
|
@media (max-width:850px) { .masthead { gap:14px; align-items:center; }.header-links { gap:4px; overflow:auto; max-width:calc(100vw - 155px); flex-wrap:nowrap; }.header-links a { flex:0 0 auto; padding:7px 8px; font-size:11px; }.wordmark .wordmark-name small { display:none; }.app-footer { align-items:flex-start; flex-direction:column; padding-bottom:24px; }.priority-board,.priority-board.calm,.device-hero { grid-template-columns:1fr; }.priority-intro { min-height:215px; }.priority-list { gap:1px; }.priority-item { min-height:84px; padding:15px 17px; }.priority-item small { white-space:normal; }.device-hero { min-height:0; padding:28px 24px; }.device-hero-state { max-width:none; }.device-hero h1 { font-size:42px; } }
|
||||||
|
|||||||
@@ -6,14 +6,16 @@
|
|||||||
<title>{% block title %}OfficeCom Sentinel{% endblock %}</title>
|
<title>{% block title %}OfficeCom Sentinel{% endblock %}</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="app-shell">
|
<body class="app-shell">
|
||||||
|
<a class="skip-link" href="#main-content">Zum Inhalt springen</a>
|
||||||
<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 class="wordmark-mark">OC</span><span class="wordmark-name">Sentinel <small>Security console</small></span></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 == '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>
|
<nav class="header-links" aria-label="Hauptnavigation"><a class="{{ 'active' if request.endpoint in ('overview', 'organization') else '' }}" href="{{ url_for('overview') }}">Lagebild</a><a class="{{ 'active' if request.endpoint == 'network' else '' }}" href="{{ url_for('network') }}">Zugriffswege</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
|
||||||
</header>
|
</header>
|
||||||
<main>{% block content %}{% endblock %}</main>
|
<main id="main-content">{% block content %}{% endblock %}</main>
|
||||||
|
<footer class="app-footer"><span>OfficeCom Sentinel</span><span>Interne Sicherheitskonsole · Verdichtete Endpoint-Signale</span></footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
|
{% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel"><div class="panel-heading"><h2>{{ report[0] }}</h2><span class="state {{ report[6] }}">{{ report[6] }}</span>{% if report[8] %}<span class="state {{ 'current' if event.is_current else 'historic' }}">{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}</span>{% endif %}</div></section>
|
<section class="device-hero {{ report[6] }}">
|
||||||
|
<div><span class="eyebrow">Geraeteanalyse</span><h1>{{ report[0] }}</h1><p>Letzter Scan {{ report[5] or '-' }} · Client zuletzt gesehen {{ report[2] or '-' }}</p></div>
|
||||||
|
<div class="device-hero-state"><span class="status-orb"></span><span>Aktueller Status</span><strong>{{ report[6] }}</strong>{% if report[8] %}<small>{{ 'Aktuelles Signal' if event.is_current else 'Historisches Signal' }} · {{ event.label }}</small>{% else %}<small>Keine Ereignisse im letzten Scan</small>{% endif %}</div>
|
||||||
|
</section>
|
||||||
<section class="metrics compact-metrics"><article><span>Ereignisse</span><strong>{{ report[8] }}</strong></article><article><span>Quell-IPs</span><strong>{{ report[9] }}</strong></article><article><span>CVEs</span><strong>{{ report[10] }}</strong></article><article><span>Kritische CVEs</span><strong class="critical">{{ report[11] }}</strong></article></section>
|
<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.enabled %}<section class="panel sensor-coverage-panel"><div class="panel-heading"><span class="eyebrow">Beta-Abdeckung</span><h2>Erkennungsquellen <small>Verfuegbarkeit im letzten Scan</small></h2><p>Ein unauffaelliger Zeitraum ist nur dann belastbar, wenn die benoetigten Datenquellen erreichbar waren.</p></div><div class="sensor-grid">{% for sensor in ransomware_sensors %}<article class="sensor-card {{ sensor.state }}"><div><span class="sensor-dot"></span><span class="sensor-state">{{ sensor.state }}</span></div><strong>{{ sensor.label }}</strong><p>{% if not sensor.enabled %}Nicht fuer diesen Client aktiviert.{% elif sensor.available %}{{ sensor.event_count }} Ereignisse im Zeitfenster verarbeitet.{% elif sensor.state == 'not-installed' %}Protokollquelle ist auf diesem System nicht installiert.{% else %}Quelle konnte im letzten Scan nicht verwendet werden.{% endif %}</p></article>{% else %}<div class="sensor-empty">Dieser Client sendet noch keine Abdeckungsdaten. Nach dem Update auf Beta 1.5.0-beta.5 erscheint die Sensoransicht automatisch.</div>{% endfor %}</div></section>{% endif %}
|
||||||
{% if ransomware_beta.smbSessions %}<section class="panel smb-context-panel"><div class="panel-heading"><span class="eyebrow">Incident-Kontext</span><h2>Aktive SMB-Sitzungen <small>Nur bei Ransomware-Warnung oder kritisch erfasst</small></h2></div><div class="table-wrap"><table><thead><tr><th>Client</th><th>Benutzer</th><th>Offene Dateien</th><th>Sitzung</th></tr></thead><tbody>{% for session in ransomware_beta.smbSessions %}<tr><td>{{ session.clientComputerName }}</td><td>{{ session.clientUserName }}</td><td>{{ session.openFileCount }}</td><td>{{ session.sessionId }}</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
|
{% 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>
|
||||||
|
|||||||
@@ -1,10 +1,46 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="situation {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
|
<section class="dashboard-hero {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
|
||||||
<div><span class="eyebrow">OfficeCom Sentinel Uebersicht</span><strong>{% if summary[2] %}Kritische Ereignisse erfordern Aufmerksamkeit{% elif summary[1] %}Hinweise im Bestand pruefen{% else %}Sicherheitslage stabil{% endif %}</strong></div>
|
<div>
|
||||||
<span>{% if summary[2] %}KRITISCH{% elif summary[1] %}PRUEFEN{% else %}STABIL{% endif %}</span>
|
<span class="eyebrow">OfficeCom Sentinel Uebersicht</span>
|
||||||
|
<h1>{% if summary[2] %}Sicherheitslage<br>braucht Aufmerksamkeit.{% elif summary[1] %}Signale im Bestand<br>gezielt pruefen.{% else %}Sicherheitslage<br>unter Kontrolle.{% endif %}</h1>
|
||||||
|
<p>Verdichtete Endpoint-Signale, Upload-Gesundheit und organisationsweite Einordnung an einem Ort.</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-status"><span class="status-orb"></span><span>Aktueller Zustand</span><strong>{% if summary[2] %}Kritisch{% elif summary[1] %}Pruefen{% else %}Stabil{% endif %}</strong><small>{{ current_alert_count }} aktuelle Auffaelligkeit{{ '' if current_alert_count == 1 else 'en' }}</small></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="quick-metrics">
|
||||||
|
<article><span>Geraete</span><strong>{{ summary[0] }}</strong><small>{{ coverage[1] }} melden aktuell</small></article>
|
||||||
|
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong><small>im letzten Status</small></article>
|
||||||
|
<article><span>Kritisch</span><strong class="critical">{{ summary[2] }}</strong><small>sofort sichtbar</small></article>
|
||||||
|
<article><span>Abdeckung</span><strong class="{% if coverage[2] %}warning{% else %}ok{% endif %}">{{ coverage[1] }}/{{ coverage[0] }}</strong><small>{{ coverage[2] }} stumm > 36 Std.</small></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if alerts %}
|
||||||
|
<section class="priority-board">
|
||||||
|
<div class="priority-intro">
|
||||||
|
<span class="eyebrow">Einsatzfokus</span>
|
||||||
|
<h2>Was jetzt<br>Aufmerksamkeit braucht.</h2>
|
||||||
|
<p>Priorisiert nach Schweregrad und zuletzt gemeldetem Signal.</p>
|
||||||
|
<div class="priority-sync"><i></i><span>Letzter Datenstand: {{ summary[7] or '-' }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="priority-list">
|
||||||
|
{% for alert in alerts[:3] %}
|
||||||
|
<a class="priority-item {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
|
||||||
|
<span class="priority-index">0{{ loop.index }}</span>
|
||||||
|
<div><span class="state {{ alert.alert_state }}">{{ alert.alert_state }}</span><strong>{{ alert.machine_name }}</strong><small>{{ alert.total_events }} Ereignisse · {{ alert.unique_ip_count }} Quell-IPs · {{ alert.event.label }}</small></div>
|
||||||
|
<span class="priority-arrow" aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="priority-board calm">
|
||||||
|
<div class="priority-intro"><span class="eyebrow">Einsatzfokus</span><h2>Aktuell keine<br>offenen Signale.</h2><p>Die meldenden Systeme liefern derzeit keine auffaelligen Sicherheitsereignisse.</p><div class="priority-sync"><i></i><span>Letzter Datenstand: {{ summary[7] or '-' }}</span></div></div>
|
||||||
|
<div class="priority-calm"><span class="status-orb"></span><strong>Keine unmittelbare Aktion notwendig</strong><p>Behalte Abdeckung und Berichtsintervall im Blick.</p></div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="panel trend-panel">
|
<section class="panel trend-panel">
|
||||||
<div class="panel-heading"><span class="eyebrow">Letzte 14 Tage</span><h2>Signalverlauf <small>Verdichtete Scan-Ergebnisse pro Tag</small></h2></div>
|
<div class="panel-heading"><span class="eyebrow">Letzte 14 Tage</span><h2>Signalverlauf <small>Verdichtete Scan-Ergebnisse pro Tag</small></h2></div>
|
||||||
<div class="trend-chart" aria-label="Signalverlauf der letzten 14 Tage">{% for day in trend %}<article class="trend-day"><div class="trend-bar" style="--bar: {{ (day.event_count * 100 / trend_max)|round(0, 'floor') }}%"><span class="trend-critical" style="--critical: {{ (day.critical_count * 100 / trend_max)|round(0, 'floor') }}%"></span><span class="trend-warning" style="--warning: {{ (day.warning_count * 100 / trend_max)|round(0, 'floor') }}%"></span></div><strong>{{ day.event_count }}</strong><small>{{ day.day.strftime('%d.%m.') }}</small></article>{% else %}<p class="empty-state">Noch keine Trenddaten vorhanden.</p>{% endfor %}</div>
|
<div class="trend-chart" aria-label="Signalverlauf der letzten 14 Tage">{% for day in trend %}<article class="trend-day"><div class="trend-bar" style="--bar: {{ (day.event_count * 100 / trend_max)|round(0, 'floor') }}%"><span class="trend-critical" style="--critical: {{ (day.critical_count * 100 / trend_max)|round(0, 'floor') }}%"></span><span class="trend-warning" style="--warning: {{ (day.warning_count * 100 / trend_max)|round(0, 'floor') }}%"></span></div><strong>{{ day.event_count }}</strong><small>{{ day.day.strftime('%d.%m.') }}</small></article>{% else %}<p class="empty-state">Noch keine Trenddaten vorhanden.</p>{% endfor %}</div>
|
||||||
@@ -16,14 +52,6 @@
|
|||||||
<div class="organization-grid">{% for organization in organizations %}<a class="organization-card" href="{{ url_for('organization', organization_id=organization.id) }}"><span class="eyebrow">{{ organization.id }}</span><strong>{{ organization.name }}</strong><div><span>{{ organization.device_count }} Geraete</span><span class="state critical">{{ organization.critical_count }} kritisch</span><span class="state warning">{{ organization.warning_count }} Warnung</span></div><small>Letzte Meldung: {{ organization.last_received_at or '-' }}</small></a>{% endfor %}</div>
|
<div class="organization-grid">{% for organization in organizations %}<a class="organization-card" href="{{ url_for('organization', organization_id=organization.id) }}"><span class="eyebrow">{{ organization.id }}</span><strong>{{ organization.name }}</strong><div><span>{{ organization.device_count }} Geraete</span><span class="state critical">{{ organization.critical_count }} kritisch</span><span class="state warning">{{ organization.warning_count }} Warnung</span></div><small>Letzte Meldung: {{ organization.last_received_at or '-' }}</small></a>{% endfor %}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="metrics">
|
|
||||||
<article><span>Geraete</span><strong>{{ summary[0] }}</strong></article>
|
|
||||||
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong></article>
|
|
||||||
<article><span>Kritisch</span><strong class="critical">{{ summary[2] }}</strong></article>
|
|
||||||
<article><span>Ereignisse</span><strong>{{ summary[3] }}</strong></article>
|
|
||||||
<article><span>Letzte Meldung</span><strong class="timestamp">{{ summary[7] or '-' }}</strong></article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel coverage-panel">
|
<section class="panel coverage-panel">
|
||||||
<div class="panel-heading"><h2>Geraeteabdeckung</h2></div>
|
<div class="panel-heading"><h2>Geraeteabdeckung</h2></div>
|
||||||
<div class="coverage-metrics"><article><span>Bekannt</span><strong>{{ coverage[0] }}</strong></article><article><span>Meldend < 36 Std.</span><strong class="ok">{{ coverage[1] }}</strong></article><article><span>Stumm > 36 Std.</span><strong class="{% if coverage[2] %}warning{% endif %}">{{ coverage[2] }}</strong></article></div>
|
<div class="coverage-metrics"><article><span>Bekannt</span><strong>{{ coverage[0] }}</strong></article><article><span>Meldend < 36 Std.</span><strong class="ok">{{ coverage[1] }}</strong></article><article><span>Stumm > 36 Std.</span><strong class="{% if coverage[2] %}warning{% endif %}">{{ coverage[2] }}</strong></article></div>
|
||||||
@@ -31,7 +59,7 @@
|
|||||||
|
|
||||||
{% if alerts %}
|
{% if alerts %}
|
||||||
<section class="panel alert-panel">
|
<section class="panel alert-panel">
|
||||||
<div class="panel-heading"><h2>Auffaellige Geraete <small>{{ current_alert_count }} aktuell, {{ alerts|length - current_alert_count }} historisch</small></h2></div>
|
<div class="panel-heading"><span class="eyebrow">Vollstaendige Liste</span><h2>Auffaellige Geraete <small>{{ current_alert_count }} aktuell, {{ alerts|length - current_alert_count }} historisch</small></h2></div>
|
||||||
<div class="alert-grid">
|
<div class="alert-grid">
|
||||||
{% for alert in alerts %}
|
{% for alert in alerts %}
|
||||||
<a class="alert-card {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
|
<a class="alert-card {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Berichte - OC Sentinel{% endblock %}
|
{% block title %}Berichte - OC Sentinel{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel"><div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Zeitraum</th><th>Geraete</th><th>Warnung</th><th>Kritisch</th><th>Events</th><th>Erstellt</th></tr></thead><tbody>
|
<section class="page-intro"><span class="eyebrow">Wochenberichte</span><h1>Sicherheitsberichte<br><em>auf einen Blick.</em></h1><p>Alle automatisch erzeugten Organisationsberichte mit direktem Zugriff auf die finale HTML-Vorschau.</p></section>
|
||||||
|
<section class="panel reports-panel"><div class="panel-heading"><span class="eyebrow">Archiv</span><h2>Gesendete Berichte <small>{{ reports|length }} Eintraege</small></h2></div><div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Zeitraum</th><th>Geraete</th><th>Warnung</th><th>Kritisch</th><th>Events</th><th>Erstellt</th></tr></thead><tbody>
|
||||||
{% for row in reports %}<tr><td><a href="{{ url_for('weekly_report', report_id=row[0]) }}">{{ row[1] }}</a></td><td>{{ row[2] }} bis {{ row[3] }}</td><td>{{ row[5] }}</td><td>{{ row[6] }}</td><td>{{ row[7] }}</td><td>{{ row[8] }}</td><td>{{ row[4] }}</td></tr>{% else %}<tr><td colspan="7">Keine Wochenberichte.</td></tr>{% endfor %}
|
{% for row in reports %}<tr><td><a href="{{ url_for('weekly_report', report_id=row[0]) }}">{{ row[1] }}</a></td><td>{{ row[2] }} bis {{ row[3] }}</td><td>{{ row[5] }}</td><td>{{ row[6] }}</td><td>{{ row[7] }}</td><td>{{ row[8] }}</td><td>{{ row[4] }}</td></tr>{% else %}<tr><td colspan="7">Keine Wochenberichte.</td></tr>{% endfor %}
|
||||||
</tbody></table></div></section>
|
</tbody></table></div></section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
13
infra/mcp-server/.env.example
Normal file
13
infra/mcp-server/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Dedicated, read-only PostgreSQL login. Do not reuse the n8n or ingest role.
|
||||||
|
DB_HOST=ocsentinel-postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=ocsentinel
|
||||||
|
DB_USER=ocsentinel_mcp
|
||||||
|
DB_PASSWORD=replace-with-a-long-random-password
|
||||||
|
|
||||||
|
# A long random bearer token for trusted MCP clients. Keep this file private.
|
||||||
|
MCP_AUTH_TOKEN=replace-with-a-second-long-random-token
|
||||||
|
|
||||||
|
# Validate browser origins and Host headers when they are present.
|
||||||
|
MCP_ALLOWED_ORIGINS=http://localhost:6274,http://127.0.0.1:6274
|
||||||
|
MCP_ALLOWED_HOSTS=localhost:8091,127.0.0.1:8091
|
||||||
2
infra/mcp-server/.gitignore
vendored
Normal file
2
infra/mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
16
infra/mcp-server/Dockerfile
Normal file
16
infra/mcp-server/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
RUN addgroup -S ocsentinel && adduser -S ocsentinel -G ocsentinel
|
||||||
|
USER ocsentinel
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--no-access-log"]
|
||||||
70
infra/mcp-server/README.md
Normal file
70
infra/mcp-server/README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# OfficeCom Sentinel MCP
|
||||||
|
|
||||||
|
Dieser Container stellt sichere, **schreibgeschuetzte** Abfragen der OfficeCom-Sentinel-Daten fuer KI-Agenten bereit. Er nutzt das offizielle Python-MCP-SDK mit Streamable HTTP unter `/mcp`.
|
||||||
|
|
||||||
|
## Sicherheitsmodell
|
||||||
|
|
||||||
|
- Der Dienst wird im ersten Schritt nur auf `127.0.0.1:8091` des n8n-Hosts gebunden. Er wird nicht ueber `sentinel.officecom.biz` veroeffentlicht.
|
||||||
|
- Jeder MCP-Aufruf verlangt einen eigenen Bearer-Token, prueft `Host` sowie vorhandene `Origin`-Header und wird ohne Aufrufparameter protokolliert.
|
||||||
|
- Der PostgreSQL-Zugang ist ein dedizierter Login mit `default_transaction_read_only=on`, einem 5-Sekunden-Statement-Timeout und ausschliesslich `SELECT`-Rechten.
|
||||||
|
- Die Werkzeuge haben feste, parametrisierte Abfragen und feste Ergebnisgrenzen. Es gibt kein Werkzeug fuer SQL, Schreiboperationen, Rohbeweise, Befehlszeilen oder Zugangsdaten.
|
||||||
|
- Die Antwort auf `get_device_security` und `search_security_events` enthaelt standardmaessig keine Kontonamen. Konten werden nur auf ausdrueckliche Tool-Anforderung ergaenzt.
|
||||||
|
|
||||||
|
## Verfuegbare Tools
|
||||||
|
|
||||||
|
| Tool | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `security_overview` | Gesamtlage, Abdeckung und dringende Systeme |
|
||||||
|
| `get_organization_status` | Status eines NinjaOne-Organisations-IDs |
|
||||||
|
| `get_device_security` | Bereinigte Sicherheitslage eines Systems |
|
||||||
|
| `search_security_events` | Zeitlich und mengenmaessig begrenzte Ereigniszusammenfassungen |
|
||||||
|
| `get_network_paths` | Beobachtete Quell-IP-zu-System-Pfade |
|
||||||
|
| `get_weekly_report` | Letzte woechentliche Kennzahlen ohne Bericht-HTML |
|
||||||
|
|
||||||
|
Zusaetzlich gibt es die Resource `ocsentinel://read-only-policy` und den Prompt `incident_triage`.
|
||||||
|
|
||||||
|
## Einmalig: Datenbankrolle anlegen
|
||||||
|
|
||||||
|
Auf dem PostgreSQL-Container als Datenbankadministrator ausfuehren. Das Passwort in diesem Befehl durch ein langes, zufaelliges Kennwort ersetzen und danach nur in der lokalen `.env` hinterlegen.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE ROLE ocsentinel_mcp LOGIN PASSWORD 'replace-with-a-long-random-password'
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT;
|
||||||
|
GRANT CONNECT ON DATABASE ocsentinel TO ocsentinel_mcp;
|
||||||
|
GRANT USAGE ON SCHEMA ocsentinel TO ocsentinel_mcp;
|
||||||
|
GRANT SELECT ON ocsentinel.device, ocsentinel.scan_report,
|
||||||
|
ocsentinel.weekly_organization_report TO ocsentinel_mcp;
|
||||||
|
GRANT SELECT ON ocsentinel.current_device_status,
|
||||||
|
ocsentinel.organization_summary TO ocsentinel_mcp;
|
||||||
|
```
|
||||||
|
|
||||||
|
Pruefung:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SET ROLE ocsentinel_mcp;
|
||||||
|
SELECT * FROM ocsentinel.organization_summary;
|
||||||
|
INSERT INTO ocsentinel.device (machine_name, machine_name_key) VALUES ('must-fail', 'must-fail');
|
||||||
|
```
|
||||||
|
|
||||||
|
Die letzte Anweisung muss scheitern.
|
||||||
|
|
||||||
|
## Dockge-Bereitstellung
|
||||||
|
|
||||||
|
1. Den Ordner `infra/mcp-server` als neuen Dockge-Stack auf dem n8n-Host ablegen.
|
||||||
|
2. `.env.example` nach `.env` kopieren, Datenbankpasswort und einen zweiten langen Zufallstoken setzen.
|
||||||
|
3. In `MCP_ALLOWED_HOSTS` nur die echten, erlaubten Host-Header lassen. Fuer den SSH-Tunnel sind `localhost:8091` und `127.0.0.1:8091` korrekt.
|
||||||
|
4. Stack starten. Der Endpunkt ist lokal: `http://127.0.0.1:8091/mcp`.
|
||||||
|
|
||||||
|
Der Container hat keinen veroeffentlichten Zugriff auf das Internet. Fuer einen Arbeitsplatz wird ein Tunnel genutzt:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh -L 8091:127.0.0.1:8091 oc@172.16.41.197 -p 1022
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach ist der lokale MCP-Endpunkt `http://localhost:8091/mcp`. Der MCP-Client muss den Header `Authorization: Bearer <MCP_AUTH_TOKEN>` mitsenden.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- Logs: `docker logs ocsentinel-mcp --tail 100`.
|
||||||
|
- Niemals den Bearer-Token in einem Git-Repository, Screenshot oder Prompt speichern.
|
||||||
|
- Fuer einen spaeteren externen Zugriff wird ein separater OAuth-geschuetzter Reverse Proxy benoetigt. Der aktuelle Token-Modus ist ausschliesslich fuer den privaten Tunnel und vertrauenswuerdige Agenten gedacht.
|
||||||
23
infra/mcp-server/compose.yml
Normal file
23
infra/mcp-server/compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
ocsentinel-mcp:
|
||||||
|
build: .
|
||||||
|
container_name: ocsentinel-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
# The first version is intentionally only reachable through an SSH tunnel.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8091:8080"
|
||||||
|
networks:
|
||||||
|
- ocsentinel-network
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ocsentinel-network:
|
||||||
|
external: true
|
||||||
|
name: n8n_n8n-network
|
||||||
3
infra/mcp-server/requirements.txt
Normal file
3
infra/mcp-server/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mcp==1.26.0
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
|
uvicorn==0.35.0
|
||||||
478
infra/mcp-server/server.py
Normal file
478
infra/mcp-server/server.py
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
"""Read-only MCP access to curated OfficeCom Sentinel security data."""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("ocsentinel.mcp")
|
||||||
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
|
MAX_RESULT_LIMIT = 100
|
||||||
|
MAX_LOOKBACK_HOURS = 24 * 90
|
||||||
|
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||||
|
|
||||||
|
|
||||||
|
def required_setting(name: str) -> str:
|
||||||
|
value = os.getenv(name, "").strip()
|
||||||
|
if not value or value.startswith("replace-with-"):
|
||||||
|
raise RuntimeError(f"{name} must be configured before starting OCSentinel MCP.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_TOKEN = required_setting("MCP_AUTH_TOKEN")
|
||||||
|
ALLOWED_ORIGINS = {value.strip() for value in os.getenv("MCP_ALLOWED_ORIGINS", "").split(",") if value.strip()}
|
||||||
|
ALLOWED_HOSTS = {value.strip().lower() for value in os.getenv("MCP_ALLOWED_HOSTS", "").split(",") if value.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def db_connection() -> psycopg.Connection:
|
||||||
|
return psycopg.connect(
|
||||||
|
host=required_setting("DB_HOST"),
|
||||||
|
port=os.getenv("DB_PORT", "5432"),
|
||||||
|
dbname=required_setting("DB_NAME"),
|
||||||
|
user=required_setting("DB_USER"),
|
||||||
|
password=required_setting("DB_PASSWORD"),
|
||||||
|
connect_timeout=5,
|
||||||
|
row_factory=dict_row,
|
||||||
|
options="-c default_transaction_read_only=on -c statement_timeout=5000",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rows(sql: str, parameters: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, parameters)
|
||||||
|
return list(cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def row(sql: str, parameters: tuple[Any, ...] = ()) -> dict[str, Any] | None:
|
||||||
|
results = rows(sql, parameters)
|
||||||
|
return results[0] if results else None
|
||||||
|
|
||||||
|
|
||||||
|
def value(record: dict[str, Any] | None, *names: str, default: Any = None) -> Any:
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
return default
|
||||||
|
for name in names:
|
||||||
|
if name in record:
|
||||||
|
return record[name]
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def json_safe(data: Any) -> Any:
|
||||||
|
return json.loads(json.dumps(data, default=lambda entry: entry.isoformat() if isinstance(entry, datetime) else str(entry)))
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_limit(limit: int) -> int:
|
||||||
|
if not isinstance(limit, int) or isinstance(limit, bool):
|
||||||
|
raise ValueError("limit must be a whole number.")
|
||||||
|
return max(1, min(limit, MAX_RESULT_LIMIT))
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_hours(hours: int) -> int:
|
||||||
|
if not isinstance(hours, int) or isinstance(hours, bool):
|
||||||
|
raise ValueError("hours must be a whole number.")
|
||||||
|
return max(1, min(hours, MAX_LOOKBACK_HOURS))
|
||||||
|
|
||||||
|
|
||||||
|
def checked_identifier(identifier: str, field_name: str) -> str:
|
||||||
|
value_to_check = (identifier or "").strip()
|
||||||
|
if value_to_check == "unknown" or IDENTIFIER_PATTERN.fullmatch(value_to_check):
|
||||||
|
return value_to_check
|
||||||
|
raise ValueError(f"{field_name} contains unsupported characters.")
|
||||||
|
|
||||||
|
|
||||||
|
def checked_machine_name(machine_name: str) -> str:
|
||||||
|
machine = (machine_name or "").strip()
|
||||||
|
if not machine or len(machine) > 255 or any(character in machine for character in "\r\n\x00"):
|
||||||
|
raise ValueError("machine_name must be a single device name of at most 255 characters.")
|
||||||
|
return machine
|
||||||
|
|
||||||
|
|
||||||
|
def organization_context(payload: dict[str, Any] | None) -> tuple[str, str]:
|
||||||
|
ninja = value(payload, "NinjaOne", "ninjaOne", default={}) or {}
|
||||||
|
return (
|
||||||
|
str(value(ninja, "OrganizationId", "organizationId", default="unknown") or "unknown"),
|
||||||
|
str(value(ninja, "OrganizationName", "organizationName", default="Organisation unbekannt") or "Organisation unbekannt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitized_ransomware(payload: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
ransomware = value(payload, "RansomwareBeta", "ransomwareBeta", default={}) or {}
|
||||||
|
sensors = []
|
||||||
|
for sensor in value(ransomware, "Sensors", "sensors", default=[]) or []:
|
||||||
|
sensors.append(
|
||||||
|
{
|
||||||
|
"name": value(sensor, "Name", "name", default="unknown"),
|
||||||
|
"enabled": bool(value(sensor, "Enabled", "enabled", default=False)),
|
||||||
|
"available": bool(value(sensor, "Available", "available", default=False)),
|
||||||
|
"state": value(sensor, "State", "state", default="unknown"),
|
||||||
|
"eventCount": value(sensor, "EventCount", "eventCount", default=0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
signals = []
|
||||||
|
for signal in value(ransomware, "Signals", "signals", default=[]) or []:
|
||||||
|
# Evidence and raw command lines intentionally never leave the MCP boundary.
|
||||||
|
signals.append(
|
||||||
|
{
|
||||||
|
"timestamp": value(signal, "Timestamp", "timestamp", default=None),
|
||||||
|
"category": value(signal, "Category", "category", default="signal"),
|
||||||
|
"process": value(signal, "Process", "process", default="-"),
|
||||||
|
"source": value(signal, "Source", "source", default="-"),
|
||||||
|
"confidence": value(signal, "Confidence", "confidence", default="low"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": bool(value(ransomware, "Enabled", "enabled", default=False)),
|
||||||
|
"state": value(ransomware, "State", "state", default="disabled"),
|
||||||
|
"reason": value(ransomware, "Reason", "reason", default="Keine Ransomware-Beta-Daten verfuegbar."),
|
||||||
|
"sensors": sensors,
|
||||||
|
"signals": signals[:20],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarized_events(payload: dict[str, Any] | None, include_accounts: bool = False) -> list[dict[str, Any]]:
|
||||||
|
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||||
|
for event in value(payload, "Events", "events", default=[]) or []:
|
||||||
|
event_type = str(value(event, "Target", "target", default="Sicherheitsereignis"))
|
||||||
|
source_ip = str(value(event, "SourceIp", "sourceIp", default="-"))
|
||||||
|
account = str(value(event, "Username", "username", default="-")) if include_accounts else ""
|
||||||
|
key = (event_type, source_ip, account)
|
||||||
|
group = groups.setdefault(key, {"type": event_type, "sourceIp": source_ip, "count": 0, "latest": None})
|
||||||
|
group["count"] += 1
|
||||||
|
timestamp = value(event, "Timestamp", "timestamp", default=None)
|
||||||
|
if timestamp and (not group["latest"] or str(timestamp) > str(group["latest"])):
|
||||||
|
group["latest"] = timestamp
|
||||||
|
if include_accounts:
|
||||||
|
group["account"] = account
|
||||||
|
return sorted(groups.values(), key=lambda entry: (str(entry["latest"]), entry["count"]), reverse=True)[:25]
|
||||||
|
|
||||||
|
|
||||||
|
def audited(tool_name: str):
|
||||||
|
def decorator(function):
|
||||||
|
@functools.wraps(function)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
result = function(*args, **kwargs)
|
||||||
|
LOGGER.info("mcp_tool=%s outcome=ok duration_ms=%d", tool_name, (time.monotonic() - started) * 1000)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
LOGGER.exception("mcp_tool=%s outcome=error duration_ms=%d", tool_name, (time.monotonic() - started) * 1000)
|
||||||
|
raise
|
||||||
|
return wrapped
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
"OfficeCom Sentinel",
|
||||||
|
instructions=(
|
||||||
|
"Read-only security context from OfficeCom Sentinel. Use this data to investigate, summarize, and prioritize. "
|
||||||
|
"Do not treat it as authorization to alter devices, NinjaOne, PostgreSQL, or security controls."
|
||||||
|
),
|
||||||
|
stateless_http=True,
|
||||||
|
json_response=True,
|
||||||
|
streamable_http_path="/mcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ocsentinel://read-only-policy")
|
||||||
|
def read_only_policy() -> str:
|
||||||
|
"""Explain the data and safety boundary of this server."""
|
||||||
|
return (
|
||||||
|
"OfficeCom Sentinel MCP is read-only. It returns curated status, event summaries, ransomware sensor coverage, "
|
||||||
|
"network paths, and weekly-report metadata. Raw event evidence, command lines, SMB sessions, credentials, "
|
||||||
|
"and every write action are deliberately excluded."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt()
|
||||||
|
def incident_triage() -> str:
|
||||||
|
"""Provide a safe, evidence-oriented workflow for analyzing Sentinel findings."""
|
||||||
|
return (
|
||||||
|
"Start with security_overview or get_organization_status. For a flagged device, use get_device_security and "
|
||||||
|
"search_security_events. Separate observed facts from hypotheses, identify the next reversible validation step, "
|
||||||
|
"and recommend escalation to the responsible OfficeCom technician for any containment action."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("security_overview")
|
||||||
|
def security_overview() -> dict[str, Any]:
|
||||||
|
"""Return the current cross-organization security posture and the most urgent devices."""
|
||||||
|
summary = row("SELECT * FROM ocsentinel.organization_summary") or {}
|
||||||
|
coverage = row(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS known_devices,
|
||||||
|
count(*) FILTER (WHERE received_at >= now() - interval '36 hours') AS reporting_devices,
|
||||||
|
count(*) FILTER (WHERE received_at IS NULL OR received_at < now() - interval '36 hours') AS stale_devices
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
"""
|
||||||
|
) or {}
|
||||||
|
urgent = rows(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, alert_state, received_at, total_events, unique_ip_count,
|
||||||
|
payload #>> '{NinjaOne,OrganizationId}' AS organization_id,
|
||||||
|
payload #>> '{NinjaOne,OrganizationName}' AS organization_name
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE alert_state IN ('warning', 'critical')
|
||||||
|
ORDER BY CASE alert_state WHEN 'critical' THEN 0 ELSE 1 END, received_at DESC NULLS LAST
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return json_safe({"summary": summary, "coverage": coverage, "urgentDevices": urgent})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("get_organization_status")
|
||||||
|
def get_organization_status(organization_id: str) -> dict[str, Any]:
|
||||||
|
"""Return current coverage and alert state for one NinjaOne organization ID."""
|
||||||
|
organization_id = checked_identifier(organization_id, "organization_id")
|
||||||
|
devices = rows(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, received_at, alert_state, base_alert_state, total_events, unique_ip_count,
|
||||||
|
cve_total, cve_critical, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s
|
||||||
|
ORDER BY CASE alert_state WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, machine_name
|
||||||
|
""",
|
||||||
|
(organization_id,),
|
||||||
|
)
|
||||||
|
if not devices:
|
||||||
|
return {"organizationId": organization_id, "found": False, "devices": []}
|
||||||
|
_, organization_name = organization_context(devices[0]["payload"])
|
||||||
|
status_counts = {state: sum(device["alert_state"] == state for device in devices) for state in ("ok", "warning", "critical", "unknown")}
|
||||||
|
return json_safe(
|
||||||
|
{
|
||||||
|
"organizationId": organization_id,
|
||||||
|
"organizationName": organization_name,
|
||||||
|
"found": True,
|
||||||
|
"deviceCount": len(devices),
|
||||||
|
"statusCounts": status_counts,
|
||||||
|
"lastReceivedAt": max((device["received_at"] for device in devices if device["received_at"]), default=None),
|
||||||
|
"devices": [
|
||||||
|
{
|
||||||
|
"machineName": device["machine_name"],
|
||||||
|
"alertState": device["alert_state"],
|
||||||
|
"receivedAt": device["received_at"],
|
||||||
|
"totalEvents": device["total_events"],
|
||||||
|
"uniqueIpCount": device["unique_ip_count"],
|
||||||
|
"criticalCves": device["cve_critical"],
|
||||||
|
}
|
||||||
|
for device in devices
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("get_device_security")
|
||||||
|
def get_device_security(machine_name: str, include_accounts: bool = False) -> dict[str, Any]:
|
||||||
|
"""Return the latest sanitized security summary for one device. Accounts are omitted by default."""
|
||||||
|
machine_name = checked_machine_name(machine_name)
|
||||||
|
device = row(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, first_seen_at, last_seen_at, last_client_version, generated_at_utc, received_at,
|
||||||
|
alert_state, base_alert_state, total_events, unique_ip_count, cve_total, cve_critical, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE machine_name = %s
|
||||||
|
""",
|
||||||
|
(machine_name,),
|
||||||
|
)
|
||||||
|
if not device:
|
||||||
|
return {"machineName": machine_name, "found": False}
|
||||||
|
organization_id, organization_name = organization_context(device["payload"])
|
||||||
|
return json_safe(
|
||||||
|
{
|
||||||
|
"found": True,
|
||||||
|
"machineName": device["machine_name"],
|
||||||
|
"organizationId": organization_id,
|
||||||
|
"organizationName": organization_name,
|
||||||
|
"receivedAt": device["received_at"],
|
||||||
|
"generatedAt": device["generated_at_utc"],
|
||||||
|
"alertState": device["alert_state"],
|
||||||
|
"baseAlertState": device["base_alert_state"],
|
||||||
|
"metrics": {
|
||||||
|
"totalEvents": device["total_events"],
|
||||||
|
"uniqueIpCount": device["unique_ip_count"],
|
||||||
|
"cveTotal": device["cve_total"],
|
||||||
|
"criticalCves": device["cve_critical"],
|
||||||
|
},
|
||||||
|
"events": summarized_events(device["payload"], include_accounts),
|
||||||
|
"ransomwareBeta": sanitized_ransomware(device["payload"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("search_security_events")
|
||||||
|
def search_security_events(
|
||||||
|
hours: int = 168,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
alert_state: str | None = None,
|
||||||
|
limit: int = 25,
|
||||||
|
include_accounts: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Find recent scanned reports with bounded filters. This returns event summaries, not raw evidence."""
|
||||||
|
hours = bounded_hours(hours)
|
||||||
|
limit = bounded_limit(limit)
|
||||||
|
if organization_id is not None:
|
||||||
|
organization_id = checked_identifier(organization_id, "organization_id")
|
||||||
|
if alert_state is not None and alert_state not in {"ok", "warning", "critical", "unknown"}:
|
||||||
|
raise ValueError("alert_state must be ok, warning, critical, or unknown.")
|
||||||
|
|
||||||
|
reports = rows(
|
||||||
|
"""
|
||||||
|
SELECT d.machine_name, r.received_at, r.generated_at_utc, r.alert_state, r.base_alert_state,
|
||||||
|
r.total_events, r.unique_ip_count, r.cve_total, r.cve_critical, r.payload
|
||||||
|
FROM ocsentinel.scan_report AS r
|
||||||
|
JOIN ocsentinel.device AS d ON d.id = r.device_id
|
||||||
|
WHERE r.received_at >= now() - (%s * interval '1 hour')
|
||||||
|
AND (%s::text IS NULL OR coalesce(r.payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s)
|
||||||
|
AND (%s::text IS NULL OR r.alert_state = %s)
|
||||||
|
ORDER BY r.received_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(hours, organization_id, organization_id, alert_state, alert_state, limit),
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
for report in reports:
|
||||||
|
org_id, org_name = organization_context(report["payload"])
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"machineName": report["machine_name"],
|
||||||
|
"organizationId": org_id,
|
||||||
|
"organizationName": org_name,
|
||||||
|
"receivedAt": report["received_at"],
|
||||||
|
"generatedAt": report["generated_at_utc"],
|
||||||
|
"alertState": report["alert_state"],
|
||||||
|
"baseAlertState": report["base_alert_state"],
|
||||||
|
"totalEvents": report["total_events"],
|
||||||
|
"uniqueIpCount": report["unique_ip_count"],
|
||||||
|
"criticalCves": report["cve_critical"],
|
||||||
|
"events": summarized_events(report["payload"], include_accounts),
|
||||||
|
"ransomwareBeta": sanitized_ransomware(report["payload"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return json_safe({"lookbackHours": hours, "resultCount": len(results), "reports": results})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("get_network_paths")
|
||||||
|
def get_network_paths(days: int = 14, organization_id: str | None = None, limit: int = 50) -> dict[str, Any]:
|
||||||
|
"""Return recent observed source-IP to device paths from summarized security events."""
|
||||||
|
days = max(1, min(bounded_hours(days * 24) // 24, 90))
|
||||||
|
limit = bounded_limit(limit)
|
||||||
|
if organization_id is not None:
|
||||||
|
organization_id = checked_identifier(organization_id, "organization_id")
|
||||||
|
|
||||||
|
reports = rows(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, received_at, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE received_at >= now() - (%s * interval '1 day')
|
||||||
|
AND (%s::text IS NULL OR coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s)
|
||||||
|
""",
|
||||||
|
(days, organization_id, organization_id),
|
||||||
|
)
|
||||||
|
flows: dict[tuple[str, str, str, str], dict[str, Any]] = {}
|
||||||
|
for report in reports:
|
||||||
|
org_id, org_name = organization_context(report["payload"])
|
||||||
|
for event in value(report["payload"], "Events", "events", default=[]) or []:
|
||||||
|
source_ip = str(value(event, "SourceIp", "sourceIp", default=""))
|
||||||
|
if not source_ip or source_ip in {"-", "127.0.0.1", "::1"}:
|
||||||
|
continue
|
||||||
|
event_type = str(value(event, "Target", "target", default="Sicherheitsereignis"))
|
||||||
|
key = (source_ip, report["machine_name"], event_type, org_id)
|
||||||
|
flow = flows.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"sourceIp": source_ip,
|
||||||
|
"machineName": report["machine_name"],
|
||||||
|
"eventType": event_type,
|
||||||
|
"organizationId": org_id,
|
||||||
|
"organizationName": org_name,
|
||||||
|
"count": 0,
|
||||||
|
"lastSeen": report["received_at"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
flow["count"] += 1
|
||||||
|
timestamp = value(event, "Timestamp", "timestamp", default=None)
|
||||||
|
if timestamp and (not flow["lastSeen"] or str(timestamp) > str(flow["lastSeen"])):
|
||||||
|
flow["lastSeen"] = timestamp
|
||||||
|
paths = sorted(flows.values(), key=lambda entry: (entry["count"], str(entry["lastSeen"])), reverse=True)[:limit]
|
||||||
|
return json_safe({"days": days, "pathCount": len(paths), "paths": paths})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@audited("get_weekly_report")
|
||||||
|
def get_weekly_report(organization_id: str) -> dict[str, Any]:
|
||||||
|
"""Return the latest weekly report metadata and structured summary for one organization, without report HTML."""
|
||||||
|
organization_id = checked_identifier(organization_id, "organization_id")
|
||||||
|
report = row(
|
||||||
|
"""
|
||||||
|
SELECT organization_id, organization_name, period_start_utc, period_end_utc, generated_at,
|
||||||
|
device_count, warning_count, critical_count, total_events, unique_ips, cve_total,
|
||||||
|
cve_critical, summary
|
||||||
|
FROM ocsentinel.weekly_organization_report
|
||||||
|
WHERE organization_id = %s
|
||||||
|
ORDER BY period_end_utc DESC, generated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(organization_id,),
|
||||||
|
)
|
||||||
|
if not report:
|
||||||
|
return {"organizationId": organization_id, "found": False}
|
||||||
|
report["found"] = True
|
||||||
|
return json_safe(report)
|
||||||
|
|
||||||
|
|
||||||
|
class GuardedMcpApp:
|
||||||
|
"""Small ASGI guard without BaseHTTPMiddleware, which can disrupt MCP streaming."""
|
||||||
|
|
||||||
|
def __init__(self, wrapped_app):
|
||||||
|
self.wrapped_app = wrapped_app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.wrapped_app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {key.decode("latin-1").lower(): value.decode("latin-1") for key, value in scope.get("headers", [])}
|
||||||
|
host = headers.get("host", "").lower()
|
||||||
|
origin = headers.get("origin")
|
||||||
|
authorization = headers.get("authorization", "")
|
||||||
|
|
||||||
|
if ALLOWED_HOSTS and host not in ALLOWED_HOSTS:
|
||||||
|
await self.reject(send, 421, "Untrusted Host header.")
|
||||||
|
return
|
||||||
|
if origin and (not ALLOWED_ORIGINS or origin not in ALLOWED_ORIGINS):
|
||||||
|
await self.reject(send, 403, "Untrusted Origin header.")
|
||||||
|
return
|
||||||
|
if authorization != f"Bearer {AUTH_TOKEN}":
|
||||||
|
await self.reject(send, 401, "Bearer token required.", {b"www-authenticate": b"Bearer"})
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.wrapped_app(scope, receive, send)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def reject(send, status: int, message: str, extra_headers: dict[bytes, bytes] | None = None):
|
||||||
|
body = json.dumps({"error": message}).encode("utf-8")
|
||||||
|
headers = [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))]
|
||||||
|
if extra_headers:
|
||||||
|
headers.extend(extra_headers.items())
|
||||||
|
await send({"type": "http.response.start", "status": status, "headers": headers})
|
||||||
|
await send({"type": "http.response.body", "body": body})
|
||||||
|
|
||||||
|
|
||||||
|
app = GuardedMcpApp(mcp.streamable_http_app())
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"channel": "beta",
|
"channel": "beta",
|
||||||
"version": "1.5.0-beta.2",
|
"version": "1.5.0-beta.5",
|
||||||
"publishedAtUtc": "2026-07-31T23:18:06.4787811Z",
|
"publishedAtUtc": "2026-07-31T23:44:23.3751992Z",
|
||||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.2/OCSentinelClient-win-x64.zip",
|
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.5/OCSentinelClient-win-x64.zip",
|
||||||
"sha256": "31dca46b50f95c1175293919177fed4370a58b7d279696d0d763a6b27b343684",
|
"sha256": "d1ac7e9cc9e0da5ea9cbff5e2be5795a5e691c89bbe329df265254c1b4898dbf",
|
||||||
"minUpdaterVersion": "1.0.0"
|
"minUpdaterVersion": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,30 @@ function Get-OCSentinelArtifact {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Enable-OCSentinelBetaDefaults {
|
||||||
|
param([Parameter(Mandatory)][string]$SettingsPath)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $SettingsPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = Get-Content -LiteralPath $SettingsPath -Raw | ConvertFrom-Json
|
||||||
|
if ($null -ne $settings.PSObject.Properties["ransomwareBetaDefaultApplied"]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $settings.PSObject.Properties["ransomwareBetaEnabled"]) {
|
||||||
|
$settings | Add-Member -NotePropertyName "ransomwareBetaEnabled" -NotePropertyValue $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$settings.ransomwareBetaEnabled = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings | Add-Member -NotePropertyName "ransomwareBetaDefaultApplied" -NotePropertyValue "1.5.0-beta.4"
|
||||||
|
$settings | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsPath -Encoding UTF8
|
||||||
|
Write-Host "Enabled passive ransomware beta defaults."
|
||||||
|
}
|
||||||
|
|
||||||
Initialize-OCSentinelTls
|
Initialize-OCSentinelTls
|
||||||
|
|
||||||
if ($ReleaseChannel -eq "stable" -and -not [string]::IsNullOrWhiteSpace($env:ReleaseChannel)) {
|
if ($ReleaseChannel -eq "stable" -and -not [string]::IsNullOrWhiteSpace($env:ReleaseChannel)) {
|
||||||
@@ -133,6 +157,7 @@ $updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
|
|||||||
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||||
$appPath = Join-Path $installRoot "app\OCSentinelCli.exe"
|
$appPath = Join-Path $installRoot "app\OCSentinelCli.exe"
|
||||||
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||||
|
$settingsPath = Join-Path $installRoot "config\ocsentinel-settings.json"
|
||||||
$secretScriptPath = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
$secretScriptPath = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
||||||
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
|
||||||
@@ -214,6 +239,10 @@ if (-not (Test-Path -LiteralPath $appPath)) {
|
|||||||
throw "OCSentinel installation completed, but the client executable was not found."
|
throw "OCSentinel installation completed, but the client executable was not found."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($ReleaseChannel -eq "beta") {
|
||||||
|
Enable-OCSentinelBetaDefaults -SettingsPath $settingsPath
|
||||||
|
}
|
||||||
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
if (-not [string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
||||||
if (-not (Test-Path -LiteralPath $clientConfigPath)) {
|
if (-not (Test-Path -LiteralPath $clientConfigPath)) {
|
||||||
throw "OCSentinel client configuration was not found: $clientConfigPath"
|
throw "OCSentinel client configuration was not found: $clientConfigPath"
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ internal sealed class AttackScanner
|
|||||||
@"D:\inetpub\logs\LogFiles"
|
@"D:\inetpub\logs\LogFiles"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] DefaultIisLogRoots =
|
||||||
|
[
|
||||||
|
@"C:\inetpub\logs\LogFiles",
|
||||||
|
@"D:\inetpub\logs\LogFiles"
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] DefaultFileZillaRoots =
|
private static readonly string[] DefaultFileZillaRoots =
|
||||||
[
|
[
|
||||||
@"C:\Program Files (x86)\FileZilla Server\Logs",
|
@"C:\Program Files (x86)\FileZilla Server\Logs",
|
||||||
@@ -33,6 +39,7 @@ internal sealed class AttackScanner
|
|||||||
ScanWindowsLogons(attacks, errors, since);
|
ScanWindowsLogons(attacks, errors, since);
|
||||||
ScanSqlLogons(attacks, errors, since);
|
ScanSqlLogons(attacks, errors, since);
|
||||||
ScanExchangeLogons(attacks, errors, since);
|
ScanExchangeLogons(attacks, errors, since);
|
||||||
|
ScanExchangeIisLogons(attacks, errors, since, configuration);
|
||||||
ScanIisFtpLogs(attacks, errors, since, configuration);
|
ScanIisFtpLogs(attacks, errors, since, configuration);
|
||||||
ScanFileZillaLogs(attacks, errors, since, configuration);
|
ScanFileZillaLogs(attacks, errors, since, configuration);
|
||||||
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
|
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
|
||||||
@@ -225,6 +232,40 @@ internal sealed class AttackScanner
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ScanExchangeIisLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
|
{
|
||||||
|
IEnumerable<string> roots = configuration.IisLogRoots.Count > 0 ? configuration.IisLogRoots : DefaultIisLogRoots;
|
||||||
|
foreach (string root in roots)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(root))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string directory in Directory.GetDirectories(root, "W3SVC*"))
|
||||||
|
{
|
||||||
|
foreach (string file in Directory.GetFiles(directory, "*.log").Where(path => File.GetLastWriteTime(path) >= since.LocalDateTime.Date))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
attacks.AddRange(ExchangeIisLogParser.ParseLines(File.ReadLines(file), since));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Exchange IIS log parse failed for {file}: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
errors.Add($"Exchange IIS log scan failed for {root}: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
{
|
{
|
||||||
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;
|
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ internal sealed record ScannerConfiguration
|
|||||||
|
|
||||||
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
public int CorrelationCriticalCveThreshold { get; init; } = 1;
|
||||||
|
|
||||||
public bool RansomwareBetaEnabled { get; init; }
|
public bool RansomwareBetaEnabled { get; init; } = true;
|
||||||
|
|
||||||
public bool RansomwareBetaAlertingEnabled { get; init; }
|
public bool RansomwareBetaAlertingEnabled { get; init; }
|
||||||
|
|
||||||
@@ -56,6 +56,8 @@ internal sealed record ScannerConfiguration
|
|||||||
|
|
||||||
public List<string> FtpRoots { get; init; } = [];
|
public List<string> FtpRoots { get; init; } = [];
|
||||||
|
|
||||||
|
public List<string> IisLogRoots { get; init; } = [];
|
||||||
|
|
||||||
public List<string> FileZillaRoots { get; init; } = [];
|
public List<string> FileZillaRoots { get; init; } = [];
|
||||||
|
|
||||||
public List<string> ExcludedIps { get; init; } = [];
|
public List<string> ExcludedIps { get; init; } = [];
|
||||||
|
|||||||
97
src/OCSentinelCli/ExchangeIisLogParser.cs
Normal file
97
src/OCSentinelCli/ExchangeIisLogParser.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace OCSentinelCli;
|
||||||
|
|
||||||
|
internal static class ExchangeIisLogParser
|
||||||
|
{
|
||||||
|
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
|
||||||
|
{
|
||||||
|
Dictionary<string, int>? fields = null;
|
||||||
|
|
||||||
|
foreach (string line in lines)
|
||||||
|
{
|
||||||
|
if (line.StartsWith("#Fields:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
fields = line[8..].Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select((field, index) => new { Field = field, Index = index })
|
||||||
|
.ToDictionary(item => item.Field, item => item.Index, StringComparer.OrdinalIgnoreCase);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (!TryValue(fields, values, "date", out string date) || !TryValue(fields, values, "time", out string time)
|
||||||
|
|| !TryValue(fields, values, "c-ip", out string sourceIp) || !TryValue(fields, values, "cs-uri-stem", out string path)
|
||||||
|
|| !TryValue(fields, values, "sc-status", out string statusText) || !int.TryParse(statusText, out int status))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status is not 401 and not 403 || !TryClassify(path, out string service))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero);
|
||||||
|
if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? destinationPort = TryValue(fields, values, "s-port", out string portText) && int.TryParse(portText, out int parsedPort) ? parsedPort : null;
|
||||||
|
string username = TryValue(fields, values, "cs-username", out string loggedUser) && loggedUser != "-" ? loggedUser : "[not logged]";
|
||||||
|
|
||||||
|
yield return new AttackEvent
|
||||||
|
{
|
||||||
|
Timestamp = timestamp.ToLocalTime(),
|
||||||
|
SourceIp = sourceIp,
|
||||||
|
Target = $"Exchange {service} login",
|
||||||
|
Username = username,
|
||||||
|
Source = "IIS W3C",
|
||||||
|
Service = service,
|
||||||
|
DestinationPort = destinationPort,
|
||||||
|
Endpoint = path,
|
||||||
|
InstanceId = status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryValue(IReadOnlyDictionary<string, int> fields, IReadOnlyList<string> values, string field, out string value)
|
||||||
|
{
|
||||||
|
value = string.Empty;
|
||||||
|
if (!fields.TryGetValue(field, out int index) || index >= values.Count)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = values[index];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryClassify(string path, out string service)
|
||||||
|
{
|
||||||
|
string normalized = path.Trim().ToLowerInvariant();
|
||||||
|
service = normalized switch
|
||||||
|
{
|
||||||
|
var value when value.StartsWith("/owa/") => "OWA",
|
||||||
|
var value when value.StartsWith("/ecp/") => "ECP",
|
||||||
|
var value when value.StartsWith("/mapi/") => "MAPI/HTTP",
|
||||||
|
var value when value.StartsWith("/ews/") => "EWS",
|
||||||
|
var value when value.StartsWith("/microsoft-server-activesync") => "ActiveSync",
|
||||||
|
var value when value.StartsWith("/autodiscover/") => "Autodiscover",
|
||||||
|
var value when value.StartsWith("/rpc/") => "Outlook Anywhere",
|
||||||
|
var value when value.StartsWith("/powershell") => "Exchange PowerShell",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
return service.Length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ internal sealed record AttackEvent
|
|||||||
public string Username { get; init; } = string.Empty;
|
public string Username { get; init; } = string.Empty;
|
||||||
|
|
||||||
public string Source { get; init; } = string.Empty;
|
public string Source { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string Service { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int? DestinationPort { get; init; }
|
||||||
|
|
||||||
|
public string Endpoint { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record AggregatedAttack
|
internal sealed record AggregatedAttack
|
||||||
@@ -33,6 +39,12 @@ internal sealed record AggregatedAttack
|
|||||||
|
|
||||||
public List<string> Sources { get; init; } = [];
|
public List<string> Sources { get; init; } = [];
|
||||||
|
|
||||||
|
public List<string> Services { get; init; } = [];
|
||||||
|
|
||||||
|
public List<int> DestinationPorts { get; init; } = [];
|
||||||
|
|
||||||
|
public List<string> Endpoints { get; init; } = [];
|
||||||
|
|
||||||
public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group)
|
public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group)
|
||||||
{
|
{
|
||||||
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
|
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
|
||||||
@@ -48,7 +60,10 @@ internal sealed record AggregatedAttack
|
|||||||
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
|
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
|
||||||
Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||||
Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||||
Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList()
|
Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||||
|
Services = ordered.Select(static attack => attack.Service).Where(static service => !string.IsNullOrWhiteSpace(service)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
|
||||||
|
DestinationPorts = ordered.Select(static attack => attack.DestinationPort).Where(static port => port.HasValue).Select(static port => port!.Value).Distinct().Order().ToList(),
|
||||||
|
Endpoints = ordered.Select(static attack => attack.Endpoint).Where(static endpoint => !string.IsNullOrWhiteSpace(endpoint)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,11 +144,26 @@ internal sealed record RansomwareBetaSummary
|
|||||||
|
|
||||||
public List<RansomwareSignal> Signals { get; init; } = [];
|
public List<RansomwareSignal> Signals { get; init; } = [];
|
||||||
|
|
||||||
|
public List<RansomwareSensorStatus> Sensors { get; init; } = [];
|
||||||
|
|
||||||
public List<RansomwareSmbSession> SmbSessions { get; init; } = [];
|
public List<RansomwareSmbSession> SmbSessions { get; init; } = [];
|
||||||
|
|
||||||
public RansomwareFileChurnSummary FileChurn { get; init; } = new();
|
public RansomwareFileChurnSummary FileChurn { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
|
public bool Available { get; init; }
|
||||||
|
|
||||||
|
public string State { get; init; } = "unknown";
|
||||||
|
|
||||||
|
public int EventCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed record RansomwareFileChurnSummary
|
internal sealed record RansomwareFileChurnSummary
|
||||||
{
|
{
|
||||||
public bool Enabled { get; init; }
|
public bool Enabled { get; init; }
|
||||||
|
|||||||
@@ -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.3</Version>
|
<Version>1.5.0-beta.6</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.3</InformationalVersion>
|
<InformationalVersion>1.5.0-beta.6</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -18,11 +18,22 @@ internal static class RansomwareBetaDetector
|
|||||||
int lookbackMinutes = Math.Clamp(configuration.RansomwareLookbackMinutes, 1, 60);
|
int lookbackMinutes = Math.Clamp(configuration.RansomwareLookbackMinutes, 1, 60);
|
||||||
DateTimeOffset since = DateTimeOffset.UtcNow.AddMinutes(-lookbackMinutes);
|
DateTimeOffset since = DateTimeOffset.UtcNow.AddMinutes(-lookbackMinutes);
|
||||||
var signals = new List<RansomwareSignal>();
|
var signals = new List<RansomwareSignal>();
|
||||||
|
var sensors = new List<RansomwareSensorStatus>
|
||||||
|
{
|
||||||
|
ScanSecurityProcesses(signals, errors, since, configuration),
|
||||||
|
ScanPowerShellScriptBlocks(signals, errors, since, configuration),
|
||||||
|
ScanSysmonProcesses(signals, errors, since, configuration)
|
||||||
|
};
|
||||||
|
|
||||||
ScanSecurityProcesses(signals, errors, since, configuration);
|
|
||||||
ScanPowerShellScriptBlocks(signals, errors, since, configuration);
|
|
||||||
ScanSysmonProcesses(signals, errors, since, configuration);
|
|
||||||
RansomwareFileChurnSummary fileChurn = RansomwareFileChurnDetector.Scan(configuration, errors);
|
RansomwareFileChurnSummary fileChurn = RansomwareFileChurnDetector.Scan(configuration, errors);
|
||||||
|
sensors.Add(new RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
Name = "security-file-audit-4663",
|
||||||
|
Enabled = fileChurn.Enabled,
|
||||||
|
Available = fileChurn.DataAvailable,
|
||||||
|
State = !fileChurn.Enabled ? "disabled" : fileChurn.IsTruncated ? "truncated" : fileChurn.DataAvailable ? "available" : "unavailable",
|
||||||
|
EventCount = fileChurn.FileOperationCount
|
||||||
|
});
|
||||||
RansomwareSignal? fileChurnSignal = RansomwareFileChurnDetector.CreateSignal(fileChurn, configuration);
|
RansomwareSignal? fileChurnSignal = RansomwareFileChurnDetector.CreateSignal(fileChurn, configuration);
|
||||||
if (fileChurnSignal is not null)
|
if (fileChurnSignal is not null)
|
||||||
{
|
{
|
||||||
@@ -61,24 +72,25 @@ internal static class RansomwareBetaDetector
|
|||||||
Reason = reason,
|
Reason = reason,
|
||||||
LookbackMinutes = lookbackMinutes,
|
LookbackMinutes = lookbackMinutes,
|
||||||
Signals = distinctSignals,
|
Signals = distinctSignals,
|
||||||
|
Sensors = sensors,
|
||||||
SmbSessions = smbSessions,
|
SmbSessions = smbSessions,
|
||||||
FileChurn = fileChurn
|
FileChurn = fileChurn
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ScanSecurityProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
private static RansomwareSensorStatus ScanSecurityProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
{
|
{
|
||||||
TryScan("Security", 4688, since, errors, record => AddSignal(signals, record, ReadProperty(record, 5), ReadProperty(record, 8), "Security", configuration));
|
return TryScan("security-process-4688", "Security", 4688, since, errors, record => AddSignal(signals, record, ReadProperty(record, 5), ReadProperty(record, 8), "Security", configuration));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ScanPowerShellScriptBlocks(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
private static RansomwareSensorStatus ScanPowerShellScriptBlocks(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
{
|
{
|
||||||
TryScan("Microsoft-Windows-PowerShell/Operational", 4104, since, errors, record => AddSignal(signals, record, "powershell", FormatDescription(record), "PowerShell", configuration));
|
return TryScan("powershell-script-block-4104", "Microsoft-Windows-PowerShell/Operational", 4104, since, errors, record => AddSignal(signals, record, "powershell", FormatDescription(record), "PowerShell", configuration));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ScanSysmonProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
private static RansomwareSensorStatus ScanSysmonProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
|
||||||
{
|
{
|
||||||
TryScan("Microsoft-Windows-Sysmon/Operational", 1, since, errors, record => AddSignal(signals, record, "sysmon-process", FormatDescription(record), "Sysmon", configuration));
|
return TryScan("sysmon-process-1", "Microsoft-Windows-Sysmon/Operational", 1, since, errors, record => AddSignal(signals, record, "sysmon-process", FormatDescription(record), "Sysmon", configuration));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddSignal(List<RansomwareSignal> signals, EventRecord record, string process, string commandLine, string source, ScannerConfiguration configuration)
|
private static void AddSignal(List<RansomwareSignal> signals, EventRecord record, string process, string commandLine, string source, ScannerConfiguration configuration)
|
||||||
@@ -146,8 +158,9 @@ internal static class RansomwareBetaDetector
|
|||||||
|
|
||||||
private static bool ContainsAll(string value, params string[] needles) => needles.All(needle => value.Contains(needle, StringComparison.Ordinal));
|
private static bool ContainsAll(string value, params string[] needles) => needles.All(needle => value.Contains(needle, StringComparison.Ordinal));
|
||||||
|
|
||||||
private static void TryScan(string logName, int eventId, DateTimeOffset since, List<string> errors, Action<EventRecord> processRecord)
|
private static RansomwareSensorStatus TryScan(string sensorName, string logName, int eventId, DateTimeOffset since, List<string> errors, Action<EventRecord> processRecord)
|
||||||
{
|
{
|
||||||
|
int eventCount = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - since).TotalMilliseconds);
|
long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - since).TotalMilliseconds);
|
||||||
@@ -157,16 +170,39 @@ internal static class RansomwareBetaDetector
|
|||||||
{
|
{
|
||||||
using (record)
|
using (record)
|
||||||
{
|
{
|
||||||
|
eventCount++;
|
||||||
processRecord(record);
|
processRecord(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
Name = sensorName,
|
||||||
|
Enabled = true,
|
||||||
|
Available = true,
|
||||||
|
State = "available",
|
||||||
|
EventCount = eventCount
|
||||||
|
};
|
||||||
}
|
}
|
||||||
catch (EventLogNotFoundException)
|
catch (EventLogNotFoundException)
|
||||||
{
|
{
|
||||||
|
return new RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
Name = sensorName,
|
||||||
|
Enabled = true,
|
||||||
|
State = "not-installed"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
errors.Add($"Ransomware beta query failed for {logName}: {exception.Message}");
|
errors.Add($"Ransomware beta query failed for {logName}: {exception.Message}");
|
||||||
|
return new RansomwareSensorStatus
|
||||||
|
{
|
||||||
|
Name = sensorName,
|
||||||
|
Enabled = true,
|
||||||
|
State = "query-failed",
|
||||||
|
EventCount = eventCount
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs
Normal file
42
tests/OCSentinelCli.Tests/ExchangeIisLogParserTests.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using OCSentinelCli;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OCSentinelCli.Tests;
|
||||||
|
|
||||||
|
public sealed class ExchangeIisLogParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParsesFailedOwaLoginWithActualIisPort()
|
||||||
|
{
|
||||||
|
string[] lines =
|
||||||
|
[
|
||||||
|
"#Fields: date time s-ip cs-method cs-uri-stem cs-username c-ip s-port sc-status",
|
||||||
|
"2026-08-02 04:15:00 10.0.0.10 POST /owa/auth.owa - 203.0.113.20 443 401"
|
||||||
|
];
|
||||||
|
|
||||||
|
AttackEvent attack = Assert.Single(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
|
||||||
|
|
||||||
|
Assert.Equal("Exchange OWA login", attack.Target);
|
||||||
|
Assert.Equal("OWA", attack.Service);
|
||||||
|
Assert.Equal(443, attack.DestinationPort);
|
||||||
|
Assert.Equal("/owa/auth.owa", attack.Endpoint);
|
||||||
|
Assert.Equal("203.0.113.20", attack.SourceIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesMapiAndIgnoresSuccessfulRequests()
|
||||||
|
{
|
||||||
|
string[] lines =
|
||||||
|
[
|
||||||
|
"#Fields: date time cs-uri-stem cs-username c-ip s-port sc-status",
|
||||||
|
"2026-08-02 04:15:00 /mapi/emsmdb/ user@example.test 198.51.100.8 444 403",
|
||||||
|
"2026-08-02 04:16:00 /ecp/ user@example.test 198.51.100.9 443 200"
|
||||||
|
];
|
||||||
|
|
||||||
|
AttackEvent attack = Assert.Single(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
|
||||||
|
|
||||||
|
Assert.Equal("MAPI/HTTP", attack.Service);
|
||||||
|
Assert.Equal(444, attack.DestinationPort);
|
||||||
|
Assert.Equal("user@example.test", attack.Username);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.Json;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace OCSentinelCli.Tests;
|
namespace OCSentinelCli.Tests;
|
||||||
@@ -6,6 +7,37 @@ namespace OCSentinelCli.Tests;
|
|||||||
[SupportedOSPlatform("windows")]
|
[SupportedOSPlatform("windows")]
|
||||||
public sealed class RansomwareBetaTests
|
public sealed class RansomwareBetaTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RansomwareBetaIsEnabledByDefault()
|
||||||
|
{
|
||||||
|
Assert.True(new ScannerConfiguration().RansomwareBetaEnabled);
|
||||||
|
Assert.False(new ScannerConfiguration().RansomwareBetaAlertingEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DisabledRansomwareBetaDoesNotReportSensorCoverage()
|
||||||
|
{
|
||||||
|
var summary = RansomwareBetaDetector.Scan(new ScannerConfiguration { RansomwareBetaEnabled = false }, []);
|
||||||
|
|
||||||
|
Assert.False(summary.Enabled);
|
||||||
|
Assert.Empty(summary.Sensors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SensorCoverageIsIncludedInTheJsonReport()
|
||||||
|
{
|
||||||
|
var summary = new RansomwareBetaSummary
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Sensors = [new RansomwareSensorStatus { Name = "security-process-4688", Enabled = true, Available = true, State = "available", EventCount = 4 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
string json = JsonSerializer.Serialize(summary, JsonOptions.Default);
|
||||||
|
|
||||||
|
Assert.Contains("\"Sensors\"", json);
|
||||||
|
Assert.Contains("security-process-4688", json);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FileChurnBelowBothThresholdsDoesNotCreateSignal()
|
public void FileChurnBelowBothThresholdsDoesNotCreateSignal()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user