24 Commits

Author SHA1 Message Date
OfficeCom Codex
64841d36e7 Correlate failed login activity before alerting
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-29 00:50:14 +02:00
OfficeCom Codex
66dcfe09b6 Show summarized security events above raw export
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-07-27 14:42:28 +02:00
OfficeCom Codex
c74d5582b0 Add device raw export summary
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-07-27 14:40:25 +02:00
OfficeCom Codex
4b9202b47c Redesign recipient management dashboard
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-27 14:39:10 +02:00
OfficeCom Codex
a5cea5ebad Unify dashboard background and administration styling
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-27 14:37:07 +02:00
OfficeCom Codex
db0533a4cf Fix Outlook report header and metrics layout
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-27 14:34:52 +02:00
OfficeCom Codex
f054702438 Provide importable Outlook weekly report workflow
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-07-27 14:31:37 +02:00
OfficeCom Codex
e357e6329d Add Outlook-compatible weekly report template
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-27 14:29:05 +02:00
OfficeCom Codex
585b4f91b1 Add robust weekly report templates
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-07-27 12:43:09 +02:00
OfficeCom Codex
1eedac4a76 Send weekly reports per organization
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-27 12:18:19 +02:00
OfficeCom Codex
5d5828db49 Publish stable client version 1.3.7
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 9m24s
2026-07-27 11:14:49 +02:00
OfficeCom Codex
2cf3281b4d Retry interrupted client package downloads
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Failing after 24s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-27 11:13:48 +02:00
OfficeCom Codex
b2da734c75 Publish stable client version 1.3.6
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-07-27 10:46:41 +02:00
OfficeCom Codex
40c6daada8 Persist Ninja context for scheduled scans
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 56s
2026-07-27 10:45:49 +02:00
OfficeCom Codex
5be4fb6c33 Show event freshness and device coverage
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-07-27 01:50:46 +02:00
OfficeCom Codex
0fe8a96057 Skip unsupported Gitea artifact upload
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-07-27 01:33:31 +02:00
OfficeCom Codex
eb621ace5a Preserve updater exit code in bootstrap
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 48s
2026-07-27 01:25:26 +02:00
OfficeCom Codex
e997e6b58c Use Roboto for Sentinel dashboard
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-27 01:24:43 +02:00
OfficeCom Codex
3f09805999 Initialize TLS for legacy client updates
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 47s
2026-07-27 01:22:46 +02:00
OfficeCom Codex
85f0682769 Simplify internal Sentinel dashboard
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 49s
2026-07-27 01:04:49 +02:00
OfficeCom Codex
6722b00ee7 Install PowerShell in Gitea build runner
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 48s
2026-07-27 00:58:51 +02:00
OfficeCom Codex
d3897c8e69 Publish stable client version 1.3.5
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-27 00:35:43 +02:00
OfficeCom Codex
f9941b0807 Harden release downloads for TLS failures
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-27 00:34:48 +02:00
OfficeCom Codex
c768e1be6b Publish stable client version 1.3.4
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 21:15:11 +02:00
27 changed files with 1079 additions and 155 deletions

View File

@@ -45,6 +45,39 @@ jobs:
with: with:
dotnet-version: "10.0.x" dotnet-version: "10.0.x"
- name: Install PowerShell
shell: bash
run: |
set -euo pipefail
if command -v pwsh >/dev/null 2>&1; then
pwsh --version
exit 0
fi
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo "PowerShell is missing and this runner cannot install packages."
exit 1
fi
. /etc/os-release
case "$ID" in
ubuntu) MICROSOFT_REPO="https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" ;;
debian) MICROSOFT_REPO="https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" ;;
*) echo "Unsupported runner distribution: $ID"; exit 1 ;;
esac
$SUDO apt-get update
$SUDO apt-get install -y ca-certificates curl
curl -fsSL "$MICROSOFT_REPO" -o /tmp/packages-microsoft-prod.deb
$SUDO dpkg -i /tmp/packages-microsoft-prod.deb
$SUDO apt-get update
$SUDO apt-get install -y powershell
pwsh --version
- name: Build client package - name: Build client package
shell: pwsh shell: pwsh
run: | run: |
@@ -62,16 +95,6 @@ jobs:
$artifactUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/$tag/OCSentinelClient-win-x64.zip" $artifactUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/$tag/OCSentinelClient-win-x64.zip"
./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl ./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl
- name: Upload package artifacts
uses: actions/upload-artifact@v4
with:
name: ocsentinel-client-${{ github.sha }}
path: |
artifacts/OCSentinelClient-win-x64.zip
artifacts/OCSentinelClient-win-x64.zip.sha256
artifacts/version.json
if-no-files-found: warn
- name: Publish Gitea release assets - name: Publish Gitea release assets
shell: pwsh shell: pwsh
env: env:

View File

@@ -4,6 +4,12 @@
"lookbackDays": 7, "lookbackDays": 7,
"topFindings": 10, "topFindings": 10,
"n8nWebhookUrl": "http://172.16.41.197:5678/webhook/ocsentinel-ingest", "n8nWebhookUrl": "http://172.16.41.197:5678/webhook/ocsentinel-ingest",
"ninjaOrganizationId": "",
"ninjaOrganizationName": "",
"ninjaMachineId": "",
"ninjaNodeId": "",
"ninjaLocationId": "",
"ninjaLocationName": "",
"deviceIdentifierMode": "machineName", "deviceIdentifierMode": "machineName",
"uploadTimeoutSeconds": 30, "uploadTimeoutSeconds": 30,
"uploadQueueMaxReports": 100, "uploadQueueMaxReports": 100,

View File

@@ -4,6 +4,12 @@
"lookbackDays": 7, "lookbackDays": 7,
"topFindings": 10, "topFindings": 10,
"n8nWebhookUrl": "https://n8n.example.com/webhook/ocsentinel-ingest", "n8nWebhookUrl": "https://n8n.example.com/webhook/ocsentinel-ingest",
"ninjaOrganizationId": "",
"ninjaOrganizationName": "",
"ninjaMachineId": "",
"ninjaNodeId": "",
"ninjaLocationId": "",
"ninjaLocationName": "",
"deviceIdentifierMode": "machineName", "deviceIdentifierMode": "machineName",
"uploadTimeoutSeconds": 30, "uploadTimeoutSeconds": 30,
"uploadQueueMaxReports": 100, "uploadQueueMaxReports": 100,

View File

