Private/Render/Export-PpaHtmlReport.ps1
|
# Export-PpaHtmlReport.ps1 - turns a normalized posture object into the HTML report. # The HTML report is the primary deliverable (report-first). Depends on PpaHtml.ps1. # ASCII-only source (Windows PowerShell 5.1). Set-StrictMode -Off function Export-PpaHtmlReport { [CmdletBinding()] param( [Parameter(Mandatory = $true)] $Normalized, [switch] $IsSample, # Titles of sections removed by the run profile (P5) - rendered as a single # footer line so a thin report never looks like a silent failure. [string[]] $ExcludedSections = @(), # P6 render-time redaction. -RedactNames implies -Redact and additionally # pseudonymizes policy/label names. Applied at the display boundary only; # the normalized object and the JSON export are never modified. [switch] $Redact, [switch] $RedactNames, # UX-2: client logo, pre-encoded as a data: URI by ConvertTo-PpaLogoDataUri. # Report chrome only - it never enters the normalized object or the JSON export. # Empty means the header slot renders nothing (the dashed placeholder is gone). [string] $LogoDataUri, # Pre-publish Part 5: overrides the -IsSample banner text so a published # fixture sample can name ITS fictional tenant honestly. Empty keeps the # historical Northwind default (existing samples render byte-identical). # Trusted build-script literal, never tenant data - rendered raw like the # rest of the chrome (may carry entities such as ·). [string] $SampleBannerText ) # Defensive: never inherit redaction state from a previous render (a failed # render cannot poison the next one - the next call always resets first). Clear-PpaRedaction if ($Redact -or $RedactNames) { Initialize-PpaRedaction -Normalized $Normalized -RedactNames:$RedactNames } $meta = $Normalized.meta $lic = $Normalized.licensing $sections = @($Normalized.sections) $sb = New-Object System.Text.StringBuilder # P7: static remediation-snippet map, joined per finding by check ID at render time. $remedCatalog = Get-PpaRemediationCatalog # Group-by-first-appearance + all-solutions totals, computed once from the same # finding objects the body renders. Shared by the posture summary (P1) and the # Solutions Summary so their counts can never drift apart. $groupOrder = New-Object System.Collections.Generic.List[string] $groupMap = @{} $groupMeta = @{} $totals = [ordered]@{ 'OK'=0; 'Improvement'=0; 'Recommendation'=0; 'Informational'=0; 'Verify manually'=0 } foreach ($sec in $sections) { $gname = [string]$sec.group if (-not $groupMap.ContainsKey($gname)) { $groupMap[$gname] = New-Object System.Collections.Generic.List[object] $groupMeta[$gname] = [pscustomobject]@{ Icon = [string]$sec.groupIcon; Tag = [string]$sec.groupTag } $groupOrder.Add($gname) } $groupMap[$gname].Add($sec) $cts = Get-PpaSectionCounts $sec foreach ($st in $script:PpaStatusOrder) { $totals[$st] = [int]$totals[$st] + [int]$cts[$st] } } # ---- document head + navbar ---- [void]$sb.AppendLine((Get-PpaReportHead)) if ($IsSample) { $bannerText = $SampleBannerText if ([string]::IsNullOrEmpty($bannerText)) { $bannerText = 'Illustrative sample data · fictional tenant (Northwind Health) · rendered from Samples/sample-normalized.json' } [void]$sb.Append('<div class="mock-flag">').Append($bannerText).AppendLine('</div>') } if ($Redact -or $RedactNames) { $scope = 'tenant domains, UPNs and email addresses masked' if ($RedactNames) { $scope += ' · policy and label names pseudonymized' } [void]$sb.Append('<div class="redact-flag">REDACTED report · ').Append($scope).AppendLine(' · masking applied at render time only</div>') } [void]$sb.AppendLine((Get-PpaNavbarHtml)) [void]$sb.AppendLine('') [void]$sb.AppendLine('<div class="app-body p-3"><main class="main">') [void]$sb.AppendLine('') # ---- title card ---- [void]$sb.AppendLine(' <div class="card"><div class="card-body">') [void]$sb.AppendLine(' <div class="row">') [void]$sb.AppendLine(' <div class="col">') [void]$sb.Append(' <h2 class="card-title">').Append((ConvertTo-PpaHtmlText $meta.reportTitle)).AppendLine('</h2>') [void]$sb.Append(' <strong>Version ').Append((ConvertTo-PpaHtmlText $meta.version)).Append(' · ').Append((ConvertTo-PpaHtmlText $meta.versionDate)).AppendLine('</strong>') [void]$sb.AppendLine(' <p>Reads your Microsoft Purview configuration and summarizes current posture across data protection, governance,') [void]$sb.AppendLine(' insider risk, discovery, and AI — an input to user judgment, not a compliance determination.') [void]$sb.AppendLine(' <em>Click any finding to drill down to the enumerated detail.</em></p>') [void]$sb.AppendLine(' <table>') [void]$sb.Append(' <tr><td><strong>Date</strong></td><td><strong>: ').Append((ConvertTo-PpaHtmlText $meta.dateDisplay)).AppendLine('</strong></td></tr>') [void]$sb.Append(' <tr><td><strong>Organization </strong></td><td><strong>: ').Append((ConvertTo-PpaHtmlText $meta.organization)).AppendLine('</strong></td></tr>') [void]$sb.Append(' <tr><td><strong>Tenant </strong></td><td><strong>: ').Append((ConvertTo-PpaHtmlText $meta.tenant)).AppendLine('</strong></td></tr>') [void]$sb.Append(' <tr><td><strong>Operator </strong></td><td><strong>: ').Append((ConvertTo-PpaHtmlText $meta.operator)).AppendLine('</strong></td></tr>') [void]$sb.Append(' <tr><td><strong>Mode </strong></td><td><strong>: ').Append((ConvertTo-PpaHtmlText $meta.mode)).AppendLine('</strong></td></tr>') [void]$sb.AppendLine(' </table>') [void]$sb.AppendLine(' </div>') # UX-2: the data URI is appended RAW, bypassing ConvertTo-PpaHtmlAttr on purpose - # its alphabet (mime prefix + base64) contains no HTML metacharacters, and routing it # through the redaction-aware encoders could let a name-mask substring-match inside # the base64 payload and corrupt the image bytes. if (-not [string]::IsNullOrEmpty($LogoDataUri)) { [void]$sb.Append(' <div class="col-auto"><img class="logo" src="').Append($LogoDataUri).AppendLine('" alt="Client logo"></div>') } [void]$sb.AppendLine(' </div>') # E5-assumption caveat (F-004, option A): one read-only CONTEXT line so an HTML-only # reader knows the report judges Purview workloads against an E5 / E5 Compliance # baseline and never reads subscriptions (decision D9) - a flagged gap may be a # licensing-tier decision, not a configuration gap. It NEVER gates a verdict # (neutrality stands); the per-finding `requires` tier stays JSON-only, and the fuller # caveat lives in README / LIMITATIONS. licensing.note still travels in the JSON export. [void]$sb.AppendLine(' <p style="color:var(--muted); font-size:var(--fs-sm); font-family:var(--font-sans); margin:.15rem 0 0;"><strong>Licensing:</strong> this report assumes a Microsoft 365 E5 / E5 Compliance baseline when judging Purview workloads and does not read your subscriptions — some flagged gaps may require higher licensing rather than a configuration change.</p>') [void]$sb.AppendLine(' </div></div>') [void]$sb.AppendLine('') # ---- posture summary (P1: page one, before the first section) ---- [void]$sb.AppendLine((Write-PpaPostureSummary -Meta $meta -Sections $sections -Totals $totals)) [void]$sb.AppendLine('') # ---- coverage matrix (Wave 4 Part D): right after the Posture Summary ---- if ($null -ne $Normalized.coverage) { [void]$sb.AppendLine((Write-PpaCoverageMatrix -Coverage $Normalized.coverage)) [void]$sb.AppendLine('') } # ---- filter bar (P2: sticky severity chips + text search; hidden in print) ---- [void]$sb.AppendLine((Write-PpaFilterBar)) [void]$sb.AppendLine('') # ---- environment at a glance ---- [void]$sb.AppendLine(' <div class="card mt-3 glance">') [void]$sb.AppendLine(' <div class="card-header"><strong>Environment at a glance</strong></div>') [void]$sb.AppendLine(' <div class="card-body">') [void]$sb.AppendLine(' <div class="row" style="row-gap:10px;">') foreach ($sec in $sections) { $g = $sec.glance $dot = (Get-PpaStatusStyle $g.status).Dot [void]$sb.Append(' <div class="col-6 col-md-3"><a class="cell" href="#').Append((ConvertTo-PpaHtmlAttr $sec.id)).Append('">') [void]$sb.Append('<div class="nm"><span class="gdot ').Append($dot).Append('"></span>').Append((ConvertTo-PpaHtmlText $g.name)).Append('</div>') [void]$sb.Append('<div class="mx">').Append((ConvertTo-PpaHtmlText $g.metric)).Append('</div>') [void]$sb.Append('<div class="sub">').Append((ConvertTo-PpaHtmlText $g.sub)).AppendLine('</div></a></div>') } [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine('') # ---- solutions summary (group/totals computed once, above) ---- [void]$sb.AppendLine(' <div class="card mt-3" id="Solutionsummary">') [void]$sb.AppendLine(' <div class="card-header"><strong>Solutions Summary</strong></div>') [void]$sb.AppendLine(' <div class="card-body">') [void]$sb.AppendLine(' <table class="table table-borderless summary" style="margin-bottom:.5rem;">') [void]$sb.AppendLine(' <tbody>') [void]$sb.AppendLine(' <tr style="border-bottom:2px solid #d7e0ea;">') [void]$sb.AppendLine(' <td width="20"><i class="fas fa-user-cog"></i></td>') [void]$sb.AppendLine(' <td><strong>All Solutions</strong></td>') [void]$sb.Append(' <td align="right">').Append((Write-PpaSummaryCountCells $totals)).AppendLine('</td>') [void]$sb.AppendLine(' </tr>') [void]$sb.AppendLine('') foreach ($gname in $groupOrder) { $gm = $groupMeta[$gname] $parentText = ConvertTo-PpaHtmlText $gname if (-not [string]::IsNullOrEmpty($gm.Tag)) { $parentText += ' <span style="font-weight:600; font-size:11px; color:#0078D4;"> ' + (ConvertTo-PpaHtmlText $gm.Tag) + '</span>' } [void]$sb.Append(' <tr class="ssparent"><td width="20"><i class="').Append((ConvertTo-PpaHtmlAttr $gm.Icon)).Append('"></i></td><td colspan="2">').Append($parentText).AppendLine('</td></tr>') foreach ($sec in $groupMap[$gname]) { $cts = Get-PpaSectionCounts $sec [void]$sb.Append(' <tr class="sschild"><td></td><td><a href="#').Append((ConvertTo-PpaHtmlAttr $sec.id)).Append('">').Append((ConvertTo-PpaHtmlText $sec.title)).AppendLine('</a></td>') [void]$sb.Append(' <td align="right">').Append((Write-PpaSummaryCountCells $cts)).AppendLine('</td></tr>') } } [void]$sb.AppendLine(' </tbody>') [void]$sb.AppendLine(' </table>') [void]$sb.AppendLine(' <div style="font-size:13px; text-align:right;">') [void]$sb.AppendLine(' <span class="badge badge-success" style="padding:5px;"> </span> OK ') [void]$sb.AppendLine(' <span class="badge badge-warning" style="padding:5px;"> </span> Improvement ') [void]$sb.AppendLine(' <span class="badge badge-info" style="padding:5px;"> </span> Recommendation ') [void]$sb.AppendLine(' <span class="badge badge-secondary" style="padding:5px;"> </span> Informational ') [void]$sb.AppendLine(' <span class="badge badge-dark" style="padding:5px;"> </span> Verify manually') [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine('') # ---- section cards ---- foreach ($sec in $sections) { $counts = Get-PpaSectionCounts $sec [void]$sb.Append(' <div class="card mt-3 seccard" id="').Append((ConvertTo-PpaHtmlAttr $sec.id)).AppendLine('">') [void]$sb.AppendLine(' <div class="card-header"><div class="row">') [void]$sb.Append(' <div class="col-sm" style="margin:auto 0;"><a>').Append((ConvertTo-PpaHtmlText $sec.title)).Append('</a>') [void]$sb.AppendLine('<span class="sec-hiddennote"></span></div>') [void]$sb.AppendLine(' <div class="col-sm text-right" style="padding-right:10px;">') [void]$sb.Append(' ').AppendLine((Write-PpaCountBadges $counts)) [void]$sb.AppendLine(' </div></div></div>') [void]$sb.AppendLine(' <div class="card-body" style="padding-top:4px;">') [void]$sb.AppendLine('') foreach ($f in @($sec.findings)) { $s = Get-PpaStatusStyle $f.status # Stable per-finding anchor derived from the check ID (never positional). # data-status feeds the P2 client-side severity filter. $anchorId = 'finding-' + (ConvertTo-PpaHtmlAttr $f.id) [void]$sb.Append(' <div class="finding" id="').Append($anchorId).Append('" data-status="').Append((ConvertTo-PpaHtmlAttr $f.status)).AppendLine('">') [void]$sb.Append(' <div class="row finding-head" data-toggle="collapse" data-target="#').Append((ConvertTo-PpaHtmlAttr $f.domId)).AppendLine('" aria-expanded="false">') [void]$sb.Append(' <div class="col-sm-10"><i class="fas fa-chevron-right chev"></i><h6>').Append((ConvertTo-PpaHtmlText $f.title)).Append('</h6>') [void]$sb.Append('<a class="anchor-link" href="#').Append($anchorId).AppendLine('" title="Copy link to this finding">¶</a></div>') [void]$sb.Append(' <div class="col-sm-2 text-right"><span class="badge ').Append($s.Badge).Append('">').Append((ConvertTo-PpaHtmlText $f.status)).AppendLine('</span></div>') [void]$sb.AppendLine(' </div>') # Note: findings may carry a 'requires' tier annotation; it travels in the JSON # export only and is deliberately NOT rendered (client-facing polish - the E5 # assumption is documented in the README, not the report). [void]$sb.Append(' <div class="collapse" id="').Append((ConvertTo-PpaHtmlAttr $f.domId)).Append('"><div class="bd-callout ').Append($s.Callout).AppendLine('">') [void]$sb.Append(' <p class="whyline">').Append((ConvertTo-PpaHtmlText $f.whyline)).AppendLine('</p>') $tableHtml = Write-PpaDetailTable $f.table if ($tableHtml) { [void]$sb.Append($tableHtml) } # P7: remediation only where the finding is actionable AND the catalog # defines an entry for this check ID (keyed join, never positional). if ($f.status -eq 'Improvement' -or $f.status -eq 'Recommendation') { $remedHtml = Write-PpaRemediation (Get-PpaRemediation -Catalog $remedCatalog -CheckId ([string]$f.id)) if ($remedHtml) { [void]$sb.Append($remedHtml) } } $lmHtml = Write-PpaLearnMore $f.learnmore if ($lmHtml) { [void]$sb.Append($lmHtml) } [void]$sb.AppendLine(' </div></div>') [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine('') } [void]$sb.AppendLine(' <div class="text-right backlink" style="padding:8px 10px 0;"><a href="#Solutionsummary">Go to Solutions Summary</a></div>') [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine(' </div>') [void]$sb.AppendLine('') } # ---- run-profile note (P5): a thin report must never look like a silent failure ---- $excludedList = @($ExcludedSections | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($excludedList.Count -gt 0) { [void]$sb.Append(' <p class="profile-note">Sections excluded by run profile: ') [void]$sb.Append((ConvertTo-PpaHtmlText ($excludedList -join ', '))).AppendLine('</p>') [void]$sb.AppendLine('') } # ---- close + footer ---- [void]$sb.AppendLine('</main></div>') [void]$sb.AppendLine('') [void]$sb.AppendLine((Get-PpaPolishScript)) [void]$sb.AppendLine((Get-PpaFooterHtml)) Clear-PpaRedaction return $sb.ToString() } |