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"