@@ -1,8 +1,13 @@
{ {
"warningEventThreshold": 1, "warningEventThreshold": 10,
"criticalEventThreshold": 20, "criticalEventThreshold": 30,
"warningUniqueIpThreshold": 1, "warningUniqueIpThreshold": 5,
"criticalUniqueIpThreshold": 10, "criticalUniqueIpThreshold": 12,
"loginBurstWindowMinutes": 15,
"warningLoginBurstCount": 5,
"criticalLoginBurstCount": 20,
"warningSprayAccountCount": 5,
"criticalSprayAccountCount": 10,
"correlationWarningCveThreshold": 1, "correlationWarningCveThreshold": 1,
"correlationCriticalCveThreshold": 1, "correlationCriticalCveThreshold": 1,
"ftpRoots": [ "ftpRoots": [

View File

@@ -32,6 +32,11 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 `
## Local Schedule And Burst Mode ## Local Schedule And Burst Mode
During a NinjaOne installation or update, OCSentinel stores the device's
NinjaOne organization, location, and device identifiers in its local client
configuration. Scheduled `SYSTEM` scans restore that context before creating a
report, so their uploads remain assigned to the correct organization.
The installer creates two Windows Scheduled Tasks running as `SYSTEM`: The installer creates two Windows Scheduled Tasks running as `SYSTEM`:
- `OCSentinel Daily Scan`: runs once per day and uploads one signed report. - `OCSentinel Daily Scan`: runs once per day and uploads one signed report.

View File

@@ -3,6 +3,4 @@ DB_PORT=5432
DB_NAME=ocsentinel DB_NAME=ocsentinel
DB_USER=ocsentinel_debug DB_USER=ocsentinel_debug
DB_PASSWORD=replace-with-server-generated-password DB_PASSWORD=replace-with-server-generated-password
DASHBOARD_USER=ocsentinel-debug
DASHBOARD_PASSWORD=replace-with-server-generated-password
DASHBOARD_CSRF_SECRET=replace-with-server-generated-secret DASHBOARD_CSRF_SECRET=replace-with-server-generated-secret

View File

@@ -2,14 +2,17 @@ import hashlib
import hmac import hmac
import json import json
import os import os
from functools import wraps from datetime import datetime, timezone
import psycopg import psycopg
from flask import Flask, Response, abort, redirect, render_template, request, url_for from flask import Flask, abort, redirect, render_template, request, url_for
app = Flask(__name__) app = Flask(__name__)
CURRENT_EVENT_HOURS = 24
STALE_REPORT_HOURS = 36
def db_connection(): def db_connection():
return psycopg.connect( return psycopg.connect(
@@ -22,20 +25,6 @@ def db_connection():
) )
def requires_auth(view):
@wraps(view)
def wrapped(*args, **kwargs):
auth = __import__("flask").request.authorization
expected_user = os.environ["DASHBOARD_USER"]
expected_password = os.environ["DASHBOARD_PASSWORD"]
valid = auth and hmac.compare_digest(auth.username or "", expected_user) and hmac.compare_digest(auth.password or "", expected_password)
if not valid:
return Response("Authentication required", 401, {"WWW-Authenticate": 'Basic realm="OCSentinel Debug"'})
return view(*args, **kwargs)
return wrapped
def csrf_token(): def csrf_token():
secret = os.environ["DASHBOARD_CSRF_SECRET"].encode("utf-8") secret = os.environ["DASHBOARD_CSRF_SECRET"].encode("utf-8")
return hmac.new(secret, b"recipient-rules", hashlib.sha256).hexdigest() return hmac.new(secret, b"recipient-rules", hashlib.sha256).hexdigest()
@@ -47,8 +36,40 @@ def require_csrf():
abort(400) abort(400)
def event_metadata(payload):
latest_event = None
for event in (payload or {}).get("Events", []):
value = event.get("Timestamp")
if not value:
continue
try:
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
continue
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
if latest_event is None or timestamp > latest_event:
latest_event = timestamp
if latest_event is None:
return {"is_current": False, "label": "keine Ereignisse", "timestamp": None}
age_seconds = max(0, int((datetime.now(timezone.utc) - latest_event.astimezone(timezone.utc)).total_seconds()))
if age_seconds < 3600:
age_label = f"vor {max(1, age_seconds // 60)} Min."
elif age_seconds < 86400:
age_label = f"vor {age_seconds // 3600} Std."
else:
age_label = f"vor {age_seconds // 86400} Tg."
return {
"is_current": age_seconds <= CURRENT_EVENT_HOURS * 3600,
"label": age_label,
"timestamp": latest_event,
}
@app.get("/") @app.get("/")
@requires_auth
def overview(): def overview():
with db_connection() as connection, connection.cursor() as cursor: with db_connection() as connection, connection.cursor() as cursor:
cursor.execute("SELECT * FROM ocsentinel.organization_summary") cursor.execute("SELECT * FROM ocsentinel.organization_summary")
@@ -57,11 +78,12 @@ def overview():
cursor.execute( cursor.execute(
""" """
SELECT machine_name, organization_name, received_at, alert_state, SELECT machine_name, organization_name, received_at, alert_state,
total_events, unique_ip_count, cve_total, cve_critical total_events, unique_ip_count, cve_total, cve_critical, payload
FROM ( FROM (
SELECT machine_name, received_at, alert_state, total_events, SELECT machine_name, received_at, alert_state, total_events,
unique_ip_count, cve_total, cve_critical, unique_ip_count, cve_total, cve_critical,
payload #>> '{NinjaOne,OrganizationName}' AS organization_name payload #>> '{NinjaOne,OrganizationName}' AS organization_name,
payload
FROM ocsentinel.current_device_status FROM ocsentinel.current_device_status
) AS status ) AS status
ORDER BY received_at DESC NULLS LAST ORDER BY received_at DESC NULLS LAST
@@ -70,6 +92,16 @@ def overview():
) )
reports = cursor.fetchall() reports = cursor.fetchall()
cursor.execute(
"""
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
"""
)
coverage = cursor.fetchone()
cursor.execute( cursor.execute(
""" """
SELECT machine_name, alert_state, total_events, unique_ip_count, SELECT machine_name, alert_state, total_events, unique_ip_count,
@@ -81,11 +113,46 @@ def overview():
) )
alerts = cursor.fetchall() alerts = cursor.fetchall()
return render_template("overview.html", summary=summary, reports=reports, alerts=alerts) report_rows = []
for row in reports:
event = event_metadata(row[8])
report_rows.append(
{
"machine_name": row[0],
"organization_name": row[1],
"received_at": row[2],
"alert_state": row[3],
"total_events": row[4],
"unique_ip_count": row[5],
"event": event,
}
)
alert_rows = []
for row in alerts:
event = event_metadata(row[5])
alert_rows.append(
{
"machine_name": row[0],
"alert_state": row[1],
"total_events": row[2],
"unique_ip_count": row[3],
"received_at": row[4],
"event": event,
}
)
return render_template(
"overview.html",
summary=summary,
coverage=coverage,
reports=report_rows,
alerts=alert_rows,
current_alert_count=sum(alert["event"]["is_current"] for alert in alert_rows),
)
@app.get("/device/<machine_name>") @app.get("/device/<machine_name>")
@requires_auth
def device(machine_name): def device(machine_name):
with db_connection() as connection, connection.cursor() as cursor: with db_connection() as connection, connection.cursor() as cursor:
cursor.execute( cursor.execute(
@@ -104,11 +171,37 @@ def device(machine_name):
abort(404) abort(404)
payload = report[12] payload = report[12]
return render_template("device.html", report=report, payload=payload, payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False)) event_groups = {}
for entry in payload.get("Events") or payload.get("events") or []:
event_type = entry.get("Target") or entry.get("target") or "Sicherheitsereignis"
account = entry.get("Username") or entry.get("username") or "-"
source_ip = entry.get("SourceIp") or entry.get("sourceIp") or "-"
key = (event_type, account, source_ip)
group = event_groups.setdefault(
key,
{"type": event_type, "account": account, "source_ip": source_ip, "count": 0, "latest": "-"},
)
group["count"] += 1
timestamp = entry.get("Timestamp") or entry.get("timestamp") or "-"
if timestamp > group["latest"]:
group["latest"] = timestamp
security_events = sorted(
event_groups.values(),
key=lambda entry: (entry["latest"], entry["count"]),
reverse=True,
)[:25]
return render_template(
"device.html",
report=report,
event=event_metadata(payload),
payload=payload,
security_events=security_events,
payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False),
)
@app.get("/reports") @app.get("/reports")
@requires_auth
def reports(): def reports():
with db_connection() as connection, connection.cursor() as cursor: with db_connection() as connection, connection.cursor() as cursor:
cursor.execute( cursor.execute(
@@ -126,7 +219,6 @@ def reports():
@app.get("/reports/<int:report_id>") @app.get("/reports/<int:report_id>")
@requires_auth
def weekly_report(report_id): def weekly_report(report_id):
with db_connection() as connection, connection.cursor() as cursor: with db_connection() as connection, connection.cursor() as cursor:
cursor.execute( cursor.execute(
@@ -147,7 +239,6 @@ def weekly_report(report_id):
@app.get("/recipients") @app.get("/recipients")
@requires_auth
def recipients(): def recipients():
with db_connection() as connection, connection.cursor() as cursor: with db_connection() as connection, connection.cursor() as cursor:
cursor.execute( cursor.execute(
@@ -173,7 +264,6 @@ def recipients():
@app.post("/recipients") @app.post("/recipients")
@requires_auth
def add_recipient(): def add_recipient():
require_csrf() require_csrf()
organization_id = request.form.get("organization_id", "").strip() organization_id = request.form.get("organization_id", "").strip()
@@ -198,7 +288,6 @@ def add_recipient():
@app.post("/recipients/<int:rule_id>/toggle") @app.post("/recipients/<int:rule_id>/toggle")
@requires_auth
def toggle_recipient(rule_id): def toggle_recipient(rule_id):
require_csrf() require_csrf()
with db_connection() as connection, connection.cursor() as cursor: with db_connection() as connection, connection.cursor() as cursor:
@@ -211,7 +300,6 @@ def toggle_recipient(rule_id):
@app.post("/recipients/<int:rule_id>/delete") @app.post("/recipients/<int:rule_id>/delete")
@requires_auth
def delete_recipient(rule_id): def delete_recipient(rule_id):
require_csrf() require_csrf()
with db_connection() as connection, connection.cursor() as cursor: with db_connection() as connection, connection.cursor() as cursor:

View File

@@ -1,15 +1,30 @@
:root { --ink:#17201d; --muted:#66736d; --paper:#f5f3eb; --panel:#fffdf7; --line:#d8d4c6; --green:#236342; --lime:#c7ee6b; --amber:#b86613; --red:#a8342b; } :root { --ink:#132a3d; --muted:#5f7180; --paper:#eaf1f7; --panel:#ffffff; --line:#d5e1eb; --green:#14735b; --lime:#b8e36a; --amber:#a55a0a; --red:#a52b31; }
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; color:var(--ink); background:radial-gradient(circle at 86% -10%, #d6efad 0, transparent 28rem), var(--paper); font-family:Georgia, 'Times New Roman', serif; }.app-shell:before { content:''; position:fixed; z-index:-1; inset:0; opacity:.28; background-image:linear-gradient(rgba(35,99,66,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(35,99,66,.06) 1px,transparent 1px); background-size:34px 34px; mask-image:linear-gradient(to bottom,black,transparent 62%); } body { margin:0; color:var(--ink); background:radial-gradient(circle at 10% -12%, #d9e9f7 0, transparent 30rem),radial-gradient(circle at 95% 8%, #dff2ec 0, transparent 24rem),var(--paper); font-family:'Roboto',sans-serif; }.app-shell:before { content:''; position:fixed; z-index:-1; inset:0; opacity:.34; background-image:linear-gradient(rgba(26,73,111,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(26,73,111,.045) 1px,transparent 1px); background-size:36px 36px; mask-image:linear-gradient(to bottom,black,transparent 68%); }
.masthead { height:70px; padding:0 6vw; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:rgba(255,253,247,.88); box-shadow:0 4px 22px rgba(35,56,42,.06); backdrop-filter:blur(14px); }.header-links { display:flex; gap:8px; align-items:center; }.header-links a { padding:7px 9px; border-radius:7px; color:var(--muted); font:700 12px Arial,sans-serif; text-decoration:none; transition:background .18s ease,color .18s ease; }.header-links a:hover,.header-links a.active { color:var(--green); background:#e6f1e9; } .masthead { height:70px; padding:0 6vw; display:flex; align-items:center; justify-content:flex-end; border-bottom:1px solid #21445f; background:#102a43; box-shadow:0 5px 24px rgba(16,42,67,.2); }.header-links { display:flex; gap:8px; align-items:center; }.header-links a { padding:8px 10px; border-radius:6px; color:#c8d6e1; font:700 12px 'Roboto',sans-serif; text-decoration:none; transition:background .18s ease,color .18s ease; }.header-links a:hover,.header-links a.active { color:#fff; background:#245a85; }
.brand { color:var(--ink); font:700 20px/1 Arial,sans-serif; text-decoration:none; letter-spacing:-.04em; }.brand span { display:inline-grid; place-items:center; margin-right:7px; width:28px; height:28px; background:var(--green); color:#fff; border-radius:50%; font-size:11px; letter-spacing:0; }.badge,.eyebrow { color:var(--muted); font:700 10px/1 Arial,sans-serif; text-transform:uppercase; letter-spacing:.12em; }.badge { border:1px solid var(--line); padding:6px 8px; border-radius:20px; } .brand { color:var(--ink); font:700 20px/1 'Roboto',sans-serif; text-decoration:none; letter-spacing:-.04em; }.brand span { display:inline-grid; place-items:center; margin-right:7px; width:28px; height:28px; background:var(--green); color:#fff; border-radius:50%; font-size:11px; letter-spacing:0; }.badge,.eyebrow { color:var(--muted); font:700 10px/1 'Roboto',sans-serif; text-transform:uppercase; letter-spacing:.12em; }.badge { border:1px solid var(--line); padding:6px 8px; border-radius:20px; }
main { max-width:1280px; margin:auto; padding:58px 6vw 80px; }.hero { max-width:760px; margin-bottom:32px; }.hero h1 { font-size:clamp(34px,5vw,64px); line-height:.98; letter-spacing:-.06em; margin:10px 0; }.hero p { color:var(--muted); font-size:18px; }.hero.compact h1 { font-size:48px; }.hero-note { display:flex; align-items:center; gap:8px; margin-top:20px; color:var(--green); font:700 11px Arial,sans-serif; letter-spacing:.03em; }.hero-note span { width:8px; height:8px; border-radius:50%; background:var(--lime); box-shadow:0 0 0 4px rgba(199,238,107,.25); } main { max-width:1280px; margin:auto; padding:32px 6vw 80px; }.hero { max-width:760px; margin-bottom:32px; }.hero h1 { font-size:clamp(34px,5vw,64px); line-height:.98; letter-spacing:-.06em; margin:10px 0; }.hero p { color:var(--muted); font-size:18px; }.hero.compact h1 { font-size:48px; }.hero-note { display:flex; align-items:center; gap:8px; margin-top:20px; color:var(--green); font:700 11px 'Roboto',sans-serif; letter-spacing:.03em; }.hero-note span { width:8px; height:8px; border-radius:50%; background:var(--lime); box-shadow:0 0 0 4px rgba(199,238,107,.25); }
.metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin:25px 0 46px; background:transparent; }.metrics article { min-height:130px; padding:20px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 16px rgba(35,56,42,.035); transition:transform .18s ease,box-shadow .18s ease; }.metrics article:hover { transform:translateY(-3px); box-shadow:0 12px 24px rgba(35,56,42,.09); }.metrics span { display:block; color:var(--muted); font:700 10px Arial,sans-serif; letter-spacing:.09em; text-transform:uppercase; }.metrics strong { display:block; margin-top:16px; font:700 31px Arial,sans-serif; letter-spacing:-.05em; }.metrics .timestamp { font-size:14px; line-height:1.25; letter-spacing:-.02em; }.warning { color:var(--amber); }.critical { color:var(--red); } .metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin:25px 0 46px; background:transparent; }.metrics article { min-height:130px; padding:20px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 16px rgba(35,56,42,.035); transition:transform .18s ease,box-shadow .18s ease; }.metrics article:hover { transform:translateY(-3px); box-shadow:0 12px 24px rgba(35,56,42,.09); }.metrics span { display:block; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.09em; text-transform:uppercase; }.metrics strong { display:block; margin-top:16px; font:700 31px 'Roboto',sans-serif; letter-spacing:-.05em; }.metrics .timestamp { font-size:14px; line-height:1.25; letter-spacing:-.02em; }.warning { color:var(--amber); }.critical { color:var(--red); }
.situation { display:flex; align-items:center; justify-content:space-between; gap:22px; margin:0 0 24px; padding:20px 22px; border:1px solid #b9d8c2; background:#edf8f0; color:#195235; }.situation.warning { border-color:#f2cf99; background:#fff6e8; color:#80450d; }.situation.critical { border-color:#edb4aa; background:#fff0ed; color:#8a2a20; }.situation strong { display:block; margin-top:7px; font:700 19px/1.15 Arial,sans-serif; letter-spacing:-.025em; }.situation > span { padding:7px 9px; border:1px solid currentColor; border-radius:20px; font:700 10px Arial,sans-serif; letter-spacing:.1em; } .situation { display:flex; align-items:center; justify-content:space-between; gap:22px; margin:0 0 24px; padding:20px 22px; border:1px solid #b9d8c2; background:#edf8f0; color:#195235; }.situation.warning { border-color:#f2cf99; background:#fff6e8; color:#80450d; }.situation.critical { border-color:#edb4aa; background:#fff0ed; color:#8a2a20; }.situation strong { display:block; margin-top:7px; font:700 19px/1.15 'Roboto',sans-serif; letter-spacing:-.025em; }.situation > span { padding:7px 9px; border:1px solid currentColor; border-radius:20px; font:700 10px 'Roboto',sans-serif; letter-spacing:.1em; }
.panel { margin-top:26px; padding:26px; background:var(--panel); border:1px solid var(--line); border-radius:5px; box-shadow:0 6px 18px rgba(35,56,42,.035); }.panel-heading h2 { margin:8px 0 22px; font-size:28px; letter-spacing:-.04em; }.alert-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }.alert-card { padding:17px; border-left:5px solid var(--amber); border-radius:3px; background:#fff7e9; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.alert-card:hover { transform:translateY(-2px); box-shadow:0 9px 18px rgba(88,57,20,.12); }.alert-card.critical { border-color:var(--red); background:#fff0ed; }.alert-card span,.alert-card small { display:block; font:700 10px Arial,sans-serif; letter-spacing:.08em; text-transform:uppercase; }.alert-card strong { display:block; margin:10px 0; font:700 22px Arial,sans-serif; letter-spacing:-.04em; } .panel { margin-top:26px; padding:26px; background:var(--panel); border:1px solid var(--line); border-radius:5px; box-shadow:0 6px 18px rgba(35,56,42,.035); }.panel-heading h2 { margin:8px 0 22px; font-size:28px; letter-spacing:-.04em; }.panel-heading h2 small { color:var(--muted); font-size:12px; font-weight:500; letter-spacing:0; }.alert-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }.alert-card { padding:17px; border-left:5px solid var(--amber); border-radius:3px; background:#fff7e9; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.alert-card:hover { transform:translateY(-2px); box-shadow:0 9px 18px rgba(88,57,20,.12); }.alert-card.critical { border-color:var(--red); background:#fff0ed; }.alert-card span,.alert-card small { display:block; font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.alert-card strong { display:block; margin:10px 0; font:700 22px 'Roboto',sans-serif; letter-spacing:-.04em; }
table { width:100%; border-collapse:collapse; font-family:Arial,sans-serif; font-size:13px; } th { text-align:left; color:var(--muted); font-size:10px; letter-spacing:.1em; text-transform:uppercase; } th,td { padding:13px 8px; border-bottom:1px solid var(--line); } td a { color:var(--green); font-weight:700; text-decoration:none; }.state { display:inline-block; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px Arial,sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); } pre { margin:0; padding:18px; overflow:auto; color:#dce7da; background:#13221b; border-radius:4px; font:12px/1.5 'Cascadia Code',Consolas,monospace; }.table-wrap { overflow:auto; } .coverage-panel { padding-bottom:22px; }.coverage-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; }.coverage-metrics article { padding:15px; border:1px solid var(--line); border-radius:4px; background:#faf9f4; }.coverage-metrics span { display:block; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.coverage-metrics strong { display:block; margin-top:8px; font-size:26px; }.ok { color:var(--green); }
.report-frame { background:#fff; border:1px solid var(--line); box-shadow:0 12px 40px rgba(20,35,27,.1); } table { width:100%; border-collapse:collapse; font-family:'Roboto',sans-serif; font-size:13px; } th { text-align:left; color:var(--muted); font-size:10px; letter-spacing:.1em; text-transform:uppercase; } th,td { padding:13px 8px; border-bottom:1px solid var(--line); } td a { color:var(--green); font-weight:700; text-decoration:none; }.state { display:inline-block; margin:1px 3px 1px 0; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px 'Roboto',sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); }.state.current { background:#e2efe6; color:var(--green); }.state.historic { background:#ece9e1; color:#68736e; } pre { margin:0; padding:18px; overflow:auto; color:#dce7da; background:#13221b; border-radius:4px; font:12px/1.5 'Cascadia Code',Consolas,monospace; }.table-wrap { overflow:auto; }
.report-frame { background:#fff; border:1px solid var(--line); border-radius:8px; box-shadow:0 18px 44px rgba(31,68,99,.12); overflow:hidden; }
.panel-heading p:last-child { max-width:720px; margin:-13px 0 20px; color:var(--muted); font-size:14px; }.calm-panel { border-color:#b9d8c2; background:#f4fbf5; } .panel-heading p:last-child { max-width:720px; margin:-13px 0 20px; color:var(--muted); font-size:14px; }.calm-panel { border-color:#b9d8c2; background:#f4fbf5; }
.recipient-form { display:grid; grid-template-columns:minmax(220px,1fr) minmax(260px,1fr) auto; gap:14px; align-items:end; }.recipient-form label { display:grid; gap:6px; color:var(--muted); font:700 10px Arial,sans-serif; letter-spacing:.08em; text-transform:uppercase; }.recipient-form input,.recipient-form select { min-height:40px; padding:9px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--ink); font:14px Arial,sans-serif; }.recipient-form button,.rule-actions button { min-height:40px; padding:9px 13px; border:1px solid var(--green); border-radius:4px; background:var(--green); color:#fff; cursor:pointer; font:700 12px Arial,sans-serif; }.rule-actions { display:flex; gap:8px; }.rule-actions form { margin:0; }.rule-actions .button-secondary { border-color:#d8d4c6; background:#fffdf7; color:var(--ink); }.rule-actions .button-danger { border-color:#e3afa7; background:#fff0ed; color:#8a2a20; } .recipient-form { display:grid; grid-template-columns:minmax(220px,1fr) minmax(260px,1fr) auto; gap:14px; align-items:end; }.recipient-form label { display:grid; gap:6px; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.recipient-form input,.recipient-form select { min-height:40px; padding:9px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--ink); font:14px 'Roboto',sans-serif; }.recipient-form button,.rule-actions button { min-height:40px; padding:9px 13px; border:1px solid var(--green); border-radius:4px; background:var(--green); color:#fff; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.rule-actions { display:flex; gap:8px; }.rule-actions form { margin:0; }.rule-actions .button-secondary { border-color:#d8d4c6; background:#fffdf7; color:var(--ink); }.rule-actions .button-danger { border-color:#e3afa7; background:#fff0ed; color:#8a2a20; }
@media (max-width:850px) { .recipient-form { grid-template-columns:1fr; }.rule-actions { min-width:220px; } } @media (max-width:850px) { .recipient-form { grid-template-columns:1fr; }.rule-actions { min-width:220px; } }
@media (max-width:850px) { .metrics { grid-template-columns:repeat(2,1fr); }.metrics article:last-child { grid-column:span 2; }.masthead { height:auto; min-height:70px; padding:14px 5vw; align-items:flex-start; }.header-links { justify-content:flex-end; flex-wrap:wrap; }.badge { display:none; } main { padding:38px 5vw; }.situation { align-items:flex-start; flex-direction:column; } } @media (max-width:850px) { .metrics { grid-template-columns:repeat(2,1fr); }.metrics article:last-child { grid-column:span 2; }.masthead { height:auto; min-height:70px; padding:14px 5vw; align-items:flex-start; }.header-links { justify-content:flex-end; flex-wrap:wrap; }.badge { display:none; } main { padding:38px 5vw; }.situation { align-items:flex-start; flex-direction:column; } }
/* Keep the administration screens visually aligned with the Sentinel reports. */
.panel { border-radius:8px; box-shadow:0 14px 34px rgba(31,68,99,.08); }
.panel > .table-wrap { border:1px solid #dce6ee; border-radius:6px; background:#fbfdff; }
.panel > .table-wrap table { margin:0; }
.panel > .table-wrap th { padding:12px 10px; color:#456174; background:#f0f5f9; }
.panel > .table-wrap td { padding:14px 10px; }
.panel > .table-wrap tbody tr:hover { background:#f2f8fb; }
.recipient-form { padding:18px; border:1px solid #dce6ee; border-radius:6px; background:#f8fbfd; }
.recipient-form input,.recipient-form select { border-radius:5px; background:#fff; }
.recipient-form input:focus,.recipient-form select:focus { outline:2px solid rgba(36,90,133,.25); border-color:#245a85; }
.rule-actions .button-secondary { border-color:var(--line); background:#f8fbfd; }
.recipient-intro { max-width:720px; margin:6px 0 28px; }.recipient-intro h1 { margin:9px 0 10px; font-size:46px; line-height:1; letter-spacing:-.055em; }.recipient-intro p { margin:0; color:var(--muted); font-size:16px; line-height:1.55; }.recipient-intro strong { color:var(--ink); }.recipient-create-panel { margin-top:0; border-color:#c8dbe8; }.recipient-create-panel .panel-heading h2,.recipient-rules-panel .panel-heading h2 { margin:7px 0 8px; }.recipient-create-panel .panel-heading p { margin:0 0 20px; }.recipient-form button { white-space:nowrap; }.recipient-rules-panel { padding-bottom:12px; }.recipient-rules-panel .panel-heading { display:flex; align-items:end; justify-content:space-between; gap:16px; }.recipient-rules-panel .panel-heading h2 { margin-bottom:20px; }.recipient-rules-panel .panel-heading small { display:inline-block; margin-left:7px; padding:4px 7px; border-radius:12px; background:#edf4f8; color:#4d687b; font-size:10px; font-weight:700; letter-spacing:.04em; vertical-align:middle; }.recipient-table td { height:64px; }.recipient-table tr:last-child td { border-bottom:0; }.recipient-email { color:#245a85; font-weight:500; }.actions-heading { text-align:right; }.recipient-table .rule-actions { justify-content:flex-end; }.empty-state { padding:30px 10px !important; color:var(--muted); text-align:center; }
.compact-metrics { grid-template-columns:repeat(4,1fr); }.event-summary-panel { margin-top:8px; }.event-summary-panel .panel-heading h2,.raw-export-panel .panel-heading h2 { margin:7px 0 8px; }.event-summary-panel .panel-heading p,.raw-export-panel .panel-heading p { margin:0 0 20px; }.event-count { display:inline-grid; min-width:28px; min-height:28px; place-items:center; border-radius:14px; background:#fff0d7; color:var(--amber); font:700 12px 'Roboto',sans-serif; }.raw-export-panel { margin-top:8px; }.raw-json { margin-top:18px; border-top:1px solid var(--line); }.raw-json summary { padding:14px 0; color:#245a85; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.raw-json pre { margin-bottom:0; } @media (max-width:850px) { .compact-metrics { grid-template-columns:repeat(2,1fr); }.compact-metrics article:last-child { grid-column:span 2; } }

View File

@@ -3,13 +3,15 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}OCSentinel Debug{% endblock %}</title> <title>{% block title %}OC 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 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">
<header class="masthead"> <header class="masthead">
<a href="/" class="brand"><span>OC</span> Sentinel</a> <nav class="header-links"><a class="{{ 'active' if request.endpoint == 'overview' else '' }}" href="/">Uebersicht</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
<div class="header-links"><a class="{{ 'active' if request.endpoint == 'overview' else '' }}" href="/">Sicherheitslage</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Wochenberichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a><div class="badge">interner Sicherheitsbereich</div></div>
</header> </header>
<main>{% block content %}{% endblock %}</main> <main>{% block content %}{% endblock %}</main>
</body> </body>

View File

@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ report[0] }} - OCSentinel{% endblock %} {% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
{% block content %} {% block content %}
<section class="hero compact"><p class="eyebrow">Geraetedetails</p><h1>{{ report[0] }}</h1><p><span class="state {{ report[6] }}">{{ report[6] }}</span> | Letzte Datenmeldung: {{ report[5] }}</p></section> <section class="panel"><div class="panel-heading"><h2>{{ report[0] }}</h2><span class="state {{ report[6] }}">{{ report[6] }}</span>{% if report[8] %}<span class="state {{ 'current' if event.is_current else 'historic' }}">{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}</span>{% endif %}</div></section>
<section class="metrics compact-metrics"><article><span>Ereignisse</span><strong>{{ report[8] }}</strong></article><article><span>Quell-IPs</span><strong>{{ report[9] }}</strong></article><article><span>CVEs</span><strong>{{ report[10] }}</strong></article><article><span>Kritische CVEs</span><strong class="critical">{{ report[11] }}</strong></article></section> <section class="metrics compact-metrics"><article><span>Ereignisse</span><strong>{{ report[8] }}</strong></article><article><span>Quell-IPs</span><strong>{{ report[9] }}</strong></article><article><span>CVEs</span><strong>{{ report[10] }}</strong></article><article><span>Kritische CVEs</span><strong class="critical">{{ report[11] }}</strong></article></section>
<section class="panel"><div class="panel-heading"><p class="eyebrow">Technische Details</p><h2>Signierter Sicherheitsbericht</h2><p>Unveraenderte Rohdaten zur Nachvollziehbarkeit und Fehleranalyse.</p></div><pre>{{ payload_pretty }}</pre></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>
{% endblock %} {% endblock %}

View File

@@ -1,43 +1,39 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="hero">
<p class="eyebrow">OCSentinel Sicherheitszentrale</p>
<h1>OfficeCom Sentinal Übersicht</h1>
<p>Aktuelle Auswertung aller signierten Sicherheitsberichte aus den betreuten Organisationen.</p>
<div class="hero-note"><span></span> Zentraler Ueberblick ueber Geraete, Ereignisse und Handlungsbedarf</div>
</section>
<section class="situation {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}"> <section class="situation {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
<div><p class="eyebrow">Aktuelle Lage</p><strong>{% if summary[2] %}Kritische Ereignisse erfordern Aufmerksamkeit.{% elif summary[1] %}Hinweise vorhanden - bitte auffaellige Geraete pruefen.{% else %}Keine kritischen Auffaelligkeiten gemeldet.{% endif %}</strong></div> <div><strong>{% if summary[2] %}Kritische Ereignisse{% elif summary[1] %}Hinweise vorhanden{% else %}Keine kritischen Auffaelligkeiten{% endif %}</strong></div>
<span>{% if summary[2] %}KRITISCH{% elif summary[1] %}PRUEFEN{% else %}STABIL{% endif %}</span> <span>{% if summary[2] %}KRITISCH{% elif summary[1] %}PRUEFEN{% else %}STABIL{% endif %}</span>
</section> </section>
<section class="metrics"> <section class="metrics">
<article><span>Geraete mit Bericht</span><strong>{{ summary[0] }}</strong></article> <article><span>Geraete</span><strong>{{ summary[0] }}</strong></article>
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong></article> <article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong></article>
<article><span>Kritische Geraete</span><strong class="critical">{{ summary[2] }}</strong></article> <article><span>Kritisch</span><strong class="critical">{{ summary[2] }}</strong></article>
<article><span>Erkannte Ereignisse</span><strong>{{ summary[3] }}</strong></article> <article><span>Ereignisse</span><strong>{{ summary[3] }}</strong></article>
<article><span>Letzte Datenmeldung</span><strong class="timestamp">{{ summary[7] or 'noch keine Daten' }}</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>
</section> </section>
{% if alerts %} {% if alerts %}
<section class="panel alert-panel"> <section class="panel alert-panel">
<div class="panel-heading"><p class="eyebrow">Handlungsbedarf</p><h2>Auffaellige Geraete</h2><p>Diese Geraete haben zuletzt Warnungen oder kritische Sicherheitsereignisse gemeldet.</p></div> <div class="panel-heading"><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[1] }}" href="{{ url_for('device', machine_name=alert[0]) }}"> <a class="alert-card {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
<span>{{ alert[1] }}</span><strong>{{ alert[0] }}</strong><small>{{ alert[2] }} Ereignisse | {{ alert[3] }} Quell-IPs | {{ alert[4] }}</small> <span>{{ alert.alert_state }} | {{ 'aktuell' if alert.event.is_current else 'historisch' }}</span><strong>{{ alert.machine_name }}</strong><small>{{ alert.total_events }} Ereignisse | {{ alert.unique_ip_count }} Quell-IPs | letztes Ereignis {{ alert.event.label }}</small>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
{% else %}
<section class="panel calm-panel"><div class="panel-heading"><p class="eyebrow">Handlungsbedarf</p><h2>Keine auffaelligen Geraete</h2><p>Die zuletzt eingegangenen Berichte enthalten keine Warnungen oder kritischen Ereignisse.</p></div></section>
{% endif %} {% endif %}
<section class="panel"> <section class="panel">
<div class="panel-heading"><p class="eyebrow">Berichtsbestand</p><h2>Aktuelle Geraetestatus</h2><p>Jede Zeile zeigt den letzten erfolgreich uebermittelten OCSentinel-Bericht eines Geraets.</p></div> <div class="panel-heading"><h2>Geraetestatus</h2></div>
<div class="table-wrap"><table><thead><tr><th>Geraet</th><th>Organisation</th><th>Status</th><th>Ereignisse</th><th>Quell-IPs</th><th>Empfangen</th></tr></thead> <div class="table-wrap"><table><thead><tr><th>Geraet</th><th>Organisation</th><th>Status</th><th>Ereignisse</th><th>Quell-IPs</th><th>Empfangen</th></tr></thead>
<tbody>{% for row in reports %}<tr><td><a href="{{ url_for('device', machine_name=row[0]) }}">{{ row[0] }}</a></td><td>{{ row[1] or '-' }}</td><td><span class="state {{ row[3] }}">{{ row[3] }}</span></td><td>{{ row[4] }}</td><td>{{ row[5] }}</td><td>{{ row[2] or '-' }}</td></tr>{% else %}<tr><td colspan="6">Noch keine Geraeteberichte vorhanden.</td></tr>{% endfor %}</tbody></table></div> <tbody>{% for row in reports %}<tr><td><a href="{{ url_for('device', machine_name=row.machine_name) }}">{{ row.machine_name }}</a></td><td>{{ row.organization_name or '-' }}</td><td><span class="state {{ row.alert_state }}">{{ row.alert_state }}</span>{% if row.total_events %}<span class="state {{ 'current' if row.event.is_current else 'historic' }}">{{ 'aktuell' if row.event.is_current else 'historisch' }}</span>{% endif %}</td><td>{{ row.total_events }}</td><td>{{ row.unique_ip_count }}</td><td>{{ row.received_at or '-' }}</td></tr>{% else %}<tr><td colspan="6">Keine Geraeteberichte.</td></tr>{% endfor %}</tbody></table></div>
</section> </section>
{% endblock %} {% endblock %}

View File

@@ -1,19 +1,27 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Berichtsempfaenger - OCSentinel{% endblock %} {% block title %}Empfaenger - OC Sentinel{% endblock %}
{% block content %} {% block content %}
<section class="hero compact"><p class="eyebrow">Wochenberichte</p><h1>Berichtsempfaenger</h1><p>Diese Regeln bestimmen, wer den Wochenbericht einer Organisation per E-Mail erhaelt.</p></section> <section class="recipient-intro">
<span class="eyebrow">Wochenberichte</span>
<h1>Empfaenger verwalten</h1>
<p>Lege fest, welche Personen den Sicherheitsbericht einer Organisation erhalten. Regeln fuer <strong>Alle Organisationen</strong> gelten zusaetzlich zu den einzelnen Organisationen.</p>
</section>
<section class="panel"><div class="panel-heading"><p class="eyebrow">Neue Regel</p><h2>Empfaenger hinzufuegen</h2></div> <section class="panel recipient-create-panel">
<form class="recipient-form" method="post" action="{{ url_for('add_recipient') }}"> <div class="panel-heading"><span class="eyebrow">Neue Regel</span><h2>Bericht zustellen</h2><p>Die Adresse wird beim naechsten Wochenbericht automatisch beruecksichtigt.</p></div>
<form class="recipient-form" method="post" action="{{ url_for('add_recipient') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Organisation<select name="organization_id" id="organization_id" required onchange="document.getElementById('organization_name').value=this.options[this.selectedIndex].dataset.name"><option value="*" data-name="Alle Organisationen">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}" data-name="{{ organization[1] }}">{{ organization[1] }}</option>{% endfor %}</select></label> <label>Organisation<select name="organization_id" id="organization_id" required onchange="document.getElementById('organization_name').value=this.options[this.selectedIndex].dataset.name"><option value="*" data-name="Alle Organisationen">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}" data-name="{{ organization[1] }}">{{ organization[1] }}</option>{% endfor %}</select></label>
<input type="hidden" name="organization_name" id="organization_name" value="Alle Organisationen"> <input type="hidden" name="organization_name" id="organization_name" value="Alle Organisationen">
<label>E-Mail-Adresse<input type="email" name="recipient_email" placeholder="name@officecom.it" required></label> <label>E-Mail-Adresse<input type="email" name="recipient_email" placeholder="name@officecom.it" required></label>
<button type="submit">Empfaenger speichern</button> <button type="submit">Empfaenger hinzufuegen</button>
</form></section> </form>
</section>
<section class="panel"><div class="panel-heading"><p class="eyebrow">Aktive Regeln</p><h2>E-Mail-Verteiler</h2><p>"Alle Organisationen" wird zu jedem organisationsspezifischen Verteiler hinzugefuegt.</p></div> <section class="panel recipient-rules-panel">
<div class="table-wrap"><table><thead><tr><th>Organisation</th><th>E-Mail-Adresse</th><th>Status</th><th>Aktion</th></tr></thead><tbody> <div class="panel-heading"><span class="eyebrow">Aktive Konfiguration</span><h2>E-Mail-Verteiler <small>{{ rules|length }} Regel{{ '' if rules|length == 1 else 'n' }}</small></h2></div>
{% for rule in rules %}<tr><td>{{ rule[2] }}</td><td>{{ rule[3] }}</td><td><span class="state {{ 'ok' if rule[4] else 'warning' }}">{{ 'aktiv' if rule[4] else 'pausiert' }}</span></td><td class="rule-actions"><form method="post" action="{{ url_for('toggle_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-secondary" type="submit">{{ 'Pausieren' if rule[4] else 'Aktivieren' }}</button></form><form method="post" action="{{ url_for('delete_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-danger" type="submit">Loeschen</button></form></td></tr>{% else %}<tr><td colspan="4">Noch keine Empfaengerregeln angelegt.</td></tr>{% endfor %} <div class="table-wrap recipient-table"><table><thead><tr><th>Organisation</th><th>E-Mail-Adresse</th><th>Status</th><th class="actions-heading">Verwalten</th></tr></thead><tbody>
</tbody></table></div></section> {% for rule in rules %}<tr><td><strong>{{ rule[2] }}</strong></td><td><a class="recipient-email" href="mailto:{{ rule[3] }}">{{ rule[3] }}</a></td><td><span class="state {{ 'ok' if rule[4] else 'warning' }}">{{ 'aktiv' if rule[4] else 'pausiert' }}</span></td><td class="rule-actions"><form method="post" action="{{ url_for('toggle_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-secondary" type="submit">{{ 'Pausieren' if rule[4] else 'Aktivieren' }}</button></form><form method="post" action="{{ url_for('delete_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-danger" type="submit">Loeschen</button></form></td></tr>{% else %}<tr><td colspan="4" class="empty-state">Noch keine Empfaengerregeln angelegt.</td></tr>{% endfor %}
</tbody></table></div>
</section>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Wochenberichte - OCSentinel Debug{% endblock %} {% block title %}Berichte - OC Sentinel{% endblock %}
{% block content %} {% block content %}
<section class="hero compact"><p class="eyebrow">Archiv</p><h1>Wochenberichte</h1><p>Je Organisation automatisch durch n8n erzeugt.</p></section>
<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="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>
{% 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">Noch keine Wochenberichte erzeugt.</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 %}

File diff suppressed because one or more lines are too long

View File

@@ -20,26 +20,11 @@
"id": "store-weekly-reports", "name": "Store Weekly Organization Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [1080, 300], "id": "store-weekly-reports", "name": "Store Weekly Organization Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [1080, 300],
"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } } "credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
}, },
{
"parameters": {
"assignments": {
"assignments": [
{ "id": "email-enabled", "name": "emailEnabled", "value": true, "type": "boolean" }
]
},
"options": {}
},
"id": "email-delivery-toggle",
"name": "E-Mail-Versand aktiv",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [1330, 300]
},
{ {
"parameters": { "parameters": {
"operation": "executeQuery", "operation": "executeQuery",
"query": "SELECT coalesce(array_agg(recipient_email ORDER BY recipient_email), ARRAY[]::text[]) AS recipients\nFROM ocsentinel.organization_report_recipient\nWHERE enabled = TRUE AND (organization_id = '*' OR organization_id = $1);", "query": "SELECT coalesce(array_agg(recipient_email ORDER BY recipient_email), ARRAY[]::text[]) AS recipients\nFROM ocsentinel.organization_report_recipient\nWHERE enabled = TRUE AND (organization_id = '*' OR organization_id = $1);",
"options": { "queryReplacement": "={{ [ $('Build Organization HTML Reports').item.json.organizationId ] }}" } "options": { "queryReplacement": "={{ [$json.organizationId] }}" }
}, },
"id": "load-report-recipients", "id": "load-report-recipients",
"name": "Empfaenger aus zentraler Zuordnung laden", "name": "Empfaenger aus zentraler Zuordnung laden",
@@ -50,7 +35,8 @@
}, },
{ {
"parameters": { "parameters": {
"jsCode": "const report = $('Build Organization HTML Reports').item.json;\nconst emailEnabled = $('E-Mail-Versand aktiv').item.json.emailEnabled === true;\nif (!emailEnabled) return [];\nconst recipients = Array.from(new Set($json.recipients || []));\nif (recipients.length === 0) throw new Error(`No weekly report recipients configured for ${report.organizationName}.`);\nreturn [{ json: { ...report, recipients } }];" "mode": "runOnceForEachItem",
"jsCode": "const report = $('Build Organization HTML Reports').item.json;\nconst recipients = Array.from(new Set($json.recipients || []));\nif (recipients.length === 0) return [];\nreturn { json: { ...report, recipients } };"
}, },
"id": "prepare-report-email", "id": "prepare-report-email",
"name": "E-Mail vorbereiten", "name": "E-Mail vorbereiten",
@@ -77,9 +63,7 @@
"connections": { "connections": {
"Every Monday 07:20": { "main": [[{ "node": "Load Latest Device Reports", "type": "main", "index": 0 }]] }, "Every Monday 07:20": { "main": [[{ "node": "Load Latest Device Reports", "type": "main", "index": 0 }]] },
"Load Latest Device Reports": { "main": [[{ "node": "Build Organization HTML Reports", "type": "main", "index": 0 }]] }, "Load Latest Device Reports": { "main": [[{ "node": "Build Organization HTML Reports", "type": "main", "index": 0 }]] },
"Build Organization HTML Reports": { "main": [[{ "node": "Store Weekly Organization Reports", "type": "main", "index": 0 }]] }, "Build Organization HTML Reports": { "main": [[{ "node": "Store Weekly Organization Reports", "type": "main", "index": 0 }, { "node": "Empfaenger aus zentraler Zuordnung laden", "type": "main", "index": 0 }]] },
"Store Weekly Organization Reports": { "main": [[{ "node": "E-Mail-Versand aktiv", "type": "main", "index": 0 }]] },
"E-Mail-Versand aktiv": { "main": [[{ "node": "Empfaenger aus zentraler Zuordnung laden", "type": "main", "index": 0 }]] },
"Empfaenger aus zentraler Zuordnung laden": { "main": [[{ "node": "E-Mail vorbereiten", "type": "main", "index": 0 }]] }, "Empfaenger aus zentraler Zuordnung laden": { "main": [[{ "node": "E-Mail vorbereiten", "type": "main", "index": 0 }]] },
"E-Mail vorbereiten": { "main": [[{ "node": "Send Weekly Organization Report", "type": "main", "index": 0 }]] } "E-Mail vorbereiten": { "main": [[{ "node": "Send Weekly Organization Report", "type": "main", "index": 0 }]] }
}, },

View File

@@ -0,0 +1,40 @@
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
const fmt = (value) => new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' });
const now = new Date();
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
end.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7));
const start = new Date(end.getTime() - 7 * 86400000);
const groups = new Map();
for (const item of items) {
const payload = item.json.payload || {}, ninja = payload.NinjaOne || payload.ninjaOne || {};
const id = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
if (!groups.has(id)) groups.set(id, { id, name: String(ninja.OrganizationName || ninja.organizationName || `Organisation ${id}`), devices: [] });
groups.get(id).devices.push({ name: item.json.machine_name || payload.MachineName || 'Unbekannt', state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(), payload });
}
const output = [];
for (const group of groups.values()) {
let warnings = 0, criticals = 0, totalEvents = 0, cveCritical = 0;
const ips = new Set(), rows = [];
for (const device of group.devices.sort((a, b) => a.name.localeCompare(b.name))) {
if (device.state === 'warning') warnings++;
if (device.state === 'critical') criticals++;
cveCritical += Number((device.payload.VulnerabilityCorrelation || {}).CriticalCount || 0);
const events = (device.payload.Events || []).filter((event) => { const date = new Date(event.Timestamp); return !Number.isNaN(date) && date >= start && date < end; });
totalEvents += events.length;
if (!events.length) { rows.push(`<tr class="clean"><td>${esc(device.name)}</td><td colspan="5">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td><b class="ok">Sauber</b></td></tr>`); continue; }
const grouped = new Map();
for (const event of events) {
const ip = event.SourceIp || '-', account = event.Username || '-', type = event.Target || 'Sicherheitsereignis', key = [type, account, ip].join('|');
const entry = grouped.get(key) || { ip, account, type, count: 0, latest: event.Timestamp };
entry.count++; if (new Date(event.Timestamp) > new Date(entry.latest)) entry.latest = event.Timestamp; grouped.set(key, entry); if (ip !== '-') ips.add(ip);
}
for (const event of grouped.values()) rows.push(`<tr class="alert"><td>${esc(device.name)}</td><td>${esc(event.type)}</td><td>${esc(event.account)}</td><td>${esc(fmt(event.latest))}</td><td>${event.count}</td><td>${esc(event.ip)}</td><td><b class="${device.state === 'critical' ? 'critical' : 'warning'}">${device.state === 'critical' ? 'Kritisch' : 'Pruefen'}</b></td></tr>`);
}
const risk = criticals ? ['Kritisch', 'critical'] : warnings ? ['Beobachten', 'warning'] : ['Unauffaellig', 'ok'];
const html = `<div class="ocs"><style>.ocs{font:13px Segoe UI,Tahoma,sans-serif;color:#172033;max-width:1160px}.ocs .head{background:#102a43;color:#fff;padding:18px 20px;border-radius:8px 8px 0 0}.ocs h1{margin:0;font-size:20px}.ocs .meta{color:#d5e2ee;margin-top:5px}.ocs .risk{float:right;padding:4px 8px;border-radius:12px}.ocs .stats{width:100%;border-collapse:separate;border-spacing:8px;margin:8px -8px}.ocs .stats td{width:16%;padding:9px;background:#f4f8fc;border:1px solid #dbe5ef}.ocs .stats b{display:block;font-size:20px;color:#102a43}.ocs table{width:100%;border-collapse:collapse}.ocs th{background:#1d4e89;color:#fff;text-align:left;padding:8px;font-size:11px}.ocs td{padding:8px;border-bottom:1px solid #dbe5ef;vertical-align:top}.ocs .alert{background:#fff8f3}.ocs .clean{background:#f3fbf6}.ocs .ok,.ocs .warning,.ocs .critical{padding:3px 6px;border-radius:4px}.ocs .ok{background:#d1fae5;color:#065f46}.ocs .warning{background:#fef3c7;color:#92400e}.ocs .critical{background:#fee2e2;color:#991b1b}.ocs .foot{margin-top:12px;text-align:right;color:#64748b;font-size:10px}</style><div class="head"><b class="risk ${risk[1]}">${risk[0]}</b><h1>OfficeCom Sentinel Sicherheitsbericht</h1><div class="meta">${esc(group.name)} | ${esc(start.toLocaleDateString('de-DE'))} bis ${esc(end.toLocaleDateString('de-DE'))}</div></div><table class="stats"><tr><td><b>${group.devices.length}</b>Ger&auml;te</td><td><b>${criticals}</b>Kritisch</td><td><b>${warnings}</b>Warnungen</td><td><b>${totalEvents}</b>Ereignisse</td><td><b>${ips.size}</b>Quell-IP-Adressen</td><td><b>${cveCritical}</b>Kritische CVEs</td></tr></table><table><thead><tr><th>System</th><th>Vorfall</th><th>Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Quell-IP</th><th>Bewertung</th></tr></thead><tbody>${rows.join('')}</tbody></table><div class="foot">Automatisch erstellt am ${esc(fmt(now))} durch OfficeCom Sentinel.</div></div>`;
output.push({ json: { organizationId: group.id, organizationName: group.name, periodStartUtc: start.toISOString(), periodEndUtc: end.toISOString(), deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveTotal: 0, cveCritical, reportHtml: html, summaryJson: JSON.stringify({ deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveCritical }) } });
}
return output;

View File

@@ -0,0 +1,129 @@
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
// Numeric entities keep German text intact in every supported mail client.
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
const formatDate = (value) => new Date(value).toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
});
const now = new Date();
const periodEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
periodEnd.setUTCDate(periodEnd.getUTCDate() - ((periodEnd.getUTCDay() + 6) % 7));
const periodStart = new Date(periodEnd.getTime() - 7 * 86400000);
const groups = new Map();
for (const item of items) {
const payload = item.json.payload || {};
const ninja = payload.NinjaOne || payload.ninjaOne || {};
const organizationId = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
const organizationName = String(ninja.OrganizationName || ninja.organizationName || `Organisation ${organizationId}`);
if (!groups.has(organizationId)) {
groups.set(organizationId, { organizationId, organizationName, devices: [] });
}
groups.get(organizationId).devices.push({
machineName: item.json.machine_name || payload.MachineName || 'Unbekannt',
state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(),
payload
});
}
const reportItems = [];
for (const group of groups.values()) {
let warningCount = 0;
let criticalCount = 0;
let totalEvents = 0;
let cveTotal = 0;
let cveCritical = 0;
const uniqueIps = new Set();
const alertRows = [];
const cleanRows = [];
for (const device of group.devices.sort((left, right) => left.machineName.localeCompare(right.machineName))) {
if (device.state === 'warning') warningCount++;
if (device.state === 'critical') criticalCount++;
const vulnerabilities = device.payload.VulnerabilityCorrelation || {};
cveTotal += Number(vulnerabilities.TotalCount || 0);
cveCritical += Number(vulnerabilities.CriticalCount || 0);
const events = (device.payload.Events || []).filter((event) => {
const timestamp = new Date(event.Timestamp);
return !Number.isNaN(timestamp) && timestamp >= periodStart && timestamp < periodEnd;
});
totalEvents += events.length;
if (events.length === 0) {
cleanRows.push(`<tr class="clean"><td>${esc(device.machineName)}</td><td colspan="5">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td><span class="badge badge-ok">Sauber</span></td></tr>`);
continue;
}
const groupedEvents = new Map();
for (const event of events) {
const sourceIp = event.SourceIp || '-';
const account = event.Username || '-';
const type = event.Target || 'Sicherheitsereignis';
const key = [type, account, sourceIp].join('|');
const row = groupedEvents.get(key) || { type, account, sourceIp, count: 0, latest: event.Timestamp };
row.count++;
if (new Date(event.Timestamp) > new Date(row.latest)) row.latest = event.Timestamp;
groupedEvents.set(key, row);
if (sourceIp !== '-') uniqueIps.add(sourceIp);
}
for (const event of groupedEvents.values()) {
const severity = device.state === 'critical' ? 'Kritisch' : 'Pruefen';
const badge = device.state === 'critical' ? 'badge-critical' : 'badge-warning';
alertRows.push(`<tr class="alert"><td>${esc(device.machineName)}</td><td>${esc(event.type)}</td><td>${esc(event.account)}</td><td>${esc(formatDate(event.latest))}</td><td>${event.count}</td><td>${esc(event.sourceIp)}</td><td><span class="badge ${badge}">${severity}</span></td></tr>`);
}
}
const summary = {
deviceCount: group.devices.length,
warningCount,
criticalCount,
totalEvents,
uniqueIps: uniqueIps.size,
cveTotal,
cveCritical
};
const riskLabel = criticalCount > 0 ? 'Kritisch' : warningCount > 0 ? 'Beobachten' : 'Unauffaellig';
const riskClass = criticalCount > 0 ? 'risk-critical' : warningCount > 0 ? 'risk-warning' : 'risk-ok';
const rows = alertRows.length > 0 ? `${alertRows.join('')}${cleanRows.join('')}` : cleanRows.join('');
const reportHtml = `<div class="ocsentinel-report">
<style>
.ocsentinel-report{max-width:1180px;margin:0 auto;font-family:Segoe UI,Tahoma,sans-serif;font-size:13px;line-height:1.35;color:#172033;background:#fff}
.ocsentinel-report .header{padding:18px 20px;background:#102a43;color:#fff;border-radius:8px 8px 0 0}
.ocsentinel-report h1{margin:0;font-size:20px;line-height:1.2}.ocsentinel-report .subtitle{margin-top:5px;color:#cbd5e1;font-size:12px}
.ocsentinel-report .risk{float:right;padding:5px 9px;border-radius:999px;font-size:11px;font-weight:700}.ocsentinel-report .risk-ok{background:#d1fae5;color:#065f46}.ocsentinel-report .risk-warning{background:#fef3c7;color:#92400e}.ocsentinel-report .risk-critical{background:#fee2e2;color:#991b1b}
.ocsentinel-report .body{padding:16px 20px 20px;border:1px solid #dbe5ef;border-top:0}.ocsentinel-report .metrics{width:100%;border-collapse:separate;border-spacing:8px 0;margin:0 -8px 15px}.ocsentinel-report .metrics td{width:16.66%;padding:10px;background:#f6f9fc;border:1px solid #dbe5ef;border-radius:5px}.ocsentinel-report .metric-value{display:block;font-size:20px;font-weight:700;color:#102a43}.ocsentinel-report .metric-label{display:block;font-size:10px;color:#526577;text-transform:uppercase;letter-spacing:.04em}
.ocsentinel-report table{width:100%;border-collapse:collapse}.ocsentinel-report th{padding:8px;background:#1d4e89;color:#fff;text-align:left;font-size:11px}.ocsentinel-report td{padding:8px;border-bottom:1px solid #dbe5ef;vertical-align:top}.ocsentinel-report tr.alert{background:#fff8f3}.ocsentinel-report tr.clean{background:#f3fbf6;color:#275b3b}.ocsentinel-report .badge{display:inline-block;padding:3px 6px;border-radius:4px;font-size:10px;font-weight:700}.ocsentinel-report .badge-ok{background:#d1fae5;color:#065f46}.ocsentinel-report .badge-warning{background:#fef3c7;color:#92400e}.ocsentinel-report .badge-critical{background:#fee2e2;color:#991b1b}.ocsentinel-report .footer{margin-top:14px;color:#64748b;font-size:10px;text-align:right}
</style>
<div class="header"><span class="risk ${riskClass}">${riskLabel}</span><h1>OfficeCom Sentinel Sicherheitsbericht</h1><div class="subtitle">${esc(group.organizationName)} | ${esc(periodStart.toLocaleDateString('de-DE'))} bis ${esc(periodEnd.toLocaleDateString('de-DE'))}</div></div>
<div class="body"><table class="metrics"><tr><td><span class="metric-value">${summary.deviceCount}</span><span class="metric-label">Ger&auml;te</span></td><td><span class="metric-value">${summary.criticalCount}</span><span class="metric-label">Kritisch</span></td><td><span class="metric-value">${summary.warningCount}</span><span class="metric-label">Warnungen</span></td><td><span class="metric-value">${summary.totalEvents}</span><span class="metric-label">Ereignisse</span></td><td><span class="metric-value">${summary.uniqueIps}</span><span class="metric-label">Quell-IP-Adressen</span></td><td><span class="metric-value">${summary.cveCritical}</span><span class="metric-label">Kritische CVEs</span></td></tr></table>
<table><thead><tr><th>System</th><th>Vorfall</th><th>Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Quell-IP</th><th>Bewertung</th></tr></thead><tbody>${rows}</tbody></table><div class="footer">Automatisch erstellt am ${esc(formatDate(now))} durch OfficeCom Sentinel.</div></div></div>`;
reportItems.push({
json: {
organizationId: group.organizationId,
organizationName: group.organizationName,
periodStartUtc: periodStart.toISOString(),
periodEndUtc: periodEnd.toISOString(),
...summary,
reportHtml,
summaryJson: JSON.stringify(summary)
}
});
}
return reportItems;

View File

@@ -0,0 +1,41 @@
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
const now = new Date();
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
end.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7));
const start = new Date(end.getTime() - 7 * 86400000);
const formatDate = (value) => new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' });
const groups = new Map();
for (const item of items) {
const payload = item.json.payload || {}, ninja = payload.NinjaOne || payload.ninjaOne || {};
const id = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
if (!groups.has(id)) groups.set(id, { id, name: String(ninja.OrganizationName || ninja.organizationName || `Organisation ${id}`), devices: [] });
groups.get(id).devices.push({ name: item.json.machine_name || payload.MachineName || 'Unbekannt', state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(), payload });
}
const output = [];
for (const group of groups.values()) {
let warnings = 0, criticals = 0, totalEvents = 0, cveCritical = 0;
const ips = new Set(), rows = [];
for (const device of group.devices.sort((a, b) => a.name.localeCompare(b.name))) {
if (device.state === 'warning') warnings++;
if (device.state === 'critical') criticals++;
cveCritical += Number((device.payload.VulnerabilityCorrelation || {}).CriticalCount || 0);
const events = (device.payload.Events || []).filter((event) => { const timestamp = new Date(event.Timestamp); return !Number.isNaN(timestamp) && timestamp >= start && timestamp < end; });
totalEvents += events.length;
if (!events.length) { rows.push(`<tr bgcolor="#f0fdf4"><td style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px">${esc(device.name)}</td><td colspan="5" style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px;color:#166534">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px;color:#166534"><b>Sauber</b></td></tr>`); continue; }
const grouped = new Map();
for (const event of events) {
const ip = event.SourceIp || '-', account = event.Username || '-', type = event.Target || 'Sicherheitsereignis', key = [type, account, ip].join('|');
const entry = grouped.get(key) || { ip, account, type, count: 0, latest: event.Timestamp };
entry.count++; if (new Date(event.Timestamp) > new Date(entry.latest)) entry.latest = event.Timestamp; grouped.set(key, entry); if (ip !== '-') ips.add(ip);
}
for (const event of grouped.values()) { const critical = device.state === 'critical'; rows.push(`<tr bgcolor="#fff7ed"><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(device.name)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.type)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.account)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(formatDate(event.latest))}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${event.count}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.ip)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px;color:${critical ? '#991b1b' : '#92400e'}"><b>${critical ? 'Kritisch' : 'Pruefen'}</b></td></tr>`); }
}
const risk = criticals ? ['Kritisch', '#991b1b', '#fee2e2'] : warnings ? ['Beobachten', '#92400e', '#fef3c7'] : ['Unauffaellig', '#166534', '#dcfce7'];
const metric = (value, label) => `<td width="16.66%" valign="top" style="padding:10px;background:#f8fafc;border:1px solid #dbe5ef;font-family:Arial,sans-serif"><b style="font-size:20px;color:#102a43">${value}</b><br><span style="font-size:10px;color:#526577">${label}</span></td>`;
const html = `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:1100px;border-collapse:collapse"><tr><td style="padding:18px 20px;background:#102a43"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td valign="top" style="font-family:Arial,sans-serif;color:#ffffff"><h2 style="margin:0;font-size:20px;color:#ffffff">OfficeCom Sentinel Sicherheitsbericht</h2><p style="margin:6px 0 0;color:#d5e2ee;font-size:12px">${esc(group.name)} | ${esc(start.toLocaleDateString('de-DE'))} bis ${esc(end.toLocaleDateString('de-DE'))}</p></td><td width="100" align="right" valign="top" style="font-family:Arial,sans-serif"><span style="display:inline-block;padding:5px 9px;background:${risk[2]};color:${risk[1]};font-size:11px"><b>${risk[0]}</b></span></td></tr></table></td></tr><tr><td style="padding:16px 20px;border:1px solid #dbe5ef"><table role="presentation" width="100%" cellspacing="6" cellpadding="0" border="0"><tr>${metric(group.devices.length, 'Ger&auml;te')}${metric(criticals, 'Kritisch')}${metric(warnings, 'Warnungen')}${metric(totalEvents, 'Ereignisse')}${metric(ips.size, 'Quell-IP-Adressen')}${metric(cveCritical, 'Kritische CVEs')}</tr></table><table width="100%" cellspacing="0" cellpadding="0" border="0" style="border-collapse:collapse"><thead><tr bgcolor="#1d4e89"><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">System</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Vorfall</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Konto</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Letzter Zeitpunkt</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Anzahl</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Quell-IP</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Bewertung</th></tr></thead><tbody>${rows.join('')}</tbody></table><p style="margin:14px 0 0;text-align:right;color:#64748b;font-family:Arial,sans-serif;font-size:10px">Automatisch erstellt am ${esc(formatDate(now))} durch OfficeCom Sentinel.</p></td></tr></table>`;
output.push({ json: { organizationId: group.id, organizationName: group.name, periodStartUtc: start.toISOString(), periodEndUtc: end.toISOString(), deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveTotal: 0, cveCritical, reportHtml: html, summaryJson: JSON.stringify({ deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveCritical }) } });
}
return output;

