Public/Invoke-AACStorageAccountAssessment.ps1
|
function Invoke-AACStorageAccountAssessment { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Assesses storage accounts across subscriptions - what Azure Monitor's Storage accounts workbook shows (transactions, latency, errors by response type, capacity by service) plus availability, throttling, security posture, data protection and cost - as a heat map, findings, a Spectre.Console view, objects, and CSV, PDF and interactive HTML reports. .DESCRIPTION Read-only (Reader is enough): Resource Graph the accounts, the Defender for Storage plan and its alerts metrics per account over -TimeRange - the workbook's Overview tab: Transactions, SuccessE2ELatency, SuccessServerLatency, Transactions by ResponseType, plus Availability, Ingress and Egress capacity the workbook's Capacity tab, the latest value: UsedCapacity, BlobCapacity (with BlobCount and ContainerCount), FileCapacity (and FileShareCount), QueueCapacity and TableCapacity - capacity is reported hourly to daily, so it is always read over at least the last two days settings blob and container soft delete, versioning, change feed, point-in-time restore, file share soft delete, lifecycle rules and where the blob logs go Response types are grouped: Success, Throttling (ClientThrottling*, ServerBusy*), Server (ServerTimeout, ServerOther), Authorization (Authorization, Authentication), Network (Network, ClientTimeout) and Client (ClientOther - mostly 404 and 409, often normal: they don't count as errors). Findings (High, Medium, Low): health availability under 99.9%, throttling, server errors, authorization failures, network errors, slow server responses, time lost between client and service security HTTP allowed, TLS below 1.2, anonymous blob access, open to every network, shared key access, keys not rotated in 90 days, no key or SAS expiration policy, cross- tenant replication, no blob logs, Defender off or alerting protection LRS only, blob, container and file share soft delete off (or under 7 days), versioning off cost general-purpose v1 or legacy Blob storage accounts, no lifecycle rules, idle accounts, hot tier with little activity The heat map scores each account 0-100 for availability, errors, throttling, latency, security, protection and cost - the 40 worst at the prompt and in the reports; clicking a cell in the HTML lists its findings. The reports' tabs follow the workbook - Overview and Capacity - then Security & protection and Findings. -View sets what is returned (piped onward, -PassThru, -NoDisplay) and written to -CsvPath: Account (default) or Finding. .PARAMETER SubscriptionId Only these subscriptions. .PARAMETER ManagementGroupId Only the subscriptions under these management groups. .PARAMETER ResourceGroupName Only these resource groups. .PARAMETER Name Only accounts with these names; wildcards work. .PARAMETER TimeRange How far back the metrics go: 1h, 4h (the workbook's default), 12h, 1d (default), 3d, 7d, 14d or 30d. .PARAMETER View Account or Finding: what is returned and written to -CsvPath. .PARAMETER ThrottleLimit How many requests run at once (1 to 32; 12 by default). .PARAMETER CsvPath Write the -View rows to this CSV file. .PARAMETER HtmlPath Write an interactive HTML report. .PARAMETER PdfPath Write a PDF report. .PARAMETER Title The reports' title. .PARAMETER PassThru Show the view and also return the rows. .PARAMETER NoDisplay Return the rows without showing the view. .PARAMETER NoPaging Show the whole view at once. .EXAMPLE Connect-AAC Invoke-AACStorageAccountAssessment Every storage account's health, capacity, posture and findings over the last day. .EXAMPLE Invoke-AACStorageAccountAssessment -ResourceGroupName 'rg-data' -TimeRange 7d -HtmlPath .\out\Storage.html -PdfPath .\out\Storage.pdf A week of a workload's storage accounts, as reports. .EXAMPLE Invoke-AACStorageAccountAssessment -View Finding -NoDisplay | Where-Object Signal -EQ 'Security' The security findings, to fix. .EXAMPLE Invoke-AACStorageAccountAssessment -NoDisplay | Sort-Object -Property UsedGB -Descending | Select-Object -First 10 -Property Account, UsedGB, BlobGB, FileGB, AccessTier, LifecycleRules The ten biggest accounts, and whether lifecycle rules keep their cost down. .OUTPUTS AAC.StorageAccountAssessment or AAC.StorageAccountFinding, by -View (piped onward, or with -PassThru or -NoDisplay) #> [CmdletBinding()] [OutputType('AAC.StorageAccountAssessment', 'AAC.StorageAccountFinding')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [ValidateNotNullOrEmpty()] [string[]] $ManagementGroupId, [ValidateNotNullOrEmpty()] [string[]] $ResourceGroupName, [SupportsWildcards()] [string[]] $Name, [ValidateSet('1h', '4h', '12h', '1d', '3d', '7d', '14d', '30d')] [string] $TimeRange = '1d', [ValidateSet('Account', 'Finding')] [string] $View = 'Account', [ValidateRange(1, 32)] [int] $ThrottleLimit = 12, [string] $CsvPath, [string] $HtmlPath, [string] $PdfPath, [string] $Title = 'Storage account assessment', [switch] $PassThru, [switch] $NoDisplay, [switch] $NoPaging ) trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength $interactive = -not $NoDisplay -and -not $pipedOnward $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } # TimeRange -> hours and the metric grain (an interval Azure Monitor accepts). $range = @{ '1h' = @(1, 'PT5M'); '4h' = @(4, 'PT15M'); '12h' = @(12, 'PT30M'); '1d' = @(24, 'PT1H'); '3d' = @(72, 'PT6H'); '7d' = @(168, 'PT6H'); '14d' = @(336, 'PT12H'); '30d' = @(720, 'P1D') }[$TimeRange] $end = [datetime]::UtcNow $span = { param([int] $Hours) "$($end.AddHours(-$Hours).ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($end.ToString('yyyy-MM-ddTHH:mm:ssZ'))" } $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); Name = @($Name | Where-Object { $_ }) Hours = $range[0]; Timespan = & $span $range[0]; Interval = $range[1] # Capacity is emitted hourly (blob capacity daily): at least two days back, hourly, for the latest value. CapacityTimespan = & $span ([Math]::Max(48, $range[0])); ThrottleLimit = $ThrottleLimit } $null = Get-AACAccessToken if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Storage account assessment' -Color 'deepskyblue1' } $state = Invoke-AACProgress -ScriptBlock { Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions' $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)" Update-AACProgress -Id 'read' -Indeterminate -Description 'Reading the storage accounts and Defender (Resource Graph)' $read = Invoke-AACGraphBatch -Query (Get-AACStorageAccountQuery -ResourceGroupName $request.ResourceGroupName) -SubscriptionId $scope.GraphScope -AllowFailure @('pricing', 'alerts', 'workspaces') $accounts = @($read.Rows['accounts'] | Where-Object { $n = [string]$_['name']; $null -ne $_ -and (-not $request.Name.Count -or @($request.Name | Where-Object { $n -like $_ }).Count) }) Update-AACProgress -Id 'read' -Complete -Description ('Found {0:N0} storage account(s)' -f $accounts.Count) $notices = [System.Collections.Generic.List[string]]::new() foreach ($key in @($read.Errors.Keys | Sort-Object)) { $notices.Add("The $key couldn't be read: $($read.Errors[$key] -replace '\s+', ' ')") } # Which services each account's kind has: blob (all but FileStorage), file, queue and table (general purpose). $services = foreach ($a in $accounts) { $kind = [string]$a['kind'] @{ Id = [string]$a['id']; Blob = $kind -ne 'FileStorage'; File = $kind -in 'StorageV2', 'Storage', 'FileStorage'; QueueTable = $kind -in 'StorageV2', 'Storage' } } $services = @($services) $overview = foreach ($a in $services) { @{ Id = $a.Id; Key = 'Core'; Metric = @('Transactions', 'Availability', 'SuccessE2ELatency', 'SuccessServerLatency', 'Ingress', 'Egress'); Aggregation = @('Total', 'Average'); Filter = '' } @{ Id = $a.Id; Key = 'Responses'; Metric = @('Transactions'); Aggregation = @('Total'); Filter = "ResponseType eq '*'" } } $capacity = foreach ($a in $services) { @{ Id = $a.Id; Key = 'Capacity'; Metric = @('UsedCapacity'); Aggregation = @('Average'); Filter = '' } if ($a.Blob) { @{ Id = "$($a.Id)/blobServices/default"; Key = 'Capacity'; Metric = @('BlobCapacity', 'BlobCount', 'ContainerCount'); Aggregation = @('Average'); Filter = '' } } if ($a.File) { @{ Id = "$($a.Id)/fileServices/default"; Key = 'Capacity'; Metric = @('FileCapacity', 'FileShareCount'); Aggregation = @('Average'); Filter = '' } } if ($a.QueueTable) { @{ Id = "$($a.Id)/queueServices/default"; Key = 'Capacity'; Metric = @('QueueCapacity'); Aggregation = @('Average'); Filter = '' } @{ Id = "$($a.Id)/tableServices/default"; Key = 'Capacity'; Metric = @('TableCapacity'); Aggregation = @('Average'); Filter = '' } } } $overview = @($overview); $capacity = @($capacity) $settingCount = 0 foreach ($a in $services) { if ($a.Blob) { $settingCount += 3 }; if ($a.File) { $settingCount += 1 } } Update-AACProgress -Id 'metrics' -Total ([Math]::Max(1, $overview.Count + $capacity.Count + $settingCount)) -Description 'Reading metrics, capacity and data protection settings' $tick = { param($Done, $Total) Update-AACProgress -Id 'metrics' -Increment 1 } $metrics = Read-AACNetworkHealthMetric -Call $overview -Timespan $request.Timespan -Interval $request.Interval -ThrottleLimit $request.ThrottleLimit -OnProgress $tick $capacityRead = Read-AACNetworkHealthMetric -Call $capacity -Timespan $request.CapacityTimespan -Interval 'PT1H' -ThrottleLimit $request.ThrottleLimit -OnProgress $tick $settings = Read-AACStorageAccountSetting -Account @($services | ForEach-Object { @{ Id = $_.Id; Blob = $_.Blob; File = $_.File } }) -ThrottleLimit $request.ThrottleLimit -OnProgress $tick Update-AACProgress -Id 'metrics' -Complete -Description ('Read metrics, capacity and settings for {0:N0} account(s)' -f $accounts.Count) $failedMetrics = @($accounts | Where-Object { $m = $metrics[[string]$_['id']]; $m -and $m['Core'] -and $m['Core'].Error }).Count if ($failedMetrics) { $notices.Add("Metrics couldn't be read for $failedMetrics account(s) - their health is left blank (see MetricsError).") } $workspaceName = @{} foreach ($w in @($read.Rows['workspaces'])) { if ($w) { $workspaceName[[string]$w['id']] = [string]$w['name'] } } $result = ConvertTo-AACStorageAccountAssessment -Account $accounts -Metric $metrics -Capacity $capacityRead -Setting $settings -Pricing @($read.Rows['pricing']) -Alert @($read.Rows['alerts']) -WorkspaceName $workspaceName -SubscriptionName $scope.Names -Hours $request.Hours @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() } } # --- Report ------------------------------------------------------------------------------------------------ $r = $state.Result $stats = $r.Stats $rank = Get-AACSeverityRank $accounts = @($r.Accounts | Sort-Object -Property @{ Expression = { if ($null -eq $_.Score) { 101 } else { $_.Score } } }, Account) $availabilities = @($r.Accounts | Where-Object { $null -ne $_.Availability -and $_.Transactions } | ForEach-Object { $_.Availability }) $worstAvailability = if ($availabilities.Count) { ($availabilities | Measure-Object -Minimum).Minimum } else { $null } $sumOf = { param([string] $Property) $v = 0.0; foreach ($a in $r.Accounts) { if ($null -ne $a.$Property) { $v += [double]$a.$Property } }; [Math]::Round($v, 1) } $yesNo = @{ True = 'good'; False = 'warn' } $report = @{ Subtitle = "Storage accounts - last $TimeRange" Facts = [ordered]@{ Scope = $state.Scope.Label; 'Time range' = "$TimeRange (capacity: the latest value)"; Generated = "$([datetime]::UtcNow.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture)) UTC" } Status = $(if ($stats.High) { 'Failed' } elseif (@($r.Findings).Count) { 'Warning' } else { 'Success' }) Headline = $(if (-not $stats.Accounts) { 'No storage account in scope.' } else { "$($stats.Accounts) account(s), $('{0:N2}' -f $stats.UsedTB) TB, $('{0:N0}' -f $stats.Transactions) transaction(s): $($stats.High) high finding(s); $('{0:N0}' -f $stats.Throttled) throttled, $('{0:N0}' -f $stats.Failed) failed; $($stats.Public) open to every network, $($stats.NoSoftDelete) without blob soft delete" }) Tiles = @( @{ Value = '{0:N0}' -f $stats.Accounts; Label = 'storage accounts'; Tone = 'info'; Table = 'accounts' } @{ Value = '{0:N2}' -f $stats.UsedTB; Label = 'TB used'; Tone = 'info'; Table = 'capacity' } @{ Value = $(if ($null -eq $worstAvailability) { '-' } else { '{0:N2}' -f $worstAvailability }); Label = 'lowest availability %'; Tone = $(if ($null -eq $worstAvailability) { 'neutral' } elseif ($worstAvailability -ge 99.9) { 'good' } else { 'bad' }) } @{ Value = '{0:N0}' -f $stats.Throttled; Label = 'throttled requests'; Tone = $(if ($stats.Throttled) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Signal = 'Throttling' } } @{ Value = '{0:N0}' -f $stats.Public; Label = 'open to every network'; Tone = $(if ($stats.Public) { 'bad' } else { 'good' }); Table = 'protection'; Filters = @{ Network = 'Public' } } @{ Value = '{0:N0}' -f $stats.NoSoftDelete; Label = 'no blob soft delete'; Tone = $(if ($stats.NoSoftDelete) { 'warn' } else { 'good' }); Table = 'findings'; Filters = @{ Finding = 'Blob soft delete off' } } @{ Value = '{0:N0}' -f $stats.High; Label = 'high findings'; Tone = $(if ($stats.High) { 'bad' } else { 'good' }); Table = 'findings'; Filters = @{ Severity = 'High' } } ) Notices = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } }) HeatMaps = @($(if (@($r.HeatMap.Rows).Count) { $r.HeatMap })) Charts = @( @{ Title = 'Requests by response type'; Kind = 'donut'; CenterLabel = 'requests'; Items = @(foreach ($k in @($r.ResponseTypes.Keys)) { if ($r.ResponseTypes[$k]) { @{ Label = $k; Value = $r.ResponseTypes[$k]; Tone = @{ Success = 'good'; Throttling = 'bad'; Server = 'bad'; Authorization = 'bad'; Network = 'warn'; 'Client (404, 409...)' = 'neutral' }[$k] } } }) } @{ Title = 'Capacity by service (GB)'; Items = @(@{ Label = 'Blob'; Value = & $sumOf 'BlobGB' }, @{ Label = 'File'; Value = & $sumOf 'FileGB' }, @{ Label = 'Queue'; Value = & $sumOf 'QueueGB' }, @{ Label = 'Table'; Value = & $sumOf 'TableGB' } | Where-Object { $_.Value }); Tone = 'info'; Table = 'capacity' } @{ Title = 'Redundancy'; Items = @($r.Accounts | Group-Object -Property Redundancy | Sort-Object -Property Count -Descending | ForEach-Object { @{ Label = $(if ($_.Name) { $_.Name } else { '(unknown)' }); Value = $_.Count; Filter = $_.Name } }); Table = 'protection'; Column = 'Redundancy'; Tone = 'violet' } ) Tabs = @( @{ Name = 'Overview'; Badge = $stats.Accounts; Tone = 'info'; Note = 'The workbook''s Overview tab: transactions, latency end to end and on the server, and errors by response type - plus availability and traffic.' } @{ Name = 'Capacity'; Badge = $stats.Accounts; Tone = 'violet'; Note = 'The workbook''s Capacity tab: used capacity and blob, file, queue and table capacity (the latest value), with blob, container and share counts.' } @{ Name = 'Security & protection'; Badge = $stats.Public + $stats.Anonymous; Tone = $(if ($stats.Public -or $stats.Anonymous) { 'bad' } else { 'good' }); Note = 'Each account''s security settings, redundancy, soft delete, versioning, lifecycle rules and logs.' } @{ Name = 'Findings'; Badge = @($r.Findings).Count; Tone = $(if ($stats.High) { 'bad' } else { 'warn' }); Note = 'What to fix, worst first.' } ) Tables = @( @{ Id = 'accounts'; Title = 'Overview'; Section = 'Overview'; Rows = $accounts; Noun = 'accounts'; GroupBy = @('Subscription', 'ResourceGroup', 'Kind', 'Redundancy'); ConsoleLimit = 15 Columns = @( @{ Key = 'Account'; Label = 'Account'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'Score'; Label = 'Score'; Type = 'score'; Format = 'N0'; Console = $true; Pdf = $true } @{ Key = 'Availability'; Label = 'Availability %'; Type = 'number'; Format = 'N2'; Console = $true; Pdf = $true } @{ Key = 'Transactions'; Label = 'Transactions'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'E2ELatencyMs'; Label = 'E2E latency ms'; Type = 'number'; Format = 'N1'; Console = $true; Pdf = $true } @{ Key = 'ServerLatencyMs'; Label = 'Server latency ms'; Type = 'number'; Format = 'N1'; Console = $true; Pdf = $true } @{ Key = 'ErrorRate'; Label = 'Error %'; Type = 'number'; Format = 'N2'; Console = $true; Pdf = $true } @{ Key = 'Throttled'; Label = 'Throttled'; Type = 'number'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'ServerErrors'; Label = 'Server errors'; Type = 'number'; Sum = $true } @{ Key = 'AuthErrors'; Label = 'Auth errors'; Type = 'number'; Sum = $true } @{ Key = 'NetworkErrors'; Label = 'Network errors'; Type = 'number'; Sum = $true } @{ Key = 'ClientErrors'; Label = 'Client (404, 409...)'; Type = 'number'; Sum = $true } @{ Key = 'ResponseTypes'; Label = 'Top failures'; Type = 'wide' } @{ Key = 'IngressGB'; Label = 'Ingress GB'; Type = 'number'; Format = 'N2'; Sum = $true } @{ Key = 'EgressGB'; Label = 'Egress GB'; Type = 'number'; Format = 'N2'; Sum = $true } @{ Key = 'Kind'; Label = 'Kind'; Facet = $true } @{ Key = 'Findings'; Label = 'Findings'; Type = 'number'; Sum = $true; Console = $true } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } ) } @{ Id = 'capacity'; Title = 'Capacity'; Section = 'Capacity'; Rows = @($r.Accounts | Sort-Object -Property @{ Expression = { if ($null -eq $_.UsedGB) { -1 } else { $_.UsedGB } }; Descending = $true }); Noun = 'accounts'; GroupBy = @('Subscription', 'AccessTier', 'Kind'); ConsoleLimit = 15 Columns = @( @{ Key = 'Account'; Label = 'Account'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'UsedGB'; Label = 'Used GB'; Type = 'number'; Format = 'N2'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'BlobGB'; Label = 'Blob GB'; Type = 'number'; Format = 'N2'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'FileGB'; Label = 'File GB'; Type = 'number'; Format = 'N2'; Sum = $true; Console = $true; Pdf = $true } @{ Key = 'QueueGB'; Label = 'Queue GB'; Type = 'number'; Format = 'N2'; Sum = $true; Pdf = $true } @{ Key = 'TableGB'; Label = 'Table GB'; Type = 'number'; Format = 'N2'; Sum = $true; Pdf = $true } @{ Key = 'Blobs'; Label = 'Blobs'; Type = 'number'; Sum = $true; Console = $true } @{ Key = 'Containers'; Label = 'Containers'; Type = 'number'; Sum = $true } @{ Key = 'FileShares'; Label = 'File shares'; Type = 'number'; Sum = $true } @{ Key = 'AccessTier'; Label = 'Access tier'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'LifecycleRules'; Label = 'Lifecycle rules'; Type = 'number'; Console = $true; Pdf = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } ) } @{ Id = 'protection'; Title = 'Security & protection'; Section = 'Security & protection'; Rows = $accounts; Noun = 'accounts'; GroupBy = @('Network', 'Redundancy', 'MinTls', 'Defender'); ConsoleLimit = 15 Columns = @( @{ Key = 'Account'; Label = 'Account'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'Network'; Label = 'Network'; Type = 'badge'; Tones = @{ Private = 'good'; Firewall = 'info'; Public = 'warn' }; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'MinTls'; Label = 'Min TLS'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'BlobAnonymousAccess'; Label = 'Anonymous blobs'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'SharedKeyAccess'; Label = 'Shared keys'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Redundancy'; Label = 'Redundancy'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'BlobSoftDeleteDays'; Label = 'Blob soft delete days'; Type = 'number'; Console = $true; Pdf = $true } @{ Key = 'ContainerSoftDeleteDays'; Label = 'Container soft delete days'; Type = 'number'; Pdf = $true } @{ Key = 'Versioning'; Label = 'Versioning'; Type = 'badge'; Tones = $yesNo; Facet = $true; Console = $true } @{ Key = 'ShareSoftDeleteDays'; Label = 'Share soft delete days'; Type = 'number' } @{ Key = 'ChangeFeed'; Label = 'Change feed'; Facet = $true } @{ Key = 'PointInTimeRestore'; Label = 'Point-in-time restore'; Facet = $true } @{ Key = 'HttpsOnly'; Label = 'HTTPS only'; Facet = $true } @{ Key = 'PrivateEndpoints'; Label = 'Private endpoints'; Type = 'number' } @{ Key = 'CrossTenantReplication'; Label = 'Cross-tenant replication'; Facet = $true } @{ Key = 'Encryption'; Label = 'Encryption'; Facet = $true } @{ Key = 'KeyAgeDays'; Label = 'Key age days'; Type = 'number' } @{ Key = 'KeyExpiryPolicy'; Label = 'Key expiry policy' } @{ Key = 'SasExpiryPolicy'; Label = 'SAS expiry policy' } @{ Key = 'LogsTo'; Label = 'Blob logs to'; Facet = $true } @{ Key = 'Defender'; Label = 'Defender'; Type = 'badge'; Tones = @{ On = 'good'; Off = 'warn' }; Facet = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } ) } @{ Id = 'findings'; Title = 'Findings'; Section = 'Findings'; Rows = @($r.Findings); Noun = 'findings'; GroupBy = @('Signal', 'Finding', 'Severity', 'Account'); ConsoleLimit = 20 Empty = 'Nothing to fix.'; EmptyStatus = 'Success' Columns = @( @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Account'; Label = 'Account'; Type = 'resource'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Signal'; Label = 'Signal'; Facet = $true; Console = $true } @{ Key = 'Finding'; Label = 'Finding'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Pdf = $true } @{ Key = 'Recommendation'; Label = 'What to do'; Type = 'wide'; Pdf = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } ) } ) Hint = '-TimeRange 4h matches the workbook (7d looks further back); -Name or -ResourceGroupName narrows it; -View Finding returns the findings; -HtmlPath, -PdfPath or -CsvPath for a report.' } $rows = if ($View -eq 'Finding') { @($r.Findings) } else { $accounts } Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun $View.ToLowerInvariant() -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) ` -ShowView:$interactive -NoPaging:$NoPaging -Object $rows -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward) } |