11 Commits

Author SHA1 Message Date
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
18 changed files with 386 additions and 99 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

@@ -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,16 @@ 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)) return render_template(
"device.html",
report=report,
event=event_metadata(payload),
payload=payload,
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 +198,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 +218,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 +243,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 +267,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 +279,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,16 @@
:root { --ink:#17201d; --muted:#66736d; --paper:#f5f3eb; --panel:#fffdf7; --line:#d8d4c6; --green:#236342; --lime:#c7ee6b; --amber:#b86613; --red:#a8342b; } :root { --ink:#17201d; --muted:#66736d; --paper:#f5f3eb; --panel:#fffdf7; --line:#d8d4c6; --green:#236342; --lime:#c7ee6b; --amber:#b86613; --red:#a8342b; }
* { 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 86% -10%, #d6efad 0, transparent 28rem), var(--paper); font-family:'Roboto',sans-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%); }
.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 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 'Roboto',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; }
.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); }
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); box-shadow:0 12px 40px rgba(20,35,27,.1); } .report-frame { background:#fff; border:1px solid var(--line); box-shadow:0 12px 40px rgba(20,35,27,.1); }
.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; } }

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,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ report[0] }} - OC Sentinel{% endblock %} {% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
{% block content %} {% block content %}
<section class="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"><pre>{{ payload_pretty }}</pre></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,17 @@
{% 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="panel"><div class="panel-heading"><h2>Empfaenger hinzufuegen</h2></div>
<section class="panel"><div class="panel-heading"><p class="eyebrow">Neue Regel</p><h2>Empfaenger hinzufuegen</h2></div>
<form class="recipient-form" method="post" action="{{ url_for('add_recipient') }}"> <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">Speichern</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"><div class="panel-heading"><h2>E-Mail-Verteiler</h2></div>
<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="table-wrap"><table><thead><tr><th>Organisation</th><th>E-Mail-Adresse</th><th>Status</th><th>Aktion</th></tr></thead><tbody>
{% 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 %} {% 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">Keine Empfaengerregeln.</td></tr>{% endfor %}
</tbody></table></div></section> </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 %}

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

@@ -38,6 +38,39 @@ function Get-OCSentinelManifest {
} }
} }
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 Initialize-OCSentinelTls
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel" $installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
@@ -122,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.4", "version": "1.3.6",
"publishedAtUtc": "2026-07-26T19:13:59.3121556Z", "publishedAtUtc": "2026-07-27T08:45:53.0419623Z",
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.3.4/OCSentinelClient-win-x64.zip", "artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.3.6/OCSentinelClient-win-x64.zip",
"sha256": "36b2ea0ce6b017bb46539eeb397ce013679155ce4805e526df983db8d3fca65f", "sha256": "48e9faa242a59797bed9732ae5b8914ab44843b9c949260a17bc15b47659a3d5",
"minUpdaterVersion": "1.0.0" "minUpdaterVersion": "1.0.0"
} }

View File

@@ -40,6 +40,76 @@ function Get-OCSentinelManifest {
} }
} }
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 Initialize-OCSentinelTls
$installRoot = Join-Path $env:ProgramFiles "OCSentinel" $installRoot = Join-Path $env:ProgramFiles "OCSentinel"
@@ -79,10 +149,7 @@ 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"
@@ -98,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()
@@ -139,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

@@ -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

@@ -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.5</Version> <Version>1.3.7</Version>
<AssemblyVersion>1.3.5.0</AssemblyVersion> <AssemblyVersion>1.3.7.0</AssemblyVersion>
<FileVersion>1.3.5.0</FileVersion> <FileVersion>1.3.7.0</FileVersion>
<InformationalVersion>1.3.5</InformationalVersion> <InformationalVersion>1.3.7</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>