View File

@@ -39,6 +39,40 @@ function Resolve-PathLike {
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue)) return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
} }
function Restore-NinjaContextFromClientConfiguration {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
return
}
try {
$clientConfiguration = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$mappings = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
)
foreach ($mapping in $mappings) {
if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($mapping.EnvironmentName, "Process"))) {
continue
}
$value = [string]$clientConfiguration.($mapping.PropertyName)
if (-not [string]::IsNullOrWhiteSpace($value)) {
[Environment]::SetEnvironmentVariable($mapping.EnvironmentName, $value, "Process")
}
}
}
catch {
Write-Warning "Could not restore stored NinjaOne context: $($_.Exception.Message)"
}
}
if (-not (Test-Path $appExe)) { if (-not (Test-Path $appExe)) {
throw "Application executable not found: $appExe" throw "Application executable not found: $appExe"
} }
@@ -54,6 +88,8 @@ else {
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $scriptDir } $secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $scriptDir }
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath) $canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
Restore-NinjaContextFromClientConfiguration -Path $clientConfigFullPath
if ($UploadMode -eq "required" -and -not $canUpload) { if ($UploadMode -eq "required" -and -not $canUpload) {
throw "UploadMode 'required' was set, but client config or protected secret is missing." throw "UploadMode 'required' was set, but client config or protected secret is missing."
} }

