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) } function Format-TLUtc { param([object]$Value) if ($Value -is [DateTime]) { return $Value.ToUniversalTime().ToString('yyyy-MM-dd HH:mm') + ' UTC' } return [string]$Value } $severities = @('Critical', 'High', 'Medium', 'Low', 'Info') $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 } }) $failBySeverity = @{} foreach ($severity in $severities) { $failBySeverity[$severity] = @($sorted | Where-Object { $_.Status -eq 'Fail' -and $_.Severity -eq $severity }).Count } $statusCounts = @{} foreach ($statusName in @('Pass', 'Fail', 'Manual', 'Skipped', 'Error')) { $statusCounts[$statusName] = @($sorted | Where-Object { $_.Status -eq $statusName }).Count } $failCount = $statusCounts['Fail'] $passCount = $statusCounts['Pass'] $evaluated = $passCount + $failCount # --- score (severity-weighted) ---------------------------------------- $scoreValue = 'n/a' $scoreDash = '0' $scoreClass = 'band-neutral' $scoreDot = 'dot-neutral' $scoreStatusLabel = 'No evaluated rules' if ($evaluated -gt 0) { $score = [Math]::Round(100.0 * (Get-TLScoreWeight -Findings $sorted)) $scoreValue = "$score" $scoreDash = [string][Math]::Round(343.5 * $score / 100.0, 1) if ($score -ge 85) { $scoreClass = 'band-good'; $scoreDot = 'dot-good'; $scoreStatusLabel = 'Healthy' } elseif ($score -ge 60) { $scoreClass = 'band-warn'; $scoreDot = 'dot-warn'; $scoreStatusLabel = 'Needs attention' } elseif ($score -ge 40) { $scoreClass = 'band-serious'; $scoreDot = 'dot-serious'; $scoreStatusLabel = 'At risk' } else { $scoreClass = 'band-critical'; $scoreDot = 'dot-critical'; $scoreStatusLabel = 'Critical exposure' } } $scoreCaption = "Weighted score · $passCount of $evaluated passed" # --- score trend from sibling runs of the same tenant ----------------- $trendHtml = '' try { $currentRunFolder = $snapshot._Path if ((Split-Path -Path $currentRunFolder -Leaf) -eq 'snapshot') { $currentRunFolder = Split-Path -Path $currentRunFolder -Parent } $currentLeaf = Split-Path -Path $currentRunFolder -Leaf if ($scoreValue -ne 'n/a' -and $currentLeaf -match '^(?<label>.+)_\d{4}-\d{2}-\d{2}_\d{4}$') { $tenantPrefix = $Matches['label'] $outputRoot = Split-Path -Path $currentRunFolder -Parent $siblingPattern = '^' + [regex]::Escape($tenantPrefix) + '_\d{4}-\d{2}-\d{2}_\d{4}$' $history = @(Get-ChildItem -Path $outputRoot -Directory | Where-Object { $_.Name -ne $currentLeaf -and $_.Name -match $siblingPattern -and (Test-Path -Path (Join-Path -Path $_.FullName -ChildPath 'findings.json')) } | Sort-Object -Property Name) $points = [System.Collections.Generic.List[object]]::new() foreach ($run in ($history | Select-Object -Last 11)) { $historicScore = Get-TLFindingsScore -Payload (Get-Content -Path (Join-Path $run.FullName 'findings.json') -Raw | ConvertFrom-Json) if ($null -ne $historicScore) { $points.Add([pscustomobject]@{ Name = $run.Name; Score = [int]$historicScore }) } } if ($points.Count -gt 0) { $previousRun = $points[$points.Count - 1] $delta = [int]$scoreValue - $previousRun.Score $deltaClass = $(if ($delta -gt 0) { 'up' } elseif ($delta -lt 0) { 'down' } else { 'flat' }) $deltaText = $(if ($delta -gt 0) { "+$delta" } elseif ($delta -eq 0) { '±0' } else { "$delta" }) $previousStamp = $(if ($previousRun.Name -match '(\d{4}-\d{2}-\d{2})_\d{4}$') { $Matches[1] } else { 'previous run' }) $allScores = @($points | ForEach-Object { $_.Score }) + @([int]$scoreValue) $stepWidth = $(if ($allScores.Count -gt 1) { 92.0 / ($allScores.Count - 1) } else { 92.0 }) $coordinates = for ($i = 0; $i -lt $allScores.Count; $i++) { '{0},{1}' -f [Math]::Round(2 + $i * $stepWidth, 1), [Math]::Round(24 - ($allScores[$i] / 100.0 * 20), 1) } $lastPoint = ($coordinates[-1] -split ',') $trendHtml = ('<div class="trend"><svg width="96" height="26" viewBox="0 0 96 26" aria-hidden="true"><polyline points="{0}"/><circle cx="{1}" cy="{2}" r="3"/></svg><span class="trend-delta {3}">{4} since {5}</span></div>' -f ` ($coordinates -join ' '), $lastPoint[0], $lastPoint[1], $deltaClass, $deltaText, (Get-TLEncoded -Text $previousStamp)) } } } catch { Write-Verbose ("Score trend unavailable: {0}" -f $_.Exception.Message) } # --- severity tiles + meter ------------------------------------------- $tiles = [System.Text.StringBuilder]::new() foreach ($severity in $severities) { $count = $failBySeverity[$severity] $zeroClass = $(if ($count -eq 0) { ' zero' } else { '' }) [void]$tiles.AppendLine(('<div class="tile{0}"><div class="tile-top"><span class="dot sev-{1}"></span>{2}</div><div class="tile-value">{3}</div></div>' -f ` $zeroClass, $severity.ToLowerInvariant(), $severity, $count)) } $meter = [System.Text.StringBuilder]::new() if ($failCount -eq 0) { [void]$meter.Append('<span class="seg s-good" style="width:100%"></span>') } else { foreach ($severity in $severities) { $count = $failBySeverity[$severity] if ($count -eq 0) { continue } $width = [Math]::Round(100.0 * $count / $failCount, 1) [void]$meter.Append(('<span class="seg s-{0}" style="width:{1}%"></span>' -f $severity.ToLowerInvariant(), $width)) } } $chips = [System.Text.StringBuilder]::new() [void]$chips.Append(('<span class="chip"><span class="ic pass">✓</span>{0} passed</span>' -f $statusCounts['Pass'])) if ($statusCounts['Manual'] -gt 0) { [void]$chips.Append(('<span class="chip"><span class="ic manual">?</span>{0} manual review</span>' -f $statusCounts['Manual'])) } if ($statusCounts['Skipped'] -gt 0) { [void]$chips.Append(('<span class="chip"><span class="ic skipped">–</span>{0} not collected</span>' -f $statusCounts['Skipped'])) } if ($statusCounts['Error'] -gt 0) { [void]$chips.Append(('<span class="chip"><span class="ic error">!</span>{0} errors</span>' -f $statusCounts['Error'])) } # --- area cards -------------------------------------------------------- $areaCards = [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]$areaCards.AppendLine(('<div class="area-card skipped" title="{0}"><span class="area-name">{1}</span><span class="area-counts">not collected</span></div>' -f ` (Get-TLEncoded -Text $areaProperty.Value.SkipReason), (Get-TLEncoded -Text $areaName))) continue } $areaPass = @($sorted | Where-Object { $_.Area -eq $areaName -and $_.Status -eq 'Pass' }).Count $areaFail = @($sorted | Where-Object { $_.Area -eq $areaName -and $_.Status -eq 'Fail' }).Count $meterHtml = '' $countsText = 'no rules evaluated' if (($areaPass + $areaFail) -gt 0) { $meterHtml = ('<span class="am-pass" style="flex:{0}"></span><span class="am-fail" style="flex:{1}"></span>' -f $areaPass, $areaFail) $countsText = "$areaPass pass · $areaFail fail" } [void]$areaCards.AppendLine(('<button type="button" class="area-card" data-area="{0}"><span class="area-name">{0}</span><div class="area-meter">{1}</div><span class="area-counts">{2}</span></button>' -f ` (Get-TLEncoded -Text $areaName), $meterHtml, $countsText)) } } # --- filter controls --------------------------------------------------- $statusFilters = [System.Text.StringBuilder]::new() [void]$statusFilters.Append(('<button data-v="" class="on">All<span class="cnt">{0}</span></button>' -f $sorted.Count)) foreach ($statusName in @('Fail', 'Pass', 'Manual', 'Skipped', 'Error')) { if ($statusCounts[$statusName] -eq 0) { continue } [void]$statusFilters.Append(('<button data-v="{0}">{0}<span class="cnt">{1}</span></button>' -f $statusName, $statusCounts[$statusName])) } $severityFilters = [System.Text.StringBuilder]::new() [void]$severityFilters.Append('<button data-v="" class="on">Any severity</button>') foreach ($severity in $severities) { $count = @($sorted | Where-Object { $_.Severity -eq $severity }).Count if ($count -eq 0) { continue } [void]$severityFilters.Append(('<button data-v="{0}">{0}<span class="cnt">{1}</span></button>' -f $severity, $count)) } # --- findings rows ----------------------------------------------------- $statusIcons = @{ Pass = '✓'; Fail = '✕'; Manual = '?'; Skipped = '–'; Error = '!' } $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 class="c-chev"><span class="chev">▸</span></td><td class="c-rule"><span class="rule-id">{0}</span></td><td class="c-title">{1}</td><td class="c-area"><span class="area-chip">{2}</span></td><td class="c-sev"><span class="badge"><span class="dot sev-{3}"></span>{4}</span></td><td class="c-status"><span class="badge st-{5}"><span class="b-ic">{6}</span>{7}</span></td></tr>' -f ` (Get-TLEncoded -Text $item.RuleId), (Get-TLEncoded -Text $item.Title), (Get-TLEncoded -Text $item.Area), $sevClass, $item.Severity, $statusClass, $statusIcons[[string]$item.Status], $item.Status)) [void]$rows.AppendLine('<tr class="detail"><td colspan="6"><div class="detail-body">') [void]$rows.AppendLine('<div>') if ($item.Rationale) { [void]$rows.AppendLine(('<h4>Why it matters</h4><p>{0}</p>' -f (Get-TLEncoded -Text $item.Rationale))) } if ($item.Remediation) { [void]$rows.AppendLine(('<h4>Remediation</h4><p>{0}</p>' -f (Get-TLEncoded -Text $item.Remediation))) } [void]$rows.AppendLine('</div><div>') $evidenceItems = @($item.Evidence | Where-Object { $_ }) if ($evidenceItems.Count -gt 0) { [void]$rows.AppendLine('<h4>Evidence</h4><ul>') foreach ($evidence in $evidenceItems) { [void]$rows.AppendLine(('<li>{0}</li>' -f (Get-TLEncoded -Text $evidence))) } [void]$rows.AppendLine('</ul>') } [void]$rows.AppendLine('</div>') $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(('<div class="d-refs">References: {0}</div>' -f ($links -join ' · '))) } [void]$rows.AppendLine('</div></td></tr>') } # --- benchmark view --------------------------------------------------- $benchmark = [System.Text.StringBuilder]::new() [void]$benchmark.AppendLine('<section class="card about-card about-wide"><h2>CIS Microsoft 365 Foundations Benchmark coverage</h2>') $cisFindings = @($sorted | Where-Object { @($_.References | Where-Object { $_ -match '^CIS' }).Count -gt 0 }) if ($cisFindings.Count -eq 0) { [void]$benchmark.AppendLine('<p class="note">No evaluated rule carries a CIS reference.</p>') } else { [void]$benchmark.AppendLine('<table><thead><tr><th>Rule</th><th>CIS reference</th><th>Severity</th><th>Status</th></tr></thead><tbody>') foreach ($item in $cisFindings) { $cisReference = @($item.References | Where-Object { $_ -match '^CIS' }) | Select-Object -First 1 [void]$benchmark.AppendLine(('<tr><td><span class="rule-id">{0}</span> {1}</td><td>{2}</td><td><span class="badge"><span class="dot sev-{3}"></span>{4}</span></td><td><span class="badge st-{5}"><span class="b-ic">{6}</span>{7}</span></td></tr>' -f ` (Get-TLEncoded -Text $item.RuleId), (Get-TLEncoded -Text $item.Title), (Get-TLEncoded -Text ($cisReference -replace '^CIS Microsoft 365 Foundations Benchmark\s*', '')), ([string]$item.Severity).ToLowerInvariant(), $item.Severity, ([string]$item.Status).ToLowerInvariant(), $statusIcons[[string]$item.Status], $item.Status)) } [void]$benchmark.AppendLine('</tbody></table>') [void]$benchmark.AppendLine(('<p class="note">{0} of {1} evaluated rules map to a CIS recommendation; the remaining {2} are TenantLens-specific and appear in the Findings tab.</p>' -f ` $cisFindings.Count, $sorted.Count, ($sorted.Count - $cisFindings.Count))) } [void]$benchmark.AppendLine('</section>') # --- documentation + about -------------------------------------------- $documentationHtml = ConvertTo-TLHtml -Markdown (Format-TLDocumentation -Snapshot $snapshot) $manifest = $snapshot._Manifest $about = [System.Text.StringBuilder]::new() [void]$about.AppendLine('<section class="card about-card"><h2>Snapshot</h2>') if ($manifest) { [void]$about.AppendLine('<dl>') [void]$about.AppendLine(('<dt>Tool version</dt><dd>{0}</dd>' -f (Get-TLEncoded -Text $manifest.version))) [void]$about.AppendLine(('<dt>Generated</dt><dd>{0}</dd>' -f (Get-TLEncoded -Text (Format-TLUtc -Value $manifest.generatedUtc)))) if ($manifest.PSObject.Properties['tenant'] -and $manifest.tenant) { [void]$about.AppendLine(('<dt>Tenant id</dt><dd class="mono">{0}</dd>' -f (Get-TLEncoded -Text $manifest.tenant.id))) [void]$about.AppendLine(('<dt>Auth type</dt><dd>{0}</dd>' -f (Get-TLEncoded -Text $manifest.tenant.authType))) } [void]$about.AppendLine(('<dt>Redacted</dt><dd>{0}</dd>' -f ([bool]$manifest.redacted))) [void]$about.AppendLine('</dl>') [void]$about.AppendLine('<h2>Graph scopes used</h2><div class="scope-chips">') foreach ($scope in @($manifest.scopes)) { [void]$about.AppendLine(('<span>{0}</span>' -f (Get-TLEncoded -Text $scope))) } [void]$about.AppendLine('</div>') } else { [void]$about.AppendLine('<p>No manifest.json found in this snapshot.</p>') } [void]$about.AppendLine('</section>') [void]$about.AppendLine('<section class="card about-card"><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></section>') if ($manifest -and $manifest.PSObject.Properties['files'] -and $manifest.files) { [void]$about.AppendLine('<section class="card about-card about-wide"><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></section>') } # --- 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 {0}' -f $manifest.tenant.id)) } if ($manifest) { $headerParts.Add((Format-TLUtc -Value $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_DOT}}', $scoreDot) $html = $html.Replace('{{SCORE_STATUS_LABEL}}', $scoreStatusLabel) $html = $html.Replace('{{SCORE_CAPTION}}', $scoreCaption) $html = $html.Replace('{{TREND_HTML}}', $trendHtml) $html = $html.Replace('{{SEVERITY_TILES}}', $tiles.ToString()) $html = $html.Replace('{{SEVERITY_METER}}', $meter.ToString()) $html = $html.Replace('{{STATUS_CHIPS}}', $chips.ToString()) $html = $html.Replace('{{AREA_CARDS}}', $areaCards.ToString()) $html = $html.Replace('{{STATUS_FILTERS}}', $statusFilters.ToString()) $html = $html.Replace('{{SEVERITY_FILTERS}}', $severityFilters.ToString()) $html = $html.Replace('{{FINDINGS_ROWS}}', $rows.ToString()) $html = $html.Replace('{{BENCHMARK_HTML}}', $benchmark.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 } } |