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('
') [void]$sb.AppendLine("

AttackTracer Report - $(Escape-Html ((Get-Date).ToString("dd.MM.yyyy")))

") [void]$sb.AppendLine("
$(Escape-Html ([string]$sortedReports.Count)) Geraete gescannt | $(Escape-Html ([string]$alertingReports.Count)) auffaellig | $(Escape-Html ([string]$criticalReports.Count)) kritisch | $(Escape-Html ([string]$totalEvents)) Events | $(Escape-Html ([string]$allIps.Count)) eindeutige IPs
") [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') if ($alertRows.Count -eq 0) { [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') [void]$sb.AppendLine(' ') } 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(" ") [void]$sb.AppendLine(" ") [void]$sb.AppendLine(" ") [void]$sb.AppendLine(" ") [void]$sb.AppendLine(" ") [void]$sb.AppendLine(" ") [void]$sb.AppendLine(" ") [void]$sb.AppendLine(' ') } } [void]$sb.AppendLine('
ServerVorfallKontoZeitpunktAnzahlIP
Keine Angriffe oder Korrelationen im ausgewerteten Bestand gefunden.
$(Escape-Html $row.MachineName)$(Escape-Html $row.Incident)$(Escape-Html $row.Account)$(Escape-Html $timestampText)$(Escape-Html ([string]$row.Count))$(Escape-Html $row.Ip)
') if ($cleanDevices.Count -gt 0) { [void]$sb.AppendLine('
') [void]$sb.AppendLine(" Log sauber / Keine Angriffe: $(Escape-Html ($cleanDevices -join ', '))") [void]$sb.AppendLine('
') } [void]$sb.AppendLine("
Automatisch generiert am $(Escape-Html $generatedAt)
") [void]$sb.AppendLine('
') 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." } $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $installRoot = Split-Path -Parent $scriptDir $reportsRootPath = Resolve-PathLike -PathValue $ReportsRoot -BasePath $installRoot $outputPathFull = Resolve-PathLike -PathValue $OutputPath -BasePath $installRoot $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 }