View File

@@ -7,6 +7,72 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
function Initialize-OCSentinelTls {
$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = $protocols
[Net.ServicePointManager]::Expect100Continue = $false
}
function Get-OCSentinelManifest {
param([Parameter(Mandatory)][string]$Uri)
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
return Invoke-RestMethod @parameters
}
catch {
if ($attempt -eq 3) {
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
}
Start-Sleep -Seconds (3 * $attempt)
}
}
}
function Get-OCSentinelArtifact {
param(
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$DestinationPath
)
$parameters = @{ Uri = $Uri; OutFile = $DestinationPath; TimeoutSec = 300 }
if ((Get-Command Invoke-WebRequest).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Remove-Item -LiteralPath $DestinationPath -Force -ErrorAction SilentlyContinue
Invoke-WebRequest @parameters
if (-not (Test-Path -LiteralPath $DestinationPath) -or (Get-Item -LiteralPath $DestinationPath).Length -eq 0) {
throw "The downloaded artifact is empty."
}
return
}
catch {
if ($attempt -eq 3) {
throw "Could not download the OCSentinel package after 3 attempts. Last error: $($_.Exception.Message)"
}
Write-Warning "Package download attempt $attempt failed. Retrying."
Start-Sleep -Seconds (5 * $attempt)
}
}
}
Initialize-OCSentinelTls
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel" $installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
$appExe = Join-Path $installRoot "app\OCSentinelCli.exe" $appExe = Join-Path $installRoot "app\OCSentinelCli.exe"
$installScript = Join-Path $installRoot "scripts\install-ocsentinel.ps1" $installScript = Join-Path $installRoot "scripts\install-ocsentinel.ps1"
@@ -66,7 +132,7 @@ if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
$resolvedManifestUrl = Resolve-ManifestUrl -ManifestUrl $ManifestUrl -Channel $Channel $resolvedManifestUrl = Resolve-ManifestUrl -ManifestUrl $ManifestUrl -Channel $Channel
Write-Host "Checking update manifest: $resolvedManifestUrl" Write-Host "Checking update manifest: $resolvedManifestUrl"
$manifest = Invoke-RestMethod -Method Get -Uri $resolvedManifestUrl -TimeoutSec 60 $manifest = Get-OCSentinelManifest -Uri $resolvedManifestUrl
if (-not $manifest.version -or -not $manifest.artifactUrl -or -not $manifest.sha256) { if (-not $manifest.version -or -not $manifest.artifactUrl -or -not $manifest.sha256) {
throw "Update manifest is missing required fields: version, artifactUrl, sha256." throw "Update manifest is missing required fields: version, artifactUrl, sha256."
} }
@@ -89,7 +155,7 @@ $extractRoot = Join-Path $downloadRoot "payload"
New-Item -ItemType Directory -Force -Path $downloadRoot, $extractRoot | Out-Null New-Item -ItemType Directory -Force -Path $downloadRoot, $extractRoot | Out-Null
Write-Host "Downloading artifact: $($manifest.artifactUrl)" Write-Host "Downloading artifact: $($manifest.artifactUrl)"
Invoke-WebRequest -Uri ([string]$manifest.artifactUrl) -OutFile $zipPath -TimeoutSec 300 Get-OCSentinelArtifact -Uri ([string]$manifest.artifactUrl) -DestinationPath $zipPath
$actualHash = Get-Sha256Hex -Path $zipPath $actualHash = Get-Sha256Hex -Path $zipPath
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant() $expectedHash = ([string]$manifest.sha256).ToLowerInvariant()

