Private/Write-AACInventoryPdf.ps1
|
function Write-AACInventoryPdf { <# .SYNOPSIS Writes the tenant inventory (ConvertTo-AACInventory) as a landscape A4 PDF report. .DESCRIPTION 1. Summary: title, scope, tiles (management groups, subscriptions, resource groups, resources, types, locations). 2. Hierarchy: the tree down to resource groups, indented, with each level's counts; empty resource groups in amber. 3. Security (with Defender for Cloud data): each subscription's secure score, the controls with the most to gain, and the unhealthy recommendations, most severe first - scores coloured Good (70%+) green, Fair (40-69%) amber, Poor red; severities High red, Medium amber, Low blue. 4. Subscriptions, resource groups, and resources by type. 5. Every resource (name, type, resource group, subscription, location, SKU) - up to -MaxResources; the HTML report and CSV have them all. -Path must be a full path; see Save-AACPdfDocument. #> [CmdletBinding()] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory)] [hashtable] $Inventory, [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Title, [System.Collections.IDictionary] $Detail, [int] $MaxResources = 5000 ) $items = @($Inventory.Items) $stats = $Inventory.Stats $pdf = New-AACPdfDocument -Title $Title -Subject "$($stats.Resources) resources in $($stats.Subscriptions) subscriptions" -Landscape $section = $pdf.Section $colors = $pdf.Colors $pt = $pdf.Pt $right = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Right $number = { param($Cell, $Value) $p = $Cell.AddParagraph(('{0:N0}' -f [double]$Value)); $p.Format.Alignment = $right } $security = [bool]$stats.HasSecurity $blue = [MigraDoc.DocumentObjectModel.Color]::Parse('#0369A1') $scoreColor = { param($Score) if ($null -eq $Score) { $colors.Muted } elseif ($Score -ge 70) { $pdf.Tone.Good.Solid } elseif ($Score -ge 40) { $pdf.Tone.Warn.Solid } else { $pdf.Tone.Bad.Solid } } $severityColor = @{ High = $pdf.Tone.Bad.Solid; Medium = $pdf.Tone.Warn.Solid; Low = $blue; Healthy = $pdf.Tone.Good.Solid; Moderate = $pdf.Tone.Warn.Solid } # A score as a bold coloured percentage; findings as coloured H / M / L counts. $addScore = { param($Cell, $Score) if ($null -eq $Score) { return } $p = $Cell.AddParagraph("$Score%") $p.Format.Alignment = $right $p.Format.Font.Name = 'Segoe UI Semibold' $p.Format.Font.Color = & $scoreColor $Score } $addFindings = { param($Cell, $Item) $p = $Cell.AddParagraph() $p.Format.Alignment = $right foreach ($level in @(@('High', 'H'), @('Medium', 'M'), @('Low', 'L'))) { $count = [int]$Item.($level[0]) if (-not $count) { continue } $text = $p.AddFormattedText("$($level[1])$count ") $text.Color = $severityColor[$level[0]] $text.Bold = $true } } # --- 1. Summary ----------------------------------------------------------------------------- & $pdf.AddTitle "Tenant inventory · generated $($pdf.Generated.ToString('dddd d MMMM yyyy, HH:mm'))" $facts = [ordered]@{} if ($script:AACSession) { $facts['Azure account'] = [string]$script:AACSession.Account $facts['Tenant'] = [string]$script:AACSession.TenantId } if ($Detail) { foreach ($key in $Detail.Keys) { $facts[[string]$key] = [string]$Detail[$key] } } $factTable = & $pdf.NewTable @(4.0, ($pdf.PageWidth - 4.0)) foreach ($key in $facts.Keys) { $row = & $pdf.AddBodyRow $factTable $row.Cells[0].AddParagraph($key).Format.Font.Color = $colors.Muted $row.Cells[1].AddParagraph($facts[$key]) | Out-Null } $section.AddParagraph().Format.SpaceAfter = & $pt 6 $tileData = @( if ($security -and $null -ne $stats.SecureScore) { @{ Value = "$($stats.SecureScore)%"; Label = "secure score ($($stats.Rating.ToLowerInvariant()))"; Color = (& $scoreColor $stats.SecureScore) } } if ($security) { @{ Value = $stats.High; Label = 'high-severity findings'; Color = $(if ($stats.High) { $pdf.Tone.Bad.Solid } else { $pdf.Tone.Good.Solid }) } } @{ Value = $stats.ManagementGroups; Label = 'management groups' } @{ Value = $stats.Subscriptions; Label = 'subscriptions' } @{ Value = $stats.ResourceGroups; Label = 'resource groups' } @{ Value = $stats.Resources; Label = 'resources' } if (-not $security) { @{ Value = $stats.Types; Label = 'resource types' } } if (-not $security) { @{ Value = $stats.Locations; Label = 'locations' } } ) $tiles = & $pdf.NewTable @(1..$tileData.Count | ForEach-Object { $pdf.PageWidth / $tileData.Count }) $tiles.TopPadding = & $pt 8 $tiles.BottomPadding = & $pt 8 $tileRow = $tiles.AddRow() for ($i = 0; $i -lt $tileData.Count; $i++) { $cell = $tileRow.Cells[$i] $cell.Shading.Color = $colors.Panel $cell.Borders.Left.Width = $(if ($i -gt 0) { 2 } else { 0 }) $cell.Borders.Left.Color = $colors.White $value = $cell.AddParagraph($(if ($tileData[$i].Value -is [string]) { $tileData[$i].Value } else { '{0:N0}' -f $tileData[$i].Value })) if ($tileData[$i].Contains('Color')) { $value.Format.Font.Color = $tileData[$i].Color } $value.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center $value.Format.Font.Size = 18 $value.Format.Font.Name = 'Segoe UI Semibold' $caption = $cell.AddParagraph($tileData[$i].Label) $caption.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center $caption.Format.Font.Size = 8 $caption.Format.Font.Color = $colors.Muted } if ($stats.EmptyGroups) { $note = $section.AddParagraph("$($stats.EmptyGroups) resource group(s) have no resources - shown in amber below.") $note.Format.Font.Color = $colors.Amber $note.Format.SpaceBefore = & $pt 6 } # --- 2. The hierarchy, down to resource groups ---------------------------------------------------- $section.AddParagraph('Hierarchy', 'Heading2') | Out-Null $levelLabel = @{ Tenant = 'Tenant'; ManagementGroup = 'Management group'; Subscription = 'Subscription'; ResourceGroup = 'Resource group' } $tree = if ($security) { & $pdf.NewTable @(7.4, 2.9, 5.2, 2.2, 2.4, 2.0, 1.8, 2.2) } else { & $pdf.NewTable @(9.0, 3.2, 6.2, 2.4, 2.6, 2.3) } & $pdf.AddHeaderRow $tree @(@('Name', 'Level', 'Detail', 'Subscriptions', 'Resource groups', 'Resources') + $(if ($security) { @('Score', 'Findings') } else { @() })) @(3, 4, 5, 6, 7) foreach ($item in @($items | Where-Object Level -NE 'Resource')) { $row = & $pdf.AddBodyRow $tree $name = $row.Cells[0].AddParagraph($item.Name) $name.Format.LeftIndent = [MigraDoc.DocumentObjectModel.Unit]::FromCentimeter([Math]::Min(6, 0.45 * $item.Depth)) if ($item.Level -ne 'ResourceGroup') { $name.Format.Font.Name = 'Segoe UI Semibold' } $row.Cells[1].AddParagraph($levelLabel[$item.Level]).Format.Font.Color = $colors.Muted $what = switch ($item.Level) { 'Subscription' { (@($item.SubscriptionId, $item.State) | Where-Object { $_ }) -join ' · ' } 'ResourceGroup' { (@($item.Location, $item.TopTypes) | Where-Object { $_ }) -join ' · ' } default { '' } } $detailText = $row.Cells[2].AddParagraph($what) $detailText.Format.Font.Size = 7.5 $detailText.Format.Font.Color = $colors.Muted if ($item.Level -in 'Tenant', 'ManagementGroup') { & $number $row.Cells[3] $item.Subscriptions } if ($item.Level -ne 'ResourceGroup') { & $number $row.Cells[4] $item.ResourceGroups } & $number $row.Cells[5] $item.Resources if ($security) { & $addScore $row.Cells[6] $item.SecureScore & $addFindings $row.Cells[7] $item } if ($item.Level -eq 'ResourceGroup' -and $item.Resources -eq 0) { foreach ($cell in $row.Cells) { foreach ($paragraph in $cell.Elements) { $paragraph.Format.Font.Color = $colors.Amber } } } } # --- 3. Security (Defender for Cloud) -------------------------------------------------------------- if ($security) { $section.AddPageBreak() $section.AddParagraph('Security posture', 'Heading2') | Out-Null $note = $section.AddParagraph('Microsoft Defender for Cloud. Secure score: Defender''s own for subscriptions (added up for management groups and the tenant); for resource groups and resources, the share of their assessed recommendations that are healthy. Good 70% or more, Fair 40-69%, Poor under 40%.') $note.Format.Font.Size = 8 $note.Format.Font.Color = $colors.Muted $note.Format.SpaceAfter = & $pt 6 $table = & $pdf.NewTable @(8.0, 7.0, 2.6, 3.0, 1.8, 1.8, 1.9) & $pdf.AddHeaderRow $table @('Subscription', 'Management group', 'Secure score', 'Points', 'High', 'Medium', 'Low') @(2, 3, 4, 5, 6) foreach ($item in @($items | Where-Object Level -EQ 'Subscription' | Sort-Object -Property @{ Expression = { if ($null -eq $_.SecureScore) { 999 } else { $_.SecureScore } } }, Name)) { $row = & $pdf.AddBodyRow $table $row.Cells[0].AddParagraph($item.Name) | Out-Null $row.Cells[1].AddParagraph($item.ManagementGroup) | Out-Null if ($null -ne $item.SecureScore) { & $addScore $row.Cells[2] $item.SecureScore } else { $row.Cells[2].AddParagraph('no data').Format.Font.Color = $colors.Muted } if ($item.ScorePoints) { $pointsText = $row.Cells[3].AddParagraph($item.ScorePoints) $pointsText.Format.Alignment = $right $pointsText.Format.Font.Color = $colors.Muted } foreach ($level in @(@(4, 'High'), @(5, 'Medium'), @(6, 'Low'))) { $count = [int]$item.($level[1]) $p = $row.Cells[$level[0]].AddParagraph("$count") $p.Format.Alignment = $right if ($count) { $p.Format.Font.Color = $severityColor[$level[1]]; $p.Format.Font.Name = 'Segoe UI Semibold' } else { $p.Format.Font.Color = $colors.Muted } } } $gain = @($Inventory.Controls | Where-Object { $_.PotentialIncrease -gt 0 } | Sort-Object -Property @{ Expression = 'PotentialIncrease'; Descending = $true }, Control) if ($gain.Count) { $section.AddParagraph('Security controls with the most to gain', 'Heading3') | Out-Null $table = & $pdf.NewTable @(10.0, 6.4, 2.4, 3.3, 4.0) & $pdf.AddHeaderRow $table @('Control', 'Subscription', 'Score', 'Unhealthy resources', 'Potential increase') @(2, 3, 4) foreach ($control in $gain) { $row = & $pdf.AddBodyRow $table $row.Cells[0].AddParagraph($control.Control) | Out-Null $row.Cells[1].AddParagraph($control.SubscriptionName) | Out-Null & $addScore $row.Cells[2] $control.Score & $number $row.Cells[3] $control.UnhealthyResources $p = $row.Cells[4].AddParagraph("+$($control.PotentialIncrease)%") $p.Format.Alignment = $right $p.Format.Font.Name = 'Segoe UI Semibold' $p.Format.Font.Color = $(if ($control.PotentialIncrease -ge 5) { $pdf.Tone.Bad.Solid } elseif ($control.PotentialIncrease -ge 2) { $pdf.Tone.Warn.Solid } else { $blue }) } } $findings = @($Inventory.Recommendations | Select-Object -First 2000) if ($findings.Count) { $section.AddParagraph("Unhealthy recommendations$(if ($findings.Count -lt $Inventory.Recommendations.Count) { " (the first $($findings.Count) of $($Inventory.Recommendations.Count))" })", 'Heading3') | Out-Null $table = & $pdf.NewTable @(1.9, 9.6, 4.8, 3.6, 3.8, 2.4) & $pdf.AddHeaderRow $table @('Severity', 'Recommendation', 'Resource', 'Resource group', 'Subscription', 'User impact') foreach ($finding in $findings) { $row = & $pdf.AddBodyRow $table $severity = $row.Cells[0].AddParagraph($finding.Severity) $severity.Format.Font.Name = 'Segoe UI Semibold' if ($severityColor.Contains($finding.Severity)) { $severity.Format.Font.Color = $severityColor[$finding.Severity] } $row.Cells[1].AddParagraph($finding.Recommendation) | Out-Null $row.Cells[2].AddParagraph($finding.Resource) | Out-Null $row.Cells[3].AddParagraph($finding.ResourceGroup) | Out-Null $row.Cells[4].AddParagraph($finding.SubscriptionName) | Out-Null $impact = $row.Cells[5].AddParagraph($finding.Impact) if ($severityColor.Contains($finding.Impact)) { $impact.Format.Font.Color = $severityColor[$finding.Impact] } } } } # --- 4. Subscriptions, resource groups, resource types --------------------------------------------- $subscriptions = @($items | Where-Object Level -EQ 'Subscription') if ($subscriptions.Count) { $section.AddPageBreak() $section.AddParagraph('Subscriptions', 'Heading2') | Out-Null $table = & $pdf.NewTable @(6.0, 6.8, 2.2, 5.0, 2.6, 2.4) & $pdf.AddHeaderRow $table @('Subscription', 'Subscription ID', 'State', 'Management group', 'Resource groups', 'Resources') @(4, 5) foreach ($item in $subscriptions) { $row = & $pdf.AddBodyRow $table $row.Cells[0].AddParagraph($item.Name) | Out-Null $row.Cells[1].AddParagraph($item.SubscriptionId).Format.Font.Size = 7.5 $row.Cells[2].AddParagraph($item.State) | Out-Null $row.Cells[3].AddParagraph($item.ManagementGroup) | Out-Null & $number $row.Cells[4] $item.ResourceGroups & $number $row.Cells[5] $item.Resources } } $groups = @($items | Where-Object Level -EQ 'ResourceGroup' | Sort-Object -Property SubscriptionName, Name) if ($groups.Count) { $section.AddParagraph('Resource groups', 'Heading2') | Out-Null $table = & $pdf.NewTable @(6.0, 5.0, 3.0, 2.2, 8.8) & $pdf.AddHeaderRow $table @('Resource group', 'Subscription', 'Location', 'Resources', 'Most common types') @(3) foreach ($item in $groups) { $row = & $pdf.AddBodyRow $table $row.Cells[0].AddParagraph($item.Name) | Out-Null $row.Cells[1].AddParagraph($item.SubscriptionName) | Out-Null $row.Cells[2].AddParagraph($item.Location) | Out-Null & $number $row.Cells[3] $item.Resources $types = $row.Cells[4].AddParagraph($(if ($item.Resources -eq 0) { 'empty' } else { $item.TopTypes })) $types.Format.Font.Size = 7.5 $types.Format.Font.Color = $(if ($item.Resources -eq 0) { $colors.Amber } else { $colors.Muted }) } } $resources = @($items | Where-Object Level -EQ 'Resource') if ($resources.Count) { $section.AddPageBreak() $section.AddParagraph('Resources by type', 'Heading2') | Out-Null $table = & $pdf.NewTable @(12.0, 3.0, 3.0) & $pdf.AddHeaderRow $table @('Type', 'Resources', 'Share') @(1, 2) foreach ($type in @($resources | Group-Object -Property Type | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name)) { $row = & $pdf.AddBodyRow $table $row.Cells[0].AddParagraph($type.Name) | Out-Null & $number $row.Cells[1] $type.Count $share = $row.Cells[2].AddParagraph(('{0:N1}%' -f (100 * $type.Count / $resources.Count))) $share.Format.Alignment = $right $share.Format.Font.Color = $colors.Muted } # --- 5. Every resource ----------------------------------------------------------------------- $section.AddPageBreak() $shown = @($resources | Sort-Object -Property SubscriptionName, ResourceGroup, Type, Name | Select-Object -First $MaxResources) $section.AddParagraph("Resources$(if ($shown.Count -lt $resources.Count) { " (the first $('{0:N0}' -f $shown.Count) of $('{0:N0}' -f $resources.Count); the HTML report and CSV have them all)" })", 'Heading2') | Out-Null $table = & $pdf.NewTable @(6.2, 6.4, 4.6, 4.2, 2.4, 2.6) & $pdf.AddHeaderRow $table @('Resource', 'Type', 'Resource group', 'Subscription', 'Location', 'SKU') foreach ($item in $shown) { $row = & $pdf.AddBodyRow $table $row.Cells[0].AddParagraph($item.Name) | Out-Null $row.Cells[1].AddParagraph($item.Type).Format.Font.Size = 7.5 $row.Cells[2].AddParagraph($item.ResourceGroup) | Out-Null $row.Cells[3].AddParagraph($item.SubscriptionName) | Out-Null $row.Cells[4].AddParagraph($item.Location) | Out-Null $row.Cells[5].AddParagraph($item.Sku) | Out-Null } } Save-AACPdfDocument -Pdf $pdf -Path $Path } |