Public/New-TenantLensReport.ps1
|
function New-TenantLensReport { <# .SYNOPSIS Renders findings and documentation into one self-contained offline HTML report. .DESCRIPTION No CDN links, no external assets - CSS/JS inline, charts as inline SVG. The report can be archived or mailed and opens anywhere. Accepts findings from the pipeline (Test-TenantLens | New-TenantLensReport) or evaluates the rule catalog itself when only -SnapshotPath is given. .EXAMPLE Test-TenantLens -SnapshotPath .\out\contoso_2026-07-23_1430 | New-TenantLensReport -Open #> [CmdletBinding()] param( [Parameter(ValueFromPipeline)] [object[]]$Finding, [string]$SnapshotPath, # Target file (defaults to report.html in the snapshot run folder). [string]$OutputPath, [string]$Title, # Open the report in the default browser after writing it. [switch]$Open ) begin { $allFindings = [System.Collections.Generic.List[object]]::new() } process { foreach ($item in @($Finding)) { if ($null -ne $item) { $allFindings.Add($item) } } } end { if (-not $SnapshotPath -and $allFindings.Count -gt 0 -and $allFindings[0].PSObject.Properties['SnapshotPath']) { $SnapshotPath = [string]$allFindings[0].SnapshotPath } if (-not $SnapshotPath) { throw 'Provide -SnapshotPath or pipe findings from Test-TenantLens.' } $snapshot = Read-TLSnapshot -Path $SnapshotPath if ($allFindings.Count -eq 0) { foreach ($item in @(Test-TenantLens -SnapshotPath $SnapshotPath)) { $allFindings.Add($item) } } function Get-TLEncoded { param([object]$Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) } $severityRank = @{ Critical = 0; High = 1; Medium = 2; Low = 3; Info = 4 } $statusRank = @{ Fail = 0; Error = 1; Manual = 2; Skipped = 3; Pass = 4 } $sorted = @($allFindings | Sort-Object -Property ` @{ Expression = { $statusRank[[string]$_.Status] } }, @{ Expression = { $severityRank[[string]$_.Severity] } }, @{ Expression = { $_.RuleId } }) # --- Score (pass/fail ratio; Manual/Skipped/Error excluded) ---------- $passCount = @($sorted | Where-Object { $_.Status -eq 'Pass' }).Count $failCount = @($sorted | Where-Object { $_.Status -eq 'Fail' }).Count $evaluated = $passCount + $failCount $scoreValue = 'n/a' $scoreDash = 0 $scoreClass = 'score-poor' if ($evaluated -gt 0) { $score = [Math]::Round(100.0 * (Get-TLScoreWeight -Findings $sorted)) $scoreValue = "$score" $scoreDash = [Math]::Round(326.73 * $score / 100.0, 1) $scoreClass = $(if ($score -ge 85) { 'score-good' } elseif ($score -ge 60) { 'score-fair' } else { 'score-poor' }) } $scoreCaption = "Score · $passCount of $evaluated passed" # --- Summary tiles ---------------------------------------------------- $tiles = [System.Text.StringBuilder]::new() foreach ($severity in @('Critical', 'High', 'Medium', 'Low', 'Info')) { $count = @($sorted | Where-Object { $_.Status -eq 'Fail' -and $_.Severity -eq $severity }).Count [void]$tiles.AppendLine(('<div class="tile"><div class="num {0}">{1}</div><div class="lbl">{2} findings</div></div>' -f ` $severity.ToLowerInvariant(), $count, $severity)) } foreach ($status in @('Pass', 'Manual', 'Skipped')) { $count = @($sorted | Where-Object { $_.Status -eq $status }).Count [void]$tiles.AppendLine(('<div class="tile"><div class="num {0}">{1}</div><div class="lbl">{2}</div></div>' -f ` $status.ToLowerInvariant(), $count, $status)) } # --- Area tiles ------------------------------------------------------- $areaTiles = [System.Text.StringBuilder]::new() if ($snapshot.PSObject.Properties['_Areas'] -and $snapshot._Areas) { foreach ($areaProperty in $snapshot._Areas.PSObject.Properties) { $areaName = $areaProperty.Name if ($areaProperty.Value.Skipped) { [void]$areaTiles.AppendLine(('<div class="area skipped"><b>{0}</b> · skipped ({1})</div>' -f ` (Get-TLEncoded -Text $areaName), (Get-TLEncoded -Text $areaProperty.Value.SkipReason))) } else { $areaFail = @($sorted | Where-Object { $_.Area -eq $areaName -and $_.Status -eq 'Fail' }).Count $areaPass = @($sorted | Where-Object { $_.Area -eq $areaName -and $_.Status -eq 'Pass' }).Count [void]$areaTiles.AppendLine(('<div class="area"><b>{0}</b> · <span class="ok">{1} pass</span> / <span class="bad">{2} fail</span></div>' -f ` (Get-TLEncoded -Text $areaName), $areaPass, $areaFail)) } } } # --- Findings rows ---------------------------------------------------- $rows = [System.Text.StringBuilder]::new() foreach ($item in $sorted) { $sevClass = ([string]$item.Severity).ToLowerInvariant() $statusClass = ([string]$item.Status).ToLowerInvariant() $searchText = ("{0} {1} {2}" -f $item.RuleId, $item.Title, $item.Area).ToLowerInvariant() [void]$rows.AppendLine(('<tr class="f" data-status="{0}" data-severity="{1}" data-area="{2}" data-text="{3}">' -f ` $item.Status, $item.Severity, (Get-TLEncoded -Text $item.Area), (Get-TLEncoded -Text $searchText))) [void]$rows.AppendLine(('<td><span class="chev">▸</span></td><td class="mono">{0}</td><td>{1}</td><td>{2}</td><td><span class="badge {3}">{4}</span></td><td><span class="badge {5}">{6}</span></td></tr>' -f ` (Get-TLEncoded -Text $item.RuleId), (Get-TLEncoded -Text $item.Title), (Get-TLEncoded -Text $item.Area), $sevClass, $item.Severity, $statusClass, $item.Status)) [void]$rows.AppendLine('<tr class="detail"><td colspan="6"><div class="detail-grid">') if ($item.Rationale) { [void]$rows.AppendLine(('<p><span class="k">Why:</span>{0}</p>' -f (Get-TLEncoded -Text $item.Rationale))) } if ($item.Remediation) { [void]$rows.AppendLine(('<p><span class="k">Remediation:</span>{0}</p>' -f (Get-TLEncoded -Text $item.Remediation))) } $evidenceItems = @($item.Evidence | Where-Object { $_ }) if ($evidenceItems.Count -gt 0) { [void]$rows.AppendLine('<p class="k">Evidence:</p><ul>') foreach ($evidence in $evidenceItems) { [void]$rows.AppendLine(('<li>{0}</li>' -f (Get-TLEncoded -Text $evidence))) } [void]$rows.AppendLine('</ul>') } $references = @($item.References | Where-Object { $_ }) if ($references.Count -gt 0) { $links = @(foreach ($reference in $references) { if ($reference -match '^https?://') { '<a href="{0}" target="_blank" rel="noopener">{0}</a>' -f (Get-TLEncoded -Text $reference) } else { Get-TLEncoded -Text $reference } }) [void]$rows.AppendLine(('<p><span class="k">References:</span>{0}</p>' -f ($links -join ' · '))) } [void]$rows.AppendLine('</div></td></tr>') } # --- Documentation + About tabs -------------------------------------- $documentationHtml = ConvertTo-TLHtml -Markdown (Format-TLDocumentation -Snapshot $snapshot) $about = [System.Text.StringBuilder]::new() [void]$about.AppendLine('<h2>Coverage</h2><table><thead><tr><th>Area</th><th>Status</th><th>Notes</th></tr></thead><tbody>') if ($snapshot.PSObject.Properties['_Areas'] -and $snapshot._Areas) { foreach ($areaProperty in $snapshot._Areas.PSObject.Properties) { $statusText = $(if ($areaProperty.Value.Skipped) { 'skipped' } else { 'collected' }) [void]$about.AppendLine(('<tr><td>{0}</td><td>{1}</td><td>{2}</td></tr>' -f ` (Get-TLEncoded -Text $areaProperty.Name), $statusText, (Get-TLEncoded -Text $areaProperty.Value.SkipReason))) } } [void]$about.AppendLine('</tbody></table>') $manifest = $snapshot._Manifest if ($manifest) { [void]$about.AppendLine('<h2>Snapshot</h2><table><tbody>') [void]$about.AppendLine(('<tr><th>Tool version</th><td>{0}</td></tr>' -f (Get-TLEncoded -Text $manifest.version))) [void]$about.AppendLine(('<tr><th>Generated (UTC)</th><td>{0}</td></tr>' -f (Get-TLEncoded -Text $manifest.generatedUtc))) if ($manifest.PSObject.Properties['tenant'] -and $manifest.tenant) { [void]$about.AppendLine(('<tr><th>Tenant id</th><td class="mono">{0}</td></tr>' -f (Get-TLEncoded -Text $manifest.tenant.id))) [void]$about.AppendLine(('<tr><th>Auth type</th><td>{0}</td></tr>' -f (Get-TLEncoded -Text $manifest.tenant.authType))) } [void]$about.AppendLine(('<tr><th>Redacted</th><td>{0}</td></tr>' -f ([bool]$manifest.redacted))) [void]$about.AppendLine(('<tr><th>Scopes</th><td class="mono">{0}</td></tr>' -f (Get-TLEncoded -Text (@($manifest.scopes) -join ', ')))) [void]$about.AppendLine('</tbody></table>') if ($manifest.PSObject.Properties['files'] -and $manifest.files) { [void]$about.AppendLine('<h2>Integrity (SHA-256)</h2><table><thead><tr><th>File</th><th>SHA-256</th></tr></thead><tbody>') foreach ($file in @($manifest.files)) { [void]$about.AppendLine(('<tr><td>{0}</td><td class="mono">{1}</td></tr>' -f ` (Get-TLEncoded -Text $file.name), (Get-TLEncoded -Text $file.sha256))) } [void]$about.AppendLine('</tbody></table>') } } # --- Assemble --------------------------------------------------------- $tenantLabel = 'tenant' $headerParts = [System.Collections.Generic.List[string]]::new() if ($manifest -and $manifest.PSObject.Properties['tenant'] -and $manifest.tenant) { $tenantLabel = [string]$manifest.tenant.label $headerParts.Add(('tenant id {0}' -f $manifest.tenant.id)) } if ($manifest) { $headerParts.Add(('generated {0}' -f $manifest.generatedUtc)) } $headerParts.Add(('{0} rules evaluated' -f $sorted.Count)) if (-not $Title) { $Title = "TenantLens report - $tenantLabel" } $templatePath = Join-Path -Path (Join-Path -Path $script:TLModuleRoot -ChildPath 'Templates') -ChildPath 'report.html' $html = Get-Content -Path $templatePath -Raw $html = $html.Replace('{{TITLE}}', (Get-TLEncoded -Text $Title)) $html = $html.Replace('{{TENANT_LABEL}}', (Get-TLEncoded -Text $tenantLabel)) $html = $html.Replace('{{HEADER_META}}', (($headerParts | ForEach-Object { '<span>{0}</span>' -f (Get-TLEncoded -Text $_) }) -join '')) $html = $html.Replace('{{SCORE_VALUE}}', $scoreValue) $html = $html.Replace('{{SCORE_DASH}}', "$scoreDash") $html = $html.Replace('{{SCORE_CLASS}}', $scoreClass) $html = $html.Replace('{{SCORE_CAPTION}}', $scoreCaption) $html = $html.Replace('{{SUMMARY_TILES}}', $tiles.ToString()) $html = $html.Replace('{{AREA_TILES}}', $areaTiles.ToString()) $html = $html.Replace('{{FINDINGS_ROWS}}', $rows.ToString()) $html = $html.Replace('{{DOC_HTML}}', $documentationHtml) $html = $html.Replace('{{ABOUT_HTML}}', $about.ToString()) $html = $html.Replace('{{TOOL_VERSION}}', (Get-TLEncoded -Text $script:TLVersion)) if (-not $OutputPath) { $runFolder = $snapshot._Path if ((Split-Path -Path $runFolder -Leaf) -eq 'snapshot') { $runFolder = Split-Path -Path $runFolder -Parent } $OutputPath = Join-Path -Path $runFolder -ChildPath 'report.html' } Write-TLFile -Path $OutputPath -Content $html Write-Verbose ("Report written to '{0}'." -f $OutputPath) if ($Open) { Invoke-Item -Path $OutputPath } return Get-Item -Path $OutputPath } } |