View File

@@ -1,8 +1,8 @@
{ {
"channel": "stable", "channel": "stable",
"version": "1.3.3", "version": "1.3.7",
"publishedAtUtc": "2026-07-26T18:25:32.8762198Z", "publishedAtUtc": "2026-07-27T09:14:02.6006737Z",
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.3.3/OCSentinelClient-win-x64.zip", "artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.3.7/OCSentinelClient-win-x64.zip",
"sha256": "9bb2b2dc25c59bc6faf53ded6095667ac73640038cfd426a74f3bf6aca81bd86", "sha256": "8afdb5e8874e06c56047c32bed58f8593fd30928ddc004b39f4abae83d1d29e9",
"minUpdaterVersion": "1.0.0" "minUpdaterVersion": "1.0.0"
} }

View File

@@ -8,7 +8,109 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue" $ProgressPreference = "SilentlyContinue"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function Initialize-OCSentinelTls {
$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = $protocols
[Net.ServicePointManager]::Expect100Continue = $false
}
function Get-OCSentinelManifest {
param([Parameter(Mandatory)][string]$Uri)
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
return Invoke-RestMethod @parameters
}
catch {
if ($attempt -eq 3) {
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
}
Start-Sleep -Seconds (3 * $attempt)
}
}
}
function Invoke-OCSentinelUpdater {
param(
[Parameter(Mandatory)][string]$UpdaterPath,
[Parameter(Mandatory)][string]$ManifestUri
)
# Existing clients can still contain an older updater without TLS setup.
# Start it in a prepared child process so it can download the current package.
$escapedUpdaterPath = $UpdaterPath.Replace("'", "''")
$escapedManifestUri = $ManifestUri.Replace("'", "''")
$command = @"
`$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') {
`$protocols = `$protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = `$protocols
[Net.ServicePointManager]::Expect100Continue = `$false
& '$escapedUpdaterPath' -ManifestUrl '$escapedManifestUri'
exit `$LASTEXITCODE
"@
for ($attempt = 1; $attempt -le 3; $attempt++) {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $command | ForEach-Object { Write-Host $_ }
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
return
}
if ($attempt -lt 3) {
Write-Warning "OCSentinel update attempt $attempt failed. Retrying."
Start-Sleep -Seconds (5 * $attempt)
}
}
throw "OCSentinel updater exited with code $exitCode after 3 attempts."
}
function Get-OCSentinelArtifact {
param(
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$DestinationPath
)
$parameters = @{ Uri = $Uri; OutFile = $DestinationPath; TimeoutSec = 300 }
if ((Get-Command Invoke-WebRequest).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Remove-Item -LiteralPath $DestinationPath -Force -ErrorAction SilentlyContinue
Invoke-WebRequest @parameters
if (-not (Test-Path -LiteralPath $DestinationPath) -or (Get-Item -LiteralPath $DestinationPath).Length -eq 0) {
throw "The downloaded artifact is empty."
}
return
}
catch {
if ($attempt -eq 3) {
throw "Could not download the OCSentinel package after 3 attempts. Last error: $($_.Exception.Message)"
}
Write-Warning "Package download attempt $attempt failed. Retrying."
Start-Sleep -Seconds (5 * $attempt)
}
}
}
Initialize-OCSentinelTls
$installRoot = Join-Path $env:ProgramFiles "OCSentinel" $installRoot = Join-Path $env:ProgramFiles "OCSentinel"
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1" $updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
@@ -47,14 +149,11 @@ function Assert-ArtifactSignature {
if (Test-Path -LiteralPath $updaterPath) { if (Test-Path -LiteralPath $updaterPath) {
Write-Host "Existing OCSentinel installation found. Checking for updates." Write-Host "Existing OCSentinel installation found. Checking for updates."
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $updaterPath -ManifestUrl $ManifestUrl Invoke-OCSentinelUpdater -UpdaterPath $updaterPath -ManifestUri $ManifestUrl
if ($LASTEXITCODE -ne 0) {
throw "OCSentinel updater exited with code $LASTEXITCODE"
}
} }
else { else {
Write-Host "Reading OCSentinel release manifest: $ManifestUrl" Write-Host "Reading OCSentinel release manifest: $ManifestUrl"
$manifest = Invoke-RestMethod -Method Get -Uri $ManifestUrl -TimeoutSec 60 $manifest = Get-OCSentinelManifest -Uri $ManifestUrl
if ([string]::IsNullOrWhiteSpace($manifest.version) -or [string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) { if ([string]::IsNullOrWhiteSpace($manifest.version) -or [string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
throw "Release manifest is missing version, artifactUrl, or sha256." throw "Release manifest is missing version, artifactUrl, or sha256."
} }
@@ -66,7 +165,7 @@ else {
try { try {
New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null
Write-Host "Downloading OCSentinel $($manifest.version)" Write-Host "Downloading OCSentinel $($manifest.version)"
Invoke-WebRequest -Uri ([string]$manifest.artifactUrl) -OutFile $zipPath -TimeoutSec 300 Get-OCSentinelArtifact -Uri ([string]$manifest.artifactUrl) -DestinationPath $zipPath
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() $actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant() $expectedHash = ([string]$manifest.sha256).ToLowerInvariant()
@@ -107,8 +206,22 @@ if (-not [string]::IsNullOrWhiteSpace($WebhookUrl)) {
$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json $clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json
$clientConfig.n8nWebhookUrl = $WebhookUrl $clientConfig.n8nWebhookUrl = $WebhookUrl
$clientConfig.environment = "production" $clientConfig.environment = "production"
$ninjaContext = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
)
foreach ($entry in $ninjaContext) {
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
if (-not [string]::IsNullOrWhiteSpace($value)) {
$clientConfig | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
}
}
$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8 $clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8
Write-Host "Configured OCSentinel upload endpoint." Write-Host "Configured OCSentinel upload endpoint and NinjaOne context."
} }
if (-not [string]::IsNullOrWhiteSpace($SecretValue)) { if (-not [string]::IsNullOrWhiteSpace($SecretValue)) {

View File

@@ -7,7 +7,39 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue" $ProgressPreference = "SilentlyContinue"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function Initialize-OCSentinelTls {
$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = $protocols
[Net.ServicePointManager]::Expect100Continue = $false
}
function Get-OCSentinelManifest {
param([Parameter(Mandatory)][string]$Uri)
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
return Invoke-RestMethod @parameters
}
catch {
if ($attempt -eq 3) {
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
}
Start-Sleep -Seconds (3 * $attempt)
}
}
}
Initialize-OCSentinelTls
function Get-NinjaValue { function Get-NinjaValue {
param([Parameter(Mandatory)][string]$Name) param([Parameter(Mandatory)][string]$Name)
@@ -26,7 +58,7 @@ if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or [string]::IsNullOrWhiteSpace($
throw "WebhookUrl and SecretValue must be set as NinjaOne script variables." throw "WebhookUrl and SecretValue must be set as NinjaOne script variables."
} }
$manifest = Invoke-RestMethod -Method Get -Uri $ManifestUrl -TimeoutSec 60 $manifest = Get-OCSentinelManifest -Uri $ManifestUrl
if ([string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) { if ([string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
throw "The release manifest is incomplete." throw "The release manifest is incomplete."
} }

View File

@@ -40,6 +40,40 @@ function Resolve-PathLike {
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue)) return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
} }
function Restore-NinjaContextFromClientConfiguration {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
return
}
try {
$clientConfiguration = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$mappings = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
)
foreach ($mapping in $mappings) {
if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($mapping.EnvironmentName, "Process"))) {
continue
}
$value = [string]$clientConfiguration.($mapping.PropertyName)
if (-not [string]::IsNullOrWhiteSpace($value)) {
[Environment]::SetEnvironmentVariable($mapping.EnvironmentName, $value, "Process")
}
}
}
catch {
Write-Warning "Could not restore stored NinjaOne context: $($_.Exception.Message)"
}
}
$arguments = @( $arguments = @(
$dllPath $dllPath
) )
@@ -53,6 +87,8 @@ else {
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $repoRoot } $secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $repoRoot }
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath) $canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
Restore-NinjaContextFromClientConfiguration -Path $clientConfigFullPath
if ($UploadMode -eq "required" -and -not $canUpload) { if ($UploadMode -eq "required" -and -not $canUpload) {
throw "UploadMode 'required' was set, but client config or protected secret is missing." throw "UploadMode 'required' was set, but client config or protected secret is missing."
} }

