modules/shared/ExecDashboardRender.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Shared executive-dashboard renderer (issue #210). .DESCRIPTION Extracts the HTML/CSS/data assembly that used to live inline in New-ExecDashboard.ps1 so that BOTH the standalone dashboard.html (back-compat) and the Summary tab embedded in report.html can render the same content from one source of truth (no copy-paste). Public surface: Get-ExecDashboardModel - load + compute everything from results.json + history Get-ExecDashboardBody - return HTML body fragment (with or without header/footer) Get-ExecDashboardCss - return CSS, optionally scoped under a CSS prefix for embedding Get-ExecDashboardHtml - full standalone HTML document (used by New-ExecDashboard.ps1) .NOTES The CSS scoping helper (ConvertTo-ScopedCss) is intentionally selector-prefix based and only handles the well-formed CSS this module emits. It is NOT a general-purpose CSS parser. #> Set-StrictMode -Version Latest # --------------------------------------------------------------------------- # Dot-source dependencies (best effort - graceful degradation when missing) # --------------------------------------------------------------------------- $script:_execDashRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) foreach ($mod in @('Sanitize','FrameworkMapper','ReportDelta','RunHistory')) { $p = Join-Path $PSScriptRoot "$mod.ps1" if (Test-Path $p) { . $p } } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param ([string]$Text) return $Text } } # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- function _ExecHE([string]$s) { if ($null -eq $s) { return '' } $s -replace '&','&' -replace '<','<' -replace '>','>' -replace '"','"' -replace "'", ''' } function Get-SafeSeverityClass([string]$Raw) { if (-not $Raw) { return 'info' } switch -Regex ($Raw.ToLowerInvariant()) { '^critical$' { return 'critical' } '^high$' { return 'high' } '^medium$' { return 'medium' } '^low$' { return 'low' } '^info$' { return 'info' } default { return 'info' } } } function Get-SeverityCounts { param ( [object[]] $Rows, [switch] $NonCompliantOnly ) $sev = [ordered]@{ Critical = 0; High = 0; Medium = 0; Low = 0; Info = 0 } foreach ($r in $Rows) { if (-not $r) { continue } if ($NonCompliantOnly) { if (-not ($r.PSObject.Properties['Compliant']) -or $r.Compliant) { continue } } $s = if ($r.PSObject.Properties['Severity']) { [string]$r.Severity } else { '' } switch -Regex ($s) { '^(?i)critical$' { $sev['Critical']++ ; break } '^(?i)high$' { $sev['High']++ ; break } '^(?i)medium$' { $sev['Medium']++ ; break } '^(?i)low$' { $sev['Low']++ ; break } '^(?i)info$' { $sev['Info']++ ; break } } } return $sev } function New-Sparkline { param ( [int[]] $Values, [int] $Width = 120, [int] $Height = 28, [string]$Color = '#0078d4' ) if (-not $Values -or $Values.Count -eq 0) { return "<span class='spark-na'>no data</span>" } if ($Values.Count -eq 1) { return "<svg width='$Width' height='$Height' viewBox='0 0 $Width $Height' xmlns='http://www.w3.org/2000/svg'><line x1='0' y1='$($Height/2)' x2='$Width' y2='$($Height/2)' stroke='$Color' stroke-width='2'/><circle cx='$($Width/2)' cy='$($Height/2)' r='3' fill='$Color'/></svg>" } $max = ($Values | Measure-Object -Maximum).Maximum $min = ($Values | Measure-Object -Minimum).Minimum if ($max -eq $min) { $max = $min + 1 } $stepX = if ($Values.Count -gt 1) { $Width / ($Values.Count - 1) } else { 0 } $points = for ($i = 0; $i -lt $Values.Count; $i++) { $x = [math]::Round($i * $stepX, 2) $y = [math]::Round($Height - (($Values[$i] - $min) / ($max - $min)) * ($Height - 4) - 2, 2) "$x,$y" } $polyline = ($points -join ' ') $lastX = ($points[-1] -split ',')[0] $lastY = ($points[-1] -split ',')[1] return "<svg width='$Width' height='$Height' viewBox='0 0 $Width $Height' xmlns='http://www.w3.org/2000/svg'><polyline points='$polyline' fill='none' stroke='$Color' stroke-width='1.6'/><circle cx='$lastX' cy='$lastY' r='2.5' fill='$Color'/></svg>" } function Get-FrameworkBadgeClass([string] $Name) { if (-not $Name) { return 'fw-default' } $n = $Name.ToLowerInvariant() switch -Regex ($n) { 'cis' { return 'fw-cis' } 'nist' { return 'fw-nist' } 'mitre' { return 'fw-mitre' } 'eidsca' { return 'fw-eidsca' } 'eidas' { return 'fw-eidas' } 'soc' { return 'fw-soc' } 'iso' { return 'fw-iso' } 'mcsb' { return 'fw-mcsb' } 'caf' { return 'fw-caf' } 'waf' { return 'fw-waf' } 'cisa' { return 'fw-cisa' } 'orca' { return 'fw-orca' } default { return 'fw-default' } } } # Schema 2.2 (#299) conditional reader. The optional Frameworks field is a # [hashtable[]] with at least a 'kind' key (e.g. CIS, NIST, MCSB). This helper # extracts unique kind names across all findings; returns @() if no finding # carries the field, so legacy results (Schema <= 2.1) render unchanged. function Get-FrameworkKindsFromFindings([object[]] $Findings) { $kinds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($f in $Findings) { if (-not $f -or -not $f.PSObject.Properties['Frameworks']) { continue } foreach ($fw in @($f.Frameworks)) { if (-not $fw) { continue } $kind = $null if ($fw -is [hashtable] -and $fw.ContainsKey('kind')) { $kind = [string]$fw['kind'] } elseif ($fw.PSObject.Properties['kind']) { $kind = [string]$fw.kind } elseif ($fw.PSObject.Properties['Kind']) { $kind = [string]$fw.Kind } if ($kind) { [void]$kinds.Add($kind.Trim()) } } } return @($kinds | Sort-Object) } function Format-SignedDelta([double] $Val, [string] $Suffix = '') { if ($Val -gt 0) { return "+{0}{1}" -f $Val, $Suffix } if ($Val -lt 0) { return "{0}{1}" -f $Val, $Suffix } return "0$Suffix" } # --------------------------------------------------------------------------- # Get-ExecDashboardModel - data loading + computation # --------------------------------------------------------------------------- function Get-ExecDashboardModel { [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $InputPath, [string] $HistoryPath = '', [string] $EntitiesPath = '', [string] $ToolStatusPath = '' ) if (-not (Test-Path $InputPath)) { if (Get-Command -Name New-FindingError -ErrorAction SilentlyContinue) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:ExecDashboardRender' ` -Category 'NotFound' ` -Reason "Results file not found: $InputPath" ` -Remediation 'Run Invoke-AzureAnalyzer.ps1 first to produce results.json, then re-run the dashboard renderer.')) } throw "Results file not found: $InputPath. Run Invoke-AzureAnalyzer.ps1 first." } $inputDir = Split-Path $InputPath -Parent if (-not $HistoryPath) { $HistoryPath = Join-Path $inputDir 'history' } if (-not $EntitiesPath) { $EntitiesPath = Join-Path $inputDir 'entities.json' } if (-not $ToolStatusPath) { $ToolStatusPath = Join-Path $inputDir 'tool-status.json' } $findings = @(Get-Content $InputPath -Raw | ConvertFrom-Json -ErrorAction Stop) $history = @() if (Get-Command Get-RunHistory -ErrorAction SilentlyContinue) { $history = @(Get-RunHistory -OutputPath $inputDir -HistoryPath $HistoryPath) } $entities = @() if (Test-Path $EntitiesPath) { try { $raw = Get-Content $EntitiesPath -Raw | ConvertFrom-Json -ErrorAction Stop if ($raw -is [PSCustomObject] -and $raw.PSObject.Properties['Entities']) { $entities = @($raw.Entities) } else { $entities = @($raw) } } catch { Write-Warning "Could not parse entities.json: $_" } } $toolStatus = @() if (Test-Path $ToolStatusPath) { try { $toolStatus = @(Get-Content $ToolStatusPath -Raw | ConvertFrom-Json -ErrorAction Stop) } catch { Write-Warning "Could not parse tool-status.json: $_" } } $total = @($findings).Count $compliantCount = @($findings | Where-Object { $_.PSObject.Properties['Compliant'] -and $_.Compliant }).Count $nonCompliant = $total - $compliantCount $compliancePct = if ($total -gt 0) { [math]::Round(($compliantCount / $total) * 100, 1) } else { 0 } $currentSev = Get-SeverityCounts -Rows $findings -NonCompliantOnly $prevRun = if ($history.Count -ge 2) { $history[$history.Count - 2] } else { $null } $delta = $null $prevPct = $null if ($prevRun -and (Test-Path $prevRun.ResultsPath)) { try { $prev = @(Get-Content $prevRun.ResultsPath -Raw | ConvertFrom-Json -ErrorAction Stop) if (Get-Command Get-ReportDelta -ErrorAction SilentlyContinue) { $delta = Get-ReportDelta -Current $findings -Previous $prev } $prevTotal = @($prev).Count $prevComp = @($prev | Where-Object { $_.PSObject.Properties['Compliant'] -and $_.Compliant }).Count if ($prevTotal -gt 0) { $prevPct = [math]::Round(($prevComp / $prevTotal) * 100, 1) } } catch { Write-Warning (Remove-Credentials "Delta computation failed: $_") } } $pctDelta = if ($null -ne $prevPct) { [math]::Round($compliancePct - $prevPct, 1) } else { $null } $historyPoints = [System.Collections.Generic.List[object]]::new() foreach ($h in $history) { $sev = $null $sc = $null if ($h.Meta -and $h.Meta.PSObject.Properties['NonCompliantSeverityCounts']) { $sc = $h.Meta.NonCompliantSeverityCounts } if ($sc) { $sev = [ordered]@{ Critical = if ($sc.PSObject.Properties['Critical']) { [int]$sc.Critical } else { 0 } High = if ($sc.PSObject.Properties['High']) { [int]$sc.High } else { 0 } Medium = if ($sc.PSObject.Properties['Medium']) { [int]$sc.Medium } else { 0 } Low = if ($sc.PSObject.Properties['Low']) { [int]$sc.Low } else { 0 } Info = if ($sc.PSObject.Properties['Info']) { [int]$sc.Info } else { 0 } } } else { try { $rows = @(Get-Content $h.ResultsPath -Raw | ConvertFrom-Json -ErrorAction Stop) $sev = Get-SeverityCounts -Rows $rows -NonCompliantOnly } catch { $sev = [ordered]@{ Critical = 0; High = 0; Medium = 0; Low = 0; Info = 0 } } } $historyPoints.Add([pscustomobject]@{ Stamp = $h.Stamp; Timestamp = $h.Timestamp; Severity = $sev }) | Out-Null } $lastStamp = if ($historyPoints.Count -gt 0) { $historyPoints[-1].Stamp } else { '' } $nowStamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd-HHmmss') if ($lastStamp -ne $nowStamp) { $historyPoints.Add([pscustomobject]@{ Stamp = 'current'; Timestamp = (Get-Date).ToUniversalTime(); Severity = $currentSev }) | Out-Null } # Top-10 risky resources $riskBySev = @{ Critical = 4; High = 3; Medium = 2; Low = 1; Info = 0 } $resourceGroups = @{} foreach ($f in $findings) { if (-not $f) { continue } if ($f.PSObject.Properties['Compliant'] -and $f.Compliant) { continue } $rid = if ($f.PSObject.Properties['ResourceId'] -and $f.ResourceId) { [string]$f.ResourceId } elseif ($f.PSObject.Properties['EntityId'] -and $f.EntityId) { [string]$f.EntityId } else { '' } if (-not $rid) { continue } $sev = if ($f.PSObject.Properties['Severity']) { [string]$f.Severity } else { '' } $score = 0 foreach ($k in $riskBySev.Keys) { if ($sev -match "^(?i)$k$") { $score = $riskBySev[$k]; break } } if (-not $resourceGroups.ContainsKey($rid)) { $resourceGroups[$rid] = [pscustomobject]@{ ResourceId = $rid FindingCount = 0 HighestSev = '' HighestScore = -1 Sources = [System.Collections.Generic.HashSet[string]]::new() } } $g = $resourceGroups[$rid] $g.FindingCount++ if ($score -gt $g.HighestScore) { $g.HighestScore = $score $g.HighestSev = $sev } if ($f.PSObject.Properties['Source'] -and $f.Source) { [void]$g.Sources.Add([string]$f.Source) } } $topResources = @($resourceGroups.Values | Sort-Object -Property @{ Expression = 'FindingCount'; Descending = $true }, @{ Expression = 'HighestScore'; Descending = $true } | Select-Object -First 10) # Subscription heat map $subAggs = @{} foreach ($f in $findings) { if (-not $f) { continue } $rid = if ($f.PSObject.Properties['ResourceId']) { [string]$f.ResourceId } else { '' } if (-not $rid) { continue } if ($rid -notmatch '/subscriptions/([0-9a-fA-F-]{36})') { continue } $sub = $Matches[1].ToLowerInvariant() if (-not $subAggs.ContainsKey($sub)) { $subAggs[$sub] = [pscustomobject]@{ SubscriptionId = $sub Total = 0 NonCompliant = 0 Critical = 0 High = 0 } } $a = $subAggs[$sub] $a.Total++ if ($f.PSObject.Properties['Compliant'] -and -not $f.Compliant) { $a.NonCompliant++ $sev = if ($f.PSObject.Properties['Severity']) { [string]$f.Severity } else { '' } if ($sev -match '^(?i)critical$') { $a.Critical++ } elseif ($sev -match '^(?i)high$') { $a.High++ } } } $subRows = @($subAggs.Values | Sort-Object -Property @{ Expression = 'Critical'; Descending = $true }, @{ Expression = 'High'; Descending = $true }, @{ Expression = 'NonCompliant'; Descending = $true }) # WAF pillar coverage $wafRows = @() $prevWafByPillar = @{} if ($findings -and @($findings).Count -gt 0 -and (Get-Command Get-WafPillarCoverage -ErrorAction SilentlyContinue)) { try { $wafRows = @(Get-WafPillarCoverage -Findings $findings) } catch { Write-Warning "WAF coverage failed: $_" } if ($prevRun -and (Test-Path $prevRun.ResultsPath)) { try { $prevFindings = @(Get-Content $prevRun.ResultsPath -Raw | ConvertFrom-Json -ErrorAction Stop) $prevWafRows = @(Get-WafPillarCoverage -Findings $prevFindings) foreach ($r in $prevWafRows) { $prevWafByPillar[$r.Pillar] = $r } } catch { Write-Warning (Remove-Credentials "Previous WAF coverage computation failed: $_") } } } foreach ($r in $wafRows) { $prev = if ($prevWafByPillar.ContainsKey($r.Pillar)) { $prevWafByPillar[$r.Pillar] } else { $null } $trend = 'flat' $trendGlyph = "$([char]0x2192)" # -> $trendDelta = $null if ($prev) { $d = [math]::Round($r.CoveragePercent - $prev.CoveragePercent, 1) $trendDelta = $d if ($d -gt 0) { $trend = 'up'; $trendGlyph = "$([char]0x2191)" } elseif ($d -lt 0) { $trend = 'down'; $trendGlyph = "$([char]0x2193)" } } $r | Add-Member -NotePropertyName Trend -NotePropertyValue $trend -Force $r | Add-Member -NotePropertyName TrendGlyph -NotePropertyValue $trendGlyph -Force $r | Add-Member -NotePropertyName TrendDelta -NotePropertyValue $trendDelta -Force } # Framework gap analysis $frameworkRows = @() if ($findings -and @($findings).Count -gt 0 -and (Get-Command Get-FrameworkCoverage -ErrorAction SilentlyContinue)) { try { $frameworkRows = @(Get-FrameworkCoverage -Findings $findings) } catch { Write-Warning "Framework coverage failed: $_" } } # MTTR $mttrRows = @() if (Get-Command Get-MttrBySeverity -ErrorAction SilentlyContinue) { try { $mttrRows = @(Get-MttrBySeverity -History $history) } catch { Write-Warning "MTTR computation failed: $_" } } return [pscustomobject]@{ Findings = $findings History = $history HistoryPoints = $historyPoints Entities = $entities ToolStatus = $toolStatus Total = $total CompliantCount = $compliantCount NonCompliant = $nonCompliant CompliancePct = $compliancePct CurrentSev = $currentSev PrevRun = $prevRun Delta = $delta PrevPct = $prevPct PctDelta = $pctDelta TopResources = $topResources SubRows = $subRows WafRows = $wafRows FrameworkRows = $frameworkRows MttrRows = $mttrRows } } # --------------------------------------------------------------------------- # Get-ExecDashboardBody - HTML body fragment # --------------------------------------------------------------------------- function Get-ExecDashboardBody { [CmdletBinding()] param ( [Parameter(Mandatory)] [object] $Model, [switch] $Embedded ) $sevColor = @{ Critical = '#a80000' High = '#d83b01' Medium = '#ca5010' Low = '#107c10' Info = '#5c5c5c' } $severitiesForTrend = @('Critical','High','Medium','Low','Info') $sparklinesHtml = ($severitiesForTrend | ForEach-Object { $sev = $_ $vals = @($Model.HistoryPoints | ForEach-Object { [int]$_.Severity[$sev] }) $latest = if ($vals.Count -gt 0) { $vals[-1] } else { 0 } @" <div class="spark-row"> <span class="spark-label" style="color:$($sevColor[$sev])">$sev</span> <span class="spark-svg">$(New-Sparkline -Values $vals -Color $sevColor[$sev])</span> <span class="spark-val">$latest</span> </div> "@ }) -join '' $topResourcesRows = ($Model.TopResources | ForEach-Object { $sevBadge = if ($_.HighestSev) { $_.HighestSev } else { '-' } $sevClass = Get-SafeSeverityClass $_.HighestSev $srcs = ($_.Sources | Sort-Object) -join ', ' @" <tr> <td class="ridcol" title="$(_ExecHE $_.ResourceId)">$(_ExecHE $_.ResourceId)</td> <td>$([int]$_.FindingCount)</td> <td><span class="sev sev-$sevClass">$(_ExecHE $sevBadge)</span></td> <td class="src">$(_ExecHE $srcs)</td> </tr> "@ }) -join '' if (-not $topResourcesRows) { $topResourcesRows = "<tr><td colspan='4' class='empty'>No non-compliant resources in current run.</td></tr>" } $subTilesHtml = ($Model.SubRows | ForEach-Object { $status = if ($_.Critical -gt 0) { 'red' } elseif ($_.High -gt 0) { 'amber' } elseif ($_.NonCompliant -gt 0) { 'amber' } else { 'green' } $shortId = if ($_.SubscriptionId.Length -ge 8) { $_.SubscriptionId.Substring(0, 8) } else { $_.SubscriptionId } @" <div class="sub-tile sub-$status"> <div class="sub-id" title="$(_ExecHE $_.SubscriptionId)">$(_ExecHE $shortId)$([char]0x2026)</div> <div class="sub-counts"> <span class="cnt-c">C: $($_.Critical)</span> <span class="cnt-h">H: $($_.High)</span> <span class="cnt-n">N/C: $($_.NonCompliant)</span> </div> </div> "@ }) -join '' if (-not $subTilesHtml) { $subTilesHtml = "<div class='empty'>No subscription-scoped findings in current run.</div>" } $wafTilesHtml = ($Model.WafRows | ForEach-Object { $statusClass = "waf-$($_.Status)" $trendClass = "trend-$($_.Trend)" $trendTitle = if ($null -eq $_.TrendDelta) { 'no prior run' } else { $sign = if ($_.TrendDelta -ge 0) { '+' } else { '' } "$sign$($_.TrendDelta)% vs previous run" } @" <div class="waf-tile $statusClass" style="border-top-color:$($_.Color)"> <div class="waf-name">$(_ExecHE $_.DisplayName)</div> <div class="waf-num">$($_.CoveragePercent)% <span class="waf-trend $trendClass" title="$(_ExecHE $trendTitle)">$($_.TrendGlyph)</span></div> <div class="waf-sub">$($_.NonCompliant) non-compliant of $($_.Total) $([char]0x2022) $($_.CriticalHigh) C/H</div> </div> "@ }) -join '' if (-not $wafTilesHtml) { $wafTilesHtml = "<div class='empty'>WAF mappings not loaded.</div>" } $frameworkTableRows = ($Model.FrameworkRows | ForEach-Object { $fwClass = Get-FrameworkBadgeClass $_.DisplayName @" <tr> <td><span class="fw-chip $fwClass">$(_ExecHE $_.DisplayName)</span> <span class="muted">v$(_ExecHE $_.Version)</span></td> <td>$($_.ControlsHit) / $($_.ControlsTotal)</td> <td><span class="cov cov-$($_.Status)">$($_.PercentCovered)%</span></td> </tr> "@ }) -join '' if (-not $frameworkTableRows) { $frameworkTableRows = "<tr><td colspan='3' class='empty'>No framework mappings loaded.</td></tr>" } $mttrTableRows = ($Model.MttrRows | ForEach-Object { $median = if ($null -eq $_.MedianDays) { 'N/A' } else { "$($_.MedianDays) d" } $mean = if ($null -eq $_.MeanDays) { 'N/A' } else { "$($_.MeanDays) d" } $sevClass = Get-SafeSeverityClass $_.Severity @" <tr> <td><span class="sev sev-$sevClass">$(_ExecHE $_.Severity)</span></td> <td>$($_.ResolvedCount)</td> <td>$median</td> <td>$mean</td> </tr> "@ }) -join '' if (-not $mttrTableRows) { $mttrTableRows = "<tr><td colspan='4' class='empty'>Run history insufficient - MTTR available after 2+ runs with resolved findings.</td></tr>" } $toolStatusHtml = '' if ($Model.ToolStatus -and @($Model.ToolStatus).Count -gt 0) { $toolStatusHtml = ($Model.ToolStatus | ForEach-Object { $status = if ($_.PSObject.Properties['Status']) { [string]$_.Status } else { 'unknown' } $name = if ($_.PSObject.Properties['Tool']) { [string]$_.Tool } elseif ($_.PSObject.Properties['Name']) { [string]$_.Name } else { 'tool' } $cls = switch -Regex ($status) { '^(?i)success$|^(?i)ok$' { 'tool-ok' } '^(?i)skip' { 'tool-skip' } '^(?i)fail|error' { 'tool-fail' } default { 'tool-other' } } "<span class='tool-pill $cls'>$(_ExecHE $name): $(_ExecHE $status)</span>" }) -join '' } if (-not $toolStatusHtml) { $toolStatusHtml = "<span class='muted'>tool-status.json not available.</span>" } $netNew = if ($Model.Delta) { [int]$Model.Delta.Summary.New } else { 0 } $netRes = if ($Model.Delta) { [int]$Model.Delta.Summary.Resolved } else { 0 } $netNet = if ($Model.Delta) { [int]$Model.Delta.Summary.NetNonCompliantDelta } else { 0 } $netHtml = if ($Model.Delta) { @" <div class="net-row"> <span class="net-new">+$netNew new</span> <span class="net-res">-$netRes resolved</span> <span class="net-net">net $(Format-SignedDelta $netNet)</span> <span class="muted">vs $(_ExecHE $Model.PrevRun.Stamp)</span> </div> "@ } else { "<div class='net-row muted'>No prior run available - first snapshot.</div>" } $pctDeltaHtml = if ($null -ne $Model.PctDelta) { $cls = if ($Model.PctDelta -ge 0) { 'delta-up' } else { 'delta-down' } "<span class='pct-delta $cls'>$(Format-SignedDelta $Model.PctDelta '%') vs prev</span>" } else { "<span class='muted'>first run</span>" } # Body fragment - the contents of <main> from the standalone dashboard. # When -Embedded, the caller wraps this in its own panel; when standalone, # New-ExecDashboard.ps1 wraps with <header>/<main>/<footer>. # Top severity-count KPI tiles - same primitive as main report's .sev-strip. $sevTilesHtml = ($severitiesForTrend | ForEach-Object { $cls = switch ($_) { 'Critical' { 'kpi-crit' } 'High' { 'kpi-high' } 'Medium' { 'kpi-med' } 'Low' { 'kpi-low' } default { 'kpi-info' } } $n = if ($Model.CurrentSev[$_]) { [int]$Model.CurrentSev[$_] } else { 0 } @" <div class="kpi-tile $cls" title="$_ non-compliant findings"> <span class="n">$n</span> <span class="l">$_</span> </div> "@ }) -join '' # Schema 2.2 (#299) conditional: if any finding carries Frameworks[], # render a framework chip strip. Absent => omitted entirely (graceful # degradation; never fabricate). $frameworkKinds = @(Get-FrameworkKindsFromFindings $Model.Findings) $fwStripHtml = '' if (@($frameworkKinds).Count -gt 0) { $chips = ($frameworkKinds | ForEach-Object { $cls = Get-FrameworkBadgeClass $_ "<span class='fw-chip $cls'>$(_ExecHE $_)</span>" }) -join '' $fwStripHtml = "<div class='fw-strip'><span class='muted'>Frameworks evaluated:</span>$chips</div>" } $body = @" <div class="kpi-strip">$sevTilesHtml</div> $fwStripHtml <div class="grid grid-top"> <div class="card"> <h2>Compliance score</h2> <div class="score"> <span class="num">$($Model.CompliancePct)</span> <span class="pct">%</span> <span>$pctDeltaHtml</span> </div> <div class="muted" style="margin-top:8px">$($Model.CompliantCount) of $($Model.Total) compliant $([char]0x00b7) $($Model.NonCompliant) non-compliant</div> <div style="margin-top:14px">$netHtml</div> </div> <div class="card"> <h2>Severity mix trend</h2> <div class="muted card-sub">Non-compliant findings only (risk over time).</div> $sparklinesHtml </div> </div> <div class="grid grid-mid" style="margin-top:16px"> <div class="card"> <h2>Top-10 risky resources</h2> <table> <thead><tr><th>Resource</th><th>Findings</th><th>Highest</th><th>Sources</th></tr></thead> <tbody>$topResourcesRows</tbody> </table> </div> <div class="card"> <h2>Subscription heat map</h2> <div class="sub-grid">$subTilesHtml</div> </div> </div> <div class="card" style="margin-top:16px"> <h2>WAF 5-pillar coverage</h2> <div class="waf-grid">$wafTilesHtml</div> </div> <div class="grid grid-bot" style="margin-top:16px"> <div class="card"> <h2>MTTR by severity</h2> <table> <thead><tr><th>Severity</th><th>Resolved</th><th>Median</th><th>Mean</th></tr></thead> <tbody>$mttrTableRows</tbody> </table> </div> <div class="card"> <h2>Framework gap analysis</h2> <table> <thead><tr><th>Framework</th><th>Controls hit</th><th>%</th></tr></thead> <tbody>$frameworkTableRows</tbody> </table> </div> <div class="card"> <h2>Tool execution</h2> <div>$toolStatusHtml</div> </div> </div> "@ return $body } # --------------------------------------------------------------------------- # Get-ExecDashboardCss - CSS, optionally scoped under a wrapper class for embedding # --------------------------------------------------------------------------- # The CSS below is the SAME selectors that used to be inline in # New-ExecDashboard.ps1. ConvertTo-ScopedCss prefixes every selector with $Scope # so that when the body fragment is wrapped in <div class="exec-dash">...</div> # inside report.html, none of these rules collide with report.html's own # .card / .muted / .empty rules. function Get-ExecDashboardCss { [CmdletBinding()] param ([string] $Scope = '') # Design tokens harmonized with samples/sample-report.html (issue #297). # Same severity palette (--crit/--high/--med/--low/--info/--pass), framework # badge palette (.fw-*), surface/border/text tokens, radii, shadows, fonts, # and dark-mode variant. Class names match the legacy ExecDashboard surface # so existing tests/selectors keep working; only the underlying tokens move. $css = @' :root{ --bg:#f7f8fa;--surface:#ffffff;--surface-2:#f1f3f6;--border:#e3e6eb;--border-strong:#cdd2da; --text:#0f172a;--text-muted:#475569;--text-faint:#64748b; --brand:#0b5fff;--brand-ink:#003fb3;--accent:#0ea5e9; --crit:#7f1d1d;--high:#b91c1c;--med:#b45309;--low:#a16207;--info:#475569; --crit-bg:#fef2f2;--high-bg:#fee2e2;--med-bg:#fef3c7;--low-bg:#fefce8;--info-bg:#f1f5f9; --pass:#15803d;--pass-bg:#dcfce7; --shadow-sm:0 1px 2px rgba(15,23,42,.06),0 1px 1px rgba(15,23,42,.04); --shadow:0 4px 12px rgba(15,23,42,.08),0 2px 4px rgba(15,23,42,.04); --radius:10px;--radius-sm:6px;--radius-lg:14px; --font:-apple-system,BlinkMacSystemFont,"Segoe UI Variable","Segoe UI",Inter,system-ui,sans-serif; --mono:ui-monospace,"Cascadia Code","JetBrains Mono",Consolas,monospace; } [data-theme="dark"]{ --bg:#0b1220;--surface:#111a2e;--surface-2:#172238;--border:#243049;--border-strong:#324264; --text:#e8edf6;--text-muted:#9aa7bf;--text-faint:#7a8aa6; --brand:#3b82f6;--brand-ink:#60a5fa;--accent:#22d3ee; --crit:#f87171;--high:#fb923c;--med:#fbbf24;--low:#facc15;--info:#94a3b8; --crit-bg:#3a1212;--high-bg:#3a1f10;--med-bg:#3a2a0a;--low-bg:#332a0a;--info-bg:#1e293b; --pass:#4ade80;--pass-bg:#0f2a1a; } *,*::before,*::after{box-sizing:border-box} body{font-family:var(--font);font-size:14px;line-height:1.5;margin:0;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility} header{position:sticky;top:0;z-index:50;background:var(--surface);color:var(--text);border-bottom:1px solid var(--border);box-shadow:var(--shadow-sm);padding:14px 28px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap} header h1{margin:0;font-size:18px;font-weight:700;letter-spacing:-.01em} header .meta{font-size:12px;color:var(--text-faint)} .theme-btn{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text-muted);cursor:pointer;font:inherit} .theme-btn:hover{color:var(--text);border-color:var(--border-strong)} main{padding:20px 28px;max-width:1440px;margin:0 auto} .grid{display:grid;gap:16px} .grid-top{grid-template-columns:minmax(280px,1fr) 2fr} .grid-mid{grid-template-columns:1fr 1fr} .grid-bot{grid-template-columns:1.2fr 1fr 1fr} @media(max-width:980px){.grid-top,.grid-mid,.grid-bot{grid-template-columns:1fr}} .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;box-shadow:var(--shadow-sm)} .card h2{margin:0 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:.05em;color:var(--text-faint);font-weight:600} .score{display:flex;align-items:baseline;gap:12px} .score .num{font-size:56px;font-weight:700;color:var(--text);line-height:1;letter-spacing:-.02em} .score .pct{font-size:24px;color:var(--text-muted)} .pct-delta{font-size:13px;padding:2px 8px;border-radius:12px;font-weight:600} .delta-up{background:var(--pass-bg);color:var(--pass)} .delta-down{background:var(--high-bg);color:var(--high)} .muted{color:var(--text-muted);font-size:12px} .kpi-strip{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 16px} .kpi-tile{display:flex;flex-direction:column;align-items:center;justify-content:center;min-width:64px;padding:8px 12px;border-radius:8px;color:#fff;font-weight:600;box-shadow:var(--shadow-sm)} .kpi-tile .n{font-size:18px;line-height:1} .kpi-tile .l{font-size:10px;text-transform:uppercase;letter-spacing:.06em;margin-top:3px;opacity:.95} .kpi-tile.kpi-crit{background:var(--crit)} .kpi-tile.kpi-high{background:var(--high)} .kpi-tile.kpi-med{background:var(--med)} .kpi-tile.kpi-low{background:var(--low)} .kpi-tile.kpi-info{background:var(--info)} .fw-strip{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 16px} .fw-strip .muted{align-self:center;margin-right:4px} .spark-row{display:grid;grid-template-columns:70px 130px 50px;align-items:center;padding:4px 0;font-size:13px} .spark-label{font-weight:600} .spark-val{text-align:right;color:var(--text);font-variant-numeric:tabular-nums} .spark-na{color:var(--text-faint);font-size:11px} table{width:100%;border-collapse:collapse;font-size:12px} th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--border);color:var(--text-faint);font-weight:600;text-transform:uppercase;letter-spacing:.04em;font-size:11px} td{padding:6px 8px;border-bottom:1px solid var(--border);vertical-align:top;color:var(--text)} td.ridcol{font-family:var(--mono);max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} td.src{color:var(--text-faint);font-size:11px} td.empty,.empty{color:var(--text-faint);font-style:italic;padding:12px} .sev{padding:1px 8px;border-radius:999px;font-size:11px;font-weight:600;color:#fff;display:inline-block;line-height:1.5} .sev-critical{background:var(--crit)} .sev-high{background:var(--high)} .sev-medium{background:var(--med)} .sev-low{background:var(--low)} .sev-info{background:var(--info)} .cov{padding:1px 8px;border-radius:999px;font-size:11px;font-weight:600;color:#fff} .cov-green{background:var(--pass)} .cov-yellow{background:var(--med)} .cov-red{background:var(--crit)} .sub-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px} .sub-tile{padding:10px 12px;border-radius:var(--radius-sm);border:1px solid var(--border);border-left:4px solid var(--border-strong);background:var(--surface)} .sub-tile.sub-red{border-left-color:var(--crit);background:var(--crit-bg)} .sub-tile.sub-amber{border-left-color:var(--med);background:var(--med-bg)} .sub-tile.sub-green{border-left-color:var(--pass);background:var(--pass-bg)} .sub-id{font-family:var(--mono);font-size:11px;color:var(--text)} .sub-counts{font-size:11px;margin-top:4px;display:flex;gap:8px} .sub-counts .cnt-c{color:var(--crit);font-weight:600} .sub-counts .cnt-h{color:var(--high);font-weight:600} .sub-counts .cnt-n{color:var(--text-muted)} .waf-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px} .waf-tile{padding:12px;border-radius:var(--radius-sm);background:var(--surface);border:1px solid var(--border);border-top:4px solid var(--border-strong);text-align:center} .waf-tile.waf-red{background:var(--crit-bg)} .waf-tile.waf-amber{background:var(--med-bg)} .waf-tile.waf-green{background:var(--pass-bg)} .waf-name{font-size:11px;font-weight:600;color:var(--text-faint);text-transform:uppercase;letter-spacing:.05em} .waf-num{font-size:28px;font-weight:700;color:var(--text);margin:4px 0;letter-spacing:-.02em} .waf-sub{font-size:10px;color:var(--text-muted)} .waf-trend{font-size:16px;vertical-align:middle;margin-left:4px} .trend-up{color:var(--pass)} .trend-down{color:var(--crit)} .trend-flat{color:var(--text-muted)} .card-sub{margin:-4px 0 8px} .net-row{display:flex;gap:14px;align-items:center;font-size:13px;padding:4px 0;flex-wrap:wrap} .net-new{color:var(--crit);font-weight:600} .net-res{color:var(--pass);font-weight:600} .net-net{color:var(--text);font-weight:600} .tool-pill{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;margin:2px 4px 2px 0;border:1px solid transparent} .tool-ok{background:var(--pass-bg);color:var(--pass);border-color:var(--pass)} .tool-skip{background:var(--surface-2);color:var(--text-muted);border-color:var(--border)} .tool-fail{background:var(--high-bg);color:var(--high);border-color:var(--high)} .tool-other{background:var(--low-bg);color:var(--low);border-color:var(--low)} .fw-chip{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;color:#fff;line-height:1.5;margin:1px 2px} .fw-cis{background:#d97706}.fw-nist{background:#374151}.fw-mitre{background:#b91c1c} .fw-eidsca{background:#1f6feb}.fw-eidas{background:#7c3aed}.fw-soc{background:#0e7490} .fw-iso{background:#0f766e}.fw-mcsb{background:#005a9e}.fw-caf{background:#1e3a8a} .fw-waf{background:#3a7d0a}.fw-cisa{background:#0f766e}.fw-orca{background:#0891b2} .fw-default{background:#475569} .mitre-chip{display:inline-flex;align-items:center;padding:1px 6px;border-radius:4px;font-size:10.5px;font-family:var(--mono);background:var(--high-bg);color:var(--high);border:1px solid var(--high);margin:1px 2px} footer{text-align:center;color:var(--text-faint);font-size:11px;padding:16px} '@ if (-not $Scope) { return $css } return (ConvertTo-ScopedCss -Css $css -Scope $Scope) } function ConvertTo-ScopedCss { [CmdletBinding()] param ([string]$Css, [Parameter(Mandatory)][string]$Scope) # Naive selector-prefix scoper. Assumes input is the well-formed CSS this # module emits (no nested @media, no @keyframes, no @supports, no comments # containing braces). For our embed use-case that is sufficient. $sb = [System.Text.StringBuilder]::new() $i = 0 $n = $Css.Length while ($i -lt $n) { $brace = $Css.IndexOf('{', $i) if ($brace -lt 0) { [void]$sb.Append($Css.Substring($i)); break } $rawSelectors = $Css.Substring($i, $brace - $i) $leadingWs = '' $sels = $rawSelectors.TrimStart() if ($sels.Length -lt $rawSelectors.Length) { $leadingWs = $rawSelectors.Substring(0, $rawSelectors.Length - $sels.Length) } # Find matching close brace (no nesting expected). $close = $Css.IndexOf('}', $brace + 1) if ($close -lt 0) { [void]$sb.Append($Css.Substring($i)); break } $declBlock = $Css.Substring($brace, $close - $brace + 1) if ($sels.StartsWith('@')) { # Pass-through (we don't emit at-rules, but be safe). [void]$sb.Append($leadingWs) [void]$sb.Append($sels) [void]$sb.Append($declBlock) } else { $parts = $sels -split ',' $scoped = foreach ($p in $parts) { $t = $p.Trim() if (-not $t) { continue } "$Scope $t" } [void]$sb.Append($leadingWs) [void]$sb.Append(($scoped -join ', ')) [void]$sb.Append($declBlock) } $i = $close + 1 } return $sb.ToString() } # --------------------------------------------------------------------------- # Get-ExecDashboardHtml - full standalone HTML doc (used by New-ExecDashboard.ps1) # --------------------------------------------------------------------------- function Get-ExecDashboardHtml { [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $InputPath, [string] $HistoryPath = '', [string] $EntitiesPath = '', [string] $ToolStatusPath = '' ) $model = Get-ExecDashboardModel -InputPath $InputPath -HistoryPath $HistoryPath -EntitiesPath $EntitiesPath -ToolStatusPath $ToolStatusPath $body = Get-ExecDashboardBody -Model $model $css = Get-ExecDashboardCss $date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'") $historyCount = $model.HistoryPoints.Count $html = @" <!DOCTYPE html> <html lang="en" data-theme="light"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>azure-analyzer - Executive Dashboard</title> <style> $css </style> </head> <body> <header> <h1>azure-analyzer - Executive Dashboard</h1> <div style="display:flex;align-items:center;gap:14px"> <div class="meta">$date $([char]0x00b7) history: $historyCount run(s)</div> <button class="theme-btn" id="themeToggle" type="button" title="Toggle light/dark theme" aria-label="Toggle theme">$([char]0x263D)</button> </div> </header> <main> $body </main> <footer>azure-analyzer $([char]0x00b7) executive dashboard v2 $([char]0x00b7) single self-contained file (no external deps) $([char]0x00b7) design tokens harmonized with main report (#297)</footer> <script> (function(){ var KEY='aa-theme'; var html=document.documentElement; var stored=null; try{stored=localStorage.getItem(KEY);}catch(e){} if(stored==='dark'||stored==='light'){html.setAttribute('data-theme',stored);} var btn=document.getElementById('themeToggle'); if(btn){ btn.addEventListener('click',function(){ var cur=html.getAttribute('data-theme')==='dark'?'light':'dark'; html.setAttribute('data-theme',cur); try{localStorage.setItem(KEY,cur);}catch(e){} }); } })(); </script> </body> </html> "@ return (Remove-Credentials $html) } |