diff --git a/docs/ocsentinel-deployment.md b/docs/ocsentinel-deployment.md index 2410d5c..25566c2 100644 --- a/docs/ocsentinel-deployment.md +++ b/docs/ocsentinel-deployment.md @@ -112,6 +112,28 @@ Runtime: -OutputPath "..\reports\ocsentinel-summary.json" ``` +## Repair Missing NinjaOne Context + +NinjaOne exposes `NINJA_ORGANIZATION_ID`, `NINJA_ORGANIZATION_NAME`, +`NINJA_AGENT_MACHINE_ID`, and location values only while an automation runs. +The scheduled OCSentinel task runs later as `SYSTEM`, so those values must be +persisted during an actual NinjaOne automation. + +If the console shows `Organisation unbekannt`, create a temporary NinjaOne +PowerShell automation named `OCSentinel - NinjaOne Kontext aktualisieren` and +copy `scripts/refresh-ocsentinel-ninja-context.ps1` into the editor. Run it as +`SYSTEM` in 64-bit PowerShell once against the affected devices or policy. + +The script has no script variables and does the following safely: + +1. updates the installed client through the stable, version-independent manifest; +2. stores the current NinjaOne organization, location, node, and machine values; +3. starts one signed status scan and upload using that stored context. + +Expected output includes `OCSENTINEL_NINJA_CONTEXT=updated`. Do not run this +script from an interactive PowerShell session, because NinjaOne does not expose +the required environment values there. + ## Secret Bootstrap ```powershell diff --git a/scripts/configure-ocsentinel-ninja.ps1 b/scripts/configure-ocsentinel-ninja.ps1 index 2e67035..67ae1d5 100644 --- a/scripts/configure-ocsentinel-ninja.ps1 +++ b/scripts/configure-ocsentinel-ninja.ps1 @@ -44,6 +44,20 @@ foreach ($path in @($configPath, $secretScript, $monitorScript)) { $config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json $config.n8nWebhookUrl = $WebhookUrl $config.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)) { + $config | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force + } +} $config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8 Write-Host "OCSentinel upload endpoint configured." diff --git a/scripts/install-ocsentinel-ninja-once.ps1 b/scripts/install-ocsentinel-ninja-once.ps1 index befdbec..91c4f42 100644 --- a/scripts/install-ocsentinel-ninja-once.ps1 +++ b/scripts/install-ocsentinel-ninja-once.ps1 @@ -96,6 +96,20 @@ $secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat" $config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json $config.n8nWebhookUrl = $WebhookUrl $config.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)) { + $config | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force + } +} $config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8 & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue diff --git a/scripts/refresh-ocsentinel-ninja-context.ps1 b/scripts/refresh-ocsentinel-ninja-context.ps1 new file mode 100644 index 0000000..d4451a9 --- /dev/null +++ b/scripts/refresh-ocsentinel-ninja-context.ps1 @@ -0,0 +1,107 @@ +[CmdletBinding()] +param( + [string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json" +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +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 Read-NinjaEnvironmentValue { + param([Parameter(Mandatory)][string]$Name) + + $value = [Environment]::GetEnvironmentVariable($Name, "Process") + if ($null -eq $value) { + return "" + } + + return $value.Trim() +} + +Initialize-OCSentinelTls + +$installRoot = Join-Path $env:ProgramFiles "OCSentinel" +$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1" +$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1" +$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json" +$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat" + +foreach ($path in @($updaterPath, $clientConfigPath, $monitorPath)) { + if (-not (Test-Path -LiteralPath $path)) { + throw "OCSentinel installation is incomplete. Missing: $path" + } +} + +# The NinjaOne context exists only during this script execution. Upgrade first so +# future scheduled scans restore the context from the local client configuration. +$escapedUpdaterPath = $updaterPath.Replace("'", "''") +$escapedManifestUrl = $ManifestUrl.Replace("'", "''") +$updateCommand = @" +`$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 '$escapedManifestUrl' +exit `$LASTEXITCODE +"@ +& powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $updateCommand | ForEach-Object { Write-Host $_ } +if ($LASTEXITCODE -ne 0) { + throw "OCSentinel updater exited with code $LASTEXITCODE" +} + +$mappings = @( + @{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId"; Required = $true }, + @{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName"; Required = $true }, + @{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId"; Required = $true }, + @{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId"; Required = $false }, + @{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId"; Required = $false }, + @{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName"; Required = $false } +) + +$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json +$missing = @() +$captured = 0 +foreach ($mapping in $mappings) { + $value = Read-NinjaEnvironmentValue -Name $mapping.EnvironmentName + if ([string]::IsNullOrWhiteSpace($value)) { + if ($mapping.Required) { $missing += $mapping.EnvironmentName } + continue + } + + $clientConfig | Add-Member -NotePropertyName $mapping.PropertyName -NotePropertyValue $value -Force + $captured++ +} + +if ($missing.Count -gt 0) { + throw "NinjaOne did not provide required context: $($missing -join ', '). Run this only from a NinjaOne automation, not from an interactive PowerShell session." +} + +$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8 +Write-Host "OCSentinel NinjaOne context captured: $captured of $($mappings.Count) values." + +if ((Test-Path -LiteralPath $secretPath) -and -not [string]::IsNullOrWhiteSpace([string]$clientConfig.n8nWebhookUrl)) { + Write-Host "Running an immediate status scan and upload with the refreshed NinjaOne context." + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorPath ` + -Mode status ` + -ClientConfigPath $clientConfigPath ` + -SecretPath $secretPath ` + -UploadMode required ` + -SuppressTriggerExit + if ($LASTEXITCODE -ne 0) { + throw "OCSentinel context refresh scan exited with code $LASTEXITCODE" + } +} +else { + Write-Warning "Context was stored, but the upload configuration or protected secret is missing. The next configured scan will use the stored context." +} + +Write-Host "OCSENTINEL_NINJA_CONTEXT=updated" diff --git a/src/OCSentinelCli/AttackScanner.cs b/src/OCSentinelCli/AttackScanner.cs index 5c0edf3..6ebc6db 100644 --- a/src/OCSentinelCli/AttackScanner.cs +++ b/src/OCSentinelCli/AttackScanner.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Net; using System.Runtime.Versioning; using System.Text.RegularExpressions; +using OCSentinelCli.Configuration; namespace OCSentinelCli; @@ -68,7 +69,7 @@ internal sealed class AttackScanner { SchemaVersion = "2.0", MachineName = Environment.MachineName, - NinjaOne = GetNinjaOneContext(), + NinjaOne = GetNinjaOneContext(options, errors), GeneratedAtLocal = generatedAtLocal, GeneratedAtUtc = generatedAtUtc, ClientVersion = BuildMetadata.Version, @@ -93,19 +94,38 @@ internal sealed class AttackScanner }; } - private static NinjaOneContext GetNinjaOneContext() + private static NinjaOneContext GetNinjaOneContext(ScanOptions options, List errors) { + ClientConfiguration? clientConfiguration = null; + if (!string.IsNullOrWhiteSpace(options.ClientConfigPath)) + { + try + { + clientConfiguration = ClientConfiguration.Load(options.ClientConfigPath); + } + catch (Exception exception) + { + errors.Add($"Could not load persisted NinjaOne context: {exception.Message}"); + } + } + 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") + OrganizationId = ReadContextValue("NINJA_ORGANIZATION_ID", clientConfiguration?.NinjaOrganizationId), + OrganizationName = ReadContextValue("NINJA_ORGANIZATION_NAME", clientConfiguration?.NinjaOrganizationName), + MachineId = ReadContextValue("NINJA_AGENT_MACHINE_ID", clientConfiguration?.NinjaMachineId), + NodeId = ReadContextValue("NINJA_AGENT_NODE_ID", clientConfiguration?.NinjaNodeId), + LocationId = ReadContextValue("NINJA_LOCATION_ID", clientConfiguration?.NinjaLocationId), + LocationName = ReadContextValue("NINJA_LOCATION_NAME", clientConfiguration?.NinjaLocationName) }; } + private static string ReadContextValue(string environmentName, string? persistedValue) + { + string currentValue = ReadEnvironmentVariable(environmentName); + return string.IsNullOrWhiteSpace(currentValue) ? persistedValue?.Trim() ?? string.Empty : currentValue; + } + private static string ReadEnvironmentVariable(string name) { return Environment.GetEnvironmentVariable(name)?.Trim() ?? string.Empty; diff --git a/src/OCSentinelCli/Commands/ScanAndUploadCommand.cs b/src/OCSentinelCli/Commands/ScanAndUploadCommand.cs index 61d0734..dd64b7f 100644 --- a/src/OCSentinelCli/Commands/ScanAndUploadCommand.cs +++ b/src/OCSentinelCli/Commands/ScanAndUploadCommand.cs @@ -23,7 +23,10 @@ internal static class ScanAndUploadCommand if (string.Equals(args[i], "--client-config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) { - clientConfigPath = args[++i]; + string configPathValue = args[++i]; + clientConfigPath = configPathValue; + scanArgs.Add("--client-config"); + scanArgs.Add(configPathValue); continue; } diff --git a/src/OCSentinelCli/Configuration/ClientConfiguration.cs b/src/OCSentinelCli/Configuration/ClientConfiguration.cs index f908f91..99b7fb0 100644 --- a/src/OCSentinelCli/Configuration/ClientConfiguration.cs +++ b/src/OCSentinelCli/Configuration/ClientConfiguration.cs @@ -14,6 +14,18 @@ internal sealed record ClientConfiguration public string N8nWebhookUrl { get; init; } = string.Empty; + public string NinjaOrganizationId { get; init; } = string.Empty; + + public string NinjaOrganizationName { get; init; } = string.Empty; + + public string NinjaMachineId { get; init; } = string.Empty; + + public string NinjaNodeId { get; init; } = string.Empty; + + public string NinjaLocationId { get; init; } = string.Empty; + + public string NinjaLocationName { get; init; } = string.Empty; + public string DeviceIdentifierMode { get; init; } = "machineName"; public int UploadTimeoutSeconds { get; init; } = 30; diff --git a/src/OCSentinelCli/ScanOptions.cs b/src/OCSentinelCli/ScanOptions.cs index 5cbc518..af9b8e2 100644 --- a/src/OCSentinelCli/ScanOptions.cs +++ b/src/OCSentinelCli/ScanOptions.cs @@ -4,13 +4,14 @@ internal sealed record ScanOptions { public const string Usage = """ Usage: - OCSentinelCli [--output ] [--lookback-days ] [--top ] [--config ] [--vulnerability-csv ] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help] + OCSentinelCli [--output ] [--lookback-days ] [--top ] [--config ] [--client-config ] [--vulnerability-csv ] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help] Options: --output Write the JSON report to the given file. --lookback-days Only include events newer than now minus n days. Default: 30 --top Number of aggregated source IPs to show. Default: 10 --config Load thresholds, path overrides, and exclusions from JSON. + --client-config Load persisted NinjaOne identity and upload settings from JSON. --vulnerability-csv Correlate local attack results with exported CVE data for this host. --json-only Print only JSON to stdout. @@ -36,6 +37,8 @@ Options: public string? ConfigPath { get; init; } + public string? ClientConfigPath { get; init; } + public string? VulnerabilityCsvPath { get; init; } public bool ShowHelp { get; init; } @@ -72,6 +75,9 @@ Options: case "--config": options = options with { ConfigPath = ReadValue(args, ref i, arg) }; break; + case "--client-config": + options = options with { ClientConfigPath = ReadValue(args, ref i, arg) }; + break; case "--vulnerability-csv": options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) }; break;