View File

@@ -53,8 +53,9 @@ internal sealed class AttackScanner
.ToList(); .ToList();
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count(); int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration); AlertAssessment baseAssessment = AssessAttackActivity(attacks, uniqueIpCount, configuration);
string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState); string baseAlertState = baseAssessment.State;
string baseAlertReason = baseAssessment.Reason;
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath) VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
? VulnerabilityCorrelationSummary.Empty() ? VulnerabilityCorrelationSummary.Empty()
: VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors); : VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors);
@@ -469,28 +470,90 @@ internal sealed class AttackScanner
return IPAddress.TryParse(input, out _); return IPAddress.TryParse(input, out _);
} }
private static string GetAlertState(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration) private static AlertAssessment AssessAttackActivity(IReadOnlyList<AttackEvent> attacks, int uniqueIpCount, ScannerConfiguration configuration)
{ {
if (totalEvents >= configuration.CriticalEventThreshold || uniqueIpCount >= configuration.CriticalUniqueIpThreshold) if (attacks.Count == 0)
{ {
return "critical"; return new AlertAssessment("ok", "No failed login activity observed.");
} }
if (totalEvents >= configuration.WarningEventThreshold || uniqueIpCount >= configuration.WarningUniqueIpThreshold) TimeSpan window = TimeSpan.FromMinutes(configuration.LoginBurstWindowMinutes);
int largestBurst = attacks
.GroupBy(attack => (attack.SourceIp, attack.Username, attack.Target))
.Select(group => GetPeakEventCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
.DefaultIfEmpty(0)
.Max();
int largestSpray = attacks
.GroupBy(attack => attack.SourceIp)
.Select(group => GetPeakDistinctAccountCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
.DefaultIfEmpty(0)
.Max();
if (largestBurst >= configuration.CriticalLoginBurstCount || largestSpray >= configuration.CriticalSprayAccountCount)
{ {
return "warning"; return new AlertAssessment("critical", $"High-confidence login attack pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
} }
return "ok"; if (largestBurst >= configuration.WarningLoginBurstCount || largestSpray >= configuration.WarningSprayAccountCount)
{
return new AlertAssessment("warning", $"Suspicious login pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
} }
private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState) int criticalEventThreshold = Math.Max(configuration.CriticalEventThreshold, configuration.CriticalLoginBurstCount);
int criticalIpThreshold = Math.Max(configuration.CriticalUniqueIpThreshold, configuration.CriticalSprayAccountCount);
int warningEventThreshold = Math.Max(configuration.WarningEventThreshold, configuration.WarningLoginBurstCount * 2);
int warningIpThreshold = Math.Max(configuration.WarningUniqueIpThreshold, configuration.WarningSprayAccountCount);
if (attacks.Count >= criticalEventThreshold || uniqueIpCount >= criticalIpThreshold)
{ {
return alertState switch return new AlertAssessment("critical", $"Critical volume threshold reached: events={attacks.Count}, unique IPs={uniqueIpCount}.");
{
"critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.",
"warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.",
_ => "No thresholds exceeded."
};
} }
if (attacks.Count >= warningEventThreshold || uniqueIpCount >= warningIpThreshold)
{
return new AlertAssessment("warning", $"Elevated failed-login volume: events={attacks.Count}, unique IPs={uniqueIpCount}.");
}
return new AlertAssessment("ok", $"Low-volume login errors observed: events={attacks.Count}, unique IPs={uniqueIpCount}; no burst or password-spraying pattern detected.");
}
private static int GetPeakEventCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
{
int start = 0;
int peak = 0;
for (int end = 0; end < events.Count; end++)
{
while (events[end].Timestamp - events[start].Timestamp > window)
{
start++;
}
peak = Math.Max(peak, end - start + 1);
}
return peak;
}
private static int GetPeakDistinctAccountCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
{
var accounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
int start = 0;
int peak = 0;
for (int end = 0; end < events.Count; end++)
{
accounts[events[end].Username] = accounts.GetValueOrDefault(events[end].Username) + 1;
while (events[end].Timestamp - events[start].Timestamp > window)
{
string account = events[start].Username;
accounts[account]--;
if (accounts[account] == 0)
{
accounts.Remove(account);
}
start++;
}
peak = Math.Max(peak, accounts.Count);
}
return peak;
}
private sealed record AlertAssessment(string State, string Reason);
} }

View File

@@ -4,13 +4,23 @@ namespace OCSentinelCli;
internal sealed record ScannerConfiguration internal sealed record ScannerConfiguration
{ {
public int WarningEventThreshold { get; init; } = 1; public int WarningEventThreshold { get; init; } = 10;
public int CriticalEventThreshold { get; init; } = 20; public int CriticalEventThreshold { get; init; } = 30;
public int WarningUniqueIpThreshold { get; init; } = 1; public int WarningUniqueIpThreshold { get; init; } = 5;
public int CriticalUniqueIpThreshold { get; init; } = 10; public int CriticalUniqueIpThreshold { get; init; } = 12;
public int LoginBurstWindowMinutes { get; init; } = 15;
public int WarningLoginBurstCount { get; init; } = 5;
public int CriticalLoginBurstCount { get; init; } = 20;
public int WarningSprayAccountCount { get; init; } = 5;
public int CriticalSprayAccountCount { get; init; } = 10;
public int CorrelationWarningCveThreshold { get; init; } = 1; public int CorrelationWarningCveThreshold { get; init; } = 1;

View File

@@ -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.3.4</Version> <Version>1.4.0</Version>
<AssemblyVersion>1.3.4.0</AssemblyVersion> <AssemblyVersion>1.4.0.0</AssemblyVersion>
<FileVersion>1.3.4.0</FileVersion> <FileVersion>1.4.0.0</FileVersion>
<InformationalVersion>1.3.4</InformationalVersion> <InformationalVersion>1.4.0</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>