Public/Get-AACInventory.ps1
|
function Get-AACInventory { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Inventories the tenant as a tree - management groups, subscriptions, resource groups and resources - with a Spectre.Console tree view, objects, and CSV, PDF and interactive HTML exports. .DESCRIPTION Reads everything with Azure Resource Graph (Reader access is enough; no Az modules): the management groups, the subscriptions and the management group each is in, the resource groups and the resources. The tree is: Tenant Management groups (nested as in Azure) Subscriptions Resource groups (location, most common resource types) Resources (type, location, SKU) with the number of subscriptions, resource groups and resources below every node. Management groups with no subscription in the result are left out, unless you asked for them with -ManagementGroupId. A subscription in a management group you can't read is shown under the tenant. Empty resource groups are flagged. Security posture comes from Microsoft Defender for Cloud (Resource Graph's securityresources; Reader or Security Reader is enough), in colour: - each subscription's secure score - Defender's own (current / max) - and management groups' and the tenant's, their subscriptions' scores added up as Defender does - each resource's score - the share of its assessed recommendations that are healthy - and its unhealthy findings by severity, rolled up to its resource group - Good (70% or more) green, Fair (40-69%) amber, Poor (under 40%) red; High findings red, Medium amber, Low blue, Healthy green - the security controls with the potential score increase of fixing each (their impact), and every unhealthy recommendation with its severity, user impact, effort and resource Without Defender data (not enabled, or no access) the inventory is shown without it. -NoSecurity skips reading it. What you get depends on where the command runs: at the prompt tiles, the tree (down to -Depth; resource groups by default) and the most common resource types - a page at a time piped onward the objects, with no view -PassThru the view and the objects -NoDisplay the objects only One AAC.InventoryItem per node: Level (Tenant, ManagementGroup, Subscription, ResourceGroup, Resource), Depth, Name, Path, the management group, subscription and resource group it is in, Type, Kind, Location, SKU, State, the counts below it, TopTypes, SecureScore, Rating, Severity, High, Medium, Low, Findings, TopFindings, Tags, Id. -CsvPath writes every node as a CSV row. -HtmlPath writes an interactive report: tiles, charts, the hierarchy as a collapsible, searchable tree with each node's score and findings in colour (click a node to see it in the tables), and tables of management groups, subscriptions, resource groups, resources, security controls and recommendations, each with its own CSV download. -PdfPath writes a PDF: the summary, the hierarchy, security (scores, controls, recommendations), subscriptions, resource groups, resources by type and the resources. With any of them, the console shows only the progress and the files written. .PARAMETER ManagementGroupId Only these management groups (their IDs, e.g. 'mg-corp') and everything below them. .PARAMETER SubscriptionId Only these subscriptions. .PARAMETER ResourceGroupName Only these resource groups (in the subscriptions or management groups given, or in any you can see). .PARAMETER Depth How deep the console tree goes: ManagementGroup, Subscription, ResourceGroup (the default) or Resource. The objects and exports always have everything. .PARAMETER NoSecurity Don't read Microsoft Defender for Cloud: no secure scores, findings or recommendations. .PARAMETER CsvPath Write every node - tenant, management groups, subscriptions, resource groups and resources - to this CSV file. .PARAMETER PdfPath Write a PDF report to this file. .PARAMETER HtmlPath Write an interactive HTML report to this file. .PARAMETER Title The PDF and HTML reports' title. .PARAMETER PassThru Show the view and also return the objects. .PARAMETER NoDisplay Return the objects without showing the view. .PARAMETER NoPaging Show the whole view at once instead of a page at a time. .EXAMPLE Connect-AAC Get-AACInventory The whole tenant as a tree, down to resource groups. .EXAMPLE Get-AACInventory -ManagementGroupId 'mg-landingzones' -Depth Resource One management group, down to every resource. .EXAMPLE Get-AACInventory -SubscriptionId '00000000-0000-0000-0000-000000000000' -HtmlPath .\out\Inventory.html -PdfPath .\out\Inventory.pdf One subscription as an interactive HTML report and a PDF. .EXAMPLE Get-AACInventory -NoDisplay | Where-Object { $_.Level -eq 'ResourceGroup' -and $_.Resources -eq 0 } The empty resource groups. .OUTPUTS AAC.InventoryItem (piped onward, or with -PassThru or -NoDisplay) #> [CmdletBinding()] [OutputType('AAC.InventoryItem')] param( [ValidateNotNullOrEmpty()] [string[]] $ManagementGroupId, [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [ValidateNotNullOrEmpty()] [string[]] $ResourceGroupName, [ValidateSet('ManagementGroup', 'Subscription', 'ResourceGroup', 'Resource')] [string] $Depth = 'ResourceGroup', [switch] $NoSecurity, [string] $CsvPath, [string] $PdfPath, [string] $HtmlPath, [string] $Title = 'Azure tenant inventory', [switch] $PassThru, [switch] $NoDisplay, [switch] $NoPaging ) # A failure anywhere below ends as a Spectre.Console error panel and this # command's own terminating error, not a line inside the module. trap { $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength $interactive = -not $NoDisplay -and -not $pipedOnward $exporting = $CsvPath -or $PdfPath -or $HtmlPath $showView = $interactive -and -not $exporting $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } $csvFullPath = & $resolve $CsvPath $pdfFullPath = & $resolve $PdfPath $htmlFullPath = & $resolve $HtmlPath $quote = { param([string] $Text) "'" + ($Text -replace '\\', '\\' -replace "'", "\'") + "'" } # Resource Graph through ARM: rows as hashtables (tags whose keys differ # only by case are fine), following $skipToken; scoped to subscriptions # or management groups when given. $graph = { param([string] $Query, [switch] $Tenant) $body = @{ query = $Query; options = @{ resultFormat = 'objectArray' } } if (-not $Tenant) { if ($SubscriptionId) { $body.subscriptions = @($SubscriptionId) } elseif ($ManagementGroupId) { $body.managementGroups = @($ManagementGroupId) } } do { $response = Invoke-AACArmRequest -Method Post -Uri '/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01' -Body ($body | ConvertTo-Json -Depth 10) $response['data'] $skipToken = $response['$skipToken'] $body.options['$skipToken'] = $skipToken } while ($skipToken) } $groupFilter = if ($ResourceGroupName) { "($((@($ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' } if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Tenant inventory' -Color 'deepskyblue3_1' } $state = Invoke-AACProgress -ScriptBlock { $null = Get-AACAccessToken $notices = [System.Collections.Generic.List[string]]::new() Update-AACProgress -Id 'read' -Total $(if ($NoSecurity) { 5 } else { 6 }) -Description 'Reading the tenant' $tenantId = if ($script:AACSession) { [string]$script:AACSession.TenantId } else { '' } $tenantName = '' try { $tenants = Invoke-AACArmRequest -Uri '/tenants?api-version=2022-12-01' $match = @($tenants['value']) | Where-Object { [string]$_['tenantId'] -eq $tenantId } | Select-Object -First 1 if ($match) { $tenantName = (@([string]$match['displayName'], [string]$match['defaultDomain']) | Where-Object { $_ } | Select-Object -First 1) } } catch { Write-Debug "The tenant's name couldn't be read: $($_.Exception.Message)" } Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading the management groups' $groups = @() try { $groups = @(& $graph "resourcecontainers | where type =~ 'microsoft.management/managementgroups' | project id, name, displayName = tostring(properties.displayName), parentId = tostring(properties.details.parent.id)" -Tenant) } catch { $notices.Add("The management groups couldn't be read ($($_.Exception.Message -replace '\s+', ' ')), so subscriptions are shown under the tenant.") } if (-not $groups.Count) { $notices.Add('No management groups are visible to this account, so subscriptions are shown under the tenant.') } # With -ManagementGroupId: those groups, the groups below them, and # the groups above them (the path from the tenant). if ($ManagementGroupId) { $byName = @{} foreach ($group in $groups) { $byName[([string]$group['name']).ToLowerInvariant()] = $group } $missing = @($ManagementGroupId | Where-Object { -not $byName.Contains($_.ToLowerInvariant()) }) if ($missing.Count -eq $ManagementGroupId.Count -and $groups.Count) { throw "No management group with the ID $(($missing | ForEach-Object { "'$_'" }) -join ', ') was found. Use the group's ID (its name), not its display name." } foreach ($name in $missing) { Write-Warning "No management group with the ID '$name' was found; it's left out." } } Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading the subscriptions' $subscriptions = @(& $graph "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name, state = tostring(properties.state), parentGroup = tostring(properties.managementGroupAncestorsChain[0].name), quotaId = tostring(properties.subscriptionPolicies.quotaId), tags") Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading the resource groups' $resourceGroups = @(& $graph "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups'$(if ($groupFilter) { " and name in~ $groupFilter" }) | project id, name, subscriptionId, location, state = tostring(properties.provisioningState), managedBy, tags") if ($ResourceGroupName) { $found = @($resourceGroups | ForEach-Object { [string]$_['name'] }) $missing = @($ResourceGroupName | Where-Object { $name = $_; -not @($found | Where-Object { $_ -eq $name }).Count }) if ($missing.Count -eq $ResourceGroupName.Count) { throw "No resource group named $(($missing | ForEach-Object { "'$_'" }) -join ', ') was found in the subscriptions you can see$(if ($SubscriptionId -or $ManagementGroupId) { ' in that scope' })." } foreach ($name in $missing) { Write-Warning "No resource group named '$name' was found; it's left out." } # Only the subscriptions those groups are in. $withGroups = @($resourceGroups | ForEach-Object { ([string]$_['subscriptionId']).ToLowerInvariant() } | Select-Object -Unique) $subscriptions = @($subscriptions | Where-Object { ([string]$_['subscriptionId']).ToLowerInvariant() -in $withGroups }) } Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading the resources' $resources = @(& $graph "resources$(if ($groupFilter) { " | where resourceGroup in~ $groupFilter" }) | project id, name, type, kind, location, resourceGroup, subscriptionId, sku = tostring(sku.name), zones, tags") # Microsoft Defender for Cloud: secure scores, controls, and each # resource's assessments (a summary, and the unhealthy ones). $security = $null if (-not $NoSecurity) { Update-AACProgress -Id 'read' -Increment 1 -Description 'Reading Microsoft Defender for Cloud: secure scores and recommendations' try { $assessments = "securityresources | where type =~ 'microsoft.security/assessments' | extend resourceId = tolower(coalesce(tostring(properties.resourceDetails.Id), tostring(properties.resourceDetails.ResourceId))), status = tostring(properties.status.code), severity = tostring(properties.metadata.severity)" $security = @{ Scores = @(& $graph "securityresources | where type =~ 'microsoft.security/securescores' and name == 'ascScore' | project subscriptionId, current = todouble(properties.score.current), max = todouble(properties.score.max)") Controls = @(& $graph "securityresources | where type =~ 'microsoft.security/securescores/securescorecontrols' | project subscriptionId, control = tostring(properties.displayName), current = todouble(properties.score.current), max = todouble(properties.score.max), healthy = toint(properties.healthyResourceCount), unhealthy = toint(properties.unhealthyResourceCount)") Summary = @(& $graph "$assessments | where status in ('Healthy', 'Unhealthy') | summarize healthy = countif(status == 'Healthy'), unhealthy = countif(status == 'Unhealthy'), high = countif(status == 'Unhealthy' and severity == 'High'), medium = countif(status == 'Unhealthy' and severity == 'Medium'), low = countif(status == 'Unhealthy' and severity == 'Low') by resourceId") Recommendations = @(& $graph "$assessments | where status == 'Unhealthy' | project resourceId, subscriptionId, name = tostring(properties.displayName), severity, impact = tostring(properties.metadata.userImpact), effort = tostring(properties.metadata.implementationEffort), categories = strcat_array(properties.metadata.categories, ', '), cause = tostring(properties.status.cause)") } if (-not @(@($security.Scores) + @($security.Summary) | Where-Object { $_ }).Count) { $notices.Add('No Microsoft Defender for Cloud data was found in this scope (not enabled, or no access), so there are no secure scores.') } } catch { $security = $null $notices.Add("Microsoft Defender for Cloud couldn't be read ($($_.Exception.Message -replace '\s+', ' ')), so there are no secure scores.") } } Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} management group(s), {1:N0} subscription(s), {2:N0} resource group(s) and {3:N0} resource(s)' -f $groups.Count, $subscriptions.Count, $resourceGroups.Count, $resources.Count) Update-AACProgress -Id 'tree' -Description 'Building the tree' -Indeterminate $inventory = ConvertTo-AACInventory -TenantId $tenantId -TenantName $tenantName -ManagementGroup $groups -Subscription $subscriptions -ResourceGroup $resourceGroups -Resource $resources -KeepManagementGroup @($ManagementGroupId | Where-Object { $_ }) -Security $security $inventory.Notice = $notices.ToArray() $posture = if ($inventory.Stats.HasSecurity -and $null -ne $inventory.Stats.SecureScore) { ", secure score $($inventory.Stats.SecureScore)%" } else { '' } Update-AACProgress -Id 'tree' -Complete -Description ('Tree: {0:N0} management group(s) > {1:N0} subscription(s) > {2:N0} resource group(s) > {3:N0} resource(s){4}' -f $inventory.Stats.ManagementGroups, $inventory.Stats.Subscriptions, $inventory.Stats.ResourceGroups, $inventory.Stats.Resources, $posture) $scope = [ordered]@{ Scope = if ($ManagementGroupId) { "management group(s) $($ManagementGroupId -join ', ')" } elseif ($SubscriptionId) { "subscription(s) $($SubscriptionId -join ', ')" } else { 'the whole tenant (everything the account can see)' } } if ($ResourceGroupName) { $scope['Resource groups'] = $ResourceGroupName -join ', ' } $null = Invoke-AACExport -CsvPath $csvFullPath -CsvObject @($inventory.Items | Select-Object -Property * -ExcludeProperty Depth) -Noun 'item' -PdfPath $pdfFullPath -WritePdf { Write-AACInventoryPdf -Inventory $inventory -Path $pdfFullPath -Title $Title -Detail $scope } -HtmlPath $htmlFullPath -WriteHtml { Write-AACInventoryHtml -Inventory $inventory -Path $htmlFullPath -Title $Title -Detail $scope } @{ Inventory = $inventory; Scope = $scope } } $inventory = $state.Inventory if ($showView) { Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock { Show-AACInventoryView -Inventory $inventory -Depth $Depth -Scope $state.Scope } } elseif ($interactive -and $inventory.Notice) { foreach ($notice in $inventory.Notice) { Write-AACMarkup "[grey58]$([Spectre.Console.Markup]::Escape($notice))[/]" } } if ($returnObjects) { $inventory.Items } } |