Initial OfficeCom Sentinel client and deployment assets

This commit is contained in:
OfficeCom Codex
2026-07-17 00:39:28 +02:00
commit 7cdc0395c4
58 changed files with 6199 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
param(
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectPath = Join-Path $repoRoot "src\AttackTracerNinjaCli\AttackTracerNinjaCli.csproj"
$installerRoot = Join-Path $repoRoot "installer"
$artifactsRoot = Join-Path $repoRoot "artifacts"
$publishRoot = Join-Path $artifactsRoot "publish\win-x64"
$packageRoot = Join-Path $artifactsRoot "installer-payload"
$bootstrapperProject = Join-Path $repoRoot "installer\AttackTracerNinjaBootstrapper\AttackTracerNinjaBootstrapper.csproj"
$bootstrapperPayload = Join-Path $repoRoot "installer\AttackTracerNinjaBootstrapper\payload.zip"
$bootstrapperPublish = Join-Path $artifactsRoot "bootstrapper\win-x64"
$outputExe = Join-Path $artifactsRoot "AttackTracerNinjaSetup.exe"
function Get-ShortPath([string]$path) {
$resolved = [System.IO.Path]::GetFullPath($path)
$cmdPath = $resolved.Replace('"', '""')
$shortPath = cmd /c "for %I in (""$cmdPath"") do @echo %~sI"
return ($shortPath | Select-Object -Last 1).Trim()
}
[xml]$projectXml = Get-Content $projectPath
$version = $projectXml.Project.PropertyGroup.Version | Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($version)) {
$version = "1.2.2"
}
Write-Host "Publishing AttackTracerNinja version $version"
if (Test-Path $publishRoot) { Remove-Item -LiteralPath $publishRoot -Recurse -Force }
if (Test-Path $packageRoot) { Remove-Item -LiteralPath $packageRoot -Recurse -Force }
if (Test-Path $bootstrapperPublish) { Remove-Item -LiteralPath $bootstrapperPublish -Recurse -Force }
if (Test-Path $bootstrapperPayload) { Remove-Item -LiteralPath $bootstrapperPayload -Force }
if (Test-Path $outputExe) { Remove-Item -LiteralPath $outputExe -Force }
New-Item -ItemType Directory -Force -Path $publishRoot, $packageRoot, $bootstrapperPublish | Out-Null
& dotnet restore $projectPath -r win-x64
if ($LASTEXITCODE -ne 0) {
throw "dotnet restore failed"
}
& dotnet publish $projectPath `
-c $Configuration `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-o $publishRoot
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed"
}
Copy-Item -Path (Join-Path $publishRoot "AttackTracerNinjaCli.exe") -Destination (Join-Path $packageRoot "AttackTracerNinjaCli.exe") -Force
if (Test-Path (Join-Path $publishRoot "AttackTracerNinjaCli.pdb")) {
Copy-Item -Path (Join-Path $publishRoot "AttackTracerNinjaCli.pdb") -Destination (Join-Path $packageRoot "AttackTracerNinjaCli.pdb") -Force
}
Copy-Item -Path (Join-Path $repoRoot "config\attacktracer-settings.example.json") -Destination (Join-Path $packageRoot "attacktracer-settings.example.json") -Force
Copy-Item -Path (Join-Path $repoRoot "config\attacktracer-client.example.json") -Destination (Join-Path $packageRoot "attacktracer-client.example.json") -Force
Copy-Item -Path (Join-Path $repoRoot "config\update-channel.example.json") -Destination (Join-Path $packageRoot "update-channel.example.json") -Force
Copy-Item -Path (Join-Path $repoRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $packageRoot "ninja-vulnerability-export.example.csv") -Force
Copy-Item -Path (Join-Path $installerRoot "install-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "install-attacktracer-ninja.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "launch-install.cmd") -Destination (Join-Path $packageRoot "launch-install.cmd") -Force
Copy-Item -Path (Join-Path $installerRoot "uninstall-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "uninstall-attacktracer-ninja.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "runtime-run-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "run-attacktracer-ninja.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "runtime-run-attacktracer-ninja-monitor.ps1") -Destination (Join-Path $packageRoot "run-attacktracer-ninja-monitor.ps1") -Force
Copy-Item -Path (Join-Path $repoRoot "scripts\protect-attacktracer-secret.ps1") -Destination (Join-Path $packageRoot "protect-attacktracer-secret.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "update-attacktracer-ninja.ps1") -Destination (Join-Path $packageRoot "update-attacktracer-ninja.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "runtime-build-attacktracer-org-report.ps1") -Destination (Join-Path $packageRoot "build-attacktracer-org-report.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "README.txt") -Destination (Join-Path $packageRoot "README.txt") -Force
Set-Content -Path (Join-Path $packageRoot "VERSION.txt") -Value $version -NoNewline
Write-Host "Creating embedded payload zip"
Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $bootstrapperPayload -CompressionLevel Optimal -Force
Write-Host "Publishing bootstrapper installer"
& dotnet restore $bootstrapperProject -r win-x64
if ($LASTEXITCODE -ne 0) {
throw "Bootstrapper restore failed"
}
& dotnet publish $bootstrapperProject `
-c $Configuration `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-o $bootstrapperPublish
if ($LASTEXITCODE -ne 0) {
throw "Bootstrapper publish failed"
}
Copy-Item -Path (Join-Path $bootstrapperPublish "AttackTracerNinjaBootstrapper.exe") -Destination $outputExe -Force
Write-Host "Installer created at $outputExe"

View File

@@ -0,0 +1,306 @@
param(
[string]$ReportsRoot = ".\reports",
[string]$OutputPath = ".\reports\attacktracer-org-report.html",
[int]$MaxAlertRows = 25,
[switch]$WriteNinjaOrgSummary,
[switch]$EmitHtml
)
$ErrorActionPreference = "Stop"
function Escape-Html {
param([AllowNull()][string]$Value)
if ($null -eq $Value) {
return ""
}
return [System.Net.WebUtility]::HtmlEncode($Value)
}
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 Get-IncidentLabel {
param([pscustomobject]$Source)
$target = @($Source.Targets)[0]
$origin = @($Source.Sources)[0]
if ($target -match "Windows login") { return "Win Login-Fail" }
if ($target -match "SQL Server") { return "SQL Login-Fail" }
if ($target -match "Exchange") { return "Exchange Login-Fail" }
if ($target -match "FTP") { return "FTP Login-Fail" }
if ($origin) { return [string]$origin }
if ($target) { return [string]$target }
return "Auffaelligkeit"
}
function Get-AlertRows {
param([pscustomobject]$Report)
$rows = @()
foreach ($source in @($Report.TopSources)) {
$rows += [pscustomobject]@{
MachineName = [string]$Report.MachineName
Incident = Get-IncidentLabel -Source $source
Account = if (@($source.Usernames).Count -gt 0) { [string](@($source.Usernames)[0]) } else { "-" }
Timestamp = [string]$source.LastSeenLocal
Count = [int]$source.Count
Ip = [string]$source.SourceIp
Status = [string]$Report.AlertState
}
}
if ($rows.Count -eq 0 -and ([string]$Report.AlertState -ne "ok" -or [int]$Report.TotalEvents -gt 0 -or [int]$Report.VulnerabilityCorrelation.CriticalCount -gt 0)) {
$rows += [pscustomobject]@{
MachineName = [string]$Report.MachineName
Incident = if ([int]$Report.VulnerabilityCorrelation.CriticalCount -gt 0) { "CVE Korrelation" } else { "Auffaelligkeit" }
Account = "-"
Timestamp = [string]$Report.GeneratedAtLocal
Count = [Math]::Max([int]$Report.TotalEvents, [int]$Report.VulnerabilityCorrelation.CriticalCount)
Ip = "-"
Status = [string]$Report.AlertState
}
}
return $rows
}
function Build-OrgReportHtml {
param(
[pscustomobject[]]$Reports,
[int]$MaxRows
)
$sortedReports = @($Reports | Sort-Object MachineName)
$alertingReports = @($sortedReports | Where-Object { $_.AlertState -ne "ok" -or $_.TotalEvents -gt 0 -or $_.VulnerabilityCorrelation.CriticalCount -gt 0 })
$criticalReports = @($sortedReports | Where-Object { $_.AlertState -eq "critical" })
$totalEvents = (@($sortedReports | Measure-Object -Property TotalEvents -Sum).Sum)
$totalEvents = if ($null -eq $totalEvents) { 0 } else { [int]$totalEvents }
$allIps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($report in $sortedReports) {
foreach ($source in @($report.TopSources)) {
if (-not [string]::IsNullOrWhiteSpace([string]$source.SourceIp)) {
$null = $allIps.Add([string]$source.SourceIp)
}
}
}
$alertRows = foreach ($report in $alertingReports) {
Get-AlertRows -Report $report
}
$alertRows = @($alertRows | Sort-Object @{ Expression = { $_.Status -eq "critical" }; Descending = $true }, @{ Expression = { [DateTimeOffset]::Parse($_.Timestamp) }; Descending = $true })
if ($alertRows.Count -gt $MaxRows) {
$alertRows = @($alertRows | Select-Object -First $MaxRows)
}
$cleanDevices = @($sortedReports | Where-Object { $_.AlertState -eq "ok" -and $_.TotalEvents -eq 0 -and $_.VulnerabilityCorrelation.CriticalCount -eq 0 } | Select-Object -ExpandProperty MachineName -Unique)
$generatedAt = (Get-Date).ToString("dd.MM.yyyy HH:mm:ss")
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine('<div style="font-family: Segoe UI, Tahoma, sans-serif; font-size: 12px; color: #333;">')
[void]$sb.AppendLine(" <h3 style=""color: #1e3a8a; margin-bottom: 8px; font-size: 14px;"">AttackTracer Report - $(Escape-Html ((Get-Date).ToString("dd.MM.yyyy")))</h3>")
[void]$sb.AppendLine(" <div style=""margin-bottom: 10px; padding: 8px; background-color: #eff6ff; border: 1px solid #bfdbfe; color: #1e3a8a;""><strong>$(Escape-Html ([string]$sortedReports.Count)) Geraete</strong> gescannt | <strong>$(Escape-Html ([string]$alertingReports.Count)) auffaellig</strong> | <strong>$(Escape-Html ([string]$criticalReports.Count)) kritisch</strong> | <strong>$(Escape-Html ([string]$totalEvents)) Events</strong> | <strong>$(Escape-Html ([string]$allIps.Count)) eindeutige IPs</strong></div>")
[void]$sb.AppendLine(' <table style="width: 100%; border-collapse: collapse; text-align: left;" border="1" cellpadding="4">')
[void]$sb.AppendLine(' <tr style="background-color: #1e3a8a; color: white;">')
[void]$sb.AppendLine(' <th>Server</th>')
[void]$sb.AppendLine(' <th>Vorfall</th>')
[void]$sb.AppendLine(' <th>Konto</th>')
[void]$sb.AppendLine(' <th>Zeitpunkt</th>')
[void]$sb.AppendLine(' <th>Anzahl</th>')
[void]$sb.AppendLine(' <th>IP</th>')
[void]$sb.AppendLine(' </tr>')
if ($alertRows.Count -eq 0) {
[void]$sb.AppendLine(' <tr style="background-color: #f0fdf4; color: #166534;">')
[void]$sb.AppendLine(' <td colspan="6">Keine Angriffe oder Korrelationen im ausgewerteten Bestand gefunden.</td>')
[void]$sb.AppendLine(' </tr>')
}
else {
foreach ($row in $alertRows) {
$rowStyle = if ($row.Status -eq "critical") { "background-color: #fee2e2; color: #991b1b;" } else { "background-color: #fff7ed; color: #c2410c;" }
$timestampText = $row.Timestamp
try {
$timestampText = ([DateTimeOffset]::Parse($row.Timestamp)).ToString("dd.MM.yy HH:mm")
}
catch {
}
[void]$sb.AppendLine(" <tr style=""$rowStyle"">")
[void]$sb.AppendLine(" <td>$(Escape-Html $row.MachineName)</td>")
[void]$sb.AppendLine(" <td>$(Escape-Html $row.Incident)</td>")
[void]$sb.AppendLine(" <td>$(Escape-Html $row.Account)</td>")
[void]$sb.AppendLine(" <td>$(Escape-Html $timestampText)</td>")
[void]$sb.AppendLine(" <td>$(Escape-Html ([string]$row.Count))</td>")
[void]$sb.AppendLine(" <td>$(Escape-Html $row.Ip)</td>")
[void]$sb.AppendLine(' </tr>')
}
}
[void]$sb.AppendLine(' </table>')
if ($cleanDevices.Count -gt 0) {
[void]$sb.AppendLine(' <div style="margin-top: 10px; font-size: 11px; color: #166534; background-color: #f0fdf4; padding: 6px; border: 1px solid #bbf7d0;">')
[void]$sb.AppendLine(" <strong>Log sauber / Keine Angriffe:</strong> $(Escape-Html ($cleanDevices -join ', '))")
[void]$sb.AppendLine(' </div>')
}
[void]$sb.AppendLine(" <div style=""margin-top: 5px; font-size: 10px; color: #6b7280; text-align: right;"">Automatisch generiert am $(Escape-Html $generatedAt)</div>")
[void]$sb.AppendLine('</div>')
return $sb.ToString()
}
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 Set-NinjaOrganizationFieldValue {
param(
[Parameter(Mandatory)]
[string]$Name,
[AllowEmptyString()]
[string]$Value
)
if (Get-Command -Name "Set-NinjaOrganizationProperty" -ErrorAction SilentlyContinue) {
Set-NinjaOrganizationProperty -Name $Name -Value $Value | Out-Null
return "Set-NinjaOrganizationProperty"
}
if (Get-Command -Name "Ninja-Organization-Property-Set" -ErrorAction SilentlyContinue) {
Ninja-Organization-Property-Set $Name $Value | Out-Null
return "Ninja-Organization-Property-Set"
}
throw "No supported NinjaOne organization custom field writer was available."
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$reportsRootPath = Resolve-PathLike -PathValue $ReportsRoot -BasePath $repoRoot
$outputPathFull = Resolve-PathLike -PathValue $OutputPath -BasePath $repoRoot
$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."
}
$latestReports = @(
$reports |
Group-Object MachineName |
ForEach-Object {
$_.Group |
Sort-Object {
try {
[DateTimeOffset]::Parse([string]$_.GeneratedAtLocal)
}
catch {
[DateTimeOffset]::MinValue
}
} -Descending |
Select-Object -First 1
}
)
$html = Build-OrgReportHtml -Reports $latestReports -MaxRows $MaxAlertRows
$orgStatus = Get-OrganizationStatus -Reports $latestReports
$orgSummary = Build-OrganizationSummaryText -Reports $latestReports
$orgLastUpdate = (Get-Date).ToString("o")
$outputDirectory = Split-Path -Parent $outputPathFull
if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) {
if (-not (Test-Path $outputDirectory)) {
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
}
}
Set-Content -Path $outputPathFull -Value $html -Encoding UTF8
Write-Host "Organization HTML report written to $outputPathFull"
if ($WriteNinjaOrgSummary) {
$writer = Set-NinjaOrganizationFieldValue -Name "attacktracerorgstatus" -Value $orgStatus
Set-NinjaOrganizationFieldValue -Name "attacktracerorgsummary" -Value $orgSummary | Out-Null
Set-NinjaOrganizationFieldValue -Name "attacktracerorglastupdate" -Value $orgLastUpdate | Out-Null
Write-Host "Organization summary fields updated via $writer"
}
if ($EmitHtml) {
Write-Output $html
}

