Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf3281b4d | ||
|
|
b2da734c75 | ||
|
|
40c6daada8 | ||
|
|
5be4fb6c33 | ||
|
|
0fe8a96057 | ||
|
|
eb621ace5a | ||
|
|
e997e6b58c | ||
|
|
3f09805999 | ||
|
|
85f0682769 | ||
|
|
6722b00ee7 | ||
|
|
d3897c8e69 | ||
|
|
f9941b0807 | ||
|
|
c768e1be6b | ||
|
|
2a780cd52f | ||
|
|
daa494fade | ||
|
|
e7fd4e7ef5 | ||
|
|
dfdae7a532 | ||
|
|
854c99e3b2 | ||
|
|
2598de2ecc | ||
|
|
6c791cc417 | ||
|
|
0adac8a0d9 | ||
|
|
a45a811040 | ||
|
|
2f2a553fc2 | ||
|
|
6ceb29a07b | ||
|
|
1c2170090e | ||
|
|
91c5794502 | ||
|
|
f392057535 | ||
|
|
7431c656d3 |
@@ -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:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Copy-Item -Path (Join-Path $installerRoot "uninstall-ocsentinel.ps1") -Destinati
|
|||||||
Copy-Item -Path (Join-Path $installerRoot "update-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\update-ocsentinel.ps1") -Force
|
Copy-Item -Path (Join-Path $installerRoot "update-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\update-ocsentinel.ps1") -Force
|
||||||
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Force
|
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Force
|
||||||
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-monitor.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Force
|
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-monitor.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Force
|
||||||
|
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-scheduled.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-scheduled.ps1") -Force
|
||||||
Copy-Item -Path (Join-Path $repoRoot "scripts\protect-ocsentinel-secret.ps1") -Destination (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Force
|
Copy-Item -Path (Join-Path $repoRoot "scripts\protect-ocsentinel-secret.ps1") -Destination (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Force
|
||||||
|
|
||||||
Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-settings.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-settings.example.json") -Force
|
Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-settings.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-settings.example.json") -Force
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
"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,
|
||||||
"enableVulnerabilityCorrelation": true,
|
"enableVulnerabilityCorrelation": true,
|
||||||
"vulnerabilityCsvPath": "",
|
"vulnerabilityCsvPath": "",
|
||||||
"secretReference": "device-default"
|
"secretReference": "device-default"
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
"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,
|
||||||
"enableVulnerabilityCorrelation": true,
|
"enableVulnerabilityCorrelation": true,
|
||||||
"vulnerabilityCsvPath": "",
|
"vulnerabilityCsvPath": "",
|
||||||
"secretReference": "device-default"
|
"secretReference": "device-default"
|
||||||
|
|||||||
@@ -30,6 +30,54 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 `
|
|||||||
- `config\ocsentinel-settings.json`
|
- `config\ocsentinel-settings.json`
|
||||||
- `config\ocsentinel-client.json`
|
- `config\ocsentinel-client.json`
|
||||||
|
|
||||||
|
## 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`:
|
||||||
|
|
||||||
|
- `OCSentinel Daily Scan`: runs once per day and uploads one signed report.
|
||||||
|
The installer deterministically assigns each device a stable slot between
|
||||||
|
`04:00` and `06:59`, derived from its Windows `MachineGuid`. This distributes
|
||||||
|
a fleet rollout instead of sending all reports at the same time.
|
||||||
|
- `OCSentinel Burst Check`: runs every five minutes. It performs no scan unless
|
||||||
|
the NinjaOne device custom field `ocsentinelburst` is enabled. Once enabled,
|
||||||
|
it scans for two hours and then disables itself automatically.
|
||||||
|
|
||||||
|
Create `ocsentinelburst` as a device-level `Checkbox` custom field and allow
|
||||||
|
automation read and write access. Set it to `true` for a device to begin the
|
||||||
|
five-minute burst scans; clear it to stop them early. The normal daily scan
|
||||||
|
continues regardless of the checkbox.
|
||||||
|
|
||||||
|
Create these accompanying device custom fields and allow automation write
|
||||||
|
access:
|
||||||
|
|
||||||
|
| Field name | Type | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ocsentinelburstuntilutc` | Date/Time | UTC time at which the active burst ends |
|
||||||
|
| `ocsentinelburststatus` | Text | `idle`, `active until ...`, or `completed` |
|
||||||
|
|
||||||
|
## Upload Reliability And Client Health
|
||||||
|
|
||||||
|
If the upload endpoint is temporarily unavailable, the client stores up to 100
|
||||||
|
signed report payloads locally under `C:\ProgramData\OCSentinel\upload-queue`.
|
||||||
|
The next scheduled run sends queued payloads before its new report. The local
|
||||||
|
health state is stored under `C:\ProgramData\OCSentinel\state`.
|
||||||
|
|
||||||
|
Create these additional device custom fields in NinjaOne and allow automation
|
||||||
|
write access:
|
||||||
|
|
||||||
|
| Field name | Type | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ocsentineluploadstatus` | Text | `ok`, `queued`, or `unknown` upload state |
|
||||||
|
| `ocsentinelqueuedreports` | Integer | Reports waiting for delivery |
|
||||||
|
| `ocsentinellastuploadutc` | Date/Time | Last successful upload time |
|
||||||
|
| `ocsentinellasterror` | Text | Last upload error, if any |
|
||||||
|
| `ocsentinelclientversion` | Text | Installed client version |
|
||||||
|
|
||||||
## NinjaOne Tasks
|
## NinjaOne Tasks
|
||||||
|
|
||||||
Create a PowerShell script in NinjaOne named `OCSentinel - Installieren oder aktualisieren`.
|
Create a PowerShell script in NinjaOne named `OCSentinel - Installieren oder aktualisieren`.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ For the isolated development environment only, HTTP is permitted at
|
|||||||
`http://172.16.41.197:5678/webhook/ocsentinel-ingest`. Do not reuse this URL,
|
`http://172.16.41.197:5678/webhook/ocsentinel-ingest`. Do not reuse this URL,
|
||||||
the development shared secret, or a disabled-TLS configuration in production.
|
the development shared secret, or a disabled-TLS configuration in production.
|
||||||
|
|
||||||
1. `Webhook`: accept `POST` on the configured private URL.
|
1. `Webhook`: accept `POST` on the configured private URL and enable **Raw Body**.
|
||||||
2. `Code`: reject a request if `X-ATN-Device`, `X-ATN-Timestamp`,
|
2. `Code`: reject a request if `X-ATN-Device`, `X-ATN-Timestamp`,
|
||||||
`X-ATN-Nonce`, `X-ATN-Version`, `X-ATN-Payload-SHA256`, or
|
`X-ATN-Nonce`, `X-ATN-Version`, `X-ATN-Payload-SHA256`, or
|
||||||
`X-ATN-Signature` is missing; reject timestamps outside five minutes.
|
`X-ATN-Signature` is missing; reject timestamps outside five minutes.
|
||||||
@@ -49,6 +49,11 @@ the development shared secret, or a disabled-TLS configuration in production.
|
|||||||
<device>\n<timestamp>\n<nonce>\n<version>\n<payload-sha256>
|
<device>\n<timestamp>\n<nonce>\n<version>\n<payload-sha256>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In the current n8n Webhook node, the raw bytes are exposed as Base64 at
|
||||||
|
`$binary.data.data`. Decode this value before calculating the payload hash.
|
||||||
|
Do not hash `JSON.stringify($json.body)`: parsing and reserializing JSON
|
||||||
|
changes whitespace and can change the signed byte sequence.
|
||||||
|
|
||||||
4. `Postgres`: insert the nonce into `ocsentinel.ingest_nonce` with a short
|
4. `Postgres`: insert the nonce into `ocsentinel.ingest_nonce` with a short
|
||||||
expiry. If it already exists, return `409` and do not process the report.
|
expiry. If it already exists, return `409` and do not process the report.
|
||||||
5. `Postgres`: upsert the device, insert a row in `ocsentinel.scan_report`,
|
5. `Postgres`: upsert the device, insert a row in `ocsentinel.scan_report`,
|
||||||
|
|||||||
6
infra/debug-dashboard/.env.example
Normal file
6
infra/debug-dashboard/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
DB_HOST=ocsentinel-postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=ocsentinel
|
||||||
|
DB_USER=ocsentinel_debug
|
||||||
|
DB_PASSWORD=replace-with-server-generated-password
|
||||||
|
DASHBOARD_CSRF_SECRET=replace-with-server-generated-secret
|
||||||
15
infra/debug-dashboard/Dockerfile
Normal file
15
infra/debug-dashboard/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
COPY templates ./templates
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
RUN addgroup -S ocsentinel && adduser -S ocsentinel -G ocsentinel
|
||||||
|
USER ocsentinel
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--threads", "4", "--timeout", "30", "app:app"]
|
||||||
301
infra/debug-dashboard/app.py
Normal file
301
infra/debug-dashboard/app.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from flask import Flask, abort, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
CURRENT_EVENT_HOURS = 24
|
||||||
|
STALE_REPORT_HOURS = 36
|
||||||
|
|
||||||
|
|
||||||
|
def db_connection():
|
||||||
|
return psycopg.connect(
|
||||||
|
host=os.environ["DB_HOST"],
|
||||||
|
port=os.getenv("DB_PORT", "5432"),
|
||||||
|
dbname=os.environ["DB_NAME"],
|
||||||
|
user=os.environ["DB_USER"],
|
||||||
|
password=os.environ["DB_PASSWORD"],
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_token():
|
||||||
|
secret = os.environ["DASHBOARD_CSRF_SECRET"].encode("utf-8")
|
||||||
|
return hmac.new(secret, b"recipient-rules", hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def require_csrf():
|
||||||
|
supplied = request.form.get("csrf_token", "")
|
||||||
|
if not hmac.compare_digest(supplied, csrf_token()):
|
||||||
|
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("/")
|
||||||
|
def overview():
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT * FROM ocsentinel.organization_summary")
|
||||||
|
summary = cursor.fetchone()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, organization_name, received_at, alert_state,
|
||||||
|
total_events, unique_ip_count, cve_total, cve_critical, payload
|
||||||
|
FROM (
|
||||||
|
SELECT machine_name, received_at, alert_state, total_events,
|
||||||
|
unique_ip_count, cve_total, cve_critical,
|
||||||
|
payload #>> '{NinjaOne,OrganizationName}' AS organization_name,
|
||||||
|
payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
) AS status
|
||||||
|
ORDER BY received_at DESC NULLS LAST
|
||||||
|
LIMIT 100
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, alert_state, total_events, unique_ip_count,
|
||||||
|
received_at, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE alert_state IN ('warning', 'critical')
|
||||||
|
ORDER BY CASE alert_state WHEN 'critical' THEN 0 ELSE 1 END, received_at DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
alerts = cursor.fetchall()
|
||||||
|
|
||||||
|
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>")
|
||||||
|
def device(machine_name):
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT machine_name, first_seen_at, last_seen_at, last_client_version,
|
||||||
|
generated_at_utc, received_at, alert_state, base_alert_state,
|
||||||
|
total_events, unique_ip_count, cve_total, cve_critical, payload
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE machine_name = %s
|
||||||
|
""",
|
||||||
|
(machine_name,),
|
||||||
|
)
|
||||||
|
report = cursor.fetchone()
|
||||||
|
|
||||||
|
if report is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
payload = report[12]
|
||||||
|
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")
|
||||||
|
def reports():
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, organization_name, period_start_utc, period_end_utc,
|
||||||
|
generated_at, device_count, warning_count, critical_count,
|
||||||
|
total_events
|
||||||
|
FROM ocsentinel.weekly_organization_report
|
||||||
|
ORDER BY period_end_utc DESC, organization_name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
weekly_reports = cursor.fetchall()
|
||||||
|
|
||||||
|
return render_template("reports.html", reports=weekly_reports)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reports/<int:report_id>")
|
||||||
|
def weekly_report(report_id):
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT organization_name, period_start_utc, period_end_utc,
|
||||||
|
generated_at, report_html
|
||||||
|
FROM ocsentinel.weekly_organization_report
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(report_id,),
|
||||||
|
)
|
||||||
|
report = cursor.fetchone()
|
||||||
|
|
||||||
|
if report is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
return render_template("weekly_report.html", report=report)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/recipients")
|
||||||
|
def recipients():
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, organization_id, organization_name, recipient_email, enabled
|
||||||
|
FROM ocsentinel.organization_report_recipient
|
||||||
|
ORDER BY organization_id = '*', organization_name, recipient_email
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rules = cursor.fetchall()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT payload #>> '{NinjaOne,OrganizationId}',
|
||||||
|
payload #>> '{NinjaOne,OrganizationName}'
|
||||||
|
FROM ocsentinel.current_device_status
|
||||||
|
WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', '') <> ''
|
||||||
|
ORDER BY 2
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
organizations = cursor.fetchall()
|
||||||
|
|
||||||
|
return render_template("recipients.html", rules=rules, organizations=organizations, csrf_token=csrf_token())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/recipients")
|
||||||
|
def add_recipient():
|
||||||
|
require_csrf()
|
||||||
|
organization_id = request.form.get("organization_id", "").strip()
|
||||||
|
organization_name = request.form.get("organization_name", "").strip()
|
||||||
|
recipient_email = request.form.get("recipient_email", "").strip().lower()
|
||||||
|
if not organization_id or not organization_name or "@" not in recipient_email:
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ocsentinel.organization_report_recipient
|
||||||
|
(organization_id, organization_name, recipient_email)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (organization_id, recipient_email) DO NOTHING
|
||||||
|
""",
|
||||||
|
(organization_id, organization_name, recipient_email),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
return redirect(url_for("recipients"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/recipients/<int:rule_id>/toggle")
|
||||||
|
def toggle_recipient(rule_id):
|
||||||
|
require_csrf()
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE ocsentinel.organization_report_recipient SET enabled = NOT enabled WHERE id = %s",
|
||||||
|
(rule_id,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
return redirect(url_for("recipients"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/recipients/<int:rule_id>/delete")
|
||||||
|
def delete_recipient(rule_id):
|
||||||
|
require_csrf()
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute("DELETE FROM ocsentinel.organization_report_recipient WHERE id = %s", (rule_id,))
|
||||||
|
connection.commit()
|
||||||
|
return redirect(url_for("recipients"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
try:
|
||||||
|
with db_connection() as connection, connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
return {"status": "ok"}
|
||||||
|
except Exception:
|
||||||
|
return {"status": "unavailable"}, 503
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8080)
|
||||||
22
infra/debug-dashboard/compose.yml
Normal file
22
infra/debug-dashboard/compose.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
ocsentinel-debug:
|
||||||
|
build: .
|
||||||
|
container_name: ocsentinel-debug
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "172.16.41.197:8090:8080"
|
||||||
|
networks:
|
||||||
|
- ocsentinel-network
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ocsentinel-network:
|
||||||
|
external: true
|
||||||
|
name: n8n_n8n-network
|
||||||
3
infra/debug-dashboard/requirements.txt
Normal file
3
infra/debug-dashboard/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.1.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
16
infra/debug-dashboard/static/app.css
Normal file
16
infra/debug-dashboard/static/app.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
:root { --ink:#17201d; --muted:#66736d; --paper:#f5f3eb; --panel:#fffdf7; --line:#d8d4c6; --green:#236342; --lime:#c7ee6b; --amber:#b86613; --red:#a8342b; }
|
||||||
|
* { 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:'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: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 '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: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 '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 '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; }.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; }
|
||||||
|
.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); }
|
||||||
|
.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 '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) { .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; } }
|
||||||
18
infra/debug-dashboard/templates/base.html
Normal file
18
infra/debug-dashboard/templates/base.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<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') }}">
|
||||||
|
</head>
|
||||||
|
<body class="app-shell">
|
||||||
|
<header class="masthead">
|
||||||
|
<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>
|
||||||
|
</header>
|
||||||
|
<main>{% block content %}{% endblock %}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
infra/debug-dashboard/templates/device.html
Normal file
7
infra/debug-dashboard/templates/device.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel"><div class="panel-heading"><h2>{{ report[0] }}</h2><span class="state {{ report[6] }}">{{ report[6] }}</span>{% if report[8] %}<span class="state {{ 'current' if event.is_current else 'historic' }}">{{ 'aktuell' if event.is_current else 'historisch' }}: {{ event.label }}</span>{% endif %}</div></section>
|
||||||
|
<section class="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"><pre>{{ payload_pretty }}</pre></section>
|
||||||
|
{% endblock %}
|
||||||
39
infra/debug-dashboard/templates/overview.html
Normal file
39
infra/debug-dashboard/templates/overview.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="situation {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="metrics">
|
||||||
|
<article><span>Geraete</span><strong>{{ summary[0] }}</strong></article>
|
||||||
|
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong></article>
|
||||||
|
<article><span>Kritisch</span><strong class="critical">{{ summary[2] }}</strong></article>
|
||||||
|
<article><span>Ereignisse</span><strong>{{ summary[3] }}</strong></article>
|
||||||
|
<article><span>Letzte Meldung</span><strong class="timestamp">{{ summary[7] or '-' }}</strong></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel coverage-panel">
|
||||||
|
<div class="panel-heading"><h2>Geraeteabdeckung</h2></div>
|
||||||
|
<div class="coverage-metrics"><article><span>Bekannt</span><strong>{{ coverage[0] }}</strong></article><article><span>Meldend < 36 Std.</span><strong class="ok">{{ coverage[1] }}</strong></article><article><span>Stumm > 36 Std.</span><strong class="{% if coverage[2] %}warning{% endif %}">{{ coverage[2] }}</strong></article></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if alerts %}
|
||||||
|
<section class="panel alert-panel">
|
||||||
|
<div class="panel-heading"><h2>Auffaellige Geraete <small>{{ current_alert_count }} aktuell, {{ alerts|length - current_alert_count }} historisch</small></h2></div>
|
||||||
|
<div class="alert-grid">
|
||||||
|
{% for alert in alerts %}
|
||||||
|
<a class="alert-card {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
|
||||||
|
<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>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
{% endblock %}
|
||||||
17
infra/debug-dashboard/templates/recipients.html
Normal file
17
infra/debug-dashboard/templates/recipients.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Empfaenger - OC Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel"><div class="panel-heading"><h2>Empfaenger hinzufuegen</h2></div>
|
||||||
|
<form class="recipient-form" method="post" action="{{ url_for('add_recipient') }}">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form></section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{% 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>
|
||||||
|
{% endblock %}
|
||||||
7
infra/debug-dashboard/templates/reports.html
Normal file
7
infra/debug-dashboard/templates/reports.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Berichte - OC Sentinel{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel"><div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Zeitraum</th><th>Geraete</th><th>Warnung</th><th>Kritisch</th><th>Events</th><th>Erstellt</th></tr></thead><tbody>
|
||||||
|
{% for row in reports %}<tr><td><a href="{{ url_for('weekly_report', report_id=row[0]) }}">{{ row[1] }}</a></td><td>{{ row[2] }} bis {{ row[3] }}</td><td>{{ row[5] }}</td><td>{{ row[6] }}</td><td>{{ row[7] }}</td><td>{{ row[8] }}</td><td>{{ row[4] }}</td></tr>{% else %}<tr><td colspan="7">Keine Wochenberichte.</td></tr>{% endfor %}
|
||||||
|
</tbody></table></div></section>
|
||||||
|
{% endblock %}
|
||||||
5
infra/debug-dashboard/templates/weekly_report.html
Normal file
5
infra/debug-dashboard/templates/weekly_report.html
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ report[0] }} - Wochenbericht{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="report-frame">{{ report[4] | safe }}</section>
|
||||||
|
{% endblock %}
|
||||||
92
infra/n8n/ocsentinel-weekly-organization-reports.json
Normal file
92
infra/n8n/ocsentinel-weekly-organization-reports.json
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"id": "OCwRpt7eK3mQ2xL9",
|
||||||
|
"name": "OCSentinel - Weekly Organization Reports",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": { "rule": { "interval": [{ "field": "weeks", "weeksInterval": 1, "triggerAtDay": [1], "triggerAtHour": 7, "triggerAtMinute": 20 }] } },
|
||||||
|
"id": "schedule-weekly-reports", "name": "Every Monday 07:20", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [300, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": { "operation": "executeQuery", "query": "WITH latest AS (\n SELECT DISTINCT ON (d.id) d.machine_name, r.alert_state, r.payload\n FROM ocsentinel.scan_report AS r\n JOIN ocsentinel.device AS d ON d.id = r.device_id\n WHERE r.received_at >= now() - interval '8 days'\n ORDER BY d.id, r.generated_at_utc DESC, r.received_at DESC\n)\nSELECT machine_name, alert_state, payload\nFROM latest\nORDER BY payload #>> '{NinjaOne,OrganizationName}', machine_name;" },
|
||||||
|
"id": "load-weekly-data", "name": "Load Latest Device Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [560, 300],
|
||||||
|
"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": { "jsCode": "const esc=v=>String(v??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\\\"/g,'"').replace(/'/g,''');\nconst now=new Date(),end=new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate()));\nend.setUTCDate(end.getUTCDate()-((end.getUTCDay()+6)%7)); const start=new Date(end-7*86400000);\nconst groups=new Map();\nfor(const item of items){const p=item.json.payload||{},n=p.NinjaOne||p.ninjaOne||{},id=String(n.OrganizationId||n.organizationId||'unknown'),name=String(n.OrganizationName||n.organizationName||`Organisation ${id}`);if(!groups.has(id))groups.set(id,{id,name,devices:[]});groups.get(id).devices.push({machine:item.json.machine_name||p.MachineName||'Unbekannt',state:String(item.json.alert_state||p.AlertState||'unknown').toLowerCase(),p});}\nconst result=[];\nfor(const group of groups.values()){let warnings=0,criticals=0,total=0,cveTotal=0,cveCritical=0;const ips=new Set(),alerts=[],clean=[];for(const d of group.devices){if(d.state==='warning')warnings++;if(d.state==='critical')criticals++;const vc=d.p.VulnerabilityCorrelation||{};cveTotal+=Number(vc.TotalCount||0);cveCritical+=Number(vc.CriticalCount||0);const events=(d.p.Events||[]).filter(e=>{const t=new Date(e.Timestamp);return !Number.isNaN(t)&&t>=start&&t<end;});total+=events.length;if(!events.length){clean.push(`<tr class=\"clean\"><td>${esc(d.machine)}</td><td>Keine</td><td>-</td><td>-</td><td>0</td><td>-</td><td>Log sauber / Keine Angriffe</td></tr>`);continue;}const rows=new Map();for(const e of events){const ip=e.SourceIp||'-',account=e.Username||'-',type=e.Target||'Sicherheitsereignis',key=[type,account,ip].join('|'),row=rows.get(key)||{ip,account,type,count:0,last:e.Timestamp};row.count++;if(new Date(e.Timestamp)>new Date(row.last))row.last=e.Timestamp;rows.set(key,row);if(ip!=='-')ips.add(ip);}for(const r of rows.values()){alerts.push(`<tr class=\"alert\"><td>${esc(d.machine)}</td><td>${esc(r.type)}</td><td>${esc(r.account)}</td><td>${esc(new Date(r.last).toLocaleString('de-DE',{timeZone:'Europe/Berlin'}))}</td><td>${r.count}</td><td>${esc(r.ip)}</td><td>${d.state==='critical'?'Problem entdeckt (kritisch)':'Problem entdeckt'}</td></tr>`);}}const summary={deviceCount:group.devices.length,warningCount:warnings,criticalCount:criticals,totalEvents:total,uniqueIps:ips.size,cveTotal,cveCritical};const html=`<div class=\"ocsentinel-report\"><style>.ocsentinel-report{font-family:Arial,sans-serif;font-size:12px;color:#1f2937;max-width:1200px}.ocsentinel-report h2{font-size:16px;color:#183b79;margin:0 0 5px;border-bottom:1px solid #183b79;padding-bottom:5px}.ocsentinel-report .meta{font-size:11px;color:#4b5563;margin-bottom:12px}.ocsentinel-report .summary{margin:10px 0;padding:8px;background:#eef5ff;border:1px solid #bfd4f2;color:#183b79}.ocsentinel-report table{width:100%;border-collapse:collapse}.ocsentinel-report th{background:#1f4a99;color:#fff;text-align:left;padding:6px;font-size:11px}.ocsentinel-report td{border:1px solid #e5e7eb;padding:6px;vertical-align:top}.ocsentinel-report tr.alert{background:#fff4ed;color:#8a3d16}.ocsentinel-report tr.clean{background:#effcf4;color:#17643a}.ocsentinel-report .footer{margin-top:10px;font-size:10px;color:#6b7280;text-align:right}</style><h2>OCSentinel - Konsolidierter Sicherheitsbericht</h2><div class=\"meta\"><strong>${esc(group.name)}</strong><br>Berichtszeitraum: ${start.toLocaleDateString('de-DE')} bis ${end.toLocaleDateString('de-DE')}<br>Erstellt am: ${now.toLocaleString('de-DE',{timeZone:'Europe/Berlin'})}</div><div class=\"summary\"><strong>${summary.deviceCount} Geräte</strong> | <strong>${summary.criticalCount} kritisch</strong> | <strong>${summary.warningCount} Warnungen</strong> | <strong>${summary.totalEvents} Ereignisse</strong> | <strong>${summary.uniqueIps} IPs</strong> | <strong>${summary.cveCritical} kritische CVEs</strong></div><table><thead><tr><th>Server</th><th>Vorfall-Typ</th><th>Betroffenes Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Angreifer-IP</th><th>Status / Bemerkung</th></tr></thead><tbody>${alerts.join('')}${clean.join('')}</tbody></table><div class=\"footer\">Automatisch durch OCSentinel und n8n erzeugt.</div></div>`;result.push({json:{organizationId:group.id,organizationName:group.name,periodStartUtc:start.toISOString(),periodEndUtc:end.toISOString(),...summary,reportHtml:html,summaryJson:JSON.stringify(summary)}});}return result;" },
|
||||||
|
"id": "build-weekly-reports", "name": "Build Organization HTML Reports", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [820, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": { "operation": "executeQuery", "query": "INSERT INTO ocsentinel.weekly_organization_report (organization_id, organization_name, period_start_utc, period_end_utc, device_count, warning_count, critical_count, total_events, unique_ips, cve_total, cve_critical, report_html, summary)\nVALUES ($1, $2, $3::timestamptz, $4::timestamptz, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)\nON CONFLICT (organization_id, period_start_utc) DO UPDATE SET organization_name=EXCLUDED.organization_name, period_end_utc=EXCLUDED.period_end_utc, generated_at=now(), device_count=EXCLUDED.device_count, warning_count=EXCLUDED.warning_count, critical_count=EXCLUDED.critical_count, total_events=EXCLUDED.total_events, unique_ips=EXCLUDED.unique_ips, cve_total=EXCLUDED.cve_total, cve_critical=EXCLUDED.cve_critical, report_html=EXCLUDED.report_html, summary=EXCLUDED.summary\nRETURNING id;", "options": { "queryReplacement": "={{ [$json.organizationId, $json.organizationName, $json.periodStartUtc, $json.periodEndUtc, $json.deviceCount, $json.warningCount, $json.criticalCount, $json.totalEvents, $json.uniqueIps, $json.cveTotal, $json.cveCritical, $json.reportHtml, $json.summaryJson] }}" } },
|
||||||
|
"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" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"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);",
|
||||||
|
"options": { "queryReplacement": "={{ [ $('Build Organization HTML Reports').item.json.organizationId ] }}" }
|
||||||
|
},
|
||||||
|
"id": "load-report-recipients",
|
||||||
|
"name": "Empfaenger aus zentraler Zuordnung laden",
|
||||||
|
"type": "n8n-nodes-base.postgres",
|
||||||
|
"typeVersion": 2.5,
|
||||||
|
"position": [1560, 300]
|
||||||
|
,"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 } }];"
|
||||||
|
},
|
||||||
|
"id": "prepare-report-email",
|
||||||
|
"name": "E-Mail vorbereiten",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [1800, 300]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"fromEmail": "donotreply@officecom.biz",
|
||||||
|
"toEmail": "={{ $json.recipients.join(', ') }}",
|
||||||
|
"subject": "=OCSentinel Wochenbericht - {{ $json.organizationName }}",
|
||||||
|
"html": "={{ $json.reportHtml }}",
|
||||||
|
"options": { "appendAttribution": false }
|
||||||
|
},
|
||||||
|
"id": "send-weekly-report-email",
|
||||||
|
"name": "Send Weekly Organization Report",
|
||||||
|
"type": "n8n-nodes-base.emailSend",
|
||||||
|
"typeVersion": 2.1,
|
||||||
|
"position": [2040, 300],
|
||||||
|
"credentials": { "smtp": { "id": "vafGYgYzM9SbxQbW", "name": "SMTP account" } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"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 }]] },
|
||||||
|
"Build Organization HTML Reports": { "main": [[{ "node": "Store Weekly Organization Reports", "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 }]] },
|
||||||
|
"E-Mail vorbereiten": { "main": [[{ "node": "Send Weekly Organization Report", "type": "main", "index": 0 }]] }
|
||||||
|
},
|
||||||
|
"settings": { "executionOrder": "v1", "timezone": "Europe/Berlin" },
|
||||||
|
"active": true,
|
||||||
|
"pinData": {},
|
||||||
|
"versionId": "af98f45b-192e-49c8-9a1e-e7b1fc7e00b2",
|
||||||
|
"meta": { "templateCredsSetupCompleted": true },
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
@@ -47,6 +47,28 @@ CREATE TABLE IF NOT EXISTS ocsentinel.ingest_nonce (
|
|||||||
CREATE INDEX IF NOT EXISTS ix_ocsentinel_ingest_nonce_expires
|
CREATE INDEX IF NOT EXISTS ix_ocsentinel_ingest_nonce_expires
|
||||||
ON ocsentinel.ingest_nonce (expires_at);
|
ON ocsentinel.ingest_nonce (expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ocsentinel.weekly_organization_report (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
organization_name TEXT NOT NULL,
|
||||||
|
period_start_utc TIMESTAMPTZ NOT NULL,
|
||||||
|
period_end_utc TIMESTAMPTZ NOT NULL,
|
||||||
|
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
device_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
warning_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
critical_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_events INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unique_ips INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cve_total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cve_critical INTEGER NOT NULL DEFAULT 0,
|
||||||
|
report_html TEXT NOT NULL,
|
||||||
|
summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
UNIQUE (organization_id, period_start_utc)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_ocsentinel_weekly_report_organization_generated
|
||||||
|
ON ocsentinel.weekly_organization_report (organization_id, generated_at DESC);
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW ocsentinel.current_device_status AS
|
CREATE OR REPLACE VIEW ocsentinel.current_device_status AS
|
||||||
SELECT DISTINCT ON (d.id)
|
SELECT DISTINCT ON (d.id)
|
||||||
d.machine_name,
|
d.machine_name,
|
||||||
|
|||||||
25
infra/postgres/002_organization_report_recipients.sql
Normal file
25
infra/postgres/002_organization_report_recipients.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Central recipient rules for OCSentinel weekly organization reports.
|
||||||
|
-- The '*' organization ID applies to every organization.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ocsentinel.organization_report_recipient (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
organization_name TEXT NOT NULL,
|
||||||
|
recipient_email TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (organization_id, recipient_email)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO ocsentinel.organization_report_recipient
|
||||||
|
(organization_id, organization_name, recipient_email)
|
||||||
|
VALUES
|
||||||
|
('*', 'Alle Organisationen', 'lg@officecom.it'),
|
||||||
|
('*', 'Alle Organisationen', 'rk@officecom.biz'),
|
||||||
|
('9', 'Mildenberger Verlag', 'dd@officecom.it'),
|
||||||
|
('16', 'Kirsch GmbH', 'dd@officecom.it')
|
||||||
|
ON CONFLICT (organization_id, recipient_email) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -32,6 +32,7 @@ if (Test-Path (Join-Path $packageRoot "config\ocsentinel-client.dev.example.json
|
|||||||
Copy-Item -Path (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $samplesRoot "ninja-vulnerability-export.example.csv") -Force
|
Copy-Item -Path (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $samplesRoot "ninja-vulnerability-export.example.csv") -Force
|
||||||
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Destination $scriptRoot -Force
|
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Destination $scriptRoot -Force
|
||||||
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Destination $scriptRoot -Force
|
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Destination $scriptRoot -Force
|
||||||
|
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-scheduled.ps1") -Destination $scriptRoot -Force
|
||||||
if (Test-Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1")) {
|
if (Test-Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1")) {
|
||||||
Copy-Item -Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Destination $scriptRoot -Force
|
Copy-Item -Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Destination $scriptRoot -Force
|
||||||
}
|
}
|
||||||
@@ -69,8 +70,34 @@ Set-ItemProperty -Path $uninstallKey -Name "QuietUninstallString" -Value $uninst
|
|||||||
Set-ItemProperty -Path $uninstallKey -Name "NoModify" -Value 1 -Type DWord
|
Set-ItemProperty -Path $uninstallKey -Name "NoModify" -Value 1 -Type DWord
|
||||||
Set-ItemProperty -Path $uninstallKey -Name "NoRepair" -Value 1 -Type DWord
|
Set-ItemProperty -Path $uninstallKey -Name "NoRepair" -Value 1 -Type DWord
|
||||||
|
|
||||||
|
$scheduledScript = Join-Path $scriptRoot "run-ocsentinel-scheduled.ps1"
|
||||||
|
$taskPrincipal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||||
|
$taskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -MultipleInstances IgnoreNew
|
||||||
|
|
||||||
|
# Spread fleet uploads across the early-morning window while keeping each device's slot stable.
|
||||||
|
$machineGuid = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Cryptography" -Name "MachineGuid").MachineGuid
|
||||||
|
$guidBytes = [Text.Encoding]::UTF8.GetBytes([string]$machineGuid)
|
||||||
|
$sha256 = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try {
|
||||||
|
$slotHash = $sha256.ComputeHash($guidBytes)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$sha256.Dispose()
|
||||||
|
}
|
||||||
|
$dailySlotMinutes = [BitConverter]::ToUInt32($slotHash, 0) % 180
|
||||||
|
$dailyRunAt = (Get-Date -Hour 4 -Minute 0 -Second 0).AddMinutes($dailySlotMinutes)
|
||||||
|
|
||||||
|
$dailyAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scheduledScript`" -Kind daily" -WorkingDirectory $scriptRoot
|
||||||
|
$dailyTrigger = New-ScheduledTaskTrigger -Daily -At $dailyRunAt
|
||||||
|
Register-ScheduledTask -TaskName "OCSentinel Daily Scan" -Action $dailyAction -Trigger $dailyTrigger -Principal $taskPrincipal -Settings $taskSettings -Description "OfficeCom Sentinel daily signed scan and upload." -Force | Out-Null
|
||||||
|
|
||||||
|
$burstAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scheduledScript`" -Kind burst" -WorkingDirectory $scriptRoot
|
||||||
|
$burstTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
|
||||||
|
Register-ScheduledTask -TaskName "OCSentinel Burst Check" -Action $burstAction -Trigger $burstTrigger -Principal $taskPrincipal -Settings $taskSettings -Description "OfficeCom Sentinel burst check; scans only when Ninja field ocsentinelburst is enabled." -Force | Out-Null
|
||||||
|
|
||||||
Write-Host "Installation complete."
|
Write-Host "Installation complete."
|
||||||
Write-Host "Main path: $installRoot"
|
Write-Host "Main path: $installRoot"
|
||||||
Write-Host "Runner: $(Join-Path $scriptRoot 'run-ocsentinel.ps1')"
|
Write-Host "Runner: $(Join-Path $scriptRoot 'run-ocsentinel.ps1')"
|
||||||
Write-Host "Monitor: $(Join-Path $scriptRoot 'run-ocsentinel-monitor.ps1')"
|
Write-Host "Monitor: $(Join-Path $scriptRoot 'run-ocsentinel-monitor.ps1')"
|
||||||
Write-Host "Updater: $(Join-Path $scriptRoot 'update-ocsentinel.ps1')"
|
Write-Host "Updater: $(Join-Path $scriptRoot 'update-ocsentinel.ps1')"
|
||||||
|
Write-Host "Schedule: Daily scan at $($dailyRunAt.ToString('HH:mm')) (deterministic 04:00-06:59 slot); burst check every 5 minutes."
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ param(
|
|||||||
[string]$UploadMode = "auto",
|
[string]$UploadMode = "auto",
|
||||||
[string]$VulnerabilityCsvPath = "",
|
[string]$VulnerabilityCsvPath = "",
|
||||||
[string]$MirrorRoot = "",
|
[string]$MirrorRoot = "",
|
||||||
|
[switch]$SuppressTriggerExit,
|
||||||
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
||||||
[string]$Mode = "status"
|
[string]$Mode = "status"
|
||||||
)
|
)
|
||||||
@@ -46,8 +47,13 @@ function Initialize-NinjaFieldWriter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
$script:NinjaFieldBackend = "powershell-modern"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||||
$script:NinjaFieldBackend = "powershell"
|
$script:NinjaFieldBackend = "powershell-legacy"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +70,20 @@ function Set-NinjaCustomFieldValue {
|
|||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$Name,
|
[string]$Name,
|
||||||
[AllowEmptyString()]
|
[AllowEmptyString()]
|
||||||
[string]$Value
|
[object]$Value,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Type
|
||||||
)
|
)
|
||||||
|
|
||||||
Initialize-NinjaFieldWriter
|
Initialize-NinjaFieldWriter
|
||||||
|
|
||||||
switch ($script:NinjaFieldBackend) {
|
switch ($script:NinjaFieldBackend) {
|
||||||
"powershell" {
|
"powershell-modern" {
|
||||||
Ninja-Property-Set $Name $Value | Out-Null
|
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
"powershell-legacy" {
|
||||||
|
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
"cli" {
|
"cli" {
|
||||||
@@ -112,28 +124,43 @@ function Publish-NinjaCustomFields {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValues = [ordered]@{
|
$uploadStatus = [string]$Report.Runtime.UploadStatus
|
||||||
"ocsentinelstatus" = [string]$Report.AlertState
|
if ([string]::IsNullOrWhiteSpace($uploadStatus)) { $uploadStatus = "unknown" }
|
||||||
"ocsentinelreason" = $Reason
|
$queuedReports = [int]$Report.Runtime.QueuedReportCount
|
||||||
"ocsentinelbasestatus" = [string]$Report.BaseAlertState
|
$lastUploadUtc = ""
|
||||||
"ocsentinelevents" = [string]([int]$Report.TotalEvents)
|
if ($Report.Runtime.LastSuccessfulUploadUtc) {
|
||||||
"ocsentineluniqueips" = [string]([int]$Report.UniqueIpCount)
|
try { $lastUploadUtc = ([DateTimeOffset]$Report.Runtime.LastSuccessfulUploadUtc).ToUniversalTime().ToString("o") } catch { $lastUploadUtc = [string]$Report.Runtime.LastSuccessfulUploadUtc }
|
||||||
"ocsentinelcvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount)
|
|
||||||
"ocsentinelcvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount)
|
|
||||||
"ocsentinelmode" = $Mode
|
|
||||||
"ocsentineltriggered" = $Triggered.ToString().ToLowerInvariant()
|
|
||||||
"ocsentinellastscanutc" = $generatedAtUtc
|
|
||||||
}
|
}
|
||||||
|
$lastUploadError = [string]$Report.Runtime.LastUploadError
|
||||||
|
if ($lastUploadError.Length -gt 900) { $lastUploadError = $lastUploadError.Substring(0, 900) }
|
||||||
|
|
||||||
|
$fieldValues = @(
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelstatus"; Type = "Text"; Value = [string]$Report.AlertState }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelreason"; Type = "Text"; Value = $Reason }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelbasestatus"; Type = "Text"; Value = [string]$Report.BaseAlertState }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelevents"; Type = "Integer"; Value = [int]$Report.TotalEvents }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineluniqueips"; Type = "Integer"; Value = [int]$Report.UniqueIpCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelcvecritical"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.CriticalCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelcvetotal"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.TotalCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelmode"; Type = "Text"; Value = $Mode }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineltriggered"; Type = "Checkbox"; Value = $Triggered }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellastscanutc"; Type = "DateTime"; Value = $generatedAtUtc }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineluploadstatus"; Type = "Text"; Value = $uploadStatus }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelqueuedreports"; Type = "Integer"; Value = $queuedReports }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellastuploadutc"; Type = "DateTime"; Value = $lastUploadUtc }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellasterror"; Type = "Text"; Value = $lastUploadError }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelclientversion"; Type = "Text"; Value = [string]$Report.ClientVersion }
|
||||||
|
)
|
||||||
|
|
||||||
$updated = 0
|
$updated = 0
|
||||||
foreach ($entry in $fieldValues.GetEnumerator()) {
|
foreach ($entry in $fieldValues) {
|
||||||
try {
|
try {
|
||||||
if (Set-NinjaCustomFieldValue -Name $entry.Key -Value $entry.Value) {
|
if (Set-NinjaCustomFieldValue -Name $entry.Name -Value $entry.Value -Type $entry.Type) {
|
||||||
$updated++
|
$updated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Warning "Failed to set Ninja custom field '$($entry.Key)': $($_.Exception.Message)"
|
Write-Warning "Failed to set Ninja custom field '$($entry.Name)': $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +205,8 @@ $events = [int]$report.TotalEvents
|
|||||||
$uniqueIps = [int]$report.UniqueIpCount
|
$uniqueIps = [int]$report.UniqueIpCount
|
||||||
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
||||||
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
||||||
|
$uploadStatus = [string]$report.Runtime.UploadStatus
|
||||||
|
$queuedReports = [int]$report.Runtime.QueuedReportCount
|
||||||
|
|
||||||
$monitorTriggered = $false
|
$monitorTriggered = $false
|
||||||
$monitorReason = ""
|
$monitorReason = ""
|
||||||
@@ -213,11 +242,13 @@ Write-Host "Events: $events"
|
|||||||
Write-Host "Unique IPs: $uniqueIps"
|
Write-Host "Unique IPs: $uniqueIps"
|
||||||
Write-Host "Critical/High CVEs: $criticalCves"
|
Write-Host "Critical/High CVEs: $criticalCves"
|
||||||
Write-Host "Total CVEs: $totalCves"
|
Write-Host "Total CVEs: $totalCves"
|
||||||
|
Write-Host "Upload status: $uploadStatus"
|
||||||
|
Write-Host "Queued reports: $queuedReports"
|
||||||
Write-Host "Report: $outputFullPath"
|
Write-Host "Report: $outputFullPath"
|
||||||
Write-Host "Runner exit code: $runnerExitCode"
|
Write-Host "Runner exit code: $runnerExitCode"
|
||||||
|
|
||||||
if ($monitorTriggered) {
|
if ($monitorTriggered -and -not $SuppressTriggerExit) {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
exit 0
|
exit $runnerExitCode
|
||||||
|
|||||||
116
installer/runtime-run-ocsentinel-scheduled.ps1
Normal file
116
installer/runtime-run-ocsentinel-scheduled.ps1
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("daily", "burst")]
|
||||||
|
[string]$Kind = "daily",
|
||||||
|
[ValidateRange(15, 480)]
|
||||||
|
[int]$BurstDurationMinutes = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$monitorScript = Join-Path $scriptDir "run-ocsentinel-monitor.ps1"
|
||||||
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
$mutexName = "Global\OfficeComSentinelScan"
|
||||||
|
|
||||||
|
function Get-NinjaBurstEnabled {
|
||||||
|
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
return [bool](Get-NinjaProperty -Name "ocsentinelburst" -Type "Checkbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
|
||||||
|
$value = Ninja-Property-Get -Name "ocsentinelburst"
|
||||||
|
return [string]$value -match "^(1|true|yes)$"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warning "Ninja custom-field reader is unavailable; burst scan skipped."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Type)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
return Get-NinjaProperty -Name $Name -Type $Type
|
||||||
|
}
|
||||||
|
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
|
||||||
|
return Ninja-Property-Get -Name $Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not read Ninja field '$Name': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Value, [Parameter(Mandatory)][string]$Type)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||||
|
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not update Ninja field '$Name': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Kind -eq "burst") {
|
||||||
|
if (-not (Get-NinjaBurstEnabled)) {
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "idle" -Type "Text" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst check: disabled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = [DateTimeOffset]::UtcNow
|
||||||
|
$untilValue = Get-NinjaValue -Name "ocsentinelburstuntilutc" -Type "DateTime"
|
||||||
|
$until = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$untilValue)) {
|
||||||
|
try { $until = [DateTimeOffset]$untilValue } catch { Write-Warning "Burst end time is invalid and will be restarted." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $until) {
|
||||||
|
$until = $now.AddMinutes($BurstDurationMinutes)
|
||||||
|
Set-NinjaValue -Name "ocsentinelburstuntilutc" -Value $until.ToString("o") -Type "DateTime" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst window started until $($until.ToString('u'))."
|
||||||
|
}
|
||||||
|
elseif ($until -le $now) {
|
||||||
|
Set-NinjaValue -Name "ocsentinelburst" -Value "false" -Type "Checkbox" | Out-Null
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "completed" -Type "Text" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst window completed and was disabled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "active until $($until.ToUniversalTime().ToString('o'))" -Type "Text" | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdNew = $false
|
||||||
|
$mutex = [Threading.Mutex]::new($false, $mutexName, [ref]$createdNew)
|
||||||
|
try {
|
||||||
|
if (-not $mutex.WaitOne(0)) {
|
||||||
|
Write-Host "OfficeCom Sentinel scan skipped: another scan is already running."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "OfficeCom Sentinel scheduled $Kind scan started."
|
||||||
|
$monitorArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $monitorScript, "-Mode", "status", "-UploadMode", "required", "-SecretPath", $secretPath, "-SuppressTriggerExit")
|
||||||
|
if ($Kind -eq "burst") {
|
||||||
|
$monitorArgs += @("-LookbackDays", "1", "-TopCount", "25")
|
||||||
|
}
|
||||||
|
& powershell.exe @monitorArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $mutex) {
|
||||||
|
try { $mutex.ReleaseMutex() } catch { }
|
||||||
|
$mutex.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ $ErrorActionPreference = "Stop"
|
|||||||
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
|
||||||
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OCSentinel"
|
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OCSentinel"
|
||||||
|
|
||||||
|
foreach ($taskName in @("OCSentinel Daily Scan", "OCSentinel Burst Check")) {
|
||||||
|
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Test-Path $uninstallKey) {
|
if (Test-Path $uninstallKey) {
|
||||||
Remove-Item -Path $uninstallKey -Force -Recurse
|
Remove-Item -Path $uninstallKey -Force -Recurse
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"version": "1.2.10",
|
"version": "1.3.6",
|
||||||
"publishedAtUtc": "2026-07-25T19:14:36.9210206Z",
|
"publishedAtUtc": "2026-07-27T08:45:53.0419623Z",
|
||||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.2.10/OCSentinelClient-win-x64.zip",
|
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.3.6/OCSentinelClient-win-x64.zip",
|
||||||
"sha256": "4de7abf96ff9ed47da02ac7f586cc39102fa508372825c2f7bf5e1df2b0f6c80",
|
"sha256": "48e9faa242a59797bed9732ae5b8914ab44843b9c949260a17bc15b47659a3d5",
|
||||||
"minUpdaterVersion": "1.0.0"
|
"minUpdaterVersion": "1.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ param(
|
|||||||
[string]$UploadMode = "auto",
|
[string]$UploadMode = "auto",
|
||||||
[string]$VulnerabilityCsvPath = "",
|
[string]$VulnerabilityCsvPath = "",
|
||||||
[string]$MirrorRoot = "",
|
[string]$MirrorRoot = "",
|
||||||
|
[switch]$SuppressTriggerExit,
|
||||||
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
||||||
[string]$Mode = "status"
|
[string]$Mode = "status"
|
||||||
)
|
)
|
||||||
@@ -46,8 +47,13 @@ function Initialize-NinjaFieldWriter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
$script:NinjaFieldBackend = "powershell-modern"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||||
$script:NinjaFieldBackend = "powershell"
|
$script:NinjaFieldBackend = "powershell-legacy"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,14 +70,20 @@ function Set-NinjaCustomFieldValue {
|
|||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$Name,
|
[string]$Name,
|
||||||
[AllowEmptyString()]
|
[AllowEmptyString()]
|
||||||
[string]$Value
|
[object]$Value,
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Type
|
||||||
)
|
)
|
||||||
|
|
||||||
Initialize-NinjaFieldWriter
|
Initialize-NinjaFieldWriter
|
||||||
|
|
||||||
switch ($script:NinjaFieldBackend) {
|
switch ($script:NinjaFieldBackend) {
|
||||||
"powershell" {
|
"powershell-modern" {
|
||||||
Ninja-Property-Set $Name $Value | Out-Null
|
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
"powershell-legacy" {
|
||||||
|
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
"cli" {
|
"cli" {
|
||||||
@@ -112,28 +124,43 @@ function Publish-NinjaCustomFields {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldValues = [ordered]@{
|
$uploadStatus = [string]$Report.Runtime.UploadStatus
|
||||||
"ocsentinelstatus" = [string]$Report.AlertState
|
if ([string]::IsNullOrWhiteSpace($uploadStatus)) { $uploadStatus = "unknown" }
|
||||||
"ocsentinelreason" = $Reason
|
$queuedReports = [int]$Report.Runtime.QueuedReportCount
|
||||||
"ocsentinelbasestatus" = [string]$Report.BaseAlertState
|
$lastUploadUtc = ""
|
||||||
"ocsentinelevents" = [string]([int]$Report.TotalEvents)
|
if ($Report.Runtime.LastSuccessfulUploadUtc) {
|
||||||
"ocsentineluniqueips" = [string]([int]$Report.UniqueIpCount)
|
try { $lastUploadUtc = ([DateTimeOffset]$Report.Runtime.LastSuccessfulUploadUtc).ToUniversalTime().ToString("o") } catch { $lastUploadUtc = [string]$Report.Runtime.LastSuccessfulUploadUtc }
|
||||||
"ocsentinelcvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount)
|
|
||||||
"ocsentinelcvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount)
|
|
||||||
"ocsentinelmode" = $Mode
|
|
||||||
"ocsentineltriggered" = $Triggered.ToString().ToLowerInvariant()
|
|
||||||
"ocsentinellastscanutc" = $generatedAtUtc
|
|
||||||
}
|
}
|
||||||
|
$lastUploadError = [string]$Report.Runtime.LastUploadError
|
||||||
|
if ($lastUploadError.Length -gt 900) { $lastUploadError = $lastUploadError.Substring(0, 900) }
|
||||||
|
|
||||||
|
$fieldValues = @(
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelstatus"; Type = "Text"; Value = [string]$Report.AlertState }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelreason"; Type = "Text"; Value = $Reason }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelbasestatus"; Type = "Text"; Value = [string]$Report.BaseAlertState }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelevents"; Type = "Integer"; Value = [int]$Report.TotalEvents }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineluniqueips"; Type = "Integer"; Value = [int]$Report.UniqueIpCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelcvecritical"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.CriticalCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelcvetotal"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.TotalCount }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelmode"; Type = "Text"; Value = $Mode }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineltriggered"; Type = "Checkbox"; Value = $Triggered }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellastscanutc"; Type = "DateTime"; Value = $generatedAtUtc }
|
||||||
|
[pscustomobject]@{ Name = "ocsentineluploadstatus"; Type = "Text"; Value = $uploadStatus }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelqueuedreports"; Type = "Integer"; Value = $queuedReports }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellastuploadutc"; Type = "DateTime"; Value = $lastUploadUtc }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinellasterror"; Type = "Text"; Value = $lastUploadError }
|
||||||
|
[pscustomobject]@{ Name = "ocsentinelclientversion"; Type = "Text"; Value = [string]$Report.ClientVersion }
|
||||||
|
)
|
||||||
|
|
||||||
$updated = 0
|
$updated = 0
|
||||||
foreach ($entry in $fieldValues.GetEnumerator()) {
|
foreach ($entry in $fieldValues) {
|
||||||
try {
|
try {
|
||||||
if (Set-NinjaCustomFieldValue -Name $entry.Key -Value $entry.Value) {
|
if (Set-NinjaCustomFieldValue -Name $entry.Name -Value $entry.Value -Type $entry.Type) {
|
||||||
$updated++
|
$updated++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Warning "Failed to set Ninja custom field '$($entry.Key)': $($_.Exception.Message)"
|
Write-Warning "Failed to set Ninja custom field '$($entry.Name)': $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +205,8 @@ $events = [int]$report.TotalEvents
|
|||||||
$uniqueIps = [int]$report.UniqueIpCount
|
$uniqueIps = [int]$report.UniqueIpCount
|
||||||
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
|
||||||
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
|
||||||
|
$uploadStatus = [string]$report.Runtime.UploadStatus
|
||||||
|
$queuedReports = [int]$report.Runtime.QueuedReportCount
|
||||||
|
|
||||||
$monitorTriggered = $false
|
$monitorTriggered = $false
|
||||||
$monitorReason = ""
|
$monitorReason = ""
|
||||||
@@ -213,11 +242,13 @@ Write-Host "Events: $events"
|
|||||||
Write-Host "Unique IPs: $uniqueIps"
|
Write-Host "Unique IPs: $uniqueIps"
|
||||||
Write-Host "Critical/High CVEs: $criticalCves"
|
Write-Host "Critical/High CVEs: $criticalCves"
|
||||||
Write-Host "Total CVEs: $totalCves"
|
Write-Host "Total CVEs: $totalCves"
|
||||||
|
Write-Host "Upload status: $uploadStatus"
|
||||||
|
Write-Host "Queued reports: $queuedReports"
|
||||||
Write-Host "Report: $outputFullPath"
|
Write-Host "Report: $outputFullPath"
|
||||||
Write-Host "Runner exit code: $runnerExitCode"
|
Write-Host "Runner exit code: $runnerExitCode"
|
||||||
|
|
||||||
if ($monitorTriggered) {
|
if ($monitorTriggered -and -not $SuppressTriggerExit) {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
exit 0
|
exit $runnerExitCode
|
||||||
|
|||||||
116
scripts/run-ocsentinel-scheduled.ps1
Normal file
116
scripts/run-ocsentinel-scheduled.ps1
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateSet("daily", "burst")]
|
||||||
|
[string]$Kind = "daily",
|
||||||
|
[ValidateRange(15, 480)]
|
||||||
|
[int]$BurstDurationMinutes = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$monitorScript = Join-Path $scriptDir "run-ocsentinel-monitor.ps1"
|
||||||
|
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||||
|
$mutexName = "Global\OfficeComSentinelScan"
|
||||||
|
|
||||||
|
function Get-NinjaBurstEnabled {
|
||||||
|
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
return [bool](Get-NinjaProperty -Name "ocsentinelburst" -Type "Checkbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
|
||||||
|
$value = Ninja-Property-Get -Name "ocsentinelburst"
|
||||||
|
return [string]$value -match "^(1|true|yes)$"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warning "Ninja custom-field reader is unavailable; burst scan skipped."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Type)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
return Get-NinjaProperty -Name $Name -Type $Type
|
||||||
|
}
|
||||||
|
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
|
||||||
|
return Ninja-Property-Get -Name $Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not read Ninja field '$Name': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-NinjaValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Value, [Parameter(Mandatory)][string]$Type)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
|
||||||
|
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
|
||||||
|
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not update Ninja field '$Name': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Kind -eq "burst") {
|
||||||
|
if (-not (Get-NinjaBurstEnabled)) {
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "idle" -Type "Text" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst check: disabled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = [DateTimeOffset]::UtcNow
|
||||||
|
$untilValue = Get-NinjaValue -Name "ocsentinelburstuntilutc" -Type "DateTime"
|
||||||
|
$until = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace([string]$untilValue)) {
|
||||||
|
try { $until = [DateTimeOffset]$untilValue } catch { Write-Warning "Burst end time is invalid and will be restarted." }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $until) {
|
||||||
|
$until = $now.AddMinutes($BurstDurationMinutes)
|
||||||
|
Set-NinjaValue -Name "ocsentinelburstuntilutc" -Value $until.ToString("o") -Type "DateTime" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst window started until $($until.ToString('u'))."
|
||||||
|
}
|
||||||
|
elseif ($until -le $now) {
|
||||||
|
Set-NinjaValue -Name "ocsentinelburst" -Value "false" -Type "Checkbox" | Out-Null
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "completed" -Type "Text" | Out-Null
|
||||||
|
Write-Host "OfficeCom Sentinel burst window completed and was disabled."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-NinjaValue -Name "ocsentinelburststatus" -Value "active until $($until.ToUniversalTime().ToString('o'))" -Type "Text" | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdNew = $false
|
||||||
|
$mutex = [Threading.Mutex]::new($false, $mutexName, [ref]$createdNew)
|
||||||
|
try {
|
||||||
|
if (-not $mutex.WaitOne(0)) {
|
||||||
|
Write-Host "OfficeCom Sentinel scan skipped: another scan is already running."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "OfficeCom Sentinel scheduled $Kind scan started."
|
||||||
|
$monitorArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $monitorScript, "-Mode", "status", "-UploadMode", "required", "-SecretPath", $secretPath, "-SuppressTriggerExit")
|
||||||
|
if ($Kind -eq "burst") {
|
||||||
|
$monitorArgs += @("-LookbackDays", "1", "-TopCount", "25")
|
||||||
|
}
|
||||||
|
& powershell.exe @monitorArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($null -ne $mutex) {
|
||||||
|
try { $mutex.ReleaseMutex() } catch { }
|
||||||
|
$mutex.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using OCSentinelCli.Configuration;
|
using OCSentinelCli.Configuration;
|
||||||
|
using OCSentinelCli.Models;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using OCSentinelCli.Security;
|
using OCSentinelCli.Security;
|
||||||
using OCSentinelCli.Transport;
|
using OCSentinelCli.Transport;
|
||||||
@@ -56,37 +57,103 @@ internal static class UploadCommand
|
|||||||
|
|
||||||
string reportFullPath = Path.GetFullPath(reportPath);
|
string reportFullPath = Path.GetFullPath(reportPath);
|
||||||
string json = File.ReadAllText(reportFullPath);
|
string json = File.ReadAllText(reportFullPath);
|
||||||
|
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath);
|
||||||
|
var client = new N8nUploadClient();
|
||||||
|
var queue = new UploadQueue(config.UploadQueueMaxReports);
|
||||||
|
UploadHealth health = queue.LoadHealth();
|
||||||
|
DateTimeOffset attemptTime = DateTimeOffset.UtcNow;
|
||||||
ScanResult? parsedReport = JsonSerializer.Deserialize<ScanResult>(json, JsonOptions.Default);
|
ScanResult? parsedReport = JsonSerializer.Deserialize<ScanResult>(json, JsonOptions.Default);
|
||||||
|
|
||||||
if (parsedReport is not null)
|
if (parsedReport is not null)
|
||||||
{
|
{
|
||||||
parsedReport = parsedReport with
|
parsedReport = parsedReport with
|
||||||
{
|
{
|
||||||
Runtime = parsedReport.Runtime with
|
Runtime = parsedReport.Runtime with
|
||||||
{
|
{
|
||||||
UploadAttempted = true
|
UploadAttempted = true,
|
||||||
|
UploadStatus = "attempting",
|
||||||
|
QueuedReportCount = health.QueuedReportCount,
|
||||||
|
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default);
|
json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default);
|
||||||
File.WriteAllText(reportFullPath, json);
|
File.WriteAllText(reportFullPath, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath);
|
UploadResult? deferredFailure = queue.Drain(
|
||||||
var client = new N8nUploadClient();
|
client,
|
||||||
var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds);
|
config.N8nWebhookUrl,
|
||||||
|
Environment.MachineName,
|
||||||
|
BuildMetadata.Version,
|
||||||
|
secret,
|
||||||
|
config.UploadTimeoutSeconds);
|
||||||
|
|
||||||
if (!result.Success)
|
if (deferredFailure is null)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"Upload failed ({result.StatusCode}): {result.Message}");
|
var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds);
|
||||||
return 1;
|
if (result.Success)
|
||||||
|
{
|
||||||
|
UploadHealth successHealth = new()
|
||||||
|
{
|
||||||
|
LastUploadAttemptUtc = attemptTime,
|
||||||
|
LastSuccessfulUploadUtc = DateTimeOffset.UtcNow,
|
||||||
|
LastUploadStatus = "ok",
|
||||||
|
LastUploadError = string.Empty,
|
||||||
|
QueuedReportCount = queue.GetQueueDepth()
|
||||||
|
};
|
||||||
|
queue.SaveHealth(successHealth);
|
||||||
|
WriteReportRuntime(reportFullPath, parsedReport, successHealth, true);
|
||||||
|
|
||||||
|
Console.WriteLine($"Upload succeeded ({result.StatusCode})");
|
||||||
|
Console.WriteLine($"Nonce: {result.Nonce}");
|
||||||
|
Console.WriteLine($"Payload SHA256: {result.PayloadSha256}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
deferredFailure = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"Upload succeeded ({result.StatusCode})");
|
UploadResult failure = deferredFailure ?? throw new InvalidOperationException("Upload failed without a result.");
|
||||||
Console.WriteLine($"Nonce: {result.Nonce}");
|
int queuedCount = queue.Enqueue(json);
|
||||||
Console.WriteLine($"Payload SHA256: {result.PayloadSha256}");
|
UploadHealth queuedHealth = new()
|
||||||
|
{
|
||||||
|
LastUploadAttemptUtc = attemptTime,
|
||||||
|
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc,
|
||||||
|
LastUploadStatus = "queued",
|
||||||
|
LastUploadError = failure.Message,
|
||||||
|
QueuedReportCount = queuedCount
|
||||||
|
};
|
||||||
|
queue.SaveHealth(queuedHealth);
|
||||||
|
WriteReportRuntime(reportFullPath, parsedReport, queuedHealth, true);
|
||||||
|
|
||||||
|
Console.WriteLine($"Upload deferred ({failure.StatusCode}): {failure.Message}");
|
||||||
|
Console.WriteLine($"Queued reports: {queuedCount}");
|
||||||
|
Console.WriteLine("The report will be retried automatically on the next scheduled run.");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void WriteReportRuntime(string reportPath, ScanResult? report, UploadHealth health, bool attempted)
|
||||||
|
{
|
||||||
|
if (report is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanResult updated = report with
|
||||||
|
{
|
||||||
|
Runtime = report.Runtime with
|
||||||
|
{
|
||||||
|
UploadAttempted = attempted,
|
||||||
|
UploadSucceeded = string.Equals(health.LastUploadStatus, "ok", StringComparison.Ordinal),
|
||||||
|
UploadStatus = health.LastUploadStatus,
|
||||||
|
QueuedReportCount = health.QueuedReportCount,
|
||||||
|
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc,
|
||||||
|
LastUploadError = health.LastUploadError
|
||||||
|
}
|
||||||
|
};
|
||||||
|
File.WriteAllText(reportPath, JsonSerializer.Serialize(updated, JsonOptions.Default));
|
||||||
|
}
|
||||||
|
|
||||||
private static string ReadValue(string[] args, ref int index, string argName)
|
private static string ReadValue(string[] args, ref int index, string argName)
|
||||||
{
|
{
|
||||||
if (index + 1 >= args.Length)
|
if (index + 1 >= args.Length)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ internal sealed record ClientConfiguration
|
|||||||
|
|
||||||
public int UploadTimeoutSeconds { get; init; } = 30;
|
public int UploadTimeoutSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
public int UploadQueueMaxReports { get; init; } = 100;
|
||||||
|
|
||||||
public bool EnableVulnerabilityCorrelation { get; init; } = true;
|
public bool EnableVulnerabilityCorrelation { get; init; } = true;
|
||||||
|
|
||||||
public string VulnerabilityCsvPath { get; init; } = string.Empty;
|
public string VulnerabilityCsvPath { get; init; } = string.Empty;
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ internal sealed record ScanRuntimeMetadata
|
|||||||
public DateTimeOffset FinishedAtUtc { get; init; }
|
public DateTimeOffset FinishedAtUtc { get; init; }
|
||||||
|
|
||||||
public bool UploadAttempted { get; init; }
|
public bool UploadAttempted { get; init; }
|
||||||
|
|
||||||
|
public bool UploadSucceeded { get; init; }
|
||||||
|
|
||||||
|
public string UploadStatus { get; init; } = "not-attempted";
|
||||||
|
|
||||||
|
public int QueuedReportCount { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastSuccessfulUploadUtc { get; init; }
|
||||||
|
|
||||||
|
public string LastUploadError { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record VulnerabilityFinding
|
internal sealed record VulnerabilityFinding
|
||||||
|
|||||||
@@ -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.2.10</Version>
|
<Version>1.3.7</Version>
|
||||||
<AssemblyVersion>1.2.10.0</AssemblyVersion>
|
<AssemblyVersion>1.3.7.0</AssemblyVersion>
|
||||||
<FileVersion>1.2.10.0</FileVersion>
|
<FileVersion>1.3.7.0</FileVersion>
|
||||||
<InformationalVersion>1.2.10</InformationalVersion>
|
<InformationalVersion>1.3.7</InformationalVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -38,17 +38,31 @@ internal sealed class N8nUploadClient
|
|||||||
request.Headers.Add("X-ATN-Payload-SHA256", payloadHash);
|
request.Headers.Add("X-ATN-Payload-SHA256", payloadHash);
|
||||||
request.Headers.Add("X-ATN-Signature", signature);
|
request.Headers.Add("X-ATN-Signature", signature);
|
||||||
|
|
||||||
using HttpResponseMessage response = httpClient.Send(request);
|
try
|
||||||
string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
return new UploadResult
|
|
||||||
{
|
{
|
||||||
Success = response.IsSuccessStatusCode,
|
using HttpResponseMessage response = httpClient.Send(request);
|
||||||
StatusCode = (int)response.StatusCode,
|
string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
Message = string.IsNullOrWhiteSpace(responseText) ? response.ReasonPhrase ?? string.Empty : responseText,
|
|
||||||
Nonce = nonce,
|
return new UploadResult
|
||||||
PayloadSha256 = payloadHash
|
{
|
||||||
};
|
Success = response.IsSuccessStatusCode,
|
||||||
|
StatusCode = (int)response.StatusCode,
|
||||||
|
Message = string.IsNullOrWhiteSpace(responseText) ? response.ReasonPhrase ?? string.Empty : responseText,
|
||||||
|
Nonce = nonce,
|
||||||
|
PayloadSha256 = payloadHash
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
return new UploadResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
StatusCode = 0,
|
||||||
|
Message = exception.Message,
|
||||||
|
Nonce = nonce,
|
||||||
|
PayloadSha256 = payloadHash
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeSha256(string value)
|
private static string ComputeSha256(string value)
|
||||||
|
|||||||
106
src/OCSentinelCli/Transport/UploadQueue.cs
Normal file
106
src/OCSentinelCli/Transport/UploadQueue.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using OCSentinelCli.Models;
|
||||||
|
|
||||||
|
namespace OCSentinelCli.Transport;
|
||||||
|
|
||||||
|
internal sealed record UploadHealth
|
||||||
|
{
|
||||||
|
public DateTimeOffset? LastUploadAttemptUtc { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset? LastSuccessfulUploadUtc { get; init; }
|
||||||
|
|
||||||
|
public string LastUploadStatus { get; init; } = "not-attempted";
|
||||||
|
|
||||||
|
public string LastUploadError { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public int QueuedReportCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UploadQueue
|
||||||
|
{
|
||||||
|
private readonly string queueDirectory;
|
||||||
|
private readonly string healthPath;
|
||||||
|
private readonly int maxReports;
|
||||||
|
|
||||||
|
public UploadQueue(int maxReports)
|
||||||
|
{
|
||||||
|
string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OCSentinel");
|
||||||
|
queueDirectory = Path.Combine(root, "upload-queue");
|
||||||
|
healthPath = Path.Combine(root, "state", "upload-health.json");
|
||||||
|
this.maxReports = Math.Clamp(maxReports, 10, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UploadHealth LoadHealth()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(healthPath))
|
||||||
|
{
|
||||||
|
return new UploadHealth { QueuedReportCount = GetQueueDepth() };
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadHealth? health = JsonSerializer.Deserialize<UploadHealth>(File.ReadAllText(healthPath), JsonOptions.Default);
|
||||||
|
return (health ?? new UploadHealth()) with { QueuedReportCount = GetQueueDepth() };
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return new UploadHealth { QueuedReportCount = GetQueueDepth() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveHealth(UploadHealth health)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(healthPath)!);
|
||||||
|
WriteAtomically(healthPath, JsonSerializer.Serialize(health, JsonOptions.Default));
|
||||||
|
}
|
||||||
|
|
||||||
|
public UploadResult? Drain(N8nUploadClient client, string webhookUrl, string machineName, string clientVersion, string secret, int timeoutSeconds)
|
||||||
|
{
|
||||||
|
foreach (string path in GetQueuedPaths())
|
||||||
|
{
|
||||||
|
string payload = File.ReadAllText(path);
|
||||||
|
UploadResult result = client.UploadJson(webhookUrl, machineName, clientVersion, payload, secret, timeoutSeconds);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Enqueue(string payloadJson)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(queueDirectory);
|
||||||
|
string fileName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.json";
|
||||||
|
WriteAtomically(Path.Combine(queueDirectory, fileName), payloadJson);
|
||||||
|
|
||||||
|
foreach (string stalePath in GetQueuedPaths().Take(Math.Max(0, GetQueueDepth() - maxReports)))
|
||||||
|
{
|
||||||
|
File.Delete(stalePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetQueueDepth();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetQueueDepth()
|
||||||
|
{
|
||||||
|
return Directory.Exists(queueDirectory) ? Directory.EnumerateFiles(queueDirectory, "*.json").Count() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetQueuedPaths()
|
||||||
|
{
|
||||||
|
return Directory.Exists(queueDirectory)
|
||||||
|
? Directory.EnumerateFiles(queueDirectory, "*.json").OrderBy(static path => path, StringComparer.Ordinal)
|
||||||
|
: Enumerable.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteAtomically(string path, string content)
|
||||||
|
{
|
||||||
|
string temporaryPath = path + ".tmp";
|
||||||
|
File.WriteAllText(temporaryPath, content);
|
||||||
|
File.Move(temporaryPath, path, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user