307 lines
12 KiB
PowerShell
307 lines
12 KiB
PowerShell
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
|
|
}
|