Src/Private/Get-AbrEntraIDCharts.ps1
|
#region --- Chart Generation Helpers --- # All functions run OUTSIDE the PScribo Document{} scriptblock. # They use [void] and $null= on every expression to ensure zero pipeline output. # PScribo intercepts ALL pipeline output inside Document{}; even boolean results # of if() statements cause "The term 'if' is not recognized" errors. # These functions return only a Base64 string (or $null) -- never pipeline output. # # Usage: # # Call BEFORE sections start (outside PScribo scope): # $b64 = New-AbrMfaDonutChart -Capable 80 -NotCapable 20 -PhishResistant 10 -Pct 80 -TenantId 'x' # # Then inside Section{}: # if ($b64) { BlankLine; Image -Text 'MFA' -Base64 $b64 -Percent 65 -Align Center; BlankLine } function New-AbrDonutChart { [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [array] $Segments, [Parameter(Mandatory)] [string] $CentreText, [string] $SubText = '', [string] $Title = '', [int] $Width = 520, [int] $Height = 300 ) # Load System.Drawing (name differs between PS5 and PS7 Core) [bool]$DrawingLoaded = $false foreach ($asm in @('System.Drawing.Common','System.Drawing')) { try { [void][System.Reflection.Assembly]::LoadWithPartialName($asm); $DrawingLoaded = $true; break } catch {} try { Add-Type -AssemblyName $asm -ErrorAction Stop; $DrawingLoaded = $true; break } catch {} } if (-not $DrawingLoaded) { return $null } [int]$total = 0 foreach ($s in $Segments) { $total += [int]$s.Value } if ($total -le 0) { return $null } try { $bmp = [System.Drawing.Bitmap]::new($Width, $Height) $gfx = [System.Drawing.Graphics]::FromImage($bmp) [void]$gfx.SmoothingMode $gfx.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias $gfx.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit $gfx.Clear([System.Drawing.Color]::White) # Title if ($Title -ne '') { $tf = [System.Drawing.Font]::new('Segoe UI', 9, [System.Drawing.FontStyle]::Bold) $gfx.DrawString($Title, $tf, [System.Drawing.Brushes]::DimGray, [System.Drawing.PointF]::new(8, 6)) $tf.Dispose() } # Donut geometry [int]$cx = [int]($Height / 2) [int]$cy = [int]($Height / 2) [int]$r = [int]($Height / 2) - 18 [int]$hole = [int]($r * 0.58) $rect = [System.Drawing.RectangleF]::new($cx - $r, $cy - $r, $r * 2, $r * 2) # Segments [float]$startAngle = -90.0 foreach ($seg in $Segments) { [int]$val = [int]$seg.Value if ($val -le 0) { continue } [float]$sweep = [float](([float]$val / [float]$total) * 360.0) $c = [System.Drawing.ColorTranslator]::FromHtml($seg.Color) $b = [System.Drawing.SolidBrush]::new($c) $gfx.FillPie($b, $rect, $startAngle, $sweep) $b.Dispose() $startAngle += $sweep } # Separators $sepPen = [System.Drawing.Pen]::new([System.Drawing.Color]::White, 2) [float]$startAngle = -90.0 foreach ($seg in $Segments) { [int]$val = [int]$seg.Value if ($val -le 0) { continue } [float]$sweep = [float](([float]$val / [float]$total) * 360.0) [double]$rad = ($startAngle * [Math]::PI) / 180.0 [int]$x2 = $cx + [int]($r * [Math]::Cos($rad)) [int]$y2 = $cy + [int]($r * [Math]::Sin($rad)) $gfx.DrawLine($sepPen, $cx, $cy, $x2, $y2) $startAngle += $sweep } $sepPen.Dispose() # White hole $hb = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White) $gfx.FillEllipse($hb, $cx - $hole, $cy - $hole, $hole * 2, $hole * 2) $hb.Dispose() # Centre text $bigFont = [System.Drawing.Font]::new('Segoe UI', 22, [System.Drawing.FontStyle]::Bold) $subFont = [System.Drawing.Font]::new('Segoe UI', 8) $darkBrush = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(50,50,50)) $bigSize = $gfx.MeasureString($CentreText, $bigFont) [float]$bigX = $cx - ($bigSize.Width / 2) [float]$bigY = $cy - ($bigSize.Height / 2) + $(if ($SubText -ne '') { -6 } else { 0 }) $gfx.DrawString($CentreText, $bigFont, $darkBrush, [System.Drawing.PointF]::new($bigX, $bigY)) if ($SubText -ne '') { $subSize = $gfx.MeasureString($SubText, $subFont) $gfx.DrawString($SubText, $subFont, [System.Drawing.Brushes]::Gray, [System.Drawing.PointF]::new($cx - ($subSize.Width/2), $bigY + $bigSize.Height - 2)) } $bigFont.Dispose(); $subFont.Dispose(); $darkBrush.Dispose() # Legend [int]$legX = $Height + 16 [int]$legY = 28 $legFont = [System.Drawing.Font]::new('Segoe UI', 8) $legValFont = [System.Drawing.Font]::new('Segoe UI', 8, [System.Drawing.FontStyle]::Bold) foreach ($seg in $Segments) { [int]$val = [int]$seg.Value if ($val -le 0) { continue } $c = [System.Drawing.ColorTranslator]::FromHtml($seg.Color) $sb = [System.Drawing.SolidBrush]::new($c) $gfx.FillRectangle($sb, $legX, $legY + 2, 12, 12) $sb.Dispose() [int]$pct = [int]([math]::Round(([float]$val / [float]$total) * 100, 0)) $gfx.DrawString("$($seg.Label)", $legFont, [System.Drawing.Brushes]::DimGray, [System.Drawing.PointF]::new($legX + 18, $legY)) $gfx.DrawString("$val ($pct%)", $legValFont, [System.Drawing.Brushes]::Black, [System.Drawing.PointF]::new($legX + 18, $legY + 13)) $legY += 36 } $legFont.Dispose(); $legValFont.Dispose() # Encode to Base64 $ms = [System.IO.MemoryStream]::new() $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) [string]$b64 = [System.Convert]::ToBase64String($ms.ToArray()) $ms.Dispose(); $gfx.Dispose(); $bmp.Dispose() return $b64 } catch { try { $gfx.Dispose() } catch {} try { $bmp.Dispose() } catch {} return $null } } function New-AbrRadarChart { [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable] $Scores, [string] $Title = 'Identity Security Posture', [string] $Framework = 'E8', [int] $Width = 520, [int] $Height = 420 ) [bool]$DrawingLoaded = $false foreach ($asm in @('System.Drawing.Common','System.Drawing')) { try { [void][System.Reflection.Assembly]::LoadWithPartialName($asm); $DrawingLoaded = $true; break } catch {} try { Add-Type -AssemblyName $asm -ErrorAction Stop; $DrawingLoaded = $true; break } catch {} } if (-not $DrawingLoaded) { return $null } [string[]]$labels = @($Scores.Keys) [int]$n = $labels.Count if ($n -lt 3) { return $null } try { $bmp = [System.Drawing.Bitmap]::new($Width, $Height) $gfx = [System.Drawing.Graphics]::FromImage($bmp) $gfx.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias $gfx.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit $gfx.Clear([System.Drawing.Color]::White) # Title $titleFont = [System.Drawing.Font]::new('Segoe UI', 10, [System.Drawing.FontStyle]::Bold) $titleSize = $gfx.MeasureString($Title, $titleFont) $gfx.DrawString($Title, $titleFont, [System.Drawing.Brushes]::DimGray, [System.Drawing.PointF]::new(($Width - $titleSize.Width) / 2, 8)) $titleFont.Dispose() [int]$cx = [int]($Width * 0.46) [int]$cy = [int]($Height * 0.52) [int]$maxR = [int]([Math]::Min($cx, $cy) * 0.72) # Grid rings $gridPen = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(200,200,210), 1) $gridFont = [System.Drawing.Font]::new('Segoe UI', 6.5) foreach ($ring in @(20, 40, 60, 80, 100)) { [int]$rr = [int]($maxR * $ring / 100) $pts = [System.Drawing.PointF[]]$(0..($n-1) | ForEach-Object { [double]$angle = (2 * [Math]::PI * $_ / $n) - ([Math]::PI / 2) [System.Drawing.PointF]::new($cx + [float]($rr * [Math]::Cos($angle)), $cy + [float]($rr * [Math]::Sin($angle))) }) for ([int]$i = 0; $i -lt $n; $i++) { $gfx.DrawLine($gridPen, $pts[$i], $pts[($i + 1) % $n]) } [string]$rl = "$ring%" $ls = $gfx.MeasureString($rl, $gridFont) $gfx.DrawString($rl, $gridFont, [System.Drawing.Brushes]::LightGray, [System.Drawing.PointF]::new($cx - ($ls.Width/2), $cy - $rr - $ls.Height + 2)) } $gridPen.Dispose() # Spokes $spokePen = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(180,180,195), 1) for ([int]$i = 0; $i -lt $n; $i++) { [double]$angle = (2 * [Math]::PI * $i / $n) - ([Math]::PI / 2) $gfx.DrawLine($spokePen, $cx, $cy, $cx + [float]($maxR * [Math]::Cos($angle)), $cy + [float]($maxR * [Math]::Sin($angle))) } $spokePen.Dispose() # Data polygon [string]$dataColorHex = $(if ($Framework -eq 'E8') { '#1a6eb5' } else { '#e87722' }) $dataHtml = [System.Drawing.ColorTranslator]::FromHtml($dataColorHex) $fillColor = [System.Drawing.Color]::FromArgb(60, $dataHtml.R, $dataHtml.G, $dataHtml.B) $fillBrush = [System.Drawing.SolidBrush]::new($fillColor) $outlinePen = [System.Drawing.Pen]::new($dataHtml, 2) $dotBrush = [System.Drawing.SolidBrush]::new($dataHtml) $dataPts = [System.Drawing.PointF[]]$(0..($n-1) | ForEach-Object { [int]$score = [math]::Max(0, [math]::Min(100, [int]($Scores[$labels[$_]]))) [float]$rr = [float]($maxR * $score / 100) [double]$angle = (2 * [Math]::PI * $_ / $n) - ([Math]::PI / 2) [System.Drawing.PointF]::new($cx + [float]($rr * [Math]::Cos($angle)), $cy + [float]($rr * [Math]::Sin($angle))) }) $gfx.FillPolygon($fillBrush, $dataPts) for ([int]$i = 0; $i -lt $n; $i++) { $gfx.DrawLine($outlinePen, $dataPts[$i], $dataPts[($i+1) % $n]) } foreach ($pt in $dataPts) { $gfx.FillEllipse($dotBrush, $pt.X - 4, $pt.Y - 4, 8, 8) } $fillBrush.Dispose(); $outlinePen.Dispose(); $dotBrush.Dispose(); $gridFont.Dispose() # Labels $labelFont = [System.Drawing.Font]::new('Segoe UI', 7.5, [System.Drawing.FontStyle]::Bold) $badgeFont = [System.Drawing.Font]::new('Segoe UI', 7) [int]$padding = 20 for ([int]$i = 0; $i -lt $n; $i++) { [double]$angle = (2 * [Math]::PI * $i / $n) - ([Math]::PI / 2) [float]$lx = $cx + [float](($maxR + $padding) * [Math]::Cos($angle)) [float]$ly = $cy + [float](($maxR + $padding) * [Math]::Sin($angle)) [string]$lbl = $labels[$i] [int]$score = [int]($Scores[$lbl]) $lSize = $gfx.MeasureString($lbl, $labelFont) [float]$drawX = $(if ($lx -lt $cx - 10) { $lx - $lSize.Width } elseif ($lx -gt $cx + 10) { $lx } else { $lx - $lSize.Width/2 }) [float]$drawY = $(if ($ly -lt $cy - 10) { $ly - $lSize.Height - 2 } else { $ly + 2 }) $scoreColor = $( if ($score -ge 80) { [System.Drawing.Color]::FromArgb(46,139,87) } elseif ($score -ge 50) { [System.Drawing.Color]::FromArgb(204,102,0) } else { [System.Drawing.Color]::FromArgb(180,30,30) } ) $scoreBrush = [System.Drawing.SolidBrush]::new($scoreColor) $gfx.DrawString($lbl, $labelFont, [System.Drawing.Brushes]::DimGray, [System.Drawing.PointF]::new($drawX, $drawY)) [string]$scoreText = "$score%" $sSize = $gfx.MeasureString($scoreText, $badgeFont) $gfx.DrawString($scoreText, $badgeFont, $scoreBrush, [System.Drawing.PointF]::new($drawX + ($lSize.Width - $sSize.Width)/2, $drawY + $lSize.Height - 1)) $scoreBrush.Dispose() } $labelFont.Dispose(); $badgeFont.Dispose() # Legend $legFont = [System.Drawing.Font]::new('Segoe UI', 7.5) [string]$legLabel = $(if ($Framework -eq 'E8') { 'ACSC E8 Score' } else { 'CIS M365 Score' }) [int]$legX = $Width - 130; [int]$legY = $Height - 50 $swatchBrush = [System.Drawing.SolidBrush]::new($dataHtml) $gfx.FillRectangle($swatchBrush, $legX, $legY + 2, 12, 12) $swatchBrush.Dispose() $gfx.DrawString($legLabel, $legFont, [System.Drawing.Brushes]::DimGray, [System.Drawing.PointF]::new($legX + 16, $legY)) $legFont.Dispose() $ms = [System.IO.MemoryStream]::new() $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) [string]$b64 = [System.Convert]::ToBase64String($ms.ToArray()) $ms.Dispose(); $gfx.Dispose(); $bmp.Dispose() return $b64 } catch { try { $gfx.Dispose() } catch {} try { $bmp.Dispose() } catch {} return $null } } function New-AbrSecurityPostureRadar { [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.ArrayList] $Checks, [string] $Framework = 'E8', [string] $Title = 'Identity Security Posture' ) if (-not $Checks -or $Checks.Count -eq 0) { return $null } $scores = [ordered]@{} [string[]]$sections = @($Checks | Select-Object -ExpandProperty Section -Unique | Sort-Object) foreach ($sec in $sections) { $secChecks = @($Checks | Where-Object { $_.Section -eq $sec }) [int]$tot = $secChecks.Count [int]$ok = @($secChecks | Where-Object { $_.Status -like '*[OK]*' }).Count $scores[$sec] = $(if ($tot -gt 0) { [math]::Round(($ok / $tot) * 100, 0) } else { 0 }) } if ($scores.Count -lt 3) { return $null } return New-AbrRadarChart -Scores $scores -Framework $Framework -Title $Title } function New-AbrCAStateDonut { [OutputType([string])] [CmdletBinding()] param([int]$Enabled, [int]$ReportOnly, [int]$Disabled, [string]$TenantId = '') [int]$total = $Enabled + $ReportOnly + $Disabled if ($total -le 0) { return $null } $segs = @( @{ Label = 'Enabled'; Value = $Enabled; Color = '#2d8f4e' } @{ Label = 'Report-Only'; Value = $ReportOnly; Color = '#e87722' } @{ Label = 'Disabled'; Value = $Disabled; Color = '#c0392b' } ) [string]$t = $(if ($TenantId) { "CA Policy State -- $TenantId" } else { 'Conditional Access Policy State' }) return New-AbrDonutChart -Segments $segs -CentreText "$total" -SubText 'Total Policies' -Title $t } function New-AbrUserBreakdownDonut { [OutputType([string])] [CmdletBinding()] param([int]$Members, [int]$Guests, [int]$Disabled, [string]$TenantId = '') [int]$total = $Members + $Guests + $Disabled if ($total -le 0) { return $null } $segs = @( @{ Label = 'Members'; Value = $Members; Color = '#1a6eb5' } @{ Label = 'Guests'; Value = $Guests; Color = '#e87722' } @{ Label = 'Disabled'; Value = $Disabled; Color = '#888888' } ) [string]$t = $(if ($TenantId) { "User Breakdown -- $TenantId" } else { 'User Type Breakdown' }) return New-AbrDonutChart -Segments $segs -CentreText "$total" -SubText 'Total Users' -Title $t } function New-AbrSecureScoreGauge { [OutputType([string])] [CmdletBinding()] param([int]$CurrentScore, [int]$MaxScore, [string]$TenantId = '') if ($MaxScore -le 0) { return $null } [int]$pct = [math]::Round(($CurrentScore / $MaxScore) * 100, 0) [string]$col = $(if ($pct -ge 80) { '#2d8f4e' } elseif ($pct -ge 50) { '#e87722' } else { '#c0392b' }) $segs = @( @{ Label = "Score ($pct%)"; Value = $CurrentScore; Color = $col } @{ Label = 'Remaining'; Value = ($MaxScore - $CurrentScore); Color = '#e0e0e0' } ) [string]$t = $(if ($TenantId) { "Identity Secure Score -- $TenantId" } else { 'Identity Secure Score' }) return New-AbrDonutChart -Segments $segs -CentreText "$pct%" -SubText "of $MaxScore pts" -Title $t } #endregion |