Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49b0e3025d | ||
|
|
aefec51581 | ||
|
|
7555e92aac | ||
|
|
b97b554f84 | ||
|
|
77d7eaa0b5 | ||
|
|
f91f45ad92 | ||
|
|
c27b53ea0d | ||
|
|
f9d7647046 | ||
|
|
d37fba137e | ||
|
|
e73d79b520 | ||
|
|
b0e3e7dc74 | ||
|
|
290033680b | ||
|
|
3ea0baa147 | ||
|
|
e01aa3dce3 | ||
|
|
8bc1d78fc9 | ||
|
|
00778175fd | ||
|
|
34eff4e012 | ||
|
|
707275f992 | ||
|
|
b1ee79ca02 | ||
|
|
053d601e93 | ||
|
|
67a14c125e | ||
|
|
335410b418 | ||
|
|
f9f4d862bf | ||
|
|
ea90ddd1f6 | ||
|
|
1ad720f919 | ||
|
|
61c5e7823a | ||
|
|
05029c9fb2 | ||
|
|
fde24f2616 | ||
|
|
38a99f2706 | ||
|
|
e8b7a2831d |
@@ -7,12 +7,35 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_windows:
|
||||
description: "Build the Windows release package"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
jobs:
|
||||
build-client:
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- windows
|
||||
validate-client:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Restore client
|
||||
run: dotnet restore ./src/OCSentinelCli/OCSentinelCli.csproj
|
||||
|
||||
- name: Build client
|
||||
run: dotnet build ./src/OCSentinelCli/OCSentinelCli.csproj -c Release --no-restore
|
||||
|
||||
build-client-windows:
|
||||
needs: validate-client
|
||||
# .NET can publish a self-contained Windows x64 client from Linux.
|
||||
# This keeps releases independent of a Windows Gitea runner.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -28,10 +51,14 @@ jobs:
|
||||
./build/build-client-package.ps1
|
||||
|
||||
- name: Build release manifest for tags
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tag = "${{ github.ref_name }}"
|
||||
$ref = if ($env:GITHUB_REF) { $env:GITHUB_REF } else { $env:GITEA_REF }
|
||||
$tag = if ($env:GITHUB_REF_NAME) { $env:GITHUB_REF_NAME } else { Split-Path -Leaf $ref }
|
||||
if ($tag -notlike "v*") {
|
||||
Write-Host "Not a version tag; skipping release manifest."
|
||||
exit 0
|
||||
}
|
||||
$artifactUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/$tag/OCSentinelClient-win-x64.zip"
|
||||
./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl
|
||||
|
||||
@@ -44,3 +71,41 @@ jobs:
|
||||
artifacts/OCSentinelClient-win-x64.zip.sha256
|
||||
artifacts/version.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Publish Gitea release assets
|
||||
shell: pwsh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
$ref = if ($env:GITHUB_REF) { $env:GITHUB_REF } else { $env:GITEA_REF }
|
||||
$tag = if ($env:GITHUB_REF_NAME) { $env:GITHUB_REF_NAME } else { Split-Path -Leaf $ref }
|
||||
if ($tag -notlike "v*") {
|
||||
Write-Host "Not a version tag; skipping Gitea release publication."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
|
||||
$repository = if ($env:GITEA_REPOSITORY) { $env:GITEA_REPOSITORY } else { $env:GITHUB_REPOSITORY }
|
||||
$serverUrl = if ($env:GITEA_SERVER_URL) { $env:GITEA_SERVER_URL } else { $env:GITHUB_SERVER_URL }
|
||||
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN) -or [string]::IsNullOrWhiteSpace($repository) -or [string]::IsNullOrWhiteSpace($serverUrl)) {
|
||||
throw "Gitea release environment is incomplete. Expected GITEA_TOKEN, repository, and server URL."
|
||||
}
|
||||
$baseUrl = "$serverUrl/api/v1/repos/$repository"
|
||||
$releaseBody = @{
|
||||
tag_name = $tag
|
||||
target_commitish = "${{ github.sha }}"
|
||||
name = "OfficeCom Sentinel $tag"
|
||||
body = "Automated OfficeCom Sentinel client release."
|
||||
} | ConvertTo-Json
|
||||
|
||||
try {
|
||||
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$baseUrl/releases/tags/$tag"
|
||||
}
|
||||
catch {
|
||||
$release = Invoke-RestMethod -Method Post -Headers $headers -ContentType "application/json" -Body $releaseBody -Uri "$baseUrl/releases"
|
||||
}
|
||||
|
||||
foreach ($file in @("artifacts/OCSentinelClient-win-x64.zip", "artifacts/OCSentinelClient-win-x64.zip.sha256", "artifacts/version.json")) {
|
||||
$assetName = [System.IO.Path]::GetFileName($file)
|
||||
Invoke-RestMethod -Method Post -Headers $headers -InFile $file -ContentType "application/octet-stream" -Uri "$baseUrl/releases/$($release.id)/assets?name=$assetName" | Out-Null
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ payload/
|
||||
*.log
|
||||
*.zip
|
||||
*.sha256
|
||||
payload.zip
|
||||
SetupAttackTracer.exe
|
||||
decompiled/
|
||||
msi-admin/
|
||||
|
||||
@@ -9,7 +9,10 @@ OfficeCom Sentinel is the hardened endpoint client for Windows event correlation
|
||||
- Ninja monitor wrapper: `scripts/run-ocsentinel-monitor.ps1`
|
||||
- packaged installer runtime: `installer/runtime-run-ocsentinel.ps1`
|
||||
- package builder: `build/build-client-package.ps1`
|
||||
- setup EXE builder: `build/build-client-installer.ps1`
|
||||
- update manifest builder: `build/build-release-manifest.ps1`
|
||||
- release checklist: `docs/release-checklist.md`
|
||||
- internal server-side target example: `infra/postgres-target.example.json`
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
50
build/build-client-installer.ps1
Normal file
50
build/build-client-installer.ps1
Normal file
@@ -0,0 +1,50 @@
|
||||
param(
|
||||
[string]$Configuration = "Release"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$artifactsRoot = Join-Path $repoRoot "artifacts"
|
||||
$zipPath = Join-Path $artifactsRoot "OCSentinelClient-win-x64.zip"
|
||||
$bootstrapperRoot = Join-Path $repoRoot "installer\OCSentinelBootstrapper"
|
||||
$payloadPath = Join-Path $bootstrapperRoot "payload.zip"
|
||||
$projectPath = Join-Path $bootstrapperRoot "OCSentinelBootstrapper.csproj"
|
||||
$publishRoot = Join-Path $artifactsRoot "bootstrapper-publish\win-x64"
|
||||
$outputExe = Join-Path $artifactsRoot "OCSentinelSetup.exe"
|
||||
|
||||
if (-not (Test-Path $zipPath)) {
|
||||
& powershell.exe -ExecutionPolicy Bypass -File (Join-Path $repoRoot "build\build-client-package.ps1") -Configuration $Configuration
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "build-client-package.ps1 failed"
|
||||
}
|
||||
}
|
||||
|
||||
Copy-Item -Path $zipPath -Destination $payloadPath -Force
|
||||
|
||||
if (Test-Path $publishRoot) {
|
||||
Remove-Item -LiteralPath $publishRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if (Test-Path $outputExe) {
|
||||
Remove-Item -LiteralPath $outputExe -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $publishRoot | Out-Null
|
||||
|
||||
& dotnet publish $projectPath `
|
||||
-c $Configuration `
|
||||
-r win-x64 `
|
||||
--self-contained true `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:EnableCompressionInSingleFile=true `
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true `
|
||||
-o $publishRoot
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "dotnet publish failed for bootstrapper"
|
||||
}
|
||||
|
||||
Copy-Item -Path (Join-Path $publishRoot "OCSentinelBootstrapper.exe") -Destination $outputExe -Force
|
||||
|
||||
Write-Host "OfficeCom Sentinel setup executable created at $outputExe"
|
||||
@@ -58,6 +58,7 @@ Copy-Item -Path (Join-Path $repoRoot "scripts\protect-ocsentinel-secret.ps1") -D
|
||||
|
||||
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-client.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-client.example.json") -Force
|
||||
Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-client.dev.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-client.dev.example.json") -Force
|
||||
Copy-Item -Path (Join-Path $repoRoot "config\update-channel.example.json") -Destination (Join-Path $packageRoot "config\update-channel.example.json") -Force
|
||||
Copy-Item -Path (Join-Path $repoRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Force
|
||||
Copy-Item -Path (Join-Path $repoRoot "samples\webhook-payload.example.json") -Destination (Join-Path $packageRoot "samples\webhook-payload.example.json") -Force
|
||||
|
||||
12
config/ocsentinel-client.dev.example.json
Normal file
12
config/ocsentinel-client.dev.example.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schemaVersion": "2.0",
|
||||
"environment": "development",
|
||||
"lookbackDays": 7,
|
||||
"topFindings": 10,
|
||||
"n8nWebhookUrl": "http://172.16.41.197:5678/webhook/ocsentinel-ingest",
|
||||
"deviceIdentifierMode": "machineName",
|
||||
"uploadTimeoutSeconds": 30,
|
||||
"enableVulnerabilityCorrelation": true,
|
||||
"vulnerabilityCsvPath": "",
|
||||
"secretReference": "device-default"
|
||||
}
|
||||
@@ -19,6 +19,15 @@ The repository now keeps only the client-side architecture:
|
||||
- optional n8n upload
|
||||
- packaged ZIP release flow for NinjaOne deployment
|
||||
|
||||
The client must not depend on a PostgreSQL IP or hostname. PostgreSQL stays a server-side concern behind the ingest or n8n layer.
|
||||
|
||||
## PostgreSQL Handling
|
||||
|
||||
- PostgreSQL is not contacted directly by endpoint clients.
|
||||
- The PostgreSQL host or IP should be tracked in the repository only as internal deployment metadata.
|
||||
- Review that internal target on every release before publishing.
|
||||
- Keep the actual production value in a private operational copy if it should not be visible in the public repository.
|
||||
|
||||
## Removed Model
|
||||
|
||||
The following older pieces are intentionally no longer part of the repo:
|
||||
|
||||
@@ -17,7 +17,7 @@ Build these locally with:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\build\build-client-package.ps1
|
||||
powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 `
|
||||
-ArtifactUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v2.0.0/OCSentinelClient-win-x64.zip"
|
||||
-ArtifactUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.2.3/OCSentinelClient-win-x64.zip"
|
||||
```
|
||||
|
||||
## Installed Layout
|
||||
@@ -32,11 +32,20 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 `
|
||||
|
||||
## NinjaOne Tasks
|
||||
|
||||
Initial install/update:
|
||||
Create a PowerShell script in NinjaOne named `OCSentinel - Installieren oder aktualisieren`.
|
||||
Run it as `SYSTEM` in 64-bit PowerShell and copy the content of
|
||||
`scripts/bootstrap-ocsentinel-ninja.ps1` into the NinjaOne script editor.
|
||||
It is idempotent: new devices install the current package, while installed devices
|
||||
only update when a newer manifest version is published.
|
||||
|
||||
Use it for the one-time rollout and, later, as the monthly update task. For an
|
||||
initial validation scan, add `-RunInitialStatusScan` to the script parameters.
|
||||
|
||||
Installed-client update only:
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\OCSentinel\scripts\update-ocsentinel.ps1" `
|
||||
-ManifestUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v2.0.0/version.json" `
|
||||
-ManifestUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json" `
|
||||
-Force
|
||||
```
|
||||
|
||||
@@ -44,7 +53,7 @@ Routine update:
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\OCSentinel\scripts\update-ocsentinel.ps1" `
|
||||
-ManifestUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v2.0.0/version.json"
|
||||
-ManifestUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json"
|
||||
```
|
||||
|
||||
Runtime:
|
||||
@@ -65,3 +74,31 @@ Runtime:
|
||||
This writes:
|
||||
|
||||
- `C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat`
|
||||
|
||||
## Development Upload
|
||||
|
||||
For the internal development environment, copy
|
||||
`config/ocsentinel-client.dev.example.json` to the installed client config
|
||||
path and use its HTTP webhook URL. Production clients must use the HTTPS
|
||||
configuration with the public Sentinel domain instead.
|
||||
|
||||
## Current Manual Release State
|
||||
|
||||
As of July 16, 2026, the first manual release is already published:
|
||||
|
||||
- tag: `v1.2.3`
|
||||
- release URL: `https://gitea.officecom.cloud/officecom/oc-sentinel/releases/tag/v1.2.3`
|
||||
- stable manifest URL: `https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json`
|
||||
|
||||
The manifest is intentionally version-independent for NinjaOne. Only the JSON content changes per release; the NinjaOne task URL stays the same.
|
||||
|
||||
This means NinjaOne rollout can start immediately without waiting for a Gitea runner.
|
||||
|
||||
## Later Automation
|
||||
|
||||
When a Gitea runner is added later, the usual next step is:
|
||||
|
||||
1. connect to the runner host through SSH or RDP, depending on the server type
|
||||
2. install and register the Gitea runner
|
||||
3. let `.gitea/workflows/client-build.yml` publish future release artifacts automatically
|
||||
4. update `release/stable/version.json` automatically as part of the release flow
|
||||
|
||||
@@ -25,3 +25,46 @@ n8n is responsible for:
|
||||
- storage in the central backend
|
||||
- organization-wide aggregation
|
||||
- NinjaOne organization API updates
|
||||
|
||||
## Required n8n Workflow
|
||||
|
||||
The webhook itself may be reachable only on the internal network. It does not
|
||||
require public access to the n8n editor or API. Every managed device must be
|
||||
able to reach the webhook URL over HTTPS.
|
||||
|
||||
For the isolated development environment only, HTTP is permitted at
|
||||
`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.
|
||||
|
||||
1. `Webhook`: accept `POST` on the configured private URL.
|
||||
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-Signature` is missing; reject timestamps outside five minutes.
|
||||
3. `Code`: calculate SHA-256 over the raw request body and compare it with
|
||||
`X-ATN-Payload-SHA256`. Calculate HMAC-SHA256 over the following exact
|
||||
newline-separated string and compare it in constant time with
|
||||
`X-ATN-Signature`:
|
||||
|
||||
```text
|
||||
<device>\n<timestamp>\n<nonce>\n<version>\n<payload-sha256>
|
||||
```
|
||||
|
||||
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.
|
||||
5. `Postgres`: upsert the device, insert a row in `ocsentinel.scan_report`,
|
||||
then return `202`.
|
||||
6. A separate scheduled n8n workflow reads
|
||||
`ocsentinel.organization_summary` and `ocsentinel.current_device_status`
|
||||
to update the NinjaOne organization fields through the API.
|
||||
|
||||
Use an n8n credential for the shared HMAC secret and a separate n8n credential
|
||||
for PostgreSQL. Do not store either value in workflow JSON or this repository.
|
||||
For the current Docker deployment, use the private hostname
|
||||
`ocsentinel-postgres` and the restricted database role `ocsentinel_n8n`; see
|
||||
`infra/dockge/README.md` for the remaining credential fields.
|
||||
|
||||
## PostgreSQL Scope
|
||||
|
||||
- The client only knows its outward upload destination.
|
||||
- PostgreSQL connection details belong to the internal ingest or n8n side.
|
||||
- If the PostgreSQL IP changes, update the internal server-side configuration and review it during the next release.
|
||||
|
||||
25
docs/release-checklist.md
Normal file
25
docs/release-checklist.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Release Checklist
|
||||
|
||||
## Goal
|
||||
|
||||
Use this checklist before publishing every OfficeCom Sentinel release.
|
||||
|
||||
## Infrastructure Check
|
||||
|
||||
1. Confirm the internal PostgreSQL target is still correct in `infra/postgres-target.example.json` or its private production counterpart.
|
||||
2. Confirm the n8n internal base URL is still correct.
|
||||
3. Confirm the public client ingest URL still forwards to the intended internal service.
|
||||
4. Confirm no internal PostgreSQL host or IP is embedded in client configuration, installer output, or public release artifacts.
|
||||
|
||||
## Build Check
|
||||
|
||||
1. Build `OCSentinelClient-win-x64.zip`.
|
||||
2. Build `OCSentinelSetup.exe`.
|
||||
3. Generate `version.json`.
|
||||
4. Verify SHA-256 output matches the released ZIP.
|
||||
|
||||
## Publish Check
|
||||
|
||||
1. Upload the ZIP, SHA256 file, and setup EXE to the release.
|
||||
2. Update `release/stable/version.json` so NinjaOne keeps a version-independent manifest URL.
|
||||
3. If infrastructure changed, update the internal Postgres target record in the repo at the same time.
|
||||
44
infra/dockge/README.md
Normal file
44
infra/dockge/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# OCSentinel PostgreSQL Dockge Stack
|
||||
|
||||
Production is deployed as the Dockge stack directory:
|
||||
|
||||
```text
|
||||
/dockerstacks/ocsentinel-postgres
|
||||
```
|
||||
|
||||
The stack runs `postgres:16-alpine` as `ocsentinel-postgres` and joins the
|
||||
existing Docker network `n8n_n8n-network`. It deliberately has no `ports:`
|
||||
mapping, so PostgreSQL is not exposed on the host network or the Internet.
|
||||
|
||||
The stack owns these private files on the server:
|
||||
|
||||
```text
|
||||
/dockerstacks/ocsentinel-postgres/compose.yaml
|
||||
/dockerstacks/ocsentinel-postgres/.env
|
||||
/dockerstacks/ocsentinel-postgres/init/
|
||||
/dockerstacks/ocsentinel-postgres/data/
|
||||
```
|
||||
|
||||
`.env` is root-readable only and contains both the PostgreSQL administrator
|
||||
password and the restricted `ocsentinel_n8n` password. Never commit it or copy
|
||||
it to endpoint devices.
|
||||
|
||||
## n8n PostgreSQL Credential
|
||||
|
||||
Create one credential in n8n with these non-secret values:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Host | `ocsentinel-postgres` |
|
||||
| Port | `5432` |
|
||||
| Database | `ocsentinel` |
|
||||
| User | `ocsentinel_n8n` |
|
||||
| SSL | disabled (private Docker network) |
|
||||
|
||||
Retrieve the password only on the server when entering the n8n credential:
|
||||
|
||||
```bash
|
||||
sudo grep '^OCSENTINEL_N8N_PASSWORD=' /dockerstacks/ocsentinel-postgres/.env
|
||||
```
|
||||
|
||||
The database schema source remains [../postgres/001_ocsentinel.sql](../postgres/001_ocsentinel.sql).
|
||||
10
infra/postgres-target.example.json
Normal file
10
infra/postgres-target.example.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"role": "server-side-only",
|
||||
"environment": "production",
|
||||
"postgresHost": "10.0.0.25",
|
||||
"postgresPort": 5432,
|
||||
"postgresDatabase": "ocsentinel",
|
||||
"postgresSslMode": "require",
|
||||
"n8nInternalBaseUrl": "http://n8n.internal:5678",
|
||||
"notes": "This file is for the internal ingest or n8n side only. Do not deploy this file to endpoint clients."
|
||||
}
|
||||
81
infra/postgres/001_ocsentinel.sql
Normal file
81
infra/postgres/001_ocsentinel.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- OfficeCom Sentinel central reporting store.
|
||||
-- Apply once as a PostgreSQL administrator to the dedicated ocsentinel database.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS ocsentinel;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ocsentinel.device (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
machine_name TEXT NOT NULL,
|
||||
machine_name_key TEXT NOT NULL UNIQUE,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_client_version TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ocsentinel.scan_report (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
device_id BIGINT NOT NULL REFERENCES ocsentinel.device(id) ON DELETE CASCADE,
|
||||
generated_at_utc TIMESTAMPTZ NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
client_version TEXT NOT NULL,
|
||||
alert_state TEXT NOT NULL CHECK (alert_state IN ('ok', 'warning', 'critical', 'unknown')),
|
||||
base_alert_state TEXT NOT NULL CHECK (base_alert_state IN ('ok', 'warning', 'critical', 'unknown')),
|
||||
total_events INTEGER NOT NULL CHECK (total_events >= 0),
|
||||
unique_ip_count INTEGER NOT NULL CHECK (unique_ip_count >= 0),
|
||||
cve_total INTEGER NOT NULL DEFAULT 0 CHECK (cve_total >= 0),
|
||||
cve_critical INTEGER NOT NULL DEFAULT 0 CHECK (cve_critical >= 0),
|
||||
payload_sha256 CHAR(64) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
UNIQUE (device_id, generated_at_utc, payload_sha256)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ocsentinel_scan_report_device_received
|
||||
ON ocsentinel.scan_report (device_id, received_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ocsentinel_scan_report_alert_received
|
||||
ON ocsentinel.scan_report (alert_state, received_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ocsentinel.ingest_nonce (
|
||||
nonce CHAR(32) PRIMARY KEY,
|
||||
device_id BIGINT NOT NULL REFERENCES ocsentinel.device(id) ON DELETE CASCADE,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_ocsentinel_ingest_nonce_expires
|
||||
ON ocsentinel.ingest_nonce (expires_at);
|
||||
|
||||
CREATE OR REPLACE VIEW ocsentinel.current_device_status AS
|
||||
SELECT DISTINCT ON (d.id)
|
||||
d.machine_name,
|
||||
d.first_seen_at,
|
||||
d.last_seen_at,
|
||||
d.last_client_version,
|
||||
r.generated_at_utc,
|
||||
r.received_at,
|
||||
r.alert_state,
|
||||
r.base_alert_state,
|
||||
r.total_events,
|
||||
r.unique_ip_count,
|
||||
r.cve_total,
|
||||
r.cve_critical,
|
||||
r.payload
|
||||
FROM ocsentinel.device AS d
|
||||
LEFT JOIN ocsentinel.scan_report AS r ON r.device_id = d.id
|
||||
ORDER BY d.id, r.generated_at_utc DESC NULLS LAST, r.received_at DESC NULLS LAST;
|
||||
|
||||
CREATE OR REPLACE VIEW ocsentinel.organization_summary AS
|
||||
SELECT
|
||||
count(*) FILTER (WHERE generated_at_utc IS NOT NULL) AS devices_reporting,
|
||||
count(*) FILTER (WHERE alert_state = 'warning') AS devices_warning,
|
||||
count(*) FILTER (WHERE alert_state = 'critical') AS devices_critical,
|
||||
coalesce(sum(total_events), 0) AS total_events,
|
||||
coalesce(sum(unique_ip_count), 0) AS total_unique_ips,
|
||||
coalesce(sum(cve_total), 0) AS total_cves,
|
||||
coalesce(sum(cve_critical), 0) AS critical_cves,
|
||||
max(received_at) AS last_report_received_at
|
||||
FROM ocsentinel.current_device_status;
|
||||
|
||||
COMMIT;
|
||||
36
infra/postgres/README.md
Normal file
36
infra/postgres/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# OfficeCom Sentinel PostgreSQL
|
||||
|
||||
PostgreSQL is the private central store for endpoint reports. It is never
|
||||
contacted directly by an endpoint; only n8n uses a database account.
|
||||
|
||||
## Provisioning
|
||||
|
||||
The production instance is deployed as the private Dockge stack documented in
|
||||
[../dockge/README.md](../dockge/README.md). The bootstrap has already created
|
||||
the database, schema, and restricted `ocsentinel_n8n` role.
|
||||
|
||||
For a separate future installation:
|
||||
|
||||
1. Create a database named `ocsentinel` on the private PostgreSQL server.
|
||||
2. Apply `001_ocsentinel.sql` as a database administrator.
|
||||
3. Create a non-superuser n8n login and grant only the necessary permissions:
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON SCHEMA ocsentinel TO ocsentinel_n8n;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ocsentinel TO ocsentinel_n8n;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ocsentinel TO ocsentinel_n8n;
|
||||
GRANT SELECT ON ocsentinel.current_device_status, ocsentinel.organization_summary TO ocsentinel_n8n;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA ocsentinel
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ocsentinel_n8n;
|
||||
```
|
||||
|
||||
Keep the database host, password, and TLS settings only in n8n credentials.
|
||||
They do not belong in Gitea, NinjaOne scripts, or endpoint configuration.
|
||||
|
||||
## Maintenance
|
||||
|
||||
Run monthly from n8n or an administrator session to remove expired replay tokens:
|
||||
|
||||
```sql
|
||||
DELETE FROM ocsentinel.ingest_nonce WHERE expires_at < now();
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>false</UseWindowsForms>
|
||||
<AssemblyName>OCSentinelBootstrapper</AssemblyName>
|
||||
<RootNamespace>OCSentinelBootstrapper</RootNamespace>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="payload.zip" LogicalName="payload.zip" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
76
installer/OCSentinelBootstrapper/Program.cs
Normal file
76
installer/OCSentinelBootstrapper/Program.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OCSentinelBootstrapper;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main()
|
||||
{
|
||||
string tempRoot = Path.Combine(Path.GetTempPath(), "OCSentinelSetup", Guid.NewGuid().ToString("N"));
|
||||
string zipPath = Path.Combine(tempRoot, "payload.zip");
|
||||
string extractRoot = Path.Combine(tempRoot, "payload");
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
|
||||
ExtractEmbeddedPayload(zipPath);
|
||||
ZipFile.ExtractToDirectory(zipPath, extractRoot, overwriteFiles: true);
|
||||
|
||||
string installScript = Path.Combine(extractRoot, "scripts", "install-ocsentinel.ps1");
|
||||
if (!File.Exists(installScript))
|
||||
{
|
||||
throw new FileNotFoundException("Embedded payload did not contain install-ocsentinel.ps1", installScript);
|
||||
}
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-ExecutionPolicy Bypass -File \"{installScript}\"",
|
||||
UseShellExecute = false
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit();
|
||||
return process.ExitCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"OCSentinel installer failed: {ex}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractEmbeddedPayload(string destinationPath)
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
using Stream? resourceStream = assembly.GetManifestResourceStream("payload.zip");
|
||||
if (resourceStream is null)
|
||||
{
|
||||
throw new InvalidOperationException("Embedded payload.zip resource was not found.");
|
||||
}
|
||||
|
||||
using FileStream output = File.Create(destinationPath);
|
||||
resourceStream.CopyTo(output);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-settings.example.json
|
||||
if (Test-Path (Join-Path $packageRoot "config\ocsentinel-client.example.json")) {
|
||||
Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-client.example.json") -Destination (Join-Path $configRoot "ocsentinel-client.example.json") -Force
|
||||
}
|
||||
if (Test-Path (Join-Path $packageRoot "config\ocsentinel-client.dev.example.json")) {
|
||||
Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-client.dev.example.json") -Destination (Join-Path $configRoot "ocsentinel-client.dev.example.json") -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-monitor.ps1") -Destination $scriptRoot -Force
|
||||
|
||||
@@ -3,6 +3,10 @@ param(
|
||||
[int]$TopCount = 10,
|
||||
[string]$OutputPath = "..\reports\ocsentinel-summary.json",
|
||||
[string]$ConfigPath = "..\config\ocsentinel-settings.json",
|
||||
[string]$ClientConfigPath = "..\config\ocsentinel-client.json",
|
||||
[string]$SecretPath = "",
|
||||
[ValidateSet("disabled", "auto", "required")]
|
||||
[string]$UploadMode = "auto",
|
||||
[string]$VulnerabilityCsvPath = "",
|
||||
[string]$MirrorRoot = "",
|
||||
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
||||
@@ -142,9 +146,15 @@ $runnerArgs = @(
|
||||
"-LookbackDays", $LookbackDays,
|
||||
"-TopCount", $TopCount,
|
||||
"-OutputPath", $OutputPath,
|
||||
"-ConfigPath", $ConfigPath
|
||||
"-ConfigPath", $ConfigPath,
|
||||
"-ClientConfigPath", $ClientConfigPath,
|
||||
"-UploadMode", $UploadMode
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($SecretPath)) {
|
||||
$runnerArgs += @("-SecretPath", $SecretPath)
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) {
|
||||
$runnerArgs += @("-VulnerabilityCsvPath", $VulnerabilityCsvPath)
|
||||
}
|
||||
|
||||
8
release/stable/version.json
Normal file
8
release/stable/version.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"channel": "stable",
|
||||
"version": "1.2.10",
|
||||
"publishedAtUtc": "2026-07-25T19:14:36.9210206Z",
|
||||
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.2.10/OCSentinelClient-win-x64.zip",
|
||||
"sha256": "4de7abf96ff9ed47da02ac7f586cc39102fa508372825c2f7bf5e1df2b0f6c80",
|
||||
"minUpdaterVersion": "1.0.0"
|
||||
}
|
||||
142
scripts/bootstrap-ocsentinel-ninja.ps1
Normal file
142
scripts/bootstrap-ocsentinel-ninja.ps1
Normal file
@@ -0,0 +1,142 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json",
|
||||
[string]$WebhookUrl = "",
|
||||
[string]$SecretValue = "",
|
||||
[switch]$RunInitialStatusScan
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
|
||||
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||
$appPath = Join-Path $installRoot "app\OCSentinelCli.exe"
|
||||
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||
$secretScriptPath = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
||||
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||
|
||||
# NinjaOne script variables are exposed as process environment variables.
|
||||
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
||||
$WebhookUrl = $env:WebhookUrl
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||
$SecretValue = $env:SecretValue
|
||||
}
|
||||
|
||||
$runInitialScan = $RunInitialStatusScan.IsPresent
|
||||
if (-not $runInitialScan -and -not [string]::IsNullOrWhiteSpace($env:RunInitialStatusScan)) {
|
||||
$runInitialScan = $env:RunInitialStatusScan -match '^(1|true|yes|on)$'
|
||||
}
|
||||
|
||||
function Assert-ArtifactSignature {
|
||||
param([Parameter(Mandatory)][string]$ExecutablePath)
|
||||
|
||||
$signature = Get-AuthenticodeSignature -FilePath $ExecutablePath
|
||||
if ($signature.Status -notin @("Valid", "NotSigned")) {
|
||||
throw "Executable signature validation failed with status: $($signature.Status)"
|
||||
}
|
||||
|
||||
if ($signature.Status -eq "NotSigned") {
|
||||
Write-Warning "The package hash was verified, but OCSentinelCli.exe is not code-signed yet."
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $updaterPath) {
|
||||
Write-Host "Existing OCSentinel installation found. Checking for updates."
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $updaterPath -ManifestUrl $ManifestUrl
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "OCSentinel updater exited with code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Reading OCSentinel release manifest: $ManifestUrl"
|
||||
$manifest = Invoke-RestMethod -Method Get -Uri $ManifestUrl -TimeoutSec 60
|
||||
if ([string]::IsNullOrWhiteSpace($manifest.version) -or [string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
|
||||
throw "Release manifest is missing version, artifactUrl, or sha256."
|
||||
}
|
||||
|
||||
$downloadRoot = Join-Path $env:ProgramData ("OCSentinel\\bootstrap\\" + [Guid]::NewGuid().ToString("N"))
|
||||
$zipPath = Join-Path $downloadRoot "OCSentinelClient.zip"
|
||||
$extractRoot = Join-Path $downloadRoot "payload"
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null
|
||||
Write-Host "Downloading OCSentinel $($manifest.version)"
|
||||
Invoke-WebRequest -Uri ([string]$manifest.artifactUrl) -OutFile $zipPath -TimeoutSec 300
|
||||
|
||||
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
throw "SHA-256 mismatch for the downloaded OCSentinel package."
|
||||
}
|
||||
|
||||
Write-Host "Package hash verified. Extracting release payload."
|
||||
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractRoot -Force
|
||||
$payloadApp = Get-ChildItem -Path $extractRoot -Recurse -Filter "OCSentinelCli.exe" | Select-Object -First 1
|
||||
$installer = Get-ChildItem -Path $extractRoot -Recurse -Filter "install-ocsentinel.ps1" | Select-Object -First 1
|
||||
if ($null -eq $payloadApp -or $null -eq $installer) {
|
||||
throw "The downloaded package is incomplete."
|
||||
}
|
||||
|
||||
Assert-ArtifactSignature -ExecutablePath $payloadApp.FullName
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer.FullName
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "OCSentinel installer exited with code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $downloadRoot) {
|
||||
Remove-Item -LiteralPath $downloadRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $appPath)) {
|
||||
throw "OCSentinel installation completed, but the client executable was not found."
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
||||
if (-not (Test-Path -LiteralPath $clientConfigPath)) {
|
||||
throw "OCSentinel client configuration was not found: $clientConfigPath"
|
||||
}
|
||||
|
||||
$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json
|
||||
$clientConfig.n8nWebhookUrl = $WebhookUrl
|
||||
$clientConfig.environment = "production"
|
||||
$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8
|
||||
Write-Host "Configured OCSentinel upload endpoint."
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||
if (-not (Test-Path -LiteralPath $secretScriptPath)) {
|
||||
throw "OCSentinel secret bootstrap script was not found: $secretScriptPath"
|
||||
}
|
||||
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScriptPath -SecretValue $SecretValue
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "OCSentinel secret bootstrap failed with code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
if ($runInitialScan) {
|
||||
if (-not (Test-Path -LiteralPath $monitorPath)) {
|
||||
throw "OCSentinel was installed, but the monitor script is missing."
|
||||
}
|
||||
|
||||
Write-Host "Running initial OCSentinel status scan."
|
||||
$scanArguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $monitorPath, "-Mode", "status", "-OutputPath", "..\\reports\\ocsentinel-summary.json")
|
||||
if ((Test-Path -LiteralPath $clientConfigPath) -and (Test-Path -LiteralPath $secretPath)) {
|
||||
$scanArguments += @("-ClientConfigPath", $clientConfigPath, "-SecretPath", $secretPath, "-UploadMode", "required")
|
||||
}
|
||||
|
||||
& powershell.exe @scanArguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Initial OCSentinel status scan exited with code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "OCSentinel bootstrap completed successfully."
|
||||
62
scripts/configure-ocsentinel-ninja.ps1
Normal file
62
scripts/configure-ocsentinel-ninja.ps1
Normal file
@@ -0,0 +1,62 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$WebhookUrl = "",
|
||||
[string]$SecretValue = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-NinjaValue {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||
if ($null -eq $value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return $value.Trim()
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) {
|
||||
$WebhookUrl = Get-NinjaValue -Name "webhookurl"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||
$SecretValue = Get-NinjaValue -Name "secretvalue"
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or [string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||
throw "WebhookUrl and SecretValue must be supplied as NinjaOne script variables."
|
||||
}
|
||||
|
||||
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||
$configPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||
$secretScript = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
||||
$monitorScript = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||
|
||||
foreach ($path in @($configPath, $secretScript, $monitorScript)) {
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
throw "OCSentinel installation is incomplete. Missing: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
||||
$config.n8nWebhookUrl = $WebhookUrl
|
||||
$config.environment = "production"
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
||||
Write-Host "OCSentinel upload endpoint configured."
|
||||
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Writing the protected upload secret failed with code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "Running signed OCSentinel test scan and upload."
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorScript `
|
||||
-Mode status `
|
||||
-ClientConfigPath $configPath `
|
||||
-SecretPath $secretPath `
|
||||
-UploadMode required
|
||||
|
||||
exit $LASTEXITCODE
|
||||
74
scripts/install-ocsentinel-ninja-once.ps1
Normal file
74
scripts/install-ocsentinel-ninja-once.ps1
Normal file
@@ -0,0 +1,74 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json",
|
||||
[string]$WebhookUrl = "",
|
||||
[string]$SecretValue = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
function Get-NinjaValue {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||
if ($null -eq $value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return $value.Trim()
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) { $WebhookUrl = Get-NinjaValue -Name "webhookurl" }
|
||||
if ([string]::IsNullOrWhiteSpace($SecretValue)) { $SecretValue = Get-NinjaValue -Name "secretvalue" }
|
||||
if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or [string]::IsNullOrWhiteSpace($SecretValue)) {
|
||||
throw "WebhookUrl and SecretValue must be set as NinjaOne script variables."
|
||||
}
|
||||
|
||||
$manifest = Invoke-RestMethod -Method Get -Uri $ManifestUrl -TimeoutSec 60
|
||||
if ([string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
|
||||
throw "The release manifest is incomplete."
|
||||
}
|
||||
|
||||
$downloadRoot = Join-Path $env:ProgramData ("OCSentinel\\install-" + [Guid]::NewGuid().ToString("N"))
|
||||
$zipPath = Join-Path $downloadRoot "OCSentinelClient.zip"
|
||||
$extractPath = Join-Path $downloadRoot "payload"
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $extractPath | Out-Null
|
||||
Write-Host "Downloading OCSentinel $($manifest.version)."
|
||||
Invoke-WebRequest -Uri $manifest.artifactUrl -OutFile $zipPath -TimeoutSec 300
|
||||
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne ([string]$manifest.sha256).ToLowerInvariant()) {
|
||||
throw "Release package SHA-256 validation failed."
|
||||
}
|
||||
|
||||
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractPath -Force
|
||||
$installer = Get-ChildItem -Path $extractPath -Recurse -Filter "install-ocsentinel.ps1" | Select-Object -First 1
|
||||
if ($null -eq $installer) { throw "The release package does not contain the installer." }
|
||||
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer.FullName
|
||||
if ($LASTEXITCODE -ne 0) { throw "Installer failed with code $LASTEXITCODE" }
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $downloadRoot) { Remove-Item -LiteralPath $downloadRoot -Recurse -Force }
|
||||
}
|
||||
|
||||
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
|
||||
$configPath = Join-Path $installRoot "config\ocsentinel-client.json"
|
||||
$secretScript = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
|
||||
$monitorScript = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
|
||||
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
|
||||
|
||||
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
||||
$config.n8nWebhookUrl = $WebhookUrl
|
||||
$config.environment = "production"
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
||||
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue
|
||||
if ($LASTEXITCODE -ne 0) { throw "Writing the protected upload secret failed with code $LASTEXITCODE" }
|
||||
|
||||
Write-Host "Running initial signed scan and upload."
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorScript -Mode status -ClientConfigPath $configPath -SecretPath $secretPath -UploadMode required
|
||||
exit $LASTEXITCODE
|
||||
@@ -3,6 +3,10 @@ param(
|
||||
[int]$TopCount = 10,
|
||||
[string]$OutputPath = ".\reports\ocsentinel-summary.json",
|
||||
[string]$ConfigPath = ".\config\ocsentinel-settings.example.json",
|
||||
[string]$ClientConfigPath = ".\config\ocsentinel-client.json",
|
||||
[string]$SecretPath = "",
|
||||
[ValidateSet("disabled", "auto", "required")]
|
||||
[string]$UploadMode = "auto",
|
||||
[string]$VulnerabilityCsvPath = "",
|
||||
[string]$MirrorRoot = "",
|
||||
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
|
||||
@@ -142,9 +146,15 @@ $runnerArgs = @(
|
||||
"-LookbackDays", $LookbackDays,
|
||||
"-TopCount", $TopCount,
|
||||
"-OutputPath", $OutputPath,
|
||||
"-ConfigPath", $ConfigPath
|
||||
"-ConfigPath", $ConfigPath,
|
||||
"-ClientConfigPath", $ClientConfigPath,
|
||||
"-UploadMode", $UploadMode
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($SecretPath)) {
|
||||
$runnerArgs += @("-SecretPath", $SecretPath)
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) {
|
||||
$runnerArgs += @("-VulnerabilityCsvPath", $VulnerabilityCsvPath)
|
||||
}
|
||||
|
||||
124
scripts/setup-gitea-windows-runner.ps1
Normal file
124
scripts/setup-gitea-windows-runner.ps1
Normal file
@@ -0,0 +1,124 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RegistrationToken,
|
||||
|
||||
[string]$InstanceUrl = "https://gitea.officecom.cloud",
|
||||
[string]$RunnerName = "officecom-oc-sentinel-windows-01",
|
||||
[string]$RunnerVersion = "1.0.8",
|
||||
[string]$RunnerAccount = "OCGiteaRunner",
|
||||
[string]$InstallRoot = "$env:ProgramData\\OCGiteaRunner"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Test-IsAdministrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function New-RunnerPassword {
|
||||
# The account is only used by Task Scheduler; no password is persisted in this script or repository.
|
||||
$bytes = New-Object byte[] 36
|
||||
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
|
||||
return [Convert]::ToBase64String($bytes).Replace('+', 'A').Replace('/', 'B').Replace('=', 'C') + "!9z"
|
||||
}
|
||||
|
||||
if (-not (Test-IsAdministrator)) {
|
||||
throw "Run this script from an elevated PowerShell window (Run as administrator)."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RegistrationToken)) {
|
||||
$secureToken = Read-Host "Paste the repository runner registration token" -AsSecureString
|
||||
$tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
|
||||
try {
|
||||
$RegistrationToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer)
|
||||
}
|
||||
finally {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer)
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RegistrationToken)) {
|
||||
throw "A repository runner registration token is required."
|
||||
}
|
||||
|
||||
$taskName = "OCSentinel Gitea Windows Runner"
|
||||
$runnerPath = Join-Path $InstallRoot "gitea-runner.exe"
|
||||
$configPath = Join-Path $InstallRoot "config.yaml"
|
||||
$runnerStatePath = Join-Path $InstallRoot ".runner"
|
||||
$logDirectory = Join-Path $InstallRoot "logs"
|
||||
$downloadUrl = "https://gitea.com/gitea/act_runner/releases/download/v$RunnerVersion/gitea-runner-$RunnerVersion-windows-amd64.exe"
|
||||
$checksumUrl = "$downloadUrl.sha256"
|
||||
$accountQualifiedName = "$env:COMPUTERNAME\\$RunnerAccount"
|
||||
|
||||
if (Test-Path -LiteralPath $runnerStatePath) {
|
||||
throw "A runner is already registered at $InstallRoot. Remove it in Gitea first, then remove this directory if a new registration is needed."
|
||||
}
|
||||
|
||||
$existingAccount = Get-LocalUser -Name $RunnerAccount -ErrorAction SilentlyContinue
|
||||
if ($existingAccount) {
|
||||
throw "The local account '$RunnerAccount' already exists. Stop and remove the existing runner before reinstalling it."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $InstallRoot, $logDirectory | Out-Null
|
||||
|
||||
try {
|
||||
Write-Host "Downloading Gitea runner $RunnerVersion..."
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $downloadUrl -OutFile $runnerPath
|
||||
$checksumText = (Invoke-WebRequest -UseBasicParsing -Uri $checksumUrl).Content.Trim()
|
||||
$expectedHash = ($checksumText -split '\s+')[0].ToLowerInvariant()
|
||||
$actualHash = (Get-FileHash -LiteralPath $runnerPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
throw "Runner checksum verification failed."
|
||||
}
|
||||
|
||||
$password = New-RunnerPassword
|
||||
$securePassword = ConvertTo-SecureString -String $password -AsPlainText -Force
|
||||
New-LocalUser -Name $RunnerAccount -Password $securePassword -Description "Restricted account for the OCSentinel Gitea Actions runner." -AccountNeverExpires | Out-Null
|
||||
|
||||
$config = & $runnerPath generate-config
|
||||
$config = $config -replace '(?m)^ labels:.*$', ' labels: ["windows:host"]'
|
||||
Set-Content -LiteralPath $configPath -Value $config -Encoding utf8
|
||||
|
||||
# Build jobs run only with this non-administrative account and only for the repository runner token supplied.
|
||||
$acl = Get-Acl -LiteralPath $InstallRoot
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
$systemRule = New-Object Security.AccessControl.FileSystemAccessRule("SYSTEM", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow")
|
||||
$adminRule = New-Object Security.AccessControl.FileSystemAccessRule("BUILTIN\\Administrators", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow")
|
||||
$runnerRule = New-Object Security.AccessControl.FileSystemAccessRule($accountQualifiedName, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow")
|
||||
$acl.AddAccessRule($systemRule)
|
||||
$acl.AddAccessRule($adminRule)
|
||||
$acl.AddAccessRule($runnerRule)
|
||||
Set-Acl -LiteralPath $InstallRoot -AclObject $acl
|
||||
|
||||
$credential = New-Object Management.Automation.PSCredential($accountQualifiedName, $securePassword)
|
||||
$registerArgs = @(
|
||||
"--config", "`"$configPath`"", "register", "--no-interactive",
|
||||
"--instance", "`"$InstanceUrl`"", "--token", "`"$RegistrationToken`"",
|
||||
"--name", "`"$RunnerName`"", "--labels", "windows:host"
|
||||
) -join " "
|
||||
$registration = Start-Process -FilePath $runnerPath -ArgumentList $registerArgs -WorkingDirectory $InstallRoot -Credential $credential -Wait -PassThru
|
||||
if ($registration.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $runnerStatePath)) {
|
||||
throw "Runner registration failed with exit code $($registration.ExitCode)."
|
||||
}
|
||||
|
||||
$action = New-ScheduledTaskAction -Execute $runnerPath -Argument "--config `"$configPath`" daemon" -WorkingDirectory $InstallRoot
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -User $accountQualifiedName -Password $password -RunLevel Limited -Description "Runs the repository-scoped OCSentinel Gitea Actions runner." -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
$task = Get-ScheduledTask -TaskName $taskName
|
||||
Write-Host "Gitea runner installed successfully."
|
||||
Write-Host "Runner: $RunnerName"
|
||||
Write-Host "Labels: windows:host"
|
||||
Write-Host "Task: $taskName ($($task.State))"
|
||||
Write-Host "Install path: $InstallRoot"
|
||||
}
|
||||
catch {
|
||||
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
}
|
||||
throw
|
||||
}
|
||||
@@ -66,6 +66,7 @@ internal sealed class AttackScanner
|
||||
{
|
||||
SchemaVersion = "2.0",
|
||||
MachineName = Environment.MachineName,
|
||||
NinjaOne = GetNinjaOneContext(),
|
||||
GeneratedAtLocal = generatedAtLocal,
|
||||
GeneratedAtUtc = generatedAtUtc,
|
||||
ClientVersion = BuildMetadata.Version,
|
||||
@@ -89,6 +90,24 @@ internal sealed class AttackScanner
|
||||
};
|
||||
}
|
||||
|
||||
private static NinjaOneContext GetNinjaOneContext()
|
||||
{
|
||||
return new NinjaOneContext
|
||||
{
|
||||
OrganizationId = ReadEnvironmentVariable("NINJA_ORGANIZATION_ID"),
|
||||
OrganizationName = ReadEnvironmentVariable("NINJA_ORGANIZATION_NAME"),
|
||||
MachineId = ReadEnvironmentVariable("NINJA_AGENT_MACHINE_ID"),
|
||||
NodeId = ReadEnvironmentVariable("NINJA_AGENT_NODE_ID"),
|
||||
LocationId = ReadEnvironmentVariable("NINJA_LOCATION_ID"),
|
||||
LocationName = ReadEnvironmentVariable("NINJA_LOCATION_NAME")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ReadEnvironmentVariable(string name)
|
||||
{
|
||||
return Environment.GetEnvironmentVariable(name)?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static ScannerConfiguration LoadConfiguration(ScanOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ConfigPath))
|
||||
|
||||
@@ -5,7 +5,10 @@ internal static class ScanAndUploadCommand
|
||||
public static int Execute(string[] args)
|
||||
{
|
||||
string outputPath = @"C:\ProgramData\OCSentinel\reports\latest.json";
|
||||
string? clientConfigPath = null;
|
||||
string? secretPath = null;
|
||||
bool hasOutput = false;
|
||||
var scanArgs = new List<string>();
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
@@ -13,11 +16,26 @@ internal static class ScanAndUploadCommand
|
||||
{
|
||||
outputPath = args[i + 1];
|
||||
hasOutput = true;
|
||||
break;
|
||||
scanArgs.Add(args[i]);
|
||||
scanArgs.Add(args[++i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(args[i], "--client-config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||
{
|
||||
clientConfigPath = args[++i];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(args[i], "--secret-path", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||
{
|
||||
secretPath = args[++i];
|
||||
continue;
|
||||
}
|
||||
|
||||
scanArgs.Add(args[i]);
|
||||
}
|
||||
|
||||
List<string> scanArgs = [.. args];
|
||||
if (!hasOutput)
|
||||
{
|
||||
scanArgs.Add("--output");
|
||||
@@ -36,6 +54,16 @@ internal static class ScanAndUploadCommand
|
||||
outputPath
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(clientConfigPath))
|
||||
{
|
||||
uploadArgs.AddRange(["--client-config", clientConfigPath]);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(secretPath))
|
||||
{
|
||||
uploadArgs.AddRange(["--secret-path", secretPath]);
|
||||
}
|
||||
|
||||
return UploadCommand.Execute([.. uploadArgs]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ internal static class JsonOptions
|
||||
public static readonly JsonSerializerOptions Default = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
// Client configuration is also written by PowerShell/NinjaOne scripts.
|
||||
// Accept their conventional camelCase names (for example n8nWebhookUrl).
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ internal sealed record ScanResult
|
||||
|
||||
public string MachineName { get; init; } = string.Empty;
|
||||
|
||||
// Populated only for runs launched by NinjaOne automation.
|
||||
public NinjaOneContext NinjaOne { get; init; } = new();
|
||||
|
||||
public DateTimeOffset GeneratedAtLocal { get; init; }
|
||||
|
||||
public DateTimeOffset GeneratedAtUtc { get; init; }
|
||||
@@ -111,6 +114,21 @@ internal sealed record ScanResult
|
||||
public List<string> Errors { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed record NinjaOneContext
|
||||
{
|
||||
public string OrganizationId { get; init; } = string.Empty;
|
||||
|
||||
public string OrganizationName { get; init; } = string.Empty;
|
||||
|
||||
public string MachineId { get; init; } = string.Empty;
|
||||
|
||||
public string NodeId { get; init; } = string.Empty;
|
||||
|
||||
public string LocationId { get; init; } = string.Empty;
|
||||
|
||||
public string LocationName { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record ScanRuntimeMetadata
|
||||
{
|
||||
public DateTimeOffset StartedAtUtc { get; init; }
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
<RootNamespace>OCSentinelCli</RootNamespace>
|
||||
<Product>OfficeCom Sentinel</Product>
|
||||
<Company>OfficeCom</Company>
|
||||
<Version>1.2.3</Version>
|
||||
<AssemblyVersion>1.2.3.0</AssemblyVersion>
|
||||
<FileVersion>1.2.3.0</FileVersion>
|
||||
<InformationalVersion>1.2.3</InformationalVersion>
|
||||
<Version>1.2.10</Version>
|
||||
<AssemblyVersion>1.2.10.0</AssemblyVersion>
|
||||
<FileVersion>1.2.10.0</FileVersion>
|
||||
<InformationalVersion>1.2.10</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user