View File

@@ -0,0 +1,55 @@
param(
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$installerRoot = Join-Path $repoRoot "installer"
$artifactsRoot = Join-Path $repoRoot "artifacts"
$packageRoot = Join-Path $artifactsRoot "server-installer-payload"
$bootstrapperProject = Join-Path $repoRoot "installer\AttackTracerNinjaServerBootstrapper\AttackTracerNinjaServerBootstrapper.csproj"
$bootstrapperPayload = Join-Path $repoRoot "installer\AttackTracerNinjaServerBootstrapper\payload.zip"
$bootstrapperPublish = Join-Path $artifactsRoot "server-bootstrapper\win-x64"
$outputExe = Join-Path $artifactsRoot "AttackTracerNinjaServerSetup.exe"
$version = "1.0.9"
Write-Host "Publishing AttackTracerNinjaServer version $version"
if (Test-Path $packageRoot) { Remove-Item -LiteralPath $packageRoot -Recurse -Force }
if (Test-Path $bootstrapperPublish) { Remove-Item -LiteralPath $bootstrapperPublish -Recurse -Force }
if (Test-Path $bootstrapperPayload) { Remove-Item -LiteralPath $bootstrapperPayload -Force }
if (Test-Path $outputExe) { Remove-Item -LiteralPath $outputExe -Force }
New-Item -ItemType Directory -Force -Path $packageRoot, $bootstrapperPublish | Out-Null
Copy-Item -Path (Join-Path $repoRoot "config\attacktracer-server-settings.example.json") -Destination (Join-Path $packageRoot "attacktracer-server-settings.example.json") -Force
Copy-Item -Path (Join-Path $installerRoot "server-install-attacktracer-ninja-server.ps1") -Destination (Join-Path $packageRoot "install-attacktracer-ninja-server.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "server-uninstall-attacktracer-ninja-server.ps1") -Destination (Join-Path $packageRoot "uninstall-attacktracer-ninja-server.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "runtime-build-attacktracer-org-report.ps1") -Destination (Join-Path $packageRoot "build-attacktracer-org-report.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "runtime-run-attacktracer-ninja-server.ps1") -Destination (Join-Path $packageRoot "run-attacktracer-ninja-server.ps1") -Force
Set-Content -Path (Join-Path $packageRoot "VERSION.txt") -Value $version -NoNewline
Write-Host "Creating embedded payload zip"
Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $bootstrapperPayload -CompressionLevel Optimal -Force
Write-Host "Publishing server bootstrapper installer"
& dotnet restore $bootstrapperProject -r win-x64
if ($LASTEXITCODE -ne 0) {
throw "Bootstrapper restore failed"
}
& dotnet publish $bootstrapperProject `
-c $Configuration `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-o $bootstrapperPublish
if ($LASTEXITCODE -ne 0) {
throw "Bootstrapper publish failed"
}
Copy-Item -Path (Join-Path $bootstrapperPublish "AttackTracerNinjaServerBootstrapper.exe") -Destination $outputExe -Force
Write-Host "Server installer created at $outputExe"

View File

@@ -0,0 +1,72 @@
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$setupExe = Join-Path $repoRoot "SetupAttackTracer.exe"
$payloadDir = Join-Path $repoRoot "payload"
$msiAdminDir = Join-Path $repoRoot "msi-admin"
$decompiledDir = Join-Path $repoRoot "decompiled\AttackTracer"
$ilspy = Join-Path $env:USERPROFILE ".dotnet\tools\ilspycmd.exe"
if (-not (Test-Path $setupExe)) {
throw "SetupAttackTracer.exe not found at $setupExe"
}
if (-not (Test-Path $ilspy)) {
throw "ilspycmd.exe not found at $ilspy"
}
New-Item -ItemType Directory -Force -Path $payloadDir | Out-Null
New-Item -ItemType Directory -Force -Path $msiAdminDir | Out-Null
New-Item -ItemType Directory -Force -Path $decompiledDir | Out-Null
$tempBefore = @(Get-ChildItem $env:TEMP -Directory | Select-Object -ExpandProperty FullName)
$proc = Start-Process -FilePath $setupExe -PassThru
Start-Sleep -Seconds 4
$tempAfter = @(Get-ChildItem $env:TEMP -Directory | Select-Object -ExpandProperty FullName)
$newTempDirs = Compare-Object $tempBefore $tempAfter |
Where-Object SideIndicator -eq "=>" |
Select-Object -ExpandProperty InputObject
try {
if (-not $newTempDirs) {
throw "No new temp directory detected while launching SetupAttackTracer.exe"
}
$payloadSource = $null
foreach ($dir in $newTempDirs) {
if (Test-Path (Join-Path $dir "AttackTracer.msi")) {
$payloadSource = $dir
break
}
}
if (-not $payloadSource) {
throw "Could not locate AttackTracer.msi in the installer temp directories"
}
Copy-Item -Path (Join-Path $payloadSource "*") -Destination $payloadDir -Recurse -Force
$msiPath = Join-Path $payloadDir "AttackTracer.msi"
if (-not (Test-Path $msiPath)) {
throw "AttackTracer.msi was not copied into $payloadDir"
}
& msiexec /a $msiPath /qn TARGETDIR=$msiAdminDir
$appExe = Join-Path $msiAdminDir "program files\Servolutions\BotFence\AttackTracer.exe"
if (-not (Test-Path $appExe)) {
throw "Deployed application executable not found at $appExe"
}
& $ilspy -p -o $decompiledDir $appExe
Write-Host "Payload extracted to: $payloadDir"
Write-Host "MSI admin image: $msiAdminDir"
Write-Host "Decompiled sources: $decompiledDir"
}
finally {
if ($proc -and -not $proc.HasExited) {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
}

View File

@@ -0,0 +1,25 @@
param(
[Parameter(Mandatory)]
[string]$SecretValue,
[string]$OutputPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Security
$outputFullPath = [System.IO.Path]::GetFullPath($OutputPath)
$outputDirectory = Split-Path -Parent $outputFullPath
if (-not [string]::IsNullOrWhiteSpace($outputDirectory)) {
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
}
$secretBytes = [System.Text.Encoding]::UTF8.GetBytes($SecretValue)
$protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect(
$secretBytes,
$null,
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
)
[System.IO.File]::WriteAllBytes($outputFullPath, $protectedBytes)
Write-Host "Protected secret written to $outputFullPath"

View File

@@ -0,0 +1,213 @@
param(
[int]$LookbackDays = 7,
[int]$TopCount = 10,
[string]$OutputPath = ".\reports\attacktracer-summary.json",
[string]$ConfigPath = ".\config\attacktracer-settings.example.json",
[string]$VulnerabilityCsvPath = "",
[string]$MirrorRoot = "",
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
[string]$Mode = "status"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$runnerScript = Join-Path $repoRoot "scripts\run-attacktracer-ninja.ps1"
$outputFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputPath))
$script:NinjaFieldBackend = $null
$script:NinjaCliPath = "C:\ProgramData\NinjaRMMAgent\ninjarmm-cli.exe"
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 Initialize-NinjaFieldWriter {
if ($null -ne $script:NinjaFieldBackend) {
return
}
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
$script:NinjaFieldBackend = "powershell"
return
}
if (Test-Path $script:NinjaCliPath) {
$script:NinjaFieldBackend = "cli"
return
}
$script:NinjaFieldBackend = "none"
}
function Set-NinjaCustomFieldValue {
param(
[Parameter(Mandatory)]
[string]$Name,
[AllowEmptyString()]
[string]$Value
)
Initialize-NinjaFieldWriter
switch ($script:NinjaFieldBackend) {
"powershell" {
Ninja-Property-Set $Name $Value | Out-Null
return $true
}
"cli" {
& $script:NinjaCliPath set $Name $Value | Out-Null
return $LASTEXITCODE -eq 0
}
default {
return $false
}
}
}
function Publish-NinjaCustomFields {
param(
[Parameter(Mandatory)]
[pscustomobject]$Report,
[Parameter(Mandatory)]
[string]$Mode,
[Parameter(Mandatory)]
[bool]$Triggered,
[Parameter(Mandatory)]
[string]$Reason
)
Initialize-NinjaFieldWriter
if ($script:NinjaFieldBackend -eq "none") {
Write-Host "Ninja custom fields: skipped (Ninja field writer not available)."
return
}
$generatedAtUtc = ""
if ($Report.GeneratedAtLocal) {
try {
$generatedAtUtc = ([DateTimeOffset]$Report.GeneratedAtLocal).ToUniversalTime().ToString("o")
}
catch {
$generatedAtUtc = [string]$Report.GeneratedAtLocal
}
}
$fieldValues = [ordered]@{
"attacktracerstatus" = [string]$Report.AlertState
"attacktracerreason" = $Reason
"attacktracerbasestatus" = [string]$Report.BaseAlertState
"attacktracerevents" = [string]([int]$Report.TotalEvents)
"attacktraceruniqueips" = [string]([int]$Report.UniqueIpCount)
"attacktracercvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount)
"attacktracercvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount)
"attacktracermode" = $Mode
"attacktracertriggered" = $Triggered.ToString().ToLowerInvariant()
"attacktracerlastscanutc" = $generatedAtUtc
}
$updated = 0
foreach ($entry in $fieldValues.GetEnumerator()) {
try {
if (Set-NinjaCustomFieldValue -Name $entry.Key -Value $entry.Value) {
$updated++
}
}
catch {
Write-Warning "Failed to set Ninja custom field '$($entry.Key)': $($_.Exception.Message)"
}
}
Write-Host "Ninja custom fields: updated $updated field(s) via $script:NinjaFieldBackend."
}
$runnerArgs = @(
"-ExecutionPolicy", "Bypass",
"-File", $runnerScript,
"-LookbackDays", $LookbackDays,
"-TopCount", $TopCount,
"-OutputPath", $OutputPath,
"-ConfigPath", $ConfigPath
)
if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) {
$runnerArgs += @("-VulnerabilityCsvPath", $VulnerabilityCsvPath)
}
if (-not [string]::IsNullOrWhiteSpace($MirrorRoot)) {
$runnerArgs += @("-MirrorRoot", (Resolve-PathLike -PathValue $MirrorRoot -BasePath $repoRoot))
}
$null = & powershell @runnerArgs
$runnerExitCode = $LASTEXITCODE
if (-not (Test-Path $outputFullPath)) {
throw "Expected report file was not created: $outputFullPath"
}
$report = Get-Content $outputFullPath -Raw | ConvertFrom-Json
$status = [string]$report.AlertState
$baseStatus = [string]$report.BaseAlertState
$events = [int]$report.TotalEvents
$uniqueIps = [int]$report.UniqueIpCount
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
$monitorTriggered = $false
$monitorReason = ""
switch ($Mode) {
"status" {
$monitorTriggered = $status -ne "ok"
$monitorReason = "Final status is $status. $($report.AlertReason)"
}
"attack-only" {
$monitorTriggered = $baseStatus -ne "ok"
$monitorReason = "Base attack status is $baseStatus. $($report.BaseAlertReason)"
}
"cve-critical" {
$monitorTriggered = $criticalCves -gt 0
$monitorReason = "Critical/high CVE count is $criticalCves out of total CVEs $totalCves."
}
"attack-plus-cve" {
$monitorTriggered = ($events -gt 0 -and $criticalCves -gt 0)
$monitorReason = "Attack events=$events and critical/high CVEs=$criticalCves."
}
}
Publish-NinjaCustomFields -Report $report -Mode $Mode -Triggered $monitorTriggered -Reason $monitorReason
Write-Host ""
Write-Host "AttackTracer Ninja monitor mode: $Mode"
Write-Host "Triggered: $monitorTriggered"
Write-Host "Reason: $monitorReason"
Write-Host "Status: $status"
Write-Host "Base status: $baseStatus"
Write-Host "Events: $events"
Write-Host "Unique IPs: $uniqueIps"
Write-Host "Critical/High CVEs: $criticalCves"
Write-Host "Total CVEs: $totalCves"
Write-Host "Report: $outputFullPath"
Write-Host "Runner exit code: $runnerExitCode"
if ($monitorTriggered) {
exit 1
}
exit 0

View File

@@ -0,0 +1,307 @@
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"

View File

@@ -0,0 +1,144 @@
param(
[int]$LookbackDays = 7,
[int]$TopCount = 10,
[string]$OutputPath = ".\reports\attacktracer-summary.json",
[string]$ConfigPath = ".\config\attacktracer-settings.example.json",
[string]$ClientConfigPath = ".\config\attacktracer-client.example.json",
[string]$SecretPath = "",
[string]$VulnerabilityCsvPath = "",
[string]$MirrorRoot = "",
[ValidateSet("disabled", "auto", "required")]
[string]$UploadMode = "auto",
[switch]$FailOnAttacks,
[switch]$FailOnThreshold
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectPath = Join-Path $repoRoot "src\AttackTracerNinjaCli\AttackTracerNinjaCli.csproj"
$outputFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $OutputPath))
$buildOutputDir = Join-Path $repoRoot "src\AttackTracerNinjaCli\bin\Debug\net10.0"
$dllPath = Join-Path $buildOutputDir "AttackTracerNinjaCli.dll"
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))
}
$arguments = @(
$dllPath
)
$configFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $ConfigPath))
if ($UploadMode -eq "disabled") {
$arguments += "scan"
}
else {
$clientConfigFullPath = Resolve-PathLike -PathValue $ClientConfigPath -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)
if ($UploadMode -eq "required" -and -not $canUpload) {
throw "UploadMode 'required' was set, but client config or protected secret is missing."
}
if ($canUpload) {
$arguments += "scan-and-upload"
$arguments += @("--client-config", $clientConfigFullPath, "--secret-path", $secretFullPath)
}
else {
$arguments += "scan"
}
}
$arguments += @(
"--lookback-days", $LookbackDays,
"--top", $TopCount,
"--output", $outputFullPath,
"--ninja-output"
)
if (Test-Path $configFullPath) {
$arguments += @("--config", $configFullPath)
}
if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) {
$vulnerabilityCsvFullPath = [System.IO.Path]::GetFullPath((Join-Path $repoRoot $VulnerabilityCsvPath))
if (Test-Path $vulnerabilityCsvFullPath) {
$arguments += @("--vulnerability-csv", $vulnerabilityCsvFullPath)
}
}
if ($FailOnAttacks) {
$arguments += "--fail-on-attacks"
}
if ($FailOnThreshold) {
$arguments += "--fail-on-threshold"
}
$null = & dotnet build $projectPath
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
if (-not (Test-Path $dllPath)) {
throw "Expected compiled DLL was not created: $dllPath"
}
& dotnet @arguments
$exitCode = $LASTEXITCODE
if (-not (Test-Path $outputFullPath)) {
throw "Expected report file was not created: $outputFullPath"
}
$report = Get-Content $outputFullPath -Raw | ConvertFrom-Json
if (-not [string]::IsNullOrWhiteSpace($MirrorRoot)) {
Write-Host "Legacy mirror mode enabled."
$mirrorRootPath = Resolve-PathLike -PathValue $MirrorRoot -BasePath $repoRoot
if (-not (Test-Path $mirrorRootPath)) {
New-Item -ItemType Directory -Force -Path $mirrorRootPath | Out-Null
}
$mirrorPath = Join-Path $mirrorRootPath "$($report.MachineName).json"
Copy-Item -Path $outputFullPath -Destination $mirrorPath -Force
Write-Host "Mirrored report: $mirrorPath"
}
Write-Host ""
Write-Host "Ninja wrapper summary"
Write-Host "Machine: $($report.MachineName)"
Write-Host "Events: $($report.TotalEvents)"
Write-Host "Unique IPs: $($report.UniqueIpCount)"
Write-Host "Status: $($report.AlertState)"
Write-Host "Reason: $($report.AlertReason)"
Write-Host "Base status: $($report.BaseAlertState)"
Write-Host "CVE findings: $($report.VulnerabilityCorrelation.TotalCount)"
Write-Host "Critical/High CVEs: $($report.VulnerabilityCorrelation.CriticalCount)"
Write-Host "Upload mode: $UploadMode"
Write-Host "Report: $outputFullPath"
if ($report.Errors.Count -gt 0) {
Write-Host "Warnings:"
foreach ($warningEntry in $report.Errors) {
Write-Host "- $warningEntry"
}
}
exit $exitCode