Private/Render/Export-PpaDeltaReport.ps1
|
# Export-PpaDeltaReport.ps1 - renders the delta model as HTML (Wave 4 spec 4.5, # reworked at C-fix 4/6). STRUCTURALLY shares the main report's render assets: # Get-PpaHtmlHead embeds the SAME shared stylesheet (Get-PpaSharedReportCss - never # hand-copied), the same navbar and footer chunks, and the same table.detail rules, # so the delta report inherits fonts, bars, spacing and print behavior from one # place. Every string flows through ConvertTo-PpaHtmlText / ConvertTo-PpaHtmlAttr; # redaction (stable pseudonyms) applies at render iff -Redact/-RedactNames. # # Layout (C-fix 6): Comparison header card, then ONE "Assessment visibility" card # collecting every VisibilityChanged / NotCompared / one-sided-check notice, then # per-section cards carrying REAL changes only (headline tier, detail tier, always # the unchanged count, finding status changes). # ASCII-only source (parses under 5.1; executes under 7.5+). Set-StrictMode -Off function Get-PpaDeltaCss { # Delta-specific additions ONLY - everything else comes from the shared asset. @' .chg-add{ color:#1e7e34; font-weight:700; } .chg-rem{ color:#c82333; font-weight:700; } .chg-mod{ color:#b8860b; font-weight:700; } .arrow{ color:#8a97a4; padding:0 4px; } .sig{ background:#fff3cd; border-radius:3px; padding:0 4px; font-size:11px; font-weight:700; color:#856404; margin-left:6px; text-transform:uppercase; } .tier{ font-size:12px; font-weight:700; color:#33445a; text-transform:uppercase; letter-spacing:.03em; margin:12px 0 2px; } .unch{ color:#5a6b7b; font-size:12.5px; margin:10px 0 2px; } .idwarn{ background:#fdecea; border:1px solid #d9534f; border-radius:5px; padding:8px 12px; margin:10px 0; font-size:13px; font-weight:600; } .vis-intro{ color:#495057; font-size:13px; margin:0 0 .5rem; } .vis-list{ margin:0; padding-left:0; list-style:none; } .vis-list li{ font-size:13px; padding:4px 0; border-bottom:1px dashed #e3e7eb; } .vis-list li:last-child{ border-bottom:0; } .delta-note{ color:#5a6b7b; font-size:12.5px; margin:1rem 2px 0; padding:8px 12px; background:#f1f5f9; border:1px solid #e3e9ef; border-radius:6px; } '@ } function Get-PpaDeltaSectionTitle { param([string]$Id) return ($Id -replace '_', ' ') } function Export-PpaDeltaReport { [CmdletBinding()] param( [Parameter(Mandatory = $true)] $Delta, [switch]$Redact, [switch]$RedactNames ) if ($Redact -or $RedactNames) { Initialize-PpaDeltaRedaction -Delta $Delta -RedactNames:$RedactNames } else { Clear-PpaRedaction } $sb = New-Object System.Text.StringBuilder [void]$sb.Append((Get-PpaHtmlHead -Title 'PurviewPostureAnalyzer (PPA) - Delta report' -ExtraCss (Get-PpaDeltaCss))) [void]$sb.Append((Get-PpaNavbarHtml)) [void]$sb.AppendLine('<div class="container-fluid">') # ---- Comparison header card (spec 4.5) ---- $span = [int]$Delta.spanDays [void]$sb.AppendLine(' <div class="card mt-3" id="delta-header"><div class="card-header"><strong>Snapshot Comparison</strong></div><div class="card-body">') [void]$sb.Append(' <p class="es-meta">From: snapshot <strong>').Append((ConvertTo-PpaHtmlText ([string]$Delta.from.snapshotId))).Append('</strong> captured ').Append((ConvertTo-PpaHtmlText ([string]$Delta.from.capturedAt))).AppendLine('</p>') [void]$sb.Append(' <p class="es-meta">To: snapshot <strong>').Append((ConvertTo-PpaHtmlText ([string]$Delta.to.snapshotId))).Append('</strong> captured ').Append((ConvertTo-PpaHtmlText ([string]$Delta.to.capturedAt))).AppendLine('</p>') $tenantDisp = [string]$Delta.from.tenantId if ([string]::IsNullOrEmpty($tenantDisp)) { $tenantDisp = '(not recorded)' } [void]$sb.Append(' <p class="es-meta">Tenant: ').Append((ConvertTo-PpaHtmlText $tenantDisp)).Append(' · span: ').Append($span).AppendLine(' days</p>') if (-not [string]::IsNullOrEmpty([string]$Delta.denylistNote)) { [void]$sb.Append(' <div class="profile-note">').Append((ConvertTo-PpaHtmlText ([string]$Delta.denylistNote))).AppendLine('</div>') } if ($span -lt 0) { [void]$sb.AppendLine(' <div class="profile-note">The FROM snapshot is newer than the TO snapshot; the comparison reads right to left (inputs were not swapped).</div>') } [void]$sb.AppendLine(' </div></div>') # ---- Assessment visibility card (C-fix 6): every notice, stated ONCE ---- $visItems = New-Object System.Collections.Generic.List[string] foreach ($sec in @($Delta.sections)) { $title = ConvertTo-PpaHtmlText (Get-PpaDeltaSectionTitle ([string]$sec.id)) if ([string]$sec.state -eq 'NotCompared') { $visItems.Add('<li><strong>' + $title + '</strong> — not compared: ' + (ConvertTo-PpaHtmlText ([string]$sec.reason)) + '</li>') } elseif ([string]$sec.state -eq 'VisibilityChanged') { $noteTxt = [string]$sec.visibilityNote if ([string]::IsNullOrEmpty($noteTxt)) { $noteTxt = ('{0} -> {1}' -f [string]$sec.fromOutcome, [string]$sec.toOutcome) } $visItems.Add('<li><strong>' + $title + '</strong> — ' + (ConvertTo-PpaHtmlText $noteTxt) + '</li>') } foreach ($n in @($sec.findingNotices)) { $visItems.Add('<li><strong>' + $title + '</strong> — check ' + (ConvertTo-PpaHtmlText ([string]$n.checkId)) + ': ' + (ConvertTo-PpaHtmlText ([string]$n.reason)) + '</li>') } } if ($visItems.Count -gt 0) { [void]$sb.AppendLine(' <div class="card mt-3" id="delta-visibility"><div class="card-header"><strong>Assessment visibility</strong></div><div class="card-body">') [void]$sb.AppendLine(' <p class="vis-intro">The areas below could not be fully compared between the two snapshots. This reflects assessment visibility at capture time (permissions, module availability, or run scope) — it is not evidence of tenant change.</p>') [void]$sb.AppendLine(' <ul class="vis-list">') foreach ($li in $visItems) { [void]$sb.Append(' ').AppendLine($li) } [void]$sb.AppendLine(' </ul>') [void]$sb.AppendLine(' </div></div>') } # ---- per-section cards: REAL changes lead (C-fix 6) ---- foreach ($sec in @($Delta.sections)) { $sid = [string]$sec.id $isCompared = ([string]$sec.state -eq 'Compared') $hasFindingChanges = (@($sec.findingChanges).Count -gt 0) if (-not $isCompared -and -not $hasFindingChanges) { continue } [void]$sb.Append(' <div class="card mt-3" id="delta-').Append((ConvertTo-PpaHtmlAttr $sid)).Append('"><div class="card-header"><strong>').Append((ConvertTo-PpaHtmlText (Get-PpaDeltaSectionTitle $sid))).AppendLine('</strong></div><div class="card-body">') if ($isCompared) { if ($sec.identityWarning) { [void]$sb.AppendLine(' <div class="idwarn">Warning: every object in this section appears Added AND Removed with nothing Modified or unchanged - this is the signature of an identity/keying failure, not a real wholesale replacement. Review the _keySource values in both snapshots before trusting this section.</div>') } $headlineRows = New-Object System.Collections.Generic.List[string] $detailRows = New-Object System.Collections.Generic.List[string] foreach ($r in @($sec.added)) { $headlineRows.Add('<tr><td><span class="chg-add">Added</span></td><td>' + (ConvertTo-PpaHtmlText ([string]$r.type)) + '</td><td>' + (ConvertTo-PpaHtmlText ([string]$r.name)) + '</td><td>—</td></tr>') } foreach ($r in @($sec.removed)) { $headlineRows.Add('<tr><td><span class="chg-rem">Removed</span></td><td>' + (ConvertTo-PpaHtmlText ([string]$r.type)) + '</td><td>' + (ConvertTo-PpaHtmlText ([string]$r.name)) + '</td><td>—</td></tr>') } foreach ($m in @($sec.modified)) { $label = ConvertTo-PpaHtmlText ([string]$m.name) if ($m.renamed) { $label = (ConvertTo-PpaHtmlText ([string]$m.renameFrom)) + '<span class="arrow">→</span>' + (ConvertTo-PpaHtmlText ([string]$m.renameTo)) + ' <span class="sig">renamed</span>' } foreach ($c in @($m.changes)) { $row = '<tr><td><span class="chg-mod">Modified</span></td><td>' + (ConvertTo-PpaHtmlText ([string]$m.type)) + '</td><td>' + $label + '</td><td>' + (ConvertTo-PpaHtmlText ([string]$c.property)) + ': ' + (ConvertTo-PpaHtmlText ([string]$c.from)) + '<span class="arrow">→</span>' + (ConvertTo-PpaHtmlText ([string]$c.to)) if ($c.significant) { $row += ' <span class="sig">significant</span>' } $row += '</td></tr>' if ($c.significant -or $m.renamed) { $headlineRows.Add($row) } else { $detailRows.Add($row) } } if (@($m.changes).Count -eq 0 -and $m.renamed) { $headlineRows.Add('<tr><td><span class="chg-mod">Modified</span></td><td>' + (ConvertTo-PpaHtmlText ([string]$m.type)) + '</td><td>' + $label + '</td><td>—</td></tr>') } } if ($headlineRows.Count -gt 0) { [void]$sb.AppendLine(' <div class="tier">Headline changes</div>') [void]$sb.AppendLine(' <table class="table table-sm detail"><thead><tr><th>Change</th><th>Type</th><th>Object</th><th>Detail</th></tr></thead><tbody>') foreach ($r in $headlineRows) { [void]$sb.Append(' ').AppendLine($r) } [void]$sb.AppendLine(' </tbody></table>') } if ($detailRows.Count -gt 0) { [void]$sb.AppendLine(' <div class="tier">Detail changes</div>') [void]$sb.AppendLine(' <table class="table table-sm detail"><thead><tr><th>Change</th><th>Type</th><th>Object</th><th>Detail</th></tr></thead><tbody>') foreach ($r in $detailRows) { [void]$sb.Append(' ').AppendLine($r) } [void]$sb.AppendLine(' </tbody></table>') } if ($headlineRows.Count -eq 0 -and $detailRows.Count -eq 0) { [void]$sb.AppendLine(' <p class="es-meta">No object-level changes.</p>') } # ALWAYS the unchanged count - the confidence signal (spec 4.5). [void]$sb.Append(' <p class="unch">').Append([int]$sec.unchangedCount).AppendLine(' unchanged object(s) in this section.</p>') } if ($hasFindingChanges) { [void]$sb.AppendLine(' <div class="tier">Finding changes</div>') [void]$sb.AppendLine(' <table class="table table-sm detail"><thead><tr><th>Check</th><th>Title</th><th>Status</th></tr></thead><tbody>') foreach ($f in @($sec.findingChanges)) { [void]$sb.Append(' <tr><td>').Append((ConvertTo-PpaHtmlText ([string]$f.checkId))).Append('</td><td>').Append((ConvertTo-PpaHtmlText ([string]$f.title))).Append('</td><td>') [void]$sb.Append((ConvertTo-PpaHtmlText ([string]$f.fromStatus))).Append('<span class="arrow">→</span>').Append((ConvertTo-PpaHtmlText ([string]$f.toStatus))).AppendLine('</td></tr>') } [void]$sb.AppendLine(' </tbody></table>') } [void]$sb.AppendLine(' </div></div>') } [void]$sb.AppendLine(' <div class="delta-note">Offline comparison of two read-only snapshots. Object changes reflect configuration metadata only; unchanged counts state how much of each section was verified identical. Statuses are inputs to user judgment, not compliance determinations.</div>') [void]$sb.AppendLine('</div>') [void]$sb.Append((Get-PpaFooterHtml)) $html = $sb.ToString() Clear-PpaRedaction return $html } |