Common/Export-ComplianceOverview.ps1
|
function Export-ComplianceOverview { <# .SYNOPSIS Renders the Compliance Overview HTML section from assessment findings. .DESCRIPTION Generates the compliance overview block including framework selector, coverage cards, status distribution bar, section filter, compliance matrix table, and embedded JSON for client-side filtering. Designed to be dot-sourced from Export-AssessmentReport.ps1 where helper functions (ConvertTo-HtmlSafe, Get-SvgHorizontalBar) are already defined. .PARAMETER Findings Array of finding objects with CheckId, Setting, Status, RiskSeverity, Section, and Frameworks hashtable. .PARAMETER ControlRegistry The control registry hashtable keyed by CheckId. .PARAMETER Frameworks Ordered array of framework definition hashtables from Import-FrameworkDefinitions. .PARAMETER FrameworkFilter Optional list of framework family names (CIS, NIST, etc.) to limit which frameworks appear in the overview. .PARAMETER Sections Array of section names from the assessment summary. .OUTPUTS System.String - HTML block for the compliance overview section. #> [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject[]]$Findings, [Parameter(Mandatory)] [hashtable]$ControlRegistry, [Parameter(Mandatory)] [hashtable[]]$Frameworks, [Parameter()] [string[]]$FrameworkFilter, [Parameter()] [string[]]$Sections ) # Apply FrameworkFilter to narrow displayed frameworks $displayFrameworks = $Frameworks if ($FrameworkFilter -and $FrameworkFilter.Count -gt 0) { $displayFrameworks = @($Frameworks | Where-Object { $_.filterFamily -in $FrameworkFilter }) } if ($displayFrameworks.Count -eq 0) { return '' } $html = [System.Text.StringBuilder]::new() $null = $html.AppendLine("<details class='section' open>") $null = $html.AppendLine("<summary><h2>Compliance Overview</h2></summary>") $null = $html.AppendLine("<p>Security findings mapped across compliance frameworks. Use the selector below to choose which frameworks to display.</p>") # Informational disclaimer $null = $html.AppendLine("<div class='cis-disclaimer'>") $null = $html.AppendLine("<strong>Informational Notice</strong>") $null = $html.AppendLine("<p>This compliance assessment is provided for <strong>informational purposes only</strong> and does not constitute a comprehensive security assessment, audit, or certification. Results reflect automated checks at a point in time and should not be considered conclusive. For a thorough security evaluation, consider engaging a qualified security professional.</p>") $null = $html.AppendLine("</div>") # License-skipped notice if ($global:CheckProgressState -and $global:CheckProgressState.LicenseSkipped.Count -gt 0) { $skipCount = $global:CheckProgressState.LicenseSkipped.Count $null = $html.AppendLine("<div class='callout callout-info'><div class='callout-title'><span class='callout-icon'>ⓘ</span> License-Aware Check Gating</div><div class='callout-body'>$skipCount checks were skipped because the tenant does not have the required license service plans. Upgrade your license to enable these checks.</div></div>") } # Framework multi-selector (one checkbox per framework) $null = $html.AppendLine("<div class='fw-selector' id='fwSelector'>") $null = $html.AppendLine("<span class='fw-selector-label'>Frameworks:</span>") foreach ($fw in $displayFrameworks) { $null = $html.AppendLine("<label class='fw-checkbox'><input type='checkbox' value='$($fw.frameworkId)' checked> $($fw.label)</label>") } $null = $html.AppendLine("<span class='fw-selector-actions'><button type='button' id='fwSelectAll' class='fw-action-btn'>All</button><button type='button' id='fwSelectNone' class='fw-action-btn'>None</button></span>") $null = $html.AppendLine("</div>") # Status counts $totalFindings = $Findings.Count $passCount = @($Findings | Where-Object { $_.Status -eq 'Pass' }).Count $failCount = @($Findings | Where-Object { $_.Status -eq 'Fail' }).Count $warnCount = @($Findings | Where-Object { $_.Status -eq 'Warning' }).Count $reviewCount = @($Findings | Where-Object { $_.Status -eq 'Review' }).Count $infoCount = @($Findings | Where-Object { $_.Status -eq 'Info' }).Count $knownStatuses = @('Pass', 'Fail', 'Warning', 'Review', 'Info') $unknownCount = @($Findings | Where-Object { $_.Status -notin $knownStatuses }).Count # Status distribution bar chart if ($totalFindings -gt 0) { $segments = @( @{ Css = 'pass'; Pct = [math]::Round(($passCount / $totalFindings) * 100, 1); Count = $passCount; Label = 'Pass' } @{ Css = 'fail'; Pct = [math]::Round(($failCount / $totalFindings) * 100, 1); Count = $failCount; Label = 'Fail' } @{ Css = 'warning'; Pct = [math]::Round(($warnCount / $totalFindings) * 100, 1); Count = $warnCount; Label = 'Warning' } @{ Css = 'review'; Pct = [math]::Round(($reviewCount / $totalFindings) * 100, 1); Count = $reviewCount; Label = 'Review' } ) if ($infoCount -gt 0) { $segments += @{ Css = 'info'; Pct = [math]::Round(($infoCount / $totalFindings) * 100, 1); Count = $infoCount; Label = 'Info' } } if ($unknownCount -gt 0) { $segments += @{ Css = 'unknown'; Pct = [math]::Round(($unknownCount / $totalFindings) * 100, 1); Count = $unknownCount; Label = 'Unknown' } } $barChart = Get-SvgHorizontalBar -Segments $segments $null = $html.AppendLine("<div class='compliance-status-bar'>") $null = $html.AppendLine("<div class='compliance-bar-header'><span class='compliance-bar-title'>Finding Status Distribution</span><span class='compliance-bar-total'>$totalFindings controls assessed</span></div>") $null = $html.AppendLine($barChart) $null = $html.AppendLine("<div class='hbar-legend'>") foreach ($seg in $segments) { if ($seg.Count -gt 0) { $dotClass = switch ($seg.Css) { 'pass' { 'success' } 'fail' { 'danger' } 'warning' { 'warning' } 'review' { 'info' } 'info' { 'neutral' } default { 'muted' } } $null = $html.AppendLine("<span class='hbar-legend-item'><span class='chart-legend-dot dot-$dotClass'></span>$($seg.Label) ($($seg.Count))</span>") } } $null = $html.AppendLine("</div>") $null = $html.AppendLine("</div>") } # Framework coverage cards (one per framework) $null = $html.AppendLine("<div class='exec-summary' id='fwCards'>") foreach ($fw in $displayFrameworks) { $fwId = $fw.frameworkId $isProfileBased = $fw.scoringMethod -eq 'profile-compliance' if ($isProfileBased -and $fw.profiles) { # Profile-based card (CIS, NIST) -- pass rate as primary, coverage bar as secondary $profileFindings = @($Findings | Where-Object { $_.Frameworks -and $_.Frameworks.ContainsKey($fwId) }) $profilePass = @($profileFindings | Where-Object { $_.Status -eq 'Pass' }).Count $profileScored = $profileFindings.Count $profileScore = if ($profileScored -gt 0) { [math]::Round(($profilePass / $profileScored) * 100, 1) } else { 0 } $scoreDisplay = if ($profileScored -gt 0) { "$profileScore%" } else { 'N/A' } $scoreClass = if ($profileScored -eq 0) { '' } elseif ($profileScore -ge 80) { 'success' } elseif ($profileScore -ge 60) { 'warning' } else { 'danger' } $catalogTotal = $fw.totalControls $coverageLabel = if ($catalogTotal -gt 0) { "$profileScored of $catalogTotal assessed" } else { "$profileScored assessed" } $coveragePct = if ($catalogTotal -gt 0) { [math]::Min(100, [math]::Round(($profileScored / $catalogTotal) * 100, 0)) } else { 0 } $tooltip = if ($fw.description) { " title='$(ConvertTo-HtmlSafe -Text $fw.description)'" } else { '' } # Detect level-based profiles (L1/L2 suffix) for sub-metric breakdown $profileKeys = @($fw.profiles.PSObject.Properties.Name) $levelKeys = @($profileKeys | ForEach-Object { if ($_ -match '-L(\d+)$') { "L$($Matches[1])" } } | Sort-Object -Unique) $levelBreakdownHtml = '' if ($levelKeys.Count -ge 2) { foreach ($level in $levelKeys) { $levelProfiles = @($profileKeys | Where-Object { $_ -like "*-$level" }) $levelFindings = @($profileFindings | Where-Object { $fwData = $_.Frameworks[$fwId] $fwProfiles = if ($fwData.profiles) { @($fwData.profiles) } else { @() } $matched = $false foreach ($lp in $levelProfiles) { if ($lp -in $fwProfiles) { $matched = $true; break } } $matched }) $lvlTotal = $levelFindings.Count $lvlPass = @($levelFindings | Where-Object { $_.Status -eq 'Pass' }).Count $lvlRate = if ($lvlTotal -gt 0) { [math]::Round(($lvlPass / $lvlTotal) * 100, 1) } else { 0 } $lvlClass = if ($lvlTotal -eq 0) { 'neutral' } elseif ($lvlRate -ge 80) { 'success' } elseif ($lvlRate -ge 60) { 'warning' } else { 'danger' } $lvlDisplay = if ($lvlTotal -gt 0) { "$lvlRate%" } else { 'N/A' } $levelBreakdownHtml += "<div class='profile-level-row'><span class='profile-level-label'>$level</span><span class='badge badge-$lvlClass' style='font-size:7.5pt;padding:2px 6px;'>$lvlDisplay</span><span class='profile-level-detail'>$lvlPass/$lvlTotal pass</span></div>" } } $null = $html.AppendLine("<div class='stat-card fw-card $scoreClass' data-fw='$fwId' data-catalog-total='$catalogTotal'$tooltip><div class='stat-value'>$scoreDisplay</div><div class='stat-label'>$($fw.label)</div><div class='stat-sublabel'>$coverageLabel</div><div class='coverage-bar'><div class='coverage-fill' style='width: $coveragePct%'></div></div><div class='coverage-label'>$coveragePct% coverage</div>$levelBreakdownHtml</div>") } else { # Non-profile card -- pass rate as primary, coverage bar as secondary $mappedFindings = @($Findings | Where-Object { $_.Frameworks -and $_.Frameworks.ContainsKey($fwId) }) $mappedControls = @($mappedFindings | ForEach-Object { $fwData = $_.Frameworks[$fwId] if ($fwData.controlId) { $fwData.controlId -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } } } | Sort-Object -Unique) $mappedCount = $mappedControls.Count $mappedPass = @($mappedFindings | Where-Object { $_.Status -eq 'Pass' }).Count $mappedTotal = $mappedFindings.Count $passRate = if ($mappedTotal -gt 0) { [math]::Round(($mappedPass / $mappedTotal) * 100, 1) } else { 0 } $passDisplay = if ($mappedTotal -gt 0) { "$passRate%" } else { 'N/A' } $passClass = if ($mappedTotal -eq 0) { '' } elseif ($passRate -ge 80) { 'success' } elseif ($passRate -ge 60) { 'warning' } else { 'danger' } $totalCount = $fw.totalControls $coveragePct = if ($totalCount -gt 0) { [math]::Min(100, [math]::Round(($mappedCount / $totalCount) * 100, 0)) } else { 0 } $coverageLabel = if ($totalCount -gt 0) { "$mappedTotal of $totalCount assessed" } else { "$mappedTotal assessed" } $coverageBarHtml = if ($totalCount -gt 0) { "<div class='coverage-bar'><div class='coverage-fill' style='width: $coveragePct%'></div></div><div class='coverage-label'>$coveragePct% coverage</div>" } else { '' } $tooltip = if ($fw.description) { " title='$(ConvertTo-HtmlSafe -Text $fw.description)'" } else { '' } $null = $html.AppendLine("<div class='stat-card fw-card $passClass' data-fw='$fwId' data-catalog-total='$totalCount'$tooltip><div class='stat-value'>$passDisplay</div><div class='stat-label'>$($fw.label)</div><div class='stat-sublabel'>$coverageLabel</div>$coverageBarHtml</div>") } } $null = $html.AppendLine("</div>") # Status filter (multi-select checkboxes) $null = $html.AppendLine("<div class='status-filter' id='statusFilter'>") $null = $html.AppendLine("<span class='status-filter-label'>Status:</span>") $null = $html.AppendLine("<label class='status-checkbox status-fail'><input type='checkbox' value='fail' checked> Fail ($failCount)</label>") if ($warnCount -gt 0) { $null = $html.AppendLine("<label class='status-checkbox status-warning'><input type='checkbox' value='warning' checked> Warning ($warnCount)</label>") } if ($reviewCount -gt 0) { $null = $html.AppendLine("<label class='status-checkbox status-review'><input type='checkbox' value='review' checked> Review ($reviewCount)</label>") } $null = $html.AppendLine("<label class='status-checkbox status-pass'><input type='checkbox' value='pass' checked> Pass ($passCount)</label>") if ($infoCount -gt 0) { $null = $html.AppendLine("<label class='status-checkbox status-info'><input type='checkbox' value='info' checked> Info ($infoCount)</label>") } if ($unknownCount -gt 0) { $null = $html.AppendLine("<label class='status-checkbox status-unknown'><input type='checkbox' value='unknown' checked> Unknown ($unknownCount)</label>") } if ($infoCount -gt 0) { $null = $html.AppendLine("<span class='info-note-inline'><span class='badge badge-neutral'>Info</span> = no pass/fail criteria; not included in pass rates</span>") } $null = $html.AppendLine("<span class='fw-selector-actions'><button type='button' id='statusSelectAll' class='fw-action-btn'>All</button><button type='button' id='statusSelectNone' class='fw-action-btn'>None</button></span>") $null = $html.AppendLine("</div>") # Section filter (scopes compliance view by assessment domain) $uniqueSections = @($Findings | Select-Object -ExpandProperty Section -ErrorAction SilentlyContinue | Where-Object { $_ } | Sort-Object -Unique) if ($uniqueSections.Count -gt 1) { $null = $html.AppendLine("<div class='section-filter' id='sectionFilter'>") $null = $html.AppendLine("<span class='section-filter-label'>Sections:</span>") foreach ($sec in $uniqueSections) { $secCount = @($Findings | Where-Object { $_.Section -eq $sec }).Count $null = $html.AppendLine("<label class='section-checkbox'><input type='checkbox' value='$(ConvertTo-HtmlSafe -Text $sec)' checked> $(ConvertTo-HtmlSafe -Text $sec) ($secCount)</label>") } $null = $html.AppendLine("<span class='fw-selector-actions'><button type='button' id='sectionSelectAll' class='fw-action-btn'>All</button><button type='button' id='sectionSelectNone' class='fw-action-btn'>None</button></span>") $null = $html.AppendLine("</div>") } # Unified compliance matrix table (one column per framework) $null = $html.AppendLine("<div class='table-wrapper'>") $null = $html.AppendLine("<table class='data-table matrix-table' id='complianceTable'>") # Header row -- fixed columns + one column per framework $headerCols = "<th scope='col'>Control</th><th scope='col'>Description</th><th scope='col'>Status</th><th scope='col'>Severity</th>" foreach ($fw in $displayFrameworks) { $headerCols += "<th scope='col' class='fw-col' data-fw='$($fw.frameworkId)'>$($fw.label)</th>" } $null = $html.AppendLine("<thead><tr>$headerCols</tr></thead>") $null = $html.AppendLine("<tbody>") # Sort findings by CheckId (groups by collector area) $matrixFindings = @($Findings | Sort-Object -Property CheckId) foreach ($finding in $matrixFindings) { $statusClass = switch ($finding.Status) { 'Pass' { 'badge-success' } 'Fail' { 'badge-failed' } 'Warning' { 'badge-warning' } 'Review' { 'badge-info' } 'Info' { 'badge-neutral' } default { 'badge-skipped' } } $statusBadge = "<span class='badge $statusClass'>$($finding.Status)</span>" $checkRef = ConvertTo-HtmlSafe -Text $finding.CheckId $settingText = ConvertTo-HtmlSafe -Text $finding.Setting $null = $html.AppendLine("<tr class='cis-row-$($finding.Status.ToLower())' data-section='$(ConvertTo-HtmlSafe -Text $finding.Section)'>") $severityClass = switch ($finding.RiskSeverity) { 'Critical' { 'badge-critical' } 'High' { 'badge-failed' } 'Medium' { 'badge-warning' } 'Low' { 'badge-info' } 'Info' { 'badge-neutral' } default { 'badge-neutral' } } $severityBadge = "<span class='badge $severityClass'>$($finding.RiskSeverity)</span>" $null = $html.AppendLine("<td class='cis-id'>$checkRef</td>") $null = $html.AppendLine("<td>$settingText</td>") $null = $html.AppendLine("<td>$statusBadge</td>") $null = $html.AppendLine("<td>$severityBadge</td>") # One cell per framework -- profile tags inline for profile-based frameworks foreach ($fw in $displayFrameworks) { $fwId = $fw.frameworkId $fwData = if ($finding.Frameworks -and $finding.Frameworks.ContainsKey($fwId)) { $finding.Frameworks[$fwId] } else { $null } if ($fwData -and $fwData.controlId) { $controlId = $fwData.controlId $tagHtml = "<span class='fw-tag $($fw.css)'>$(ConvertTo-HtmlSafe -Text $controlId)</span>" # Add inline profile tags for profile-based frameworks if ($fwData.profiles -and $fwData.profiles.Count -gt 0) { $profileTags = ($fwData.profiles | ForEach-Object { "<span class='fw-profile-tag'>$_</span>" }) -join '' $tagHtml += " $profileTags" } $null = $html.AppendLine("<td class='fw-col framework-refs' data-fw='$fwId'>$tagHtml</td>") } else { $null = $html.AppendLine("<td class='fw-col framework-refs' data-fw='$fwId'><span class='fw-unmapped'>—</span></td>") } } $null = $html.AppendLine("</tr>") } $null = $html.AppendLine("</tbody></table>") $null = $html.AppendLine("</div>") $null = $html.AppendLine("<div id='complianceNoResults' class='no-results' style='display:none'><p>No findings match the current filter selection.</p></div>") # Embed compliance data for client-side filtering/recalculation $complianceJson = @($Findings | ForEach-Object { $fwMap = [ordered]@{} if ($_.Frameworks) { foreach ($fw in $displayFrameworks) { $fwId = $fw.frameworkId if ($_.Frameworks.ContainsKey($fwId)) { $fwEntry = $_.Frameworks[$fwId] $jsEntry = @{ id = $fwEntry.controlId } if ($fwEntry.profiles -and $fwEntry.profiles.Count -gt 0) { $jsEntry['p'] = @($fwEntry.profiles) } $fwMap[$fwId] = $jsEntry } } } [PSCustomObject]@{ c = $_.CheckId s = $_.Section st = $_.Status sv = $_.RiskSeverity fw = $fwMap } }) | ConvertTo-Json -Compress -Depth 4 $null = $html.AppendLine("<script>var complianceData = $complianceJson;</script>") $null = $html.AppendLine("</details>") return $html.ToString() } |