Private/Write-AACInventoryHtml.ps1
|
function Write-AACInventoryHtml { <# .SYNOPSIS Writes the tenant inventory (ConvertTo-AACInventory) as an interactive HTML report: tiles, charts, the hierarchy as a tree, and tables of management groups, subscriptions, resource groups and resources - each searchable, filterable and downloadable as CSV. .DESCRIPTION Clicking a node in the tree shows it in the tables: a management group its subscriptions, a subscription its resource groups, a resource group its resources. #> [CmdletBinding()] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory)] [hashtable] $Inventory, [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Title, [System.Collections.IDictionary] $Detail ) $items = @($Inventory.Items) $stats = $Inventory.Stats $of = { param([string] $Level) @($items | Where-Object Level -EQ $Level) } $groups = & $of 'ManagementGroup' $subscriptions = & $of 'Subscription' $resourceGroups = @(& $of 'ResourceGroup' | ForEach-Object { $_ | Select-Object -Property *, @{ Name = 'Status'; Expression = { if ($_.Resources -eq 0) { 'Empty' } else { '' } } } }) $resources = & $of 'Resource' # The tree: every node with its counts and what clicking it shows. $plural = { param([int] $Count, [string] $One, [string] $Many) @($Count, $(if ($Count -eq 1) { $One } else { $Many })) } function ConvertTo-Node($Node) { $n = [ordered]@{ l = @{ Tenant = 't'; ManagementGroup = 'm'; Subscription = 's'; ResourceGroup = 'g'; Resource = 'r' }[$Node.Level]; n = [string]$Node.DisplayName; d = [string]$Node.Detail } switch ($Node.Level) { { $_ -in 'Tenant', 'ManagementGroup' } { $n.c = @(, (& $plural $Node.Subscriptions 'subscription' 'subscriptions')) + @(, (& $plural $Node.ResourceGroups 'resource group' 'resource groups')) + @(, (& $plural $Node.Resources 'resource' 'resources')) $n.f = if ($Node.Level -eq 'ManagementGroup') { @{ table = 'subscriptions'; filters = @{ ManagementGroup = $Node.DisplayName } } } else { @{ table = 'subscriptions'; filters = @{} } } } 'Subscription' { $n.c = @(, (& $plural $Node.ResourceGroups 'resource group' 'resource groups')) + @(, (& $plural $Node.Resources 'resource' 'resources')) $n.f = @{ table = 'groups'; filters = @{ SubscriptionId = $Node.SubscriptionId } } } 'ResourceGroup' { $n.d = (@($Node.Detail, $Node.TopTypes) | Where-Object { $_ }) -join ' · ' $n.c = @(, (& $plural $Node.Resources 'resource' 'resources')) $n.f = @{ table = 'resources'; filters = @{ SubscriptionId = $Node.SubscriptionId; ResourceGroup = $Node.ResourceGroup } } if ($Node.Resources -eq 0) { $n.x = 1 } } 'Resource' { $n.f = @{ table = 'resources'; filters = @{ Id = $Node.Id } } } } if ($null -ne $Node.SecureScore) { $n.p = $Node.SecureScore } if ($Node.High) { $n.h = $Node.High } if ($Node.Medium) { $n.m = $Node.Medium } if ($Node.Low) { $n.lo = $Node.Low } if ($Node.Children.Count) { $n.k = @(foreach ($child in $Node.Children) { ConvertTo-Node $child }) } $n } $top = { param([object[]] $Rows, [string] $Property, [int] $First = 12) @($Rows | Group-Object -Property $Property | Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, Name | Select-Object -First $First | ForEach-Object { @{ Label = $(if ($_.Name) { $_.Name } else { '(none)' }); Value = $_.Count; Filter = $_.Name } }) } $notices = @() $tiles = @( @{ Value = '{0:N0}' -f $stats.ManagementGroups; Label = 'management groups'; Tone = 'violet'; Table = 'managementGroups' } @{ Value = '{0:N0}' -f $stats.Subscriptions; Label = 'subscriptions'; Tone = 'warn'; Table = 'subscriptions' } @{ Value = '{0:N0}' -f $stats.ResourceGroups; Label = 'resource groups'; Tone = 'info'; Table = 'groups' } @{ Value = '{0:N0}' -f $stats.Resources; Label = 'resources'; Tone = 'good'; Table = 'resources' } @{ Value = '{0:N0}' -f $stats.Types; Label = 'resource types'; Tone = 'neutral'; Table = 'resources' } @{ Value = '{0:N0}' -f $stats.Locations; Label = 'locations'; Tone = 'neutral'; Table = 'resources' } ) if ($stats.EmptyGroups) { $tiles += @{ Value = '{0:N0}' -f $stats.EmptyGroups; Label = 'empty resource groups'; Tone = 'bad'; Table = 'groups'; Filters = @{ Status = 'Empty' } } } $security = [bool]$stats.HasSecurity if ($security) { $tone = @{ Good = 'good'; Fair = 'warn'; Poor = 'bad' } if ($null -ne $stats.SecureScore) { $tiles = @(@{ Value = "$($stats.SecureScore)%"; Label = "secure score ($($stats.Rating.ToLowerInvariant()))"; Tone = $tone[$stats.Rating]; Table = 'controls' }) + $tiles } $tiles += @{ Value = '{0:N0}' -f $stats.High; Label = 'high-severity findings'; Tone = $(if ($stats.High) { 'bad' } else { 'good' }); Table = 'recommendations'; Filters = @{ Severity = 'High' } } $tiles += @{ Value = '{0:N0}' -f $stats.Medium; Label = 'medium-severity findings'; Tone = $(if ($stats.Medium) { 'warn' } else { 'good' }); Table = 'recommendations'; Filters = @{ Severity = 'Medium' } } $tiles += @{ Value = '{0:N0}' -f $stats.Low; Label = 'low-severity findings'; Tone = 'info'; Table = 'recommendations'; Filters = @{ Severity = 'Low' } } } $charts = @( @{ Title = 'Resources by type'; Items = @(& $top $resources 'Type'); Table = 'resources'; Column = 'Type'; Tone = 'good' } @{ Title = 'Resources by location'; Items = @(& $top $resources 'Location'); Table = 'resources'; Column = 'Location' } @{ Title = 'Resources by subscription'; Items = @(& $top $resources 'SubscriptionName'); Table = 'resources'; Column = 'SubscriptionName'; Tone = 'warn' } ) if ($security) { $bySeverity = @(foreach ($level in @(@('High', 'bad'), @('Medium', 'warn'), @('Low', 'info'))) { $count = @($Inventory.Recommendations | Where-Object Severity -EQ $level[0]).Count if ($count) { @{ Label = $level[0]; Value = $count; Tone = $level[1]; Filter = $level[0] } } }) $gain = @($Inventory.Controls | Where-Object { $_.PotentialIncrease -gt 0 } | Sort-Object -Property @{ Expression = 'PotentialIncrease'; Descending = $true } | Select-Object -First 10 | ForEach-Object { @{ Label = "$($_.Control) ($($_.SubscriptionName))"; Value = $_.PotentialIncrease; Display = "+$($_.PotentialIncrease)%"; Tone = $(if ($_.PotentialIncrease -ge 5) { 'bad' } elseif ($_.PotentialIncrease -ge 2) { 'warn' } else { 'info' }); Filter = $_.Control } }) $charts = @( @{ Title = 'Unhealthy findings by severity'; Items = $bySeverity; Table = 'recommendations'; Column = 'Severity' } @{ Title = 'Security controls with the most to gain'; Items = $gain; Table = 'controls'; Column = 'Control'; Wide = $true } ) + $charts $notices += @{ Tone = 'info'; Text = 'Secure score: Microsoft Defender for Cloud''s 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%.' } } $states = @{ Enabled = 'good'; Warned = 'warn'; PastDue = 'bad'; Disabled = 'bad'; Deleted = 'bad'; Expired = 'bad' } $tables = @( @{ Id = 'managementGroups'; Title = 'Management groups'; Noun = 'management groups'; File = 'management-groups'; Rows = $groups Columns = @( @{ Key = 'Name'; Label = 'Management group' } @{ Key = 'SecureScore'; Label = 'Secure score'; Type = 'score' } @{ Key = 'Path'; Label = 'Path'; Type = 'wide' } @{ Key = 'ManagementGroups'; Label = 'Groups below'; Type = 'number' } @{ Key = 'Subscriptions'; Label = 'Subscriptions'; Type = 'number' } @{ Key = 'ResourceGroups'; Label = 'Resource groups'; Type = 'number' } @{ Key = 'Resources'; Label = 'Resources'; Type = 'number' } @{ Key = 'Id'; Label = 'ID'; Type = 'mono' } ) } @{ Id = 'subscriptions'; Title = 'Subscriptions'; Noun = 'subscriptions'; File = 'subscriptions'; Rows = $subscriptions; GroupBy = @('ManagementGroup', 'State') Columns = @( @{ Key = 'Name'; Label = 'Subscription'; Type = 'resource'; IdKey = 'Id' } @{ Key = 'SubscriptionId'; Label = 'Subscription ID'; Type = 'mono' } @{ Key = 'State'; Label = 'State'; Type = 'badge'; Tones = $states; Facet = $true } @{ Key = 'ManagementGroup'; Label = 'Management group'; Facet = $true } @{ Key = 'SecureScore'; Label = 'Secure score'; Type = 'score' } @{ Key = 'Severity'; Label = 'Worst finding'; Type = 'badge'; Tones = @{ High = 'bad'; Medium = 'warn'; Low = 'info'; Healthy = 'good' }; Facet = $true } @{ Key = 'High'; Label = 'High'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'bad' } @{ Key = 'Medium'; Label = 'Medium'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'warn' } @{ Key = 'Low'; Label = 'Low'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'ResourceGroups'; Label = 'Resource groups'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'Resources'; Label = 'Resources'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'TopTypes'; Label = 'Most common types'; Type = 'wide' } @{ Key = 'Path'; Label = 'Path'; Type = 'wide' } @{ Key = 'Tags'; Label = 'Tags'; Type = 'wide' } @{ Key = 'Id'; Label = 'ID'; Hidden = $true } ) } @{ Id = 'groups'; Title = 'Resource groups'; Noun = 'resource groups'; File = 'resource-groups'; Rows = $resourceGroups; GroupBy = @('SubscriptionName', 'Location', 'ManagementGroup') Sort = @{ Key = 'Resources'; Desc = $true } Columns = @( @{ Key = 'Name'; Label = 'Resource group'; Type = 'resource'; IdKey = 'Id' } @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true } @{ Key = 'ManagementGroup'; Label = 'Management group'; Facet = $true } @{ Key = 'Location'; Label = 'Location'; Facet = $true } @{ Key = 'Resources'; Label = 'Resources'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'SecureScore'; Label = 'Secure score'; Type = 'score' } @{ Key = 'Severity'; Label = 'Worst finding'; Type = 'badge'; Tones = @{ High = 'bad'; Medium = 'warn'; Low = 'info'; Healthy = 'good' }; Facet = $true } @{ Key = 'High'; Label = 'High'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'bad' } @{ Key = 'Medium'; Label = 'Medium'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'warn' } @{ Key = 'Low'; Label = 'Low'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'TopTypes'; Label = 'Most common types'; Type = 'wide' } @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = @{ Empty = 'warn' }; Facet = $true } @{ Key = 'Tags'; Label = 'Tags'; Type = 'wide' } @{ Key = 'SubscriptionId'; Label = 'Subscription ID'; Hidden = $true } @{ Key = 'Id'; Label = 'ID'; Hidden = $true } ) } @{ Id = 'resources'; Title = 'Resources'; Noun = 'resources'; File = 'resources'; Rows = $resources; GroupBy = @('Type', 'ResourceGroup', 'SubscriptionName', 'Location') Columns = @( @{ Key = 'Name'; Label = 'Resource'; Type = 'resource'; IdKey = 'Id' } @{ Key = 'Type'; Label = 'Type'; Facet = $true } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true } @{ Key = 'Location'; Label = 'Location'; Facet = $true } @{ Key = 'SecureScore'; Label = 'Secure score'; Type = 'score' } @{ Key = 'Severity'; Label = 'Worst finding'; Type = 'badge'; Tones = @{ High = 'bad'; Medium = 'warn'; Low = 'info'; Healthy = 'good' }; Facet = $true } @{ Key = 'High'; Label = 'High'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'bad' } @{ Key = 'Medium'; Label = 'Medium'; Type = 'number'; Sum = $true; Format = 'N0'; Tone = 'warn' } @{ Key = 'Low'; Label = 'Low'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'TopFindings'; Label = 'Findings'; Type = 'wide' } @{ Key = 'Kind'; Label = 'Kind' } @{ Key = 'Sku'; Label = 'SKU' } @{ Key = 'Zones'; Label = 'Zones' } @{ Key = 'Tags'; Label = 'Tags'; Type = 'wide' } @{ Key = 'ManagementGroup'; Label = 'Management group'; Hidden = $true } @{ Key = 'SubscriptionId'; Label = 'Subscription ID'; Hidden = $true } @{ Key = 'Id'; Label = 'Resource ID'; Hidden = $true } ) } ) if ($security) { $tables += @{ Id = 'controls'; Title = 'Security controls'; Note = 'Microsoft Defender for Cloud: the potential increase is how much the subscription''s secure score rises when the control is healthy.' Noun = 'controls'; File = 'security-controls'; Rows = @($Inventory.Controls); Sort = @{ Key = 'PotentialIncrease'; Desc = $true }; GroupBy = @('SubscriptionName', 'Control') Columns = @( @{ Key = 'Control'; Label = 'Control' } @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true } @{ Key = 'Score'; Label = 'Score'; Type = 'score' } @{ Key = 'Current'; Label = 'Points'; Type = 'number'; Format = 'N2' } @{ Key = 'Max'; Label = 'Out of'; Type = 'number'; Format = 'N2' } @{ Key = 'PotentialIncrease'; Label = 'Potential increase (%)'; Type = 'number'; Format = 'N1'; Tone = 'bad' } @{ Key = 'UnhealthyResources'; Label = 'Unhealthy resources'; Type = 'number'; Sum = $true; Format = 'N0' } @{ Key = 'HealthyResources'; Label = 'Healthy resources'; Type = 'number'; Sum = $true; Format = 'N0' } ) } $tables += @{ Id = 'recommendations'; Title = 'Security recommendations'; Note = 'Unhealthy Microsoft Defender for Cloud recommendations, most severe first.' Noun = 'findings'; File = 'security-recommendations'; Rows = @($Inventory.Recommendations); GroupBy = @('Recommendation', 'Resource', 'SubscriptionName', 'Severity') Columns = @( @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = @{ High = 'bad'; Medium = 'warn'; Low = 'info' }; Facet = $true } @{ Key = 'Recommendation'; Label = 'Recommendation'; Type = 'wide'; Facet = $true } @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; IdKey = 'ResourceId' } @{ Key = 'Impact'; Label = 'User impact'; Type = 'badge'; Tones = @{ High = 'bad'; Moderate = 'warn'; Low = 'info' }; Facet = $true } @{ Key = 'Effort'; Label = 'Effort'; Facet = $true } @{ Key = 'Category'; Label = 'Category'; Facet = $true } @{ Key = 'Type'; Label = 'Type'; Facet = $true } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true } @{ Key = 'Cause'; Label = 'Cause'; Type = 'wide' } ) } } else { # No Defender data: no security columns. foreach ($table in $tables) { $table.Columns = @($table.Columns | Where-Object { $_.Key -notin 'SecureScore', 'Severity', 'High', 'Medium', 'Low', 'TopFindings' }) } } # The zones column only when a resource has one. $resourceTable = $tables[3] if (-not @($resources | Where-Object Zones).Count) { $resourceTable.Columns = @($resourceTable.Columns | Where-Object { $_.Key -ne 'Zones' }) } if ($Inventory.Contains('Notice')) { $notices += @($Inventory.Notice | Where-Object { $_ } | ForEach-Object { @{ Tone = 'info'; Text = $_ } }) } Write-AACHtmlReport -Path $Path -Title $Title -Subtitle 'Tenant inventory: management groups, subscriptions, resource groups and resources' -Fact $Detail ` -Tile $tiles -Chart $charts -Table $tables -Notice $notices -Tree @{ Title = 'Hierarchy'; OpenTo = 's'; Root = (ConvertTo-Node $Inventory.Root) } } |