Files
oc-sentinel/scripts/run-attacktracer-ninja-server.ps1
2026-07-17 00:39:28 +02:00

308 lines
11 KiB
PowerShell

param(
[string]$ConfigPath = ".\config\attacktracer-server-settings.json"
)
$ErrorActionPreference = "Stop"
function Resolve-PathLike {
param(
[Parameter(Mandatory)]
[string]$PathValue,
[Parameter(Mandatory)]
[string]$BasePath
)
if ([string]::IsNullOrWhiteSpace($PathValue)) {
return $PathValue
}
if ([System.IO.Path]::IsPathRooted($PathValue) -or $PathValue.StartsWith("\\")) {
return [System.IO.Path]::GetFullPath($PathValue)
}
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
}
function Join-Url {
param(
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][string]$RelativePath
)
$base = $BaseUrl.TrimEnd('/')
$relative = $RelativePath.TrimStart('/')
return "$base/$relative"
}
function ConvertTo-PlainText {
param([Parameter(Mandatory)][string]$EncryptedValue)
$secure = ConvertTo-SecureString $EncryptedValue
$credential = New-Object System.Management.Automation.PSCredential("ignored", $secure)
return $credential.GetNetworkCredential().Password
}
function Normalize-NinjaScope {
param([AllowEmptyString()][string]$Scope)
if ([string]::IsNullOrWhiteSpace($Scope)) {
return ""
}
$tokens = @(
$Scope -split '[,\s;]+' |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object {
switch ($_.Trim().ToLowerInvariant()) {
"monitoring" { "monitoring"; break }
"uberwachen" { "monitoring"; break }
"ueberwachen" { "monitoring"; break }
"management" { "management"; break }
"verwalten" { "management"; break }
"control" { "control"; break }
"steuerung" { "control"; break }
"offline_access" { "offline_access"; break }
default { $_.Trim().ToLowerInvariant() }
}
}
)
return ($tokens | Select-Object -Unique) -join " "
}
function Get-OrganizationStatus {
param([pscustomobject[]]$Reports)
if (@($Reports | Where-Object { $_.AlertState -eq "critical" }).Count -gt 0) {
return "critical"
}
if (@($Reports | Where-Object { $_.AlertState -eq "warning" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 }).Count -gt 0) {
return "warning"
}
return "ok"
}
function Build-OrganizationSummaryText {
param([pscustomobject[]]$Reports)
$deviceCount = @($Reports).Count
$alertingCount = @($Reports | Where-Object { $_.AlertState -ne "ok" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 }).Count
$criticalCount = @($Reports | Where-Object { $_.AlertState -eq "critical" }).Count
$totalEvents = (@($Reports | Measure-Object -Property TotalEvents -Sum).Sum)
$totalEvents = if ($null -eq $totalEvents) { 0 } else { [int]$totalEvents }
$topSystems = @(
$Reports |
Where-Object { $_.AlertState -ne "ok" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 } |
Sort-Object @{ Expression = { $_.AlertState -eq "critical" }; Descending = $true }, @{ Expression = { [int]$_.TotalEvents }; Descending = $true } |
Select-Object -ExpandProperty MachineName -First 5
)
$summary = "$deviceCount Geraete gescannt, $alertingCount auffaellig, $criticalCount kritisch, $totalEvents Events."
if ($topSystems.Count -gt 0) {
$summary += " Top-Systeme: $($topSystems -join ', ')."
}
return $summary
}
function Get-LatestReports {
param([Parameter(Mandatory)][string]$ReportsRootPath)
$reportFiles = @()
if (Test-Path $ReportsRootPath -PathType Leaf) {
$reportFiles = @($ReportsRootPath)
}
elseif (Test-Path $ReportsRootPath -PathType Container) {
$reportFiles = @(Get-ChildItem -Path $ReportsRootPath -Recurse -Filter *.json | Select-Object -ExpandProperty FullName)
}
if ($reportFiles.Count -eq 0) {
throw "No report JSON files found under $ReportsRootPath"
}
$reports = foreach ($file in $reportFiles) {
try {
$report = Get-Content $file -Raw | ConvertFrom-Json
if ($report.MachineName) {
$report
}
}
catch {
Write-Warning "Skipping invalid report file ${file}: $($_.Exception.Message)"
}
}
if (@($reports).Count -eq 0) {
throw "No valid AttackTracer report files were parsed."
}
return @(
$reports |
Group-Object MachineName |
ForEach-Object {
$_.Group |
Sort-Object {
try {
[DateTimeOffset]::Parse([string]$_.GeneratedAtLocal)
}
catch {
[DateTimeOffset]::MinValue
}
} -Descending |
Select-Object -First 1
}
)
}
function Get-NinjaAccessToken {
param(
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][string]$ClientId,
[Parameter(Mandatory)][string]$ClientSecret,
[string]$Scope = ""
)
$body = @{
grant_type = "client_credentials"
client_id = $ClientId
client_secret = $ClientSecret
}
$normalizedScope = Normalize-NinjaScope -Scope $Scope
if (-not [string]::IsNullOrWhiteSpace($normalizedScope)) {
$body.scope = $normalizedScope
}
$tokenEndpoints = @(
(Join-Url -BaseUrl $BaseUrl -RelativePath "ws/oauth/token"),
(Join-Url -BaseUrl $BaseUrl -RelativePath "oauth/token")
)
$failures = New-Object System.Collections.Generic.List[string]
foreach ($tokenEndpoint in $tokenEndpoints) {
try {
Write-Host "Requesting NinjaOne OAuth token from $tokenEndpoint"
$response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $body -ContentType "application/x-www-form-urlencoded" -TimeoutSec 60
if (-not $response.access_token) {
throw "OAuth token response did not contain an access_token."
}
Write-Host "NinjaOne OAuth token acquired successfully"
return [string]$response.access_token
}
catch {
$message = "Token endpoint $tokenEndpoint failed: " + $_.Exception.Message
if ($_.ErrorDetails.Message) {
$message += " | " + $_.ErrorDetails.Message
}
$failures.Add($message)
}
}
throw "Failed to obtain NinjaOne OAuth token. " + ($failures -join " || ")
}
function Invoke-NinjaOrganizationFieldPatch {
param(
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][int]$OrganizationId,
[Parameter(Mandatory)][string]$AccessToken,
[Parameter(Mandatory)][hashtable]$FieldValues
)
$endpoint = (Join-Url -BaseUrl $BaseUrl -RelativePath "v2/organization/$OrganizationId/custom-fields")
$headers = @{
Authorization = "Bearer $AccessToken"
Accept = "application/json"
}
$payloadCandidates = @(
$FieldValues,
@{ customFields = $FieldValues },
@{ fields = @($FieldValues.GetEnumerator() | ForEach-Object { @{ name = $_.Key; value = $_.Value } }) }
)
$failures = New-Object System.Collections.Generic.List[string]
foreach ($payload in $payloadCandidates) {
try {
$json = $payload | ConvertTo-Json -Depth 8
$fieldNames = @($FieldValues.Keys) -join ", "
Write-Host "Organization custom fields endpoint: $endpoint"
Write-Host "Updating NinjaOne organization custom fields: $fieldNames"
Write-Host "PATCH payload size: $($json.Length) characters"
Invoke-RestMethod -Method Patch -Uri $endpoint -Headers $headers -ContentType "application/json" -Body $json -TimeoutSec 60 | Out-Null
Write-Host "NinjaOne organization custom fields updated successfully"
return
}
catch {
$message = $_.Exception.Message
if ($_.ErrorDetails.Message) {
$message += " | " + $_.ErrorDetails.Message
}
$failures.Add($message)
}
}
throw "Failed to update NinjaOne organization custom fields. " + ($failures -join " || ")
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$configBasePath = $PSScriptRoot
$configFullPath = Resolve-PathLike -PathValue $ConfigPath -BasePath $configBasePath
if (-not (Test-Path $configFullPath)) {
throw "Server config file not found: $configFullPath"
}
$config = Get-Content $configFullPath -Raw | ConvertFrom-Json
$reportsRootPath = Resolve-PathLike -PathValue $config.reportsRoot -BasePath $repoRoot
$htmlOutputPath = Resolve-PathLike -PathValue $config.htmlOutputPath -BasePath $repoRoot
$orgReportsScript = Join-Path $repoRoot "scripts\build-attacktracer-org-report.ps1"
& powershell -ExecutionPolicy Bypass -File $orgReportsScript -ReportsRoot $reportsRootPath -OutputPath $htmlOutputPath -MaxAlertRows $config.maxAlertRows
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$latestReports = Get-LatestReports -ReportsRootPath $reportsRootPath
$orgStatus = Get-OrganizationStatus -Reports $latestReports
$orgSummary = Build-OrganizationSummaryText -Reports $latestReports
$orgLastUpdate = (Get-Date).ToString("o")
$htmlContent = Get-Content $htmlOutputPath -Raw
Write-Host "Organization summary prepared"
Write-Host "HTML report size: $($htmlContent.Length) characters"
$clientSecret = ConvertTo-PlainText -EncryptedValue ([string]$config.clientSecretEncrypted)
$accessToken = Get-NinjaAccessToken -BaseUrl ([string]$config.ninjaBaseUrl) -ClientId ([string]$config.clientId) -ClientSecret $clientSecret -Scope ([string]$config.oauthScope)
$fieldValues = @{
([string]$config.statusFieldName) = $orgStatus
([string]$config.summaryFieldName) = $orgSummary
([string]$config.lastUpdateFieldName) = $orgLastUpdate
}
Invoke-NinjaOrganizationFieldPatch -BaseUrl ([string]$config.ninjaBaseUrl) -OrganizationId ([int]$config.organizationId) -AccessToken $accessToken -FieldValues $fieldValues
if ($config.updateHtmlField -and -not [string]::IsNullOrWhiteSpace([string]$config.htmlFieldName)) {
$htmlFieldName = [string]$config.htmlFieldName
Write-Host "Attempting separate HTML organization field update for '$htmlFieldName'"
try {
Invoke-NinjaOrganizationFieldPatch -BaseUrl ([string]$config.ninjaBaseUrl) -OrganizationId ([int]$config.organizationId) -AccessToken $accessToken -FieldValues @{
$htmlFieldName = $htmlContent
}
}
catch {
Write-Warning "HTML organization field update failed for '$htmlFieldName'. Keeping local HTML report only. $($_.Exception.Message)"
}
}
Write-Host "Organization HTML report written to $htmlOutputPath"
Write-Host "Organization custom fields updated via NinjaOne API"
Write-Host "Status field: $($config.statusFieldName)=$orgStatus"