Public/New-TenantLensSummaryReport.ps1
|
function New-TenantLensSummaryReport { <# .SYNOPSIS Builds a multi-tenant overview report from a folder of assessment runs. .DESCRIPTION Scans a root folder (e.g. \\srv\assessments or .\out) for run folders containing findings.json, groups them by tenant, takes the newest run per tenant and renders one self-contained HTML overview: score, open findings by severity and the trend versus the previous run. Only findings.json is read - no snapshot data is required. .EXAMPLE New-TenantLensSummaryReport -Path \\srv\assessments -Open #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$Path, # Target file (defaults to summary.html inside -Path). [string]$OutputPath, [string]$Title = 'TenantLens - tenant overview', [switch]$Open ) function Get-TLEncoded { param([object]$Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) } function Get-TLScoreBand { param([AllowNull()][object]$Score) if ($null -eq $Score) { return 'band-critical' } if ($Score -ge 85) { return 'band-good' } if ($Score -ge 60) { return 'band-warn' } if ($Score -ge 40) { return 'band-serious' } return 'band-critical' } $runFolders = @(Get-ChildItem -Path $Path -Directory -ErrorAction Stop | Where-Object { Test-Path -Path (Join-Path -Path $_.FullName -ChildPath 'findings.json') }) if ($runFolders.Count -eq 0) { throw "No run folders with findings.json found under '$Path'. Run Invoke-TenantLens (or Test-TenantLens -OutputPath) per tenant first." } $runs = foreach ($folder in $runFolders) { $payload = $null try { $payload = Get-Content -Path (Join-Path -Path $folder.FullName -ChildPath 'findings.json') -Raw | ConvertFrom-Json } catch { Write-Warning ("'{0}\findings.json' could not be parsed: {1}" -f $folder.Name, $_.Exception.Message) continue } $label = $folder.Name $stamp = $null if ($folder.Name -match '^(?<label>.+)_(?<stamp>\d{4}-\d{2}-\d{2}_\d{4})$') { $label = $Matches['label'] $stamp = $Matches['stamp'] } $openBySeverity = [ordered]@{} foreach ($severityName in @('Critical', 'High', 'Medium', 'Low')) { $openBySeverity[$severityName] = @($payload.findings | Where-Object { $_.Status -eq 'Fail' -and $_.Severity -eq $severityName }).Count } [pscustomobject]@{ Label = $label Stamp = $stamp FolderName = $folder.Name GeneratedUtc = [string]$payload.generatedUtc Score = (Get-TLFindingsScore -Payload $payload) OpenBySeverity = $openBySeverity FailTotal = @($payload.findings | Where-Object { $_.Status -eq 'Fail' }).Count } } $tenants = foreach ($group in ($runs | Group-Object -Property Label)) { $ordered = @($group.Group | Sort-Object -Property @{ Expression = { "$($_.Stamp)$($_.GeneratedUtc)" } }) $latest = $ordered[-1] $previous = $(if ($ordered.Count -gt 1) { $ordered[-2] } else { $null }) [pscustomobject]@{ Label = $group.Name RunCount = $ordered.Count Latest = $latest Previous = $previous } } $tenants = @($tenants | Sort-Object -Property @{ Expression = { if ($null -eq $_.Latest.Score) { 999 } else { $_.Latest.Score } } }) # --- KPI tiles ------------------------------------------------------------ $scores = @($tenants | ForEach-Object { $_.Latest.Score } | Where-Object { $null -ne $_ }) $averageScore = $(if ($scores.Count -gt 0) { [int][Math]::Round(($scores | Measure-Object -Average).Average) } else { 'n/a' }) $totalCritical = [int](@($tenants | ForEach-Object { $_.Latest.OpenBySeverity['Critical'] } | Measure-Object -Sum).Sum) $totalHigh = [int](@($tenants | ForEach-Object { $_.Latest.OpenBySeverity['High'] } | Measure-Object -Sum).Sum) $kpis = [System.Text.StringBuilder]::new() [void]$kpis.AppendLine(('<div class="tile"><div class="num">{0}</div><div class="lbl">Tenants</div></div>' -f $tenants.Count)) [void]$kpis.AppendLine(('<div class="tile"><div class="num">{0}</div><div class="lbl">Average score</div></div>' -f $averageScore)) [void]$kpis.AppendLine(('<div class="tile"><div class="num critical">{0}</div><div class="lbl">Open critical findings</div></div>' -f $totalCritical)) [void]$kpis.AppendLine(('<div class="tile"><div class="num high">{0}</div><div class="lbl">Open high findings</div></div>' -f $totalHigh)) # --- tenant rows ---------------------------------------------------------- $rows = [System.Text.StringBuilder]::new() foreach ($tenant in $tenants) { $latest = $tenant.Latest $scoreText = $(if ($null -eq $latest.Score) { 'n/a' } else { [string]$latest.Score }) $scoreWidth = $(if ($null -eq $latest.Score) { 0 } else { $latest.Score }) $band = Get-TLScoreBand -Score $latest.Score $severityHtml = [System.Text.StringBuilder]::new() foreach ($severityName in @('Critical', 'High', 'Medium', 'Low')) { $count = [int]$latest.OpenBySeverity[$severityName] if ($count -eq 0) { continue } [void]$severityHtml.Append(('<span class="sev"><span class="dot d-{0}"></span>{1}</span>' -f $severityName.ToLowerInvariant(), $count)) } if ($latest.FailTotal -eq 0) { [void]$severityHtml.Append('<span class="sev">none</span>') } $deltaHtml = '<span class="delta flat">first run</span>' if ($tenant.Previous -and $null -ne $tenant.Previous.Score -and $null -ne $latest.Score) { $delta = [int]$latest.Score - [int]$tenant.Previous.Score if ($delta -gt 0) { $deltaHtml = ('<span class="delta up">▲ +{0}</span>' -f $delta) } elseif ($delta -lt 0) { $deltaHtml = ('<span class="delta down">▼ {0}</span>' -f $delta) } else { $deltaHtml = '<span class="delta flat">±0</span>' } } $dateText = $(if ($latest.Stamp) { $latest.Stamp -replace '_', ' ' } else { $latest.GeneratedUtc }) [void]$rows.AppendLine(('<tr class="{0}"><td class="t-name">{1}<span class="runs">{2} run(s) · {3}</span></td><td><div class="scorecell"><span class="score-num">{4}</span><div class="scorebar"><span style="width:{5}%"></span></div></div></td><td>{6}</td><td>{7}</td><td class="date">{8}</td></tr>' -f ` $band, (Get-TLEncoded -Text $tenant.Label), $tenant.RunCount, (Get-TLEncoded -Text $latest.FolderName), $scoreText, $scoreWidth, $severityHtml.ToString(), $deltaHtml, (Get-TLEncoded -Text $dateText))) } $templatePath = Join-Path -Path (Join-Path -Path $script:TLModuleRoot -ChildPath 'Templates') -ChildPath 'summary.html' $html = Get-Content -Path $templatePath -Raw $html = $html.Replace('{{TITLE}}', (Get-TLEncoded -Text $Title)) $html = $html.Replace('{{TENANT_COUNT}}', "$($tenants.Count)") $html = $html.Replace('{{GENERATED}}', ([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm') + ' UTC')) $html = $html.Replace('{{KPI_TILES}}', $kpis.ToString()) $html = $html.Replace('{{TENANT_ROWS}}', $rows.ToString()) $html = $html.Replace('{{TOOL_VERSION}}', (Get-TLEncoded -Text $script:TLVersion)) if (-not $OutputPath) { $OutputPath = Join-Path -Path $Path -ChildPath 'summary.html' } Write-TLFile -Path $OutputPath -Content $html Write-Verbose ("Summary report written to '{0}'." -f $OutputPath) if ($Open) { Invoke-Item -Path $OutputPath } return Get-Item -Path $OutputPath } |