11 Commits

Author SHA1 Message Date
OfficeCom Codex
6e10a71820 Correlate Exchange IIS failures with security accounts
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-08-05 09:39:33 +02:00
OfficeCom Codex
7f4cd291e7 Refine Exchange IIS authentication detection
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 52s
2026-08-05 09:16:46 +02:00
OfficeCom Codex
705b543e5a Publish beta 1.5.0-beta.7 manifest
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-08-03 01:21:25 +02:00
OfficeCom Codex
2ca50a4ee9 Limit uploaded event details
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-08-03 01:18:35 +02:00
OfficeCom Codex
d1f78a38fd Publish beta 1.5.0-beta.6 manifest
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 48s
2026-08-02 23:53:25 +02:00
OfficeCom Codex
cc77c45a10 Add Exchange IIS service telemetry
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 52s
2026-08-02 23:50:12 +02:00
OfficeCom Codex
c73bab139b Refine Sentinel security dashboard hierarchy
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-08-01 02:09:53 +02:00
OfficeCom Codex
7009596efc Add read-only Sentinel MCP server
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-08-01 02:07:17 +02:00
OfficeCom Codex
a494bc4ba3 Add progressive CSS enhancements to Sentinel dashboard
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-08-01 01:58:20 +02:00
OfficeCom Codex
ddba660b1a Refresh Sentinel dashboard and sensor coverage
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-08-01 01:56:41 +02:00
OfficeCom Codex
517cfa6773 Publish beta 1.5.0-beta.5 manifest
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-08-01 01:45:17 +02:00
23 changed files with 1191 additions and 28 deletions

View File

@@ -1,5 +1,6 @@
{
"warningEventThreshold": 10,
"maxReportedEvents": 1000,
"criticalEventThreshold": 30,
"warningUniqueIpThreshold": 5,
"criticalUniqueIpThreshold": 12,
@@ -28,6 +29,10 @@
"C:\\inetpub\\logs\\LogFiles",
"D:\\inetpub\\logs\\LogFiles"
],
"iisLogRoots": [
"C:\\inetpub\\logs\\LogFiles",
"D:\\inetpub\\logs\\LogFiles"
],
"fileZillaRoots": [
"C:\\Program Files (x86)\\FileZilla Server\\Logs",
"D:\\Program Files (x86)\\FileZilla Server\\Logs"

View File

@@ -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("/")
def overview():
with db_connection() as connection, connection.cursor() as cursor:
@@ -387,13 +396,55 @@ def device(machine_name):
key=lambda entry: (entry["latest"], entry["count"]),
reverse=True,
)[:25]
ransomware_raw = payload.get("RansomwareBeta") or payload.get("ransomwareBeta") or {}
sensor_labels = {
"security-process-4688": "Prozessstarts (Security 4688)",
"powershell-script-block-4104": "PowerShell-Skriptblöcke (4104)",
"sysmon-process-1": "Sysmon-Prozesse (1)",
"security-file-audit-4663": "Datei-Auditing (4663)",
}
ransomware_sensors = [
{
"label": sensor_labels.get(payload_value(sensor, "Name", "name"), payload_value(sensor, "Name", "name", default="Unbekannter Sensor")),
"enabled": payload_value(sensor, "Enabled", "enabled", default=False),
"available": payload_value(sensor, "Available", "available", default=False),
"state": payload_value(sensor, "State", "state", default="unknown"),
"event_count": payload_value(sensor, "EventCount", "eventCount", default=0),
}
for sensor in payload_value(ransomware_raw, "Sensors", "sensors", default=[])
]
ransomware_beta = {
"enabled": payload_value(ransomware_raw, "Enabled", "enabled", default=False),
"state": payload_value(ransomware_raw, "State", "state", default="disabled"),
"reason": payload_value(ransomware_raw, "Reason", "reason", default="Keine Ransomware-Beta-Daten verfuegbar."),
"signals": [
{
"timestamp": payload_value(signal, "Timestamp", "timestamp", default="-"),
"category": payload_value(signal, "Category", "category", default="Signal"),
"process": payload_value(signal, "Process", "process", default="-"),
"source": payload_value(signal, "Source", "source", default="-"),
"confidence": payload_value(signal, "Confidence", "confidence", default="low"),
}
for signal in payload_value(ransomware_raw, "Signals", "signals", default=[])
],
"smbSessions": [
{
"clientComputerName": payload_value(session, "ClientComputerName", "clientComputerName", default="-"),
"clientUserName": payload_value(session, "ClientUserName", "clientUserName", default="-"),
"openFileCount": payload_value(session, "OpenFileCount", "openFileCount", default=0),
"sessionId": payload_value(session, "SessionId", "sessionId", default="-"),
}
for session in payload_value(ransomware_raw, "SmbSessions", "smbSessions", default=[])
],
}
return render_template(
"device.html",
report=report,
event=event_metadata(payload),
payload=payload,
security_events=security_events,
ransomware_beta=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),
)

View File

@@ -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-map-panel { overflow:hidden; }.map-toolbar { display:flex; flex-wrap:wrap; gap:8px; margin:0 0 16px; }.map-toolbar label { flex:1 1 240px; display:grid; gap:5px; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-toolbar input,.map-toolbar button { min-height:38px; padding:8px 10px; border:1px solid var(--line); border-radius:5px; background:#fff; color:var(--ink); font:700 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#f5f9fc; }.network-map-layout { display:grid; grid-template-columns:minmax(0,1fr) 260px; min-height:560px; overflow:hidden; border:1px solid #d7e4ed; border-radius:9px; background:radial-gradient(circle at 18% 12%,#f5fbff,transparent 28rem),#edf4f8; }.network-map-layout #network-map { min-height:560px; background-image:linear-gradient(rgba(36,90,133,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(36,90,133,.05) 1px,transparent 1px); background-size:32px 32px; }.network-map-layout aside { padding:22px; border-left:1px solid #d7e4ed; background:#fff; }.network-map-layout aside strong { display:block; margin:8px 0 12px; font-size:18px; line-height:1.15; letter-spacing:-.035em; }.network-map-layout aside p { color:var(--muted); font-size:13px; line-height:1.5; }.network-map-layout dl { display:grid; grid-template-columns:1fr; gap:4px; margin:18px 0 0; }.network-map-layout dt { color:var(--muted); font-size:10px; font-weight:700; text-transform:uppercase; }.network-map-layout dd { margin:0 0 10px; font-size:13px; overflow-wrap:anywhere; }.inspector-arrow { color:var(--green); font-size:13px; }.legend.source { background:#245a85; }.legend.target { background:#14735b; } @media (max-width:850px) { .network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid #d7e4ed; border-left:0; }.network-map-layout #network-map { min-height:460px; } }
.map-hero { display:flex; align-items:end; justify-content:space-between; gap:28px; margin:0 -2vw 26px; padding:38px 3vw 30px; border-radius:14px; color:#eaf2f8; background:radial-gradient(circle at 82% 10%,rgba(79,163,223,.25),transparent 20rem),linear-gradient(132deg,#102a43,#0c1c2a 70%); box-shadow:0 18px 50px rgba(13,30,44,.22); }.map-hero h1 { max-width:650px; margin:8px 0 14px; font-size:clamp(42px,6vw,76px); line-height:.87; letter-spacing:-.07em; }.map-hero h1 em { color:#b8e36a; font-style:normal; }.map-hero p { max-width:620px; margin:0; color:#b7cad9; font-size:15px; line-height:1.55; }.map-hero .eyebrow { color:#9cc8e8; }.map-hero-status { display:grid; min-width:145px; gap:4px; padding:16px 18px; border:1px solid rgba(184,227,106,.35); border-radius:10px; background:rgba(11,31,45,.55); }.map-hero-status span { color:#b8e36a; font:700 9px 'Roboto',sans-serif; letter-spacing:.13em; }.map-hero-status strong { font-size:37px; line-height:1; letter-spacing:-.06em; }.map-hero-status small { color:#b7cad9; }.map-stat-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; margin:-10px 2vw 28px; border:1px solid #d8e5ee; border-radius:9px; overflow:hidden; background:#d8e5ee; box-shadow:0 10px 22px rgba(31,68,99,.08); }.map-stat-strip article { padding:15px 18px; background:#fff; }.map-stat-strip span,.map-stat-strip small { display:block; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-stat-strip strong { display:block; margin:8px 0 4px; font-size:29px; letter-spacing:-.06em; }.network-map-panel { margin-top:0; padding:0; border:0; border-radius:12px; background:#102a43; box-shadow:0 20px 44px rgba(16,42,67,.2); }.map-header { display:flex; justify-content:space-between; align-items:end; gap:16px; padding:24px 26px 18px; color:#eef6fa; }.map-header .eyebrow { color:#9cc8e8; }.map-header h2 { margin:6px 0 0; font-size:30px; letter-spacing:-.05em; }.map-legend { display:flex; gap:12px; color:#b7cad9; font-size:11px; }.map-legend span { display:flex; align-items:center; gap:5px; }.map-legend i { width:8px; height:8px; border-radius:50%; background:#4fa3df; }.map-legend i.target { background:#3bca99; border-radius:2px; }.map-legend i.hot { background:#ffb454; }.map-toolbar { align-items:end; margin:0; padding:0 26px 18px; border-bottom:1px solid rgba(156,200,232,.16); }.map-toolbar label { flex:0 1 180px; color:#9cc8e8; }.map-toolbar .search-field { flex:1 1 240px; }.map-toolbar input,.map-toolbar select,.map-toolbar button { min-height:40px; border:1px solid rgba(156,200,232,.24); border-radius:6px; background:#17374e; color:#eef6fa; font:600 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#245a85; }.map-toolbar button:hover { background:#326f9f; }.map-actions { display:flex; gap:7px; }.network-map-layout { grid-template-columns:minmax(0,1fr) 280px; min-height:610px; border:0; border-radius:0; background:#0d1e2c; }.network-map-layout #network-map { min-height:610px; background-image:radial-gradient(circle at 50% 0,rgba(79,163,223,.1),transparent 28rem),linear-gradient(rgba(156,200,232,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(156,200,232,.045) 1px,transparent 1px); background-size:auto,36px 36px,36px 36px; }.network-map-layout aside { padding:24px; border-left:1px solid rgba(156,200,232,.16); background:#112b3d; color:#eef6fa; }.network-map-layout aside .eyebrow { color:#9cc8e8; }.network-map-layout aside p { color:#b7cad9; }.network-map-layout dt { color:#82b7dc; }.network-map-layout dd { color:#eef6fa; }.inspector-arrow { color:#b8e36a; }.legend.source { background:#4fa3df; }.legend.target { background:#3bca99; } @media (max-width:850px) { .map-hero { flex-direction:column; align-items:start; margin:0 0 20px; }.map-stat-strip { grid-template-columns:repeat(2,1fr); margin:0 0 20px; }.map-header { align-items:start; flex-direction:column; }.map-toolbar { padding:0 18px 18px; }.network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid rgba(156,200,232,.16); border-left:0; } }
:root { --font-sans:'Manrope','Segoe UI',sans-serif; --font-mono:'IBM Plex Mono','Cascadia Code',monospace; --surface:#f7fafc; --surface-strong:#edf4f8; --navy:#0c2438; --blue:#2e6b9a; }
body,body * { font-family:var(--font-sans); }
pre,code,.raw-json pre { font-family:var(--font-mono); }
body { background:radial-gradient(circle at 8% -10%,rgba(102,176,225,.24),transparent 31rem),radial-gradient(circle at 94% 6%,rgba(85,201,155,.16),transparent 24rem),linear-gradient(180deg,#edf4f8 0,#f8fafc 42%,#eef4f7 100%); }
.masthead { position:sticky; z-index:5; top:0; backdrop-filter:blur(16px); background:rgba(12,36,56,.94); }
.dashboard-hero { display:grid; grid-template-columns:minmax(0,1fr) 230px; gap:28px; min-height:276px; margin:0 0 18px; padding:38px; border:1px solid rgba(134,192,225,.24); border-radius:18px; color:#ecf5fb; background:radial-gradient(circle at 88% 8%,rgba(96,183,227,.28),transparent 19rem),linear-gradient(135deg,#102f49,#0a1d2c 72%); box-shadow:0 24px 50px rgba(18,51,75,.18); overflow:hidden; }
.dashboard-hero .eyebrow { color:#a6d2ec; }.dashboard-hero h1 { max-width:730px; margin:11px 0 15px; font-size:clamp(38px,5.2vw,66px); line-height:.94; letter-spacing:-.067em; }.dashboard-hero p { max-width:620px; margin:0; color:#bdd3e1; font-size:15px; line-height:1.65; }
.dashboard-status { align-self:end; display:grid; gap:5px; padding:19px; border:1px solid rgba(168,216,241,.26); border-radius:14px; background:rgba(4,22,35,.34); box-shadow:inset 0 1px rgba(255,255,255,.06); }.dashboard-status > span:not(.status-orb) { color:#a9c7d8; font-size:10px; font-weight:800; letter-spacing:.11em; text-transform:uppercase; }.dashboard-status strong { font-size:30px; letter-spacing:-.055em; }.dashboard-status small { color:#c5d9e5; font-size:11px; }.status-orb { width:10px; height:10px; margin-bottom:4px; border-radius:50%; background:#72d39c; box-shadow:0 0 0 6px rgba(114,211,156,.13); }.dashboard-hero.warning .status-orb { background:#ffbe62; box-shadow:0 0 0 6px rgba(255,190,98,.13); }.dashboard-hero.critical .status-orb { background:#f4796d; box-shadow:0 0 0 6px rgba(244,121,109,.13); }
.quick-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:0 0 35px; }.quick-metrics article { min-height:112px; padding:18px 20px; border:1px solid #d6e3ec; border-radius:12px; background:rgba(255,255,255,.86); box-shadow:0 10px 22px rgba(30,68,94,.055); }.quick-metrics span,.quick-metrics small { display:block; color:#607b8e; font-size:10px; font-weight:800; letter-spacing:.08em; text-transform:uppercase; }.quick-metrics strong { display:block; margin:10px 0 6px; font-size:30px; letter-spacing:-.06em; }.quick-metrics small { color:#7d94a4; font-size:9px; letter-spacing:.045em; text-transform:none; }
.page-intro { max-width:770px; margin:10px 0 30px; }.page-intro h1 { margin:10px 0 13px; color:var(--navy); font-size:clamp(38px,5vw,62px); line-height:.94; letter-spacing:-.07em; }.page-intro h1 em { color:var(--green); font-style:normal; }.page-intro p { max-width:600px; margin:0; color:var(--muted); font-size:15px; line-height:1.65; }.reports-panel { margin-top:0; }
.sensor-coverage-panel { margin-top:8px; }.sensor-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(215px,1fr)); gap:11px; }.sensor-card { min-height:160px; display:grid; align-content:start; gap:10px; padding:17px; border:1px solid #dbe7ee; border-radius:11px; background:linear-gradient(145deg,#fff,#f6fafc); }.sensor-card > div { display:flex; align-items:center; gap:7px; }.sensor-card strong { color:#17354a; font-size:14px; line-height:1.3; }.sensor-card p { margin:0; color:#607b8e; font-size:12px; line-height:1.55; }.sensor-dot { width:8px; height:8px; border-radius:50%; background:#6abf90; box-shadow:0 0 0 4px rgba(106,191,144,.13); }.sensor-state { color:#5d788a; font-size:9px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; }.sensor-card.disabled { background:#f4f6f7; }.sensor-card.disabled .sensor-dot,.sensor-card.not-installed .sensor-dot { background:#9caeba; box-shadow:0 0 0 4px rgba(156,174,186,.13); }.sensor-card.query-failed { border-color:#f2c59c; background:#fff8ef; }.sensor-card.query-failed .sensor-dot,.sensor-card.truncated .sensor-dot { background:#e9a64d; box-shadow:0 0 0 4px rgba(233,166,77,.13); }.sensor-empty { width:100%; padding:24px; border:1px dashed #b8cbd7; border-radius:10px; color:#607b8e; text-align:center; font-size:13px; }.ransomware-panel + .sensor-coverage-panel { margin-top:8px; }
@media (max-width:850px) { .dashboard-hero { grid-template-columns:1fr; min-height:0; padding:28px 24px; }.dashboard-status { align-self:auto; }.quick-metrics { grid-template-columns:repeat(2,1fr); }.quick-metrics article { min-height:102px; }.page-intro { margin-top:4px; }.sensor-grid { grid-template-columns:1fr; } }
@layer enhancements {
:where(a,button,input,select,summary):focus-visible { outline:3px solid var(--lime); outline:3px solid color-mix(in srgb,var(--lime) 72%,white); outline-offset:3px; }
:where(a,button) { -webkit-tap-highlight-color:transparent; }
.quick-metrics,.sensor-grid { container-type:inline-size; }
.dashboard-hero { isolation:isolate; }
.dashboard-hero::after { position:absolute; z-index:-1; inset:auto -8% -54% auto; width:300px; aspect-ratio:1; border-radius:999px; background:radial-gradient(circle,rgba(184,227,106,.15),transparent 68%); content:''; filter:blur(3px); }
.quick-metrics article,.organization-card,.sensor-card { position:relative; overflow:hidden; }
.quick-metrics article::before,.sensor-card::before { position:absolute; inset:0; opacity:0; background:linear-gradient(120deg,transparent 18%,rgba(255,255,255,.65),transparent 82%); content:''; transform:translateX(-110%); transition:transform .55s ease,opacity .2s ease; }
@media (hover:hover) { .quick-metrics article:hover::before,.sensor-card:hover::before { opacity:1; transform:translateX(110%); }.quick-metrics article:hover { border-color:color-mix(in srgb,var(--blue) 42%,var(--line)); transform:translateY(-3px); box-shadow:0 18px 34px rgba(30,68,94,.12); }.sensor-card:hover { border-color:color-mix(in srgb,var(--blue) 34%,var(--line)); transform:translateY(-2px); box-shadow:0 14px 28px rgba(30,68,94,.09); } }
.quick-metrics article,.sensor-card { transition:transform .22s cubic-bezier(.2,.8,.2,1),box-shadow .22s ease,border-color .22s ease; }
.dashboard-hero,.quick-metrics article,.panel { animation:sentinel-rise .55s cubic-bezier(.2,.8,.2,1) both; }
.quick-metrics article:nth-child(2),.panel:nth-of-type(2) { animation-delay:60ms; }.quick-metrics article:nth-child(3),.panel:nth-of-type(3) { animation-delay:120ms; }.quick-metrics article:nth-child(4),.panel:nth-of-type(4) { animation-delay:180ms; }
@container (max-width:520px) { .sensor-card { min-height:0; grid-template-columns:auto 1fr; column-gap:12px; }.sensor-card > div { grid-column:1 / -1; }.sensor-card p { grid-column:1 / -1; } }
@supports (backdrop-filter:blur(1px)) { .panel { background:color-mix(in srgb,var(--panel) 90%,transparent); backdrop-filter:blur(10px); }.quick-metrics article { background:color-mix(in srgb,white 82%,transparent); backdrop-filter:blur(12px); } }
@supports not (backdrop-filter:blur(1px)) { .masthead { background:#0c2438; } }
@supports selector(body:has(.dashboard-hero.critical)) { body:has(.dashboard-hero.critical) .masthead { border-bottom-color:color-mix(in srgb,var(--red) 52%,#21445f); } body:has(.dashboard-hero.warning) .masthead { border-bottom-color:color-mix(in srgb,var(--amber) 55%,#21445f); } }
@media (prefers-contrast:more) { .panel,.quick-metrics article,.sensor-card { border-width:2px; }.state { border:1px solid currentColor; } }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; } }
}
@keyframes sentinel-rise { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
/* Operational hierarchy and responsive chrome. */
.skip-link { position:fixed; z-index:20; top:10px; left:10px; padding:10px 13px; border-radius:8px; background:var(--lime); color:var(--navy); font-weight:800; text-decoration:none; transform:translateY(-160%); transition:transform .2s ease; }
.skip-link:focus { transform:translateY(0); }
.wordmark { display:flex; align-items:center; gap:8px; }.wordmark .wordmark-mark { display:grid; flex:0 0 auto; place-items:center; width:29px; height:29px; margin:0; border-radius:8px; background:var(--lime); color:#102a43; font:800 10px var(--font-sans); letter-spacing:0; }.wordmark .wordmark-name { display:grid; gap:1px; color:#fff; font:800 18px/1 var(--font-sans); letter-spacing:-.045em; }.wordmark .wordmark-name small { color:#9cc8e8; font:700 8px/1 var(--font-sans); letter-spacing:.12em; text-transform:uppercase; }
.app-footer { display:flex; justify-content:space-between; gap:16px; max-width:1280px; margin:0 auto; padding:0 6vw 32px; color:#6c8494; font-size:10px; letter-spacing:.04em; }.app-footer span:first-child { color:#426277; font-weight:800; text-transform:uppercase; }
.priority-board { display:grid; grid-template-columns:minmax(240px,.82fr) minmax(0,1.65fr); gap:1px; margin:0 0 30px; overflow:hidden; border:1px solid #193a53; border-radius:16px; background:#193a53; box-shadow:0 20px 42px rgba(16,42,67,.15); }.priority-intro { display:grid; align-content:space-between; min-height:272px; padding:27px; color:#eaf3f8; background:radial-gradient(circle at 15% 8%,rgba(79,163,223,.2),transparent 16rem),linear-gradient(145deg,#153b58,#0d2437); }.priority-intro .eyebrow { color:#a4cae4; }.priority-intro h2 { margin:10px 0; font-size:clamp(28px,3.2vw,43px); line-height:.95; letter-spacing:-.065em; }.priority-intro p { max-width:290px; margin:0; color:#b7ccda; font-size:13px; line-height:1.55; }.priority-sync { display:flex; align-items:center; gap:8px; margin-top:20px; color:#9fc0d2; font:700 10px var(--font-sans); }.priority-sync i { width:7px; height:7px; border-radius:50%; background:#72d39c; box-shadow:0 0 0 5px rgba(114,211,156,.12); }.priority-list { display:grid; align-content:center; gap:1px; background:#d7e4ec; }.priority-item { display:grid; grid-template-columns:34px minmax(0,1fr) 24px; align-items:center; gap:15px; min-height:90px; padding:16px 22px; background:rgba(255,255,255,.96); color:var(--ink); text-decoration:none; transition:background .2s ease,transform .2s ease; }.priority-item:hover { background:#f4faff; }.priority-item.critical:hover { background:#fff4f1; }.priority-index { align-self:start; color:#8aa0ae; font:700 11px var(--font-mono); }.priority-item strong,.priority-item small { display:block; }.priority-item strong { margin:6px 0 3px; font-size:18px; letter-spacing:-.035em; }.priority-item small { overflow:hidden; color:#657f90; font-size:11px; text-overflow:ellipsis; white-space:nowrap; }.priority-arrow { color:#3a7196; font-size:20px; transition:transform .2s ease; }.priority-item:hover .priority-arrow { transform:translate(2px,-2px); }.priority-calm { display:grid; align-content:center; justify-items:start; gap:9px; padding:30px; color:#eaf3f8; background:linear-gradient(145deg,#173d4b,#0e2a35); }.priority-calm .status-orb { margin:0 0 8px; }.priority-calm strong { font-size:22px; letter-spacing:-.04em; }.priority-calm p { margin:0; color:#b4cbd4; font-size:13px; }.priority-board.calm { grid-template-columns:minmax(240px,.82fr) minmax(0,1.65fr); }
.device-hero { display:grid; grid-template-columns:minmax(0,1fr) 240px; gap:28px; align-items:end; min-height:225px; margin:0 0 18px; padding:31px 33px; overflow:hidden; border:1px solid rgba(134,192,225,.24); border-radius:18px; color:#ecf5fb; background:radial-gradient(circle at 88% 10%,rgba(96,183,227,.25),transparent 17rem),linear-gradient(135deg,#102f49,#0a1d2c 72%); box-shadow:0 24px 50px rgba(18,51,75,.15); }.device-hero.warning { background:radial-gradient(circle at 88% 10%,rgba(247,181,91,.22),transparent 17rem),linear-gradient(135deg,#40301a,#20180e 72%); }.device-hero.critical { background:radial-gradient(circle at 88% 10%,rgba(240,110,100,.24),transparent 17rem),linear-gradient(135deg,#45252b,#1d1116 72%); }.device-hero .eyebrow { color:#a6d2ec; }.device-hero h1 { margin:9px 0 12px; font-size:clamp(37px,5vw,62px); line-height:.92; letter-spacing:-.07em; }.device-hero p { margin:0; color:#bdd3e1; font-size:13px; line-height:1.55; }.device-hero-state { display:grid; gap:5px; padding:18px; border:1px solid rgba(168,216,241,.26); border-radius:14px; background:rgba(4,22,35,.34); }.device-hero-state > span:not(.status-orb) { color:#a9c7d8; font-size:10px; font-weight:800; letter-spacing:.11em; text-transform:uppercase; }.device-hero-state strong { font-size:27px; letter-spacing:-.055em; text-transform:capitalize; }.device-hero-state small { color:#c5d9e5; font-size:11px; line-height:1.45; }
.header-links a,.state,.event-count,.recipient-form button,.rule-actions button,table,th,td,.map-toolbar input,.map-toolbar select,.map-toolbar button { font-family:var(--font-sans); }
@media (max-width:850px) { .masthead { gap:14px; align-items:center; }.header-links { gap:4px; overflow:auto; max-width:calc(100vw - 155px); flex-wrap:nowrap; }.header-links a { flex:0 0 auto; padding:7px 8px; font-size:11px; }.wordmark .wordmark-name small { display:none; }.app-footer { align-items:flex-start; flex-direction:column; padding-bottom:24px; }.priority-board,.priority-board.calm,.device-hero { grid-template-columns:1fr; }.priority-intro { min-height:215px; }.priority-list { gap:1px; }.priority-item { min-height:84px; padding:15px 17px; }.priority-item small { white-space:normal; }.device-hero { min-height:0; padding:28px 24px; }.device-hero-state { max-width:none; }.device-hero h1 { font-size:42px; } }

View File

@@ -6,14 +6,16 @@
<title>{% block title %}OfficeCom Sentinel{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=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') }}">
</head>
<body class="app-shell">
<a class="skip-link" href="#main-content">Zum Inhalt springen</a>
<header class="masthead">
<a class="wordmark" href="{{ url_for('overview') }}"><span>OC</span>Sentinel</a>
<nav class="header-links"><a class="{{ 'active' if request.endpoint in ('overview', 'organization') else '' }}" href="{{ url_for('overview') }}">Lagebild</a><a class="{{ 'active' if request.endpoint == '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>
<a class="wordmark" href="{{ url_for('overview') }}"><span class="wordmark-mark">OC</span><span class="wordmark-name">Sentinel <small>Security console</small></span></a>
<nav class="header-links" aria-label="Hauptnavigation"><a class="{{ 'active' if request.endpoint in ('overview', 'organization') else '' }}" href="{{ url_for('overview') }}">Lagebild</a><a class="{{ 'active' if request.endpoint == 'network' else '' }}" href="{{ url_for('network') }}">Zugriffswege</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
</header>
<main>{% 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>
</html>

View File

@@ -1,9 +1,13 @@
{% extends "base.html" %}
{% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
{% 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>
{% if ransomware_beta.enabled %}<section class="panel ransomware-panel {{ ransomware_beta.state }}"><div class="panel-heading"><span class="eyebrow">Passive Beta</span><h2>Ransomware-Frueherkennung <small>{{ ransomware_beta.state }}</small></h2><p>{{ ransomware_beta.reason }}</p></div><div class="table-wrap"><table><thead><tr><th>Zeitpunkt</th><th>Signal</th><th>Prozess</th><th>Quelle</th><th>Bewertung</th></tr></thead><tbody>{% for signal in ransomware_beta.signals %}<tr><td>{{ signal.timestamp }}</td><td>{{ signal.category }}</td><td>{{ signal.process }}</td><td>{{ signal.source }}</td><td><span class="state {{ 'critical' if signal.confidence == 'high' else 'warning' }}">{{ signal.confidence }}</span></td></tr>{% else %}<tr><td colspan="5" class="empty-state">Keine Signale im aktuellen Beta-Zeitfenster.</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
{% if ransomware_beta.enabled %}<section class="panel sensor-coverage-panel"><div class="panel-heading"><span class="eyebrow">Beta-Abdeckung</span><h2>Erkennungsquellen <small>Verfuegbarkeit im letzten Scan</small></h2><p>Ein unauffaelliger Zeitraum ist nur dann belastbar, wenn die benoetigten Datenquellen erreichbar waren.</p></div><div class="sensor-grid">{% for sensor in ransomware_sensors %}<article class="sensor-card {{ sensor.state }}"><div><span class="sensor-dot"></span><span class="sensor-state">{{ sensor.state }}</span></div><strong>{{ sensor.label }}</strong><p>{% if not sensor.enabled %}Nicht fuer diesen Client aktiviert.{% elif sensor.available %}{{ sensor.event_count }} Ereignisse im Zeitfenster verarbeitet.{% elif sensor.state == 'not-installed' %}Protokollquelle ist auf diesem System nicht installiert.{% else %}Quelle konnte im letzten Scan nicht verwendet werden.{% endif %}</p></article>{% else %}<div class="sensor-empty">Dieser Client sendet noch keine Abdeckungsdaten. Nach dem Update auf Beta 1.5.0-beta.5 erscheint die Sensoransicht automatisch.</div>{% endfor %}</div></section>{% endif %}
{% if ransomware_beta.smbSessions %}<section class="panel smb-context-panel"><div class="panel-heading"><span class="eyebrow">Incident-Kontext</span><h2>Aktive SMB-Sitzungen <small>Nur bei Ransomware-Warnung oder kritisch erfasst</small></h2></div><div class="table-wrap"><table><thead><tr><th>Client</th><th>Benutzer</th><th>Offene Dateien</th><th>Sitzung</th></tr></thead><tbody>{% for session in ransomware_beta.smbSessions %}<tr><td>{{ session.clientComputerName }}</td><td>{{ session.clientUserName }}</td><td>{{ session.openFileCount }}</td><td>{{ session.sessionId }}</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
<section class="panel event-summary-panel"><div class="panel-heading"><span class="eyebrow">Schnelluebersicht</span><h2>Erkannte Sicherheitsereignisse</h2><p>Fehlgeschlagene Anmeldungen und weitere Vorfaelle aus dem letzten Scan, nach Konto und Quell-IP zusammengefasst.</p></div><div class="table-wrap"><table><thead><tr><th>Vorfall</th><th>Konto</th><th>Quell-IP</th><th>Letzter Zeitpunkt</th><th>Anzahl</th></tr></thead><tbody>{% for entry in security_events %}<tr><td><strong>{{ entry.type }}</strong></td><td>{{ entry.account }}</td><td>{{ entry.source_ip }}</td><td>{{ entry.latest }}</td><td><span class="event-count">{{ entry.count }}</span></td></tr>{% else %}<tr><td colspan="5" class="empty-state">Keine sicherheitsrelevanten Ereignisse im letzten Scan.</td></tr>{% endfor %}</tbody></table></div></section>
<section class="panel raw-export-panel"><div class="panel-heading"><span class="eyebrow">Technische Daten</span><h2>Roh-Export</h2><p>Vollstaendige, unveraenderte Nutzlast des zuletzt eingegangenen Scans.</p></div><details class="raw-json" open><summary>JSON-Rohdaten</summary><pre>{{ payload_pretty }}</pre></details></section>

View File

@@ -1,10 +1,46 @@
{% extends "base.html" %}
{% block content %}
<section class="situation {% 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>
<span>{% if summary[2] %}KRITISCH{% elif summary[1] %}PRUEFEN{% else %}STABIL{% endif %}</span>
<section class="dashboard-hero {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
<div>
<span class="eyebrow">OfficeCom Sentinel Uebersicht</span>
<h1>{% if summary[2] %}Sicherheitslage<br>braucht Aufmerksamkeit.{% elif summary[1] %}Signale im Bestand<br>gezielt pruefen.{% else %}Sicherheitslage<br>unter Kontrolle.{% endif %}</h1>
<p>Verdichtete Endpoint-Signale, Upload-Gesundheit und organisationsweite Einordnung an einem Ort.</p>
</div>
<div class="dashboard-status"><span class="status-orb"></span><span>Aktueller Zustand</span><strong>{% if summary[2] %}Kritisch{% elif summary[1] %}Pruefen{% else %}Stabil{% endif %}</strong><small>{{ current_alert_count }} aktuelle Auffaelligkeit{{ '' if current_alert_count == 1 else 'en' }}</small></div>
</section>
<section class="quick-metrics">
<article><span>Geraete</span><strong>{{ summary[0] }}</strong><small>{{ coverage[1] }} melden aktuell</small></article>
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong><small>im letzten Status</small></article>
<article><span>Kritisch</span><strong class="critical">{{ summary[2] }}</strong><small>sofort sichtbar</small></article>
<article><span>Abdeckung</span><strong class="{% if coverage[2] %}warning{% else %}ok{% endif %}">{{ coverage[1] }}/{{ coverage[0] }}</strong><small>{{ coverage[2] }} stumm &gt; 36 Std.</small></article>
</section>
{% if alerts %}
<section class="priority-board">
<div class="priority-intro">
<span class="eyebrow">Einsatzfokus</span>
<h2>Was jetzt<br>Aufmerksamkeit braucht.</h2>
<p>Priorisiert nach Schweregrad und zuletzt gemeldetem Signal.</p>
<div class="priority-sync"><i></i><span>Letzter Datenstand: {{ summary[7] or '-' }}</span></div>
</div>
<div class="priority-list">
{% for alert in alerts[:3] %}
<a class="priority-item {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
<span class="priority-index">0{{ loop.index }}</span>
<div><span class="state {{ alert.alert_state }}">{{ alert.alert_state }}</span><strong>{{ alert.machine_name }}</strong><small>{{ alert.total_events }} Ereignisse · {{ alert.unique_ip_count }} Quell-IPs · {{ alert.event.label }}</small></div>
<span class="priority-arrow" aria-hidden="true"></span>
</a>
{% endfor %}
</div>
</section>
{% else %}
<section class="priority-board calm">
<div class="priority-intro"><span class="eyebrow">Einsatzfokus</span><h2>Aktuell keine<br>offenen Signale.</h2><p>Die meldenden Systeme liefern derzeit keine auffaelligen Sicherheitsereignisse.</p><div class="priority-sync"><i></i><span>Letzter Datenstand: {{ summary[7] or '-' }}</span></div></div>
<div class="priority-calm"><span class="status-orb"></span><strong>Keine unmittelbare Aktion notwendig</strong><p>Behalte Abdeckung und Berichtsintervall im Blick.</p></div>
</section>
{% endif %}
<section class="panel trend-panel">
<div class="panel-heading"><span class="eyebrow">Letzte 14 Tage</span><h2>Signalverlauf <small>Verdichtete Scan-Ergebnisse pro Tag</small></h2></div>
<div class="trend-chart" aria-label="Signalverlauf der letzten 14 Tage">{% for day in trend %}<article class="trend-day"><div class="trend-bar" style="--bar: {{ (day.event_count * 100 / trend_max)|round(0, 'floor') }}%"><span class="trend-critical" style="--critical: {{ (day.critical_count * 100 / trend_max)|round(0, 'floor') }}%"></span><span class="trend-warning" style="--warning: {{ (day.warning_count * 100 / trend_max)|round(0, 'floor') }}%"></span></div><strong>{{ day.event_count }}</strong><small>{{ day.day.strftime('%d.%m.') }}</small></article>{% else %}<p class="empty-state">Noch keine Trenddaten vorhanden.</p>{% endfor %}</div>
@@ -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>
</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">
<div class="panel-heading"><h2>Geraeteabdeckung</h2></div>
<div class="coverage-metrics"><article><span>Bekannt</span><strong>{{ coverage[0] }}</strong></article><article><span>Meldend &lt; 36 Std.</span><strong class="ok">{{ coverage[1] }}</strong></article><article><span>Stumm &gt; 36 Std.</span><strong class="{% if coverage[2] %}warning{% endif %}">{{ coverage[2] }}</strong></article></div>
@@ -31,7 +59,7 @@
{% if alerts %}
<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">
{% for alert in alerts %}
<a class="alert-card {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">

View File

@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% block title %}Berichte - OC Sentinel{% endblock %}
{% 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 %}
</tbody></table></div></section>
{% endblock %}

View 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
View File

@@ -0,0 +1,2 @@
.env
__pycache__/

View 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"]

View 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.

View 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

View 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
View 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())

View File

@@ -1,8 +1,8 @@
{
"channel": "beta",
"version": "1.5.0-beta.4",
"publishedAtUtc": "2026-07-31T23:38:20.1674646Z",
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.4/OCSentinelClient-win-x64.zip",
"sha256": "f8e8714a4ea1426674bca27a403c33e166a31b38f77f0c638cb4a6a51b8f3343",
"version": "1.5.0-beta.7",
"publishedAtUtc": "2026-08-02T23:20:59.4454466Z",
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.7/OCSentinelClient-win-x64.zip",
"sha256": "b40da61499d0d9b5f95922f84aebbb3b6de6bf8a2ba2196873ecf2fab1789c0d",
"minUpdaterVersion": "1.0.0"
}

View File

@@ -16,6 +16,12 @@ internal sealed class AttackScanner
@"D:\inetpub\logs\LogFiles"
];
private static readonly string[] DefaultIisLogRoots =
[
@"C:\inetpub\logs\LogFiles",
@"D:\inetpub\logs\LogFiles"
];
private static readonly string[] DefaultFileZillaRoots =
[
@"C:\Program Files (x86)\FileZilla Server\Logs",
@@ -33,8 +39,10 @@ internal sealed class AttackScanner
ScanWindowsLogons(attacks, errors, since);
ScanSqlLogons(attacks, errors, since);
ScanExchangeLogons(attacks, errors, since);
ScanExchangeIisLogons(attacks, errors, since, configuration);
ScanIisFtpLogs(attacks, errors, since, configuration);
ScanFileZillaLogs(attacks, errors, since, configuration);
attacks = ExchangeIisAccountCorrelator.Enrich(attacks);
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
if (configuration.ExcludedIps.Count > 0)
@@ -46,6 +54,14 @@ internal sealed class AttackScanner
attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp));
int totalEventCount = attacks.Count;
int maxReportedEvents = Math.Clamp(configuration.MaxReportedEvents, 100, 5000);
List<AttackEvent> reportedEvents = attacks
.OrderByDescending(static attack => attack.Timestamp)
.Take(maxReportedEvents)
.OrderBy(static attack => attack.Timestamp)
.ToList();
List<AggregatedAttack> topSources = attacks
.GroupBy(static attack => attack.SourceIp)
.Select(group => AggregatedAttack.FromGroup(group))
@@ -74,7 +90,9 @@ internal sealed class AttackScanner
GeneratedAtUtc = generatedAtUtc,
ClientVersion = BuildMetadata.Version,
LookbackDays = options.LookbackDays,
TotalEvents = attacks.Count,
TotalEvents = totalEventCount,
ReportedEventCount = reportedEvents.Count,
EventsTruncated = reportedEvents.Count < totalEventCount,
UniqueIpCount = uniqueIpCount,
AlertState = correlationAssessment.FinalAlertState,
AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason,
@@ -88,7 +106,7 @@ internal sealed class AttackScanner
FinishedAtUtc = generatedAtUtc,
UploadAttempted = false
},
Events = attacks,
Events = reportedEvents,
TopSources = topSources,
Errors = errors
};
@@ -164,6 +182,7 @@ internal sealed class AttackScanner
Target = "Windows login",
Username = ReadProperty(eventRecord, 5, "[unknown]"),
Source = "Security",
Service = ReadProperty(eventRecord, 10) == "3" ? "Network" : string.Empty,
InstanceId = 4625
});
});
@@ -225,6 +244,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)
{
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;

View File

@@ -6,6 +6,8 @@ internal sealed record ScannerConfiguration
{
public int WarningEventThreshold { get; init; } = 10;
public int MaxReportedEvents { get; init; } = 1000;
public int CriticalEventThreshold { get; init; } = 30;
public int WarningUniqueIpThreshold { get; init; } = 5;
@@ -56,6 +58,8 @@ internal sealed record ScannerConfiguration
public List<string> FtpRoots { get; init; } = [];
public List<string> IisLogRoots { get; init; } = [];
public List<string> FileZillaRoots { get; init; } = [];
public List<string> ExcludedIps { get; init; } = [];

View File

@@ -0,0 +1,53 @@
namespace OCSentinelCli;
internal static class ExchangeIisAccountCorrelator
{
private static readonly TimeSpan CorrelationWindow = TimeSpan.FromMinutes(2);
internal static List<AttackEvent> Enrich(IReadOnlyList<AttackEvent> attacks)
{
List<AttackEvent> securityFailures = attacks
.Where(IsNetworkSecurityFailure)
.Where(HasRecordedAccount)
.ToList();
return attacks.Select(attack => IsUnresolvedExchangeIisFailure(attack)
? EnrichFromSecurityFailure(attack, securityFailures)
: attack).ToList();
}
private static AttackEvent EnrichFromSecurityFailure(AttackEvent iisFailure, IReadOnlyList<AttackEvent> securityFailures)
{
List<string> accounts = securityFailures
.Where(failure => string.Equals(failure.SourceIp, iisFailure.SourceIp, StringComparison.OrdinalIgnoreCase))
.Where(failure => (failure.Timestamp - iisFailure.Timestamp).Duration() <= CorrelationWindow)
.Select(failure => failure.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// A shared NAT address can produce concurrent failures; do not guess between accounts.
return accounts.Count == 1 ? iisFailure with { Username = accounts[0] } : iisFailure;
}
private static bool IsUnresolvedExchangeIisFailure(AttackEvent attack)
{
return string.Equals(attack.Source, "IIS W3C", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(attack.Username, "[not logged]", StringComparison.OrdinalIgnoreCase)
|| string.Equals(attack.Username, "[unknown]", StringComparison.OrdinalIgnoreCase));
}
private static bool IsNetworkSecurityFailure(AttackEvent attack)
{
return attack.InstanceId == 4625
&& string.Equals(attack.Source, "Security", StringComparison.OrdinalIgnoreCase)
&& string.Equals(attack.Service, "Network", StringComparison.OrdinalIgnoreCase)
&& string.Equals(attack.Target, "Windows login", StringComparison.OrdinalIgnoreCase);
}
private static bool HasRecordedAccount(AttackEvent attack)
{
return !string.IsNullOrWhiteSpace(attack.Username)
&& !string.Equals(attack.Username, "[unknown]", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(attack.Username, "[not logged]", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,168 @@
using System.Globalization;
namespace OCSentinelCli;
internal static class ExchangeIisLogParser
{
private static readonly TimeSpan AuthenticationCompletionWindow = TimeSpan.FromMinutes(2);
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
{
Dictionary<string, int>? fields = null;
var pendingFailures = new List<ExchangeIisObservation>();
var attacks = new List<AttackEvent>();
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 || !TryParseObservation(fields, line, since, out ExchangeIisObservation? observation))
{
continue;
}
if (observation is null)
{
continue;
}
FlushExpiredCandidates(pendingFailures, attacks, observation.Timestamp);
if (observation.IsSuccessfulAuthentication)
{
pendingFailures.RemoveAll(candidate => candidate.MatchesSuccessfulAuthentication(observation));
}
else if (observation.IsCredentialFailure)
{
pendingFailures.Add(observation);
}
}
attacks.AddRange(pendingFailures.Select(static candidate => candidate.ToAttackEvent()));
return attacks;
}
private static void FlushExpiredCandidates(List<ExchangeIisObservation> pendingFailures, List<AttackEvent> attacks, DateTimeOffset currentTimestamp)
{
DateTimeOffset cutoff = currentTimestamp - AuthenticationCompletionWindow;
foreach (ExchangeIisObservation candidate in pendingFailures.Where(candidate => candidate.Timestamp < cutoff).ToList())
{
attacks.Add(candidate.ToAttackEvent());
pendingFailures.Remove(candidate);
}
}
private static bool TryParseObservation(IReadOnlyDictionary<string, int> fields, string line, DateTimeOffset since, out ExchangeIisObservation? observation)
{
observation = null;
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)
|| !TryClassify(path, out string service))
{
return false;
}
if ((status < 200 || status >= 400) && status is not 401 and not 403)
{
return false;
}
if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc))
{
return false;
}
DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero);
if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-")
{
return false;
}
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]";
string userAgent = TryValue(fields, values, "cs(User-Agent)", out string parsedUserAgent) && parsedUserAgent != "-" ? parsedUserAgent : string.Empty;
string substatus = TryValue(fields, values, "sc-substatus", out string parsedSubstatus) ? parsedSubstatus : string.Empty;
observation = new ExchangeIisObservation(timestamp, sourceIp, path, service, destinationPort, username, userAgent, status, substatus);
return true;
}
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;
}
private sealed record ExchangeIisObservation(
DateTimeOffset Timestamp,
string SourceIp,
string Endpoint,
string Service,
int? DestinationPort,
string Username,
string UserAgent,
int Status,
string Substatus)
{
public bool IsSuccessfulAuthentication => Status is >= 200 and < 400;
// IIS 401.0 and 401.2 commonly occur during normal authentication negotiation or server configuration checks.
public bool IsCredentialFailure => Status == 403 || (Status == 401 && (string.IsNullOrWhiteSpace(Substatus) || Substatus == "1"));
public bool MatchesSuccessfulAuthentication(ExchangeIisObservation success)
{
return success.IsSuccessfulAuthentication
&& success.Timestamp >= Timestamp
&& success.Timestamp - Timestamp <= AuthenticationCompletionWindow
&& string.Equals(success.SourceIp, SourceIp, StringComparison.OrdinalIgnoreCase)
&& string.Equals(success.Endpoint, Endpoint, StringComparison.OrdinalIgnoreCase)
&& success.DestinationPort == DestinationPort
&& string.Equals(success.UserAgent, UserAgent, StringComparison.OrdinalIgnoreCase);
}
public AttackEvent ToAttackEvent() => new()
{
Timestamp = Timestamp.ToLocalTime(),
SourceIp = SourceIp,
Target = $"Exchange {Service} login",
Username = Username,
Source = "IIS W3C",
Service = Service,
DestinationPort = DestinationPort,
Endpoint = Endpoint,
InstanceId = Status
};
}
}

View File

@@ -13,6 +13,12 @@ internal sealed record AttackEvent
public string Username { 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
@@ -33,6 +39,12 @@ internal sealed record AggregatedAttack
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)
{
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
@@ -48,7 +60,10 @@ internal sealed record AggregatedAttack
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
Targets = ordered.Select(static attack => attack.Target).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()
};
}
@@ -92,6 +107,10 @@ internal sealed record ScanResult
public int TotalEvents { get; init; }
public int ReportedEventCount { get; init; }
public bool EventsTruncated { get; init; }
public int UniqueIpCount { get; init; }
public string AlertState { get; init; } = "ok";

View File

@@ -9,10 +9,10 @@
<RootNamespace>OCSentinelCli</RootNamespace>
<Product>OfficeCom Sentinel</Product>
<Company>OfficeCom</Company>
<Version>1.5.0-beta.5</Version>
<Version>1.5.0-beta.7</Version>
<AssemblyVersion>1.5.0.0</AssemblyVersion>
<FileVersion>1.5.0.0</FileVersion>
<InformationalVersion>1.5.0-beta.5</InformationalVersion>
<InformationalVersion>1.5.0-beta.7</InformationalVersion>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,58 @@
using OCSentinelCli;
using Xunit;
namespace OCSentinelCli.Tests;
public sealed class ExchangeIisAccountCorrelatorTests
{
[Fact]
public void EnrichesAnIisFailureFromOneMatchingSecurityFailure()
{
DateTimeOffset timestamp = new(2026, 8, 5, 4, 0, 0, TimeSpan.Zero);
List<AttackEvent> events =
[
IisFailure(timestamp),
SecurityFailure(timestamp.AddSeconds(20), "user@example.test")
];
AttackEvent enriched = Assert.Single(ExchangeIisAccountCorrelator.Enrich(events), attack => attack.Source == "IIS W3C");
Assert.Equal("user@example.test", enriched.Username);
}
[Fact]
public void KeepsIisAccountUnresolvedWhenMultipleAccountsMatch()
{
DateTimeOffset timestamp = new(2026, 8, 5, 4, 0, 0, TimeSpan.Zero);
List<AttackEvent> events =
[
IisFailure(timestamp),
SecurityFailure(timestamp.AddSeconds(20), "first@example.test"),
SecurityFailure(timestamp.AddSeconds(30), "second@example.test")
];
AttackEvent unresolved = Assert.Single(ExchangeIisAccountCorrelator.Enrich(events), attack => attack.Source == "IIS W3C");
Assert.Equal("[not logged]", unresolved.Username);
}
private static AttackEvent IisFailure(DateTimeOffset timestamp) => new()
{
Timestamp = timestamp,
SourceIp = "198.51.100.8",
Username = "[not logged]",
Source = "IIS W3C",
Target = "Exchange ActiveSync login"
};
private static AttackEvent SecurityFailure(DateTimeOffset timestamp, string username) => new()
{
Timestamp = timestamp,
SourceIp = "198.51.100.8",
Username = username,
Source = "Security",
Service = "Network",
Target = "Windows login",
InstanceId = 4625
};
}

View File

@@ -0,0 +1,67 @@
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);
}
[Fact]
public void IgnoresNormalIisAuthenticationHandshake()
{
string[] lines =
[
"#Fields: date time cs-uri-stem cs-username c-ip s-port cs(User-Agent) sc-status sc-substatus",
"2026-08-02 04:15:00 /mapi/emsmdb/ - 198.51.100.8 443 Outlook 401 1",
"2026-08-02 04:15:01 /mapi/emsmdb/ user@example.test 198.51.100.8 443 Outlook 200 0"
];
Assert.Empty(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
}
[Fact]
public void IgnoresNonCredentialIis401Substatus()
{
string[] lines =
[
"#Fields: date time cs-uri-stem cs-username c-ip s-port sc-status sc-substatus",
"2026-08-02 04:15:00 /ews/Exchange.asmx - 198.51.100.8 443 401 0"
];
Assert.Empty(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
}
}