Private/ConvertTo-AACPolicyAssessment.ps1
|
function ConvertTo-AACPolicyAssessment { <# .SYNOPSIS Assesses Azure Policy across a scope - what is assigned, how compliant it is by subscription, assignment, policy and category, the exemptions and the managed identities' roles - and what to improve: the model behind Invoke-AACPolicyAssessment (AzPolicyLens's analysis, in the spirit of github.com/Azure/AzPolicyLens). .DESCRIPTION No Azure calls: every input is Resource Graph rows (Get-AACPolicyAssessmentQuery). The scope: (nothing) everything the account can see -ManagementGroupId that management group and everything under it -SubscriptionId those subscriptions (an application team's), and the assignments that reach them - from their management groups too -Audience Platform (AzPolicyLens's detailed wiki) adds the management group hierarchy, unassigned custom definitions and initiatives, metadata hygiene and hidden- metadata and tags; Application (its basic wiki) leaves those out. Compliance is counted as AzPolicyLens counts it: each resource once, at its worst state (NonCompliant, then Compliant, Conflict, Exempt); compliance = (compliant + exempt) / all. A rate below -ComplianceWarningPercent is Warning, below half of it Poor. Returns a hashtable: Assignments, AssignmentCompliance (per assignment and subscription), Policies (per assignment and policy), Categories, Subscriptions, ManagementGroups, Initiatives, Definitions, Exemptions, Roles, Findings, Tree (for the HTML report), Stats. #> [CmdletBinding()] [OutputType([hashtable])] param( [AllowEmptyCollection()] [object[]] $Subscription = @(), [AllowEmptyCollection()] [object[]] $ManagementGroup = @(), [AllowEmptyCollection()] [object[]] $Assignment = @(), [AllowEmptyCollection()] [object[]] $Exemption = @(), # Definition or initiative ID (lower case) -> its row: every custom one, and the built-in ones assigned. [hashtable] $Definition = @{}, [AllowEmptyCollection()] [object[]] $RoleAssignment = @(), [AllowEmptyCollection()] [object[]] $RoleDefinition = @(), [AllowEmptyCollection()] [object[]] $ComplianceBySubscription = @(), [AllowEmptyCollection()] [object[]] $ComplianceByAssignment = @(), [AllowEmptyCollection()] [object[]] $ComplianceByPolicy = @(), [string[]] $SubscriptionId = @(), [string] $ManagementGroupId, [ValidateSet('Platform', 'Application')] [string] $Audience = 'Platform', [ValidateRange(1, 99)] [int] $ComplianceWarningPercent = 80, [ValidateRange(1, 365)] [int] $ExemptionWarningDays = 30, [datetime] $Now = (Get-Date) ) # --- Helpers ----------------------------------------------------------------------------------------------------- $at = { param($Item, [string] $Path) foreach ($part in ($Path -split '\.')) { if ($Item -is [System.Collections.IDictionary]) { $Item = if ($Item.Contains($part)) { $Item[$part] } else { $null } } elseif ($null -ne $Item -and $Item -isnot [string] -and $Item -isnot [ValueType] -and $Item -isnot [System.Collections.IEnumerable]) { $Item = Get-AACPropertyValue -InputObject $Item -Name $part } else { return $null } } $Item } $list = { param($Item) @(if ($Item -is [System.Collections.IEnumerable] -and $Item -isnot [string] -and $Item -isnot [System.Collections.IDictionary]) { $Item } elseif ($null -ne $Item) { , $Item }) | Where-Object { $null -ne $_ } } $lower = { param($Text) ([string]$Text).ToLowerInvariant() } $leaf = { param($Id) if ($Id) { ([string]$Id).TrimEnd('/') -replace '^.*/', '' } else { '' } } $isTrue = { param($Value) $null -ne $Value -and [string]$Value -in 'True', 'true' } $object = { param([string] $TypeName, [System.Collections.IDictionary] $Property) $item = [pscustomobject]$Property; $item.PSObject.TypeNames.Insert(0, $TypeName); $item } $platform = $Audience -eq 'Platform' $docs = 'https://learn.microsoft.com/azure/governance/policy' $percent = { param([long] $Compliant, [long] $Exempt, [long] $Total) if ($Total -le 0) { return $null } [Math]::Round(($Compliant + $Exempt) / $Total * 100, 1) } $rate = { param($Percent) if ($null -eq $Percent) { 'No data' } elseif ($Percent -ge $ComplianceWarningPercent) { 'Good' } elseif ($Percent -ge $ComplianceWarningPercent / 2) { 'Warning' } else { 'Poor' } } $hidden = { # Metadata (or tags) named hidden-* or hidden_*: for the platform team only. param($Bag) if (-not $platform -or $Bag -isnot [System.Collections.IDictionary]) { return '' } (@($Bag.Keys | Where-Object { $_ -match '^hidden[-_]' } | Sort-Object | ForEach-Object { "$_=$(if ($Bag[$_] -is [string] -or $Bag[$_] -is [ValueType]) { $Bag[$_] } else { ConvertTo-Json -InputObject $Bag[$_] -Compress -Depth 5 })" })) -join '; ' } $findings = [System.Collections.Generic.List[object]]::new() $finding = { param([string] $Severity, [string] $Area, [string] $Title, [string] $Item, [string] $Detail, [string] $Action, [string] $Link, [string] $Scope, [string] $Id) $findings.Add((& $object 'AAC.PolicyFinding' ([ordered]@{ Severity = $Severity; Area = $Area; Finding = $Title; Item = $Item; Detail = $Detail; Recommendation = $Action; Link = $Link; Scope = $Scope; ResourceId = $Id }))) } # --- The hierarchy, and what is in scope -------------------------------------------------------------------------- $groupNames = @{}; $groupParent = @{} foreach ($row in $ManagementGroup) { $key = & $lower (& $at $row 'name'); $groupNames[$key] = [string]$(if (& $at $row 'displayName') { & $at $row 'displayName' } else { & $at $row 'name' }); $groupParent[$key] = & $lower (& $at $row 'parent') } $ancestorsOfGroup = { param([string] $Name) $chain = [System.Collections.Generic.List[string]]::new(); $walk = $groupParent[(& $lower $Name)]; $guard = 0 while ($walk -and $guard -lt 20) { $chain.Add($walk); $walk = $groupParent[$walk]; $guard++ } , $chain.ToArray() } $subscriptionNames = @{}; $subscriptionChain = @{} foreach ($row in $Subscription) { $key = & $lower (& $at $row 'subscriptionId') $subscriptionNames[$key] = [string](& $at $row 'name') $subscriptionChain[$key] = @(@(& $list (& $at $row 'chain')) | ForEach-Object { & $lower (& $at $_ 'name') } | Where-Object { $_ }) } $scopeGroup = & $lower $ManagementGroupId $wantedSubscriptions = @($SubscriptionId | Where-Object { $_ } | ForEach-Object { & $lower $_ }) $inScopeSubscriptions = [System.Collections.Generic.HashSet[string]]::new() foreach ($key in $subscriptionNames.Keys) { if ($wantedSubscriptions.Count) { if ($wantedSubscriptions -contains $key) { [void]$inScopeSubscriptions.Add($key) } } elseif ($scopeGroup) { if ($subscriptionChain[$key] -contains $scopeGroup) { [void]$inScopeSubscriptions.Add($key) } } else { [void]$inScopeSubscriptions.Add($key) } } $inScopeGroups = [System.Collections.Generic.HashSet[string]]::new() foreach ($key in $groupNames.Keys) { if ($wantedSubscriptions.Count) { foreach ($sub in $inScopeSubscriptions) { if ($subscriptionChain[$sub] -contains $key) { [void]$inScopeGroups.Add($key) } } } elseif ($scopeGroup) { if ($key -eq $scopeGroup -or (& $ancestorsOfGroup $key) -contains $scopeGroup) { [void]$inScopeGroups.Add($key) } } else { [void]$inScopeGroups.Add($key) } } # Where a scope ID sits: a management group, a subscription (and below). $scopeOf = { param([string] $ScopeId) if ($ScopeId -match '(?i)/providers/Microsoft\.Management/managementGroups/([^/]+)') { return @{ Type = 'Management group'; Group = $Matches[1].ToLowerInvariant(); Subscription = ''; Name = $(if ($groupNames.Contains($Matches[1].ToLowerInvariant())) { $groupNames[$Matches[1].ToLowerInvariant()] } else { $Matches[1] }) } } if ($ScopeId -match '(?i)^/subscriptions/([^/]+)(/resourceGroups/([^/]+))?') { $sub = $Matches[1].ToLowerInvariant(); $name = if ($subscriptionNames.Contains($sub)) { $subscriptionNames[$sub] } else { $sub } return @{ Type = $(if ($Matches[3]) { 'Resource group' } else { 'Subscription' }); Group = ''; Subscription = $sub; Name = $(if ($Matches[3]) { "$name / $($Matches[3])" } else { $name }) } } @{ Type = 'Tenant'; Group = ''; Subscription = ''; Name = $ScopeId } } # The subscriptions an assignment applies to (its excluded scopes taken off). $reach = { param($Row) $where = & $scopeOf ([string](& $at $Row 'scope')) $excluded = @(@(& $list (& $at $Row 'notScopes')) | ForEach-Object { & $scopeOf ([string]$_) }) @(foreach ($sub in $subscriptionNames.Keys) { $applies = if ($where.Group) { $subscriptionChain[$sub] -contains $where.Group } else { $where.Subscription -eq $sub } if (-not $applies) { continue } if (@($excluded | Where-Object { ($_.Type -eq 'Subscription' -and $_.Subscription -eq $sub) -or ($_.Group -and $subscriptionChain[$sub] -contains $_.Group) }).Count) { continue } $sub }) } # --- Definitions, and what each assignment assigns ------------------------------------------------------------------ $definitionOf = { param($Id) $key = & $lower $Id; if ($key -and $Definition.Contains($key)) { $Definition[$key] } else { $null } } $isSet = { param($Id) [string]$Id -match '(?i)/policySetDefinitions/' } $deprecated = { param($Row) (& $isTrue (& $at $Row 'metadata.deprecated')) -or [string](& $at $Row 'displayName') -match '^\s*\[Deprecated\]' } $preview = { param($Row) (& $isTrue (& $at $Row 'metadata.preview')) -or [string](& $at $Row 'displayName') -match '^\s*\[Preview\]' } $nameOf = { param($Id) $row = & $definitionOf $Id; if ($row -and (& $at $row 'displayName')) { [string](& $at $row 'displayName') } else { & $leaf $Id } } $effectOf = { # A definition's effect: the literal, or its effect parameter's value - the assignment's, else the default. param($Row, $Parameters) $effect = [string](& $at $Row 'rule.then.effect') if ($effect -match "^\[parameters\('([^']+)'\)\]$") { $name = $Matches[1] $value = & $at $Parameters "$name.value" if ($null -eq $value) { $value = & $at $Row "parameters.$name.defaultValue" } $effect = [string]$value } $effect } $rolesOf = { param($Row) @(@(& $list (& $at $Row 'rule.then.details.roleDefinitionIds')) | ForEach-Object { & $leaf $_ } | ForEach-Object { $_.ToLowerInvariant() }) } $roleNames = @{} foreach ($row in $RoleDefinition) { $roleNames[(& $lower (& $at $row 'name'))] = [string](& $at $row 'roleName') } $assignedDefinitions = [System.Collections.Generic.HashSet[string]]::new() $memberCount = @{} # --- Assignments in scope ----------------------------------------------------------------------------------------- $inScope = @(foreach ($row in $Assignment) { $where = & $scopeOf ([string](& $at $row 'scope')) $subs = @(& $reach $row) $keep = if ($wantedSubscriptions.Count) { @($subs | Where-Object { $inScopeSubscriptions.Contains($_) }).Count -gt 0 } elseif ($scopeGroup) { ($where.Group -and $inScopeGroups.Contains($where.Group)) -or ($where.Subscription -and $inScopeSubscriptions.Contains($where.Subscription)) } else { $true } if ($keep) { @{ Row = $row; Where = $where; Subscriptions = @($subs | Where-Object { $inScopeSubscriptions.Contains($_) }) } } }) $assignmentIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($entry in $inScope) { [void]$assignmentIds.Add((& $lower (& $at $entry.Row 'id'))) } $compliance = @{} foreach ($row in $ComplianceByAssignment) { $sub = & $lower (& $at $row 'subscriptionId') if (-not $inScopeSubscriptions.Contains($sub)) { continue } $key = & $lower (& $at $row 'assignmentId') if (-not $compliance.Contains($key)) { $compliance[$key] = @{ NonCompliant = 0L; Compliant = 0L; Conflict = 0L; Exempt = 0L } } foreach ($state in 'NonCompliant', 'Compliant', 'Conflict', 'Exempt') { $compliance[$key][$state] += [long](& $at $row ($state.Substring(0, 1).ToLowerInvariant() + $state.Substring(1))) } } $exemptionsOf = @{} foreach ($row in $Exemption) { $key = & $lower (& $at $row 'assignmentId'); $exemptionsOf[$key] = 1 + $(if ($exemptionsOf.Contains($key)) { $exemptionsOf[$key] } else { 0 }) } $principalRoles = @{} foreach ($row in $RoleAssignment) { $principal = & $lower (& $at $row 'principalId') if (-not $principalRoles.Contains($principal)) { $principalRoles[$principal] = [System.Collections.Generic.List[object]]::new() } $principalRoles[$principal].Add($row) } $principalOf = { param($Row) $identity = & $at $Row 'identity' $type = [string](& $at $identity 'type') if ($type -match 'SystemAssigned') { return @{ Type = 'System-assigned'; Principals = @(& $lower (& $at $identity 'principalId')) } } if ($type -match 'UserAssigned') { $users = & $at $identity 'userAssignedIdentities'; return @{ Type = 'User-assigned'; Principals = @(if ($users -is [System.Collections.IDictionary]) { foreach ($k in $users.Keys) { & $lower (& $at $users[$k] 'principalId') } }) } } @{ Type = ''; Principals = @() } } $assignmentRows = [System.Collections.Generic.List[object]]::new() $roleRows = [System.Collections.Generic.List[object]]::new() foreach ($entry in $inScope) { $row = $entry.Row $id = [string](& $at $row 'id'); $key = & $lower $id $definitionId = [string](& $at $row 'definitionId') $target = & $definitionOf $definitionId $set = & $isSet $definitionId [void]$assignedDefinitions.Add((& $lower $definitionId)) $members = @(if ($set) { & $list (& $at $target 'members') }) foreach ($member in $members) { [void]$assignedDefinitions.Add((& $lower (& $at $member 'policyDefinitionId'))) } $memberCount[(& $lower $definitionId)] = 1 + $(if ($memberCount.Contains((& $lower $definitionId))) { $memberCount[(& $lower $definitionId)] } else { 0 }) $parameters = & $at $row 'parameters' $counts = if ($compliance.Contains($key)) { $compliance[$key] } else { @{ NonCompliant = 0L; Compliant = 0L; Conflict = 0L; Exempt = 0L } } $total = $counts.NonCompliant + $counts.Compliant + $counts.Conflict + $counts.Exempt $pct = & $percent $counts.Compliant $counts.Exempt $total $identity = & $principalOf $row # The roles the assignment's policies need to remediate (DeployIfNotExists, Modify). $needed = @(@(if ($set) { foreach ($member in $members) { & $rolesOf (& $definitionOf (& $at $member 'policyDefinitionId')) } } else { & $rolesOf $target }) | Sort-Object -Unique) $held = @(foreach ($principal in $identity.Principals) { if ($principalRoles.Contains($principal)) { $principalRoles[$principal] } }) foreach ($role in $held) { $roleRows.Add((& $object 'AAC.PolicyRoleAssignment' ([ordered]@{ Assignment = [string]$(if (& $at $row 'displayName') { & $at $row 'displayName' } else { & $at $row 'name' }); Identity = $identity.Type; PrincipalId = [string](& $at $role 'principalId') Role = $(if ($roleNames.Contains((& $leaf (& $at $role 'roleDefinitionId')))) { $roleNames[(& $leaf (& $at $role 'roleDefinitionId'))] } else { & $leaf (& $at $role 'roleDefinitionId') }) Scope = (& $scopeOf ([string](& $at $role 'scope'))).Name; ScopeId = [string](& $at $role 'scope'); Required = $(if ($needed -contains (& $leaf (& $at $role 'roleDefinitionId'))) { 'Yes' } else { 'No' }); AssignmentId = $id }))) } $heldRoles = @($held | ForEach-Object { & $leaf (& $at $_ 'roleDefinitionId') } | ForEach-Object { $_.ToLowerInvariant() }) $missingRoles = @($needed | Where-Object { $heldRoles -notcontains $_ }) $display = [string]$(if (& $at $row 'displayName') { & $at $row 'displayName' } else { & $at $row 'name' }) $effect = if ($set) { [string](& $at $parameters 'effect.value') } else { & $effectOf $target $parameters } $assignmentRows.Add((& $object 'AAC.PolicyAssignmentReport' ([ordered]@{ Assignment = $display; Name = [string](& $at $row 'name'); Scope = $entry.Where.Name; ScopeType = $entry.Where.Type Definition = & $nameOf $definitionId; Kind = $(if ($set) { 'Initiative' } else { 'Policy' }); PolicyType = [string](& $at $target 'policyType') Policies = $(if ($set) { $members.Count } else { 1 }); Category = [string](& $at $target 'metadata.category') Enforcement = $(if (& $at $row 'enforcement') { [string](& $at $row 'enforcement') } else { 'Default' }); Effect = $effect Parameters = $(if ($parameters -is [System.Collections.IDictionary]) { $parameters.Count } else { 0 }) ExcludedScopes = @(& $list (& $at $row 'notScopes')).Count; Overrides = @(& $list (& $at $row 'overrides')).Count; ResourceSelectors = @(& $list (& $at $row 'resourceSelectors')).Count NonComplianceMessage = $(if (@(& $list (& $at $row 'messages')).Count) { 'Yes' } else { 'No' }) Identity = $identity.Type; RolesNeeded = (@($needed | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', ') RolesMissing = (@($missingRoles | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', ') Exemptions = $(if ($exemptionsOf.Contains($key)) { $exemptionsOf[$key] } else { 0 }); Subscriptions = $entry.Subscriptions.Count NonCompliant = $counts.NonCompliant; Compliant = $counts.Compliant; Conflict = $counts.Conflict; Exempt = $counts.Exempt; Resources = $total CompliancePercent = $pct; Rating = & $rate $pct AssignedBy = [string](& $at $row 'metadata.assignedBy'); DefinitionVersion = [string](& $at $row 'definitionVersion'); Description = [string](& $at $row 'description') HiddenMetadata = & $hidden (& $at $row 'metadata'); ResourceId = $id; DefinitionId = $definitionId }))) # --- Assignment findings --------------------------------------------------------------------------------------- $scopeText = $entry.Where.Name if (-not $target) { & $finding 'Medium' 'Assignment' 'Definition not found' $display "The assignment's $(if ($set) { 'initiative' } else { 'definition' }) ($definitionId) couldn't be read: deleted, or defined at a scope you can't see." 'Delete the assignment if its definition is gone, or check access to the definition''s scope.' "$docs/concepts/assignment-structure" $scopeText $id } if (-not $set -and $target) { & $finding 'Low' 'Assignment' 'Policy definition assigned directly' $display "'$(& $nameOf $definitionId)' is assigned on its own, not through an initiative." 'Group related policies into initiatives and assign those: fewer assignments, one set of parameters, and controls you can map.' "$docs/concepts/initiative-definition-structure" $scopeText $id } if ([string](& $at $row 'enforcement') -eq 'DoNotEnforce') { & $finding 'Medium' 'Assignment' 'Not enforced (DoNotEnforce)' $display 'The assignment is evaluated but not enforced: Deny doesn''t block, and DeployIfNotExists and Modify don''t act on new resources.' 'Set enforcement to Default once the impact is understood, or document why it stays audit-only.' "$docs/concepts/assignment-structure#enforcement-mode" $scopeText $id } if ($needed.Count -and -not $identity.Principals.Count) { & $finding 'High' 'Identity' 'Remediation without a managed identity' $display "Its policies deploy or modify resources (they need $(@($needed | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', ')), but the assignment has no managed identity: nothing can be remediated." 'Give the assignment a system- or user-assigned managed identity with the roles its policies list.' "$docs/how-to/remediate-resources" $scopeText $id } elseif ($missingRoles.Count) { & $finding 'High' 'Identity' 'Managed identity missing roles' $display "The assignment's identity lacks $(@($missingRoles | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', '), which its policies need to remediate." 'Grant the identity those roles at the assignment''s scope (the portal does it when the assignment is created there).' "$docs/how-to/remediate-resources#configure-the-managed-identity" $scopeText $id } if ($null -ne $pct -and $pct -lt $ComplianceWarningPercent) { & $finding $(if ($pct -lt $ComplianceWarningPercent / 2) { 'High' } else { 'Medium' }) 'Compliance' 'Assignment below the compliance threshold' $display "$pct% compliant ($($counts.NonCompliant) non-compliant of $total resources), under $ComplianceWarningPercent%." 'Remediate the non-compliant resources (or exempt them on purpose), starting with the policies with the most non-compliant resources.' "$docs/how-to/get-compliance-data" $scopeText $id } if ($target) { $assignedDeprecated = @(@($target) + @($members | ForEach-Object { & $definitionOf (& $at $_ 'policyDefinitionId') }) | Where-Object { $_ -and (& $deprecated $_) }) foreach ($item in $assignedDeprecated) { & $finding 'Medium' $(if (& $isSet (& $at $item 'id')) { 'Initiative' } else { 'Definition' }) 'Deprecated policy assigned' $display "'$(& $at $item 'displayName')' is deprecated and may stop being supported." 'Replace it with its up-to-date replacement, or plan to remove it.' "$docs/concepts/definition-structure-basics#common-metadata-properties" $scopeText $id } if (& $preview $target) { & $finding 'Low' $(if ($set) { 'Initiative' } else { 'Definition' }) 'Preview policy assigned' $display "'$(& $at $target 'displayName')' is in preview: it can change." 'Use preview policies with audit effects only, and review them when they reach general availability.' "$docs/concepts/definition-structure-basics#common-metadata-properties" $scopeText $id } } foreach ($notScope in @(& $list (& $at $row 'notScopes'))) { $excluded = & $scopeOf ([string]$notScope) $exists = if ($excluded.Group) { $groupNames.Contains($excluded.Group) } elseif ($excluded.Type -eq 'Subscription') { $subscriptionNames.Contains($excluded.Subscription) } else { $true } if (-not $exists) { & $finding 'Low' 'Assignment' 'Excluded scope that doesn''t exist' $display "It excludes $notScope, which can't be found." 'Remove the excluded scope (or check it is only out of your sight).' "$docs/concepts/assignment-structure#excluded-scopes" $scopeText $id } } if ([string](& $at $row 'enforcement') -ne 'DoNotEnforce' -and $effect -eq 'deny' -and -not @(& $list (& $at $row 'messages')).Count) { & $finding 'Low' 'Assignment' 'Deny without a non-compliance message' $display 'People who are blocked see only the policy''s name.' 'Add a non-compliance message that says why, and who to ask.' "$docs/concepts/assignment-structure#non-compliance-messages" $scopeText $id } } # The same definition assigned twice on one scope path. $scopeById = @{} foreach ($entry in $inScope) { $scopeById[(& $lower (& $at $entry.Row 'id'))] = [string](& $at $entry.Row 'scope') } foreach ($group in $assignmentRows | Group-Object { & $lower $_.DefinitionId } | Where-Object Count -GT 1) { $rows = @($group.Group) for ($i = 0; $i -lt $rows.Count; $i++) { for ($j = $i + 1; $j -lt $rows.Count; $j++) { $a = & $scopeOf $scopeById[(& $lower $rows[$i].ResourceId)]; $b = & $scopeOf $scopeById[(& $lower $rows[$j].ResourceId)] $nested = ($a.Group -and $b.Group -and ($a.Group -eq $b.Group -or (& $ancestorsOfGroup $a.Group) -contains $b.Group -or (& $ancestorsOfGroup $b.Group) -contains $a.Group)) -or ($a.Group -and $b.Subscription -and $subscriptionChain[$b.Subscription] -contains $a.Group) -or ($b.Group -and $a.Subscription -and $subscriptionChain[$a.Subscription] -contains $b.Group) -or ($a.Subscription -and $a.Subscription -eq $b.Subscription) if ($nested) { & $finding 'Low' 'Assignment' 'Assigned twice on the same scope path' "$($rows[$i].Assignment) / $($rows[$j].Assignment)" "'$($rows[$i].Definition)' is assigned at $($rows[$i].Scope) and at $($rows[$j].Scope): resources under both are evaluated twice." 'Keep one assignment (at the higher scope), with exclusions or overrides where the lower one differs.' "$docs/concepts/assignment-structure" $rows[$i].Scope $rows[$i].ResourceId } } } } # --- Compliance by subscription, assignment and policy ------------------------------------------------------------------ $subscriptionRows = @(foreach ($sub in $inScopeSubscriptions) { $row = (@($ComplianceBySubscription | Where-Object { (& $lower (& $at $_ 'subscriptionId')) -eq $sub }) | Select-Object -First 1) $c = [long](& $at $row 'compliant'); $e = [long](& $at $row 'exempt'); $n = [long](& $at $row 'nonCompliant'); $x = [long](& $at $row 'conflict') $pct = & $percent $c $e ($c + $e + $n + $x) $tags = & $at ((@($Subscription | Where-Object { (& $lower (& $at $_ 'subscriptionId')) -eq $sub }) | Select-Object -First 1)) 'tags' $chain = @($subscriptionChain[$sub]) [array]::Reverse($chain) & $object 'AAC.PolicySubscription' ([ordered]@{ Subscription = $subscriptionNames[$sub]; SubscriptionId = $sub; ManagementGroups = (@($chain | ForEach-Object { if ($groupNames.Contains($_)) { $groupNames[$_] } else { $_ } }) -join ' > ') Assignments = @($inScope | Where-Object { $_.Subscriptions -contains $sub }).Count Exemptions = @($Exemption | Where-Object { (& $lower (& $at $_ 'subscriptionId')) -eq $sub -or ([string](& $at $_ 'id')) -match "(?i)^/subscriptions/$sub/" }).Count NonCompliant = $n; Compliant = $c; Conflict = $x; Exempt = $e; Resources = $c + $e + $n + $x; CompliancePercent = $pct; Rating = & $rate $pct HiddenTags = & $hidden $tags; ResourceId = "/subscriptions/$sub" }) if ($null -ne $pct -and $pct -lt $ComplianceWarningPercent) { & $finding 'Medium' 'Compliance' 'Subscription below the compliance threshold' $subscriptionNames[$sub] "$pct% of its resources are compliant ($n non-compliant), under $ComplianceWarningPercent%." 'Work through its non-compliant assignments and policies (the Policies table, by subscription).' "$docs/how-to/get-compliance-data" $subscriptionNames[$sub] "/subscriptions/$sub" } }) | Sort-Object -Property @{ Expression = { if ($null -eq $_.CompliancePercent) { 101 } else { $_.CompliancePercent } } }, Subscription $assignmentCompliance = @(foreach ($row in $ComplianceByAssignment) { $key = & $lower (& $at $row 'assignmentId'); $sub = & $lower (& $at $row 'subscriptionId') if (-not $assignmentIds.Contains($key) -or -not $inScopeSubscriptions.Contains($sub)) { continue } $report = (@($assignmentRows | Where-Object { (& $lower $_.ResourceId) -eq $key }) | Select-Object -First 1) $c = [long](& $at $row 'compliant'); $e = [long](& $at $row 'exempt'); $n = [long](& $at $row 'nonCompliant'); $x = [long](& $at $row 'conflict') $pct = & $percent $c $e ($c + $e + $n + $x) & $object 'AAC.PolicyAssignmentCompliance' ([ordered]@{ Assignment = $report.Assignment; Subscription = $subscriptionNames[$sub]; NonCompliant = $n; Compliant = $c; Conflict = $x; Exempt = $e; Resources = $c + $e + $n + $x; CompliancePercent = $pct; Rating = & $rate $pct; AssignmentId = $report.ResourceId }) }) | Sort-Object -Property @{ Expression = 'NonCompliant'; Descending = $true } $policyGroups = @{} foreach ($row in $ComplianceByPolicy) { $key = & $lower (& $at $row 'assignmentId'); $sub = & $lower (& $at $row 'subscriptionId') if (-not $assignmentIds.Contains($key) -or -not $inScopeSubscriptions.Contains($sub)) { continue } $groupKey = "$key|$(& $at $row 'referenceId')|$(& $lower (& $at $row 'definitionId'))" if (-not $policyGroups.Contains($groupKey)) { $policyGroups[$groupKey] = @{ Row = $row; NonCompliant = 0L; Compliant = 0L; Conflict = 0L; Exempt = 0L; Subscriptions = [System.Collections.Generic.HashSet[string]]::new() } } foreach ($state in 'NonCompliant', 'Compliant', 'Conflict', 'Exempt') { $policyGroups[$groupKey][$state] += [long](& $at $row ($state.Substring(0, 1).ToLowerInvariant() + $state.Substring(1))) } [void]$policyGroups[$groupKey].Subscriptions.Add($sub) } $policyRows = @(foreach ($groupKey in $policyGroups.Keys) { $entry = $policyGroups[$groupKey]; $row = $entry.Row $report = (@($assignmentRows | Where-Object { (& $lower $_.ResourceId) -eq (& $lower (& $at $row 'assignmentId')) }) | Select-Object -First 1) $policyDefinition = & $definitionOf (& $at $row 'definitionId') $member = (@(if ($report.Kind -eq 'Initiative') { @(& $list (& $at (& $definitionOf $report.DefinitionId) 'members')) | Where-Object { [string](& $at $_ 'policyDefinitionReferenceId') -eq [string](& $at $row 'referenceId') } }) | Select-Object -First 1) $total = $entry.NonCompliant + $entry.Compliant + $entry.Conflict + $entry.Exempt $pct = & $percent $entry.Compliant $entry.Exempt $total & $object 'AAC.PolicyAssessmentPolicy' ([ordered]@{ Assignment = $report.Assignment; Policy = & $nameOf (& $at $row 'definitionId'); ReferenceId = [string](& $at $row 'referenceId'); Effect = [string](& $at $row 'effect') Category = [string](& $at $policyDefinition 'metadata.category'); Groups = (@(& $list (& $at $member 'groupNames')) -join ', ') NonCompliant = $entry.NonCompliant; Compliant = $entry.Compliant; Conflict = $entry.Conflict; Exempt = $entry.Exempt; Resources = $total CompliancePercent = $pct; Rating = & $rate $pct; Subscriptions = $entry.Subscriptions.Count; PolicyType = [string](& $at $policyDefinition 'policyType') AssignmentId = $report.ResourceId; DefinitionId = [string](& $at $row 'definitionId') }) }) | Sort-Object -Property @{ Expression = 'NonCompliant'; Descending = $true }, Assignment, Policy $categoryRows = @(foreach ($group in $policyRows | Group-Object { if ($_.Category) { $_.Category } else { '(no category)' } }) { $n = [long](($group.Group | Measure-Object NonCompliant -Sum).Sum); $c = [long](($group.Group | Measure-Object Compliant -Sum).Sum); $e = [long](($group.Group | Measure-Object Exempt -Sum).Sum); $x = [long](($group.Group | Measure-Object Conflict -Sum).Sum) $pct = & $percent $c $e ($n + $c + $e + $x) & $object 'AAC.PolicyCategory' ([ordered]@{ Category = $group.Name; Policies = @($group.Group | ForEach-Object DefinitionId | Sort-Object -Unique).Count; Assignments = @($group.Group | ForEach-Object AssignmentId | Sort-Object -Unique).Count; NonCompliant = $n; Compliant = $c; Conflict = $x; Exempt = $e; CompliancePercent = $pct; Rating = & $rate $pct }) }) | Sort-Object -Property @{ Expression = { if ($null -eq $_.CompliancePercent) { 101 } else { $_.CompliancePercent } } }, Category # --- Exemptions ----------------------------------------------------------------------------------------------------- $allAssignmentIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($row in $Assignment) { [void]$allAssignmentIds.Add((& $lower (& $at $row 'id'))) } $exemptionRows = @(foreach ($row in $Exemption) { $id = [string](& $at $row 'id') $scopeId = $id -replace '(?i)/providers/Microsoft\.Authorization/policyExemptions/.*$', '' $where = & $scopeOf $scopeId $assignmentKey = & $lower (& $at $row 'assignmentId') $inScopeExemption = if ($wantedSubscriptions.Count -or $scopeGroup) { $assignmentIds.Contains($assignmentKey) -and (($where.Subscription -and $inScopeSubscriptions.Contains($where.Subscription)) -or ($where.Group -and $inScopeGroups.Contains($where.Group))) } else { $true } if (-not $inScopeExemption) { continue } $expires = [string](& $at $row 'expiresOn') $date = [datetime]::MinValue $expiry = if ($expires -and [datetime]::TryParse($expires, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$date)) { $date } else { $null } $days = if ($expiry) { [int][Math]::Floor(($expiry - $Now.ToUniversalTime()).TotalDays) } else { $null } $status = if (-not $expiry) { 'No expiry' } elseif ($days -lt 0) { 'Expired' } elseif ($days -le $ExemptionWarningDays) { 'Expiring' } else { 'Active' } $display = [string]$(if (& $at $row 'displayName') { & $at $row 'displayName' } else { & $at $row 'name' }) $assignmentName = (@($Assignment | Where-Object { (& $lower (& $at $_ 'id')) -eq $assignmentKey } | ForEach-Object { [string]$(if (& $at $_ 'displayName') { & $at $_ 'displayName' } else { & $at $_ 'name' }) }) | Select-Object -First 1) & $object 'AAC.PolicyExemptionReport' ([ordered]@{ Exemption = $display; Assignment = $(if ($assignmentName) { $assignmentName } else { & $leaf $assignmentKey }); Category = [string](& $at $row 'category'); Scope = $where.Name; ScopeType = $where.Type Policies = $(if (@(& $list (& $at $row 'referenceIds')).Count) { (@(& $list (& $at $row 'referenceIds')) -join ', ') } else { 'All' }); ExpiresOn = $(if ($expiry) { $expiry.ToString('yyyy-MM-dd') } else { '' }); DaysLeft = $days; Status = $status Description = [string](& $at $row 'description'); HiddenMetadata = & $hidden (& $at $row 'metadata'); ResourceId = $id }) switch ($status) { 'Expired' { & $finding 'Medium' 'Exemption' 'Exemption expired' $display "Expired $(-$days) day(s) ago ($($expiry.ToString('yyyy-MM-dd'))): the resources are evaluated again, so they may show as non-compliant." 'Remove the exemption, or renew it with a new expiry if it is still needed.' "$docs/concepts/exemption-structure#expiration" $where.Name $id } 'Expiring' { & $finding 'Low' 'Exemption' 'Exemption expiring soon' $display "Expires in $days day(s) ($($expiry.ToString('yyyy-MM-dd')))." 'Fix the resources before then, or agree a renewal with the owner.' "$docs/concepts/exemption-structure#expiration" $where.Name $id } 'No expiry' { & $finding 'Low' 'Exemption' 'Exemption with no expiry' $display "A$(if ([string](& $at $row 'category') -eq 'Waiver') { ' waiver' } else { 'n exemption' }) that never expires is easily forgotten." 'Give every exemption an expiry date, and review it then.' "$docs/concepts/exemption-structure#expiration" $where.Name $id } } if (-not $allAssignmentIds.Contains($assignmentKey)) { & $finding 'Low' 'Exemption' 'Exemption for an assignment that doesn''t exist' $display "Its assignment ($assignmentKey) is gone." 'Delete the exemption.' "$docs/concepts/exemption-structure" $where.Name $id } }) | Sort-Object -Property @{ Expression = { @{ Expired = 0; Expiring = 1; 'No expiry' = 2; Active = 3 }[$_.Status] } }, ExpiresOn # --- Initiatives and definitions ----------------------------------------------------------------------------------- $definitionScope = { param([string] $Id) if ($Id -match '(?i)^/providers/Microsoft\.Authorization/') { 'Built-in' } else { (& $scopeOf ($Id -replace '(?i)/providers/Microsoft\.Authorization/policy(Set)?Definitions/.*$', '')).Name } } $definedInScope = { param([string] $Id) if ($Id -match '(?i)^/providers/Microsoft\.Authorization/') { return $false } $where = & $scopeOf ($Id -replace '(?i)/providers/Microsoft\.Authorization/policy(Set)?Definitions/.*$', '') if (-not ($wantedSubscriptions.Count -or $scopeGroup)) { return $true } ($where.Group -and $inScopeGroups.Contains($where.Group)) -or ($where.Subscription -and $inScopeSubscriptions.Contains($where.Subscription)) } $usedByInitiative = @{} foreach ($key in $Definition.Keys) { if (-not (& $isSet $key)) { continue } foreach ($member in @(& $list (& $at $Definition[$key] 'members'))) { $m = & $lower (& $at $member 'policyDefinitionId'); $usedByInitiative[$m] = 1 + $(if ($usedByInitiative.Contains($m)) { $usedByInitiative[$m] } else { 0 }) } } $initiativeRows = [System.Collections.Generic.List[object]]::new() $definitionRows = [System.Collections.Generic.List[object]]::new() $groupMetadata = @{} foreach ($key in $Definition.Keys) { $row = $Definition[$key] $id = [string](& $at $row 'id') $assigned = $assignedDefinitions.Contains($key) $custom = [string](& $at $row 'policyType') -eq 'Custom' if (-not $assigned -and -not ($custom -and $platform -and (& $definedInScope $id))) { continue } $display = [string](& $at $row 'displayName') if (& $isSet $key) { $members = @(& $list (& $at $row 'members')) $groups = @(& $list (& $at $row 'groups')) $usedGroups = @($members | ForEach-Object { @(& $list (& $at $_ 'groupNames')) } | Sort-Object -Unique) $unused = @($groups | Where-Object { $usedGroups -notcontains [string](& $at $_ 'name') } | ForEach-Object { [string](& $at $_ 'name') }) $initiativeRows.Add((& $object 'AAC.PolicyInitiative' ([ordered]@{ Initiative = $display; PolicyType = [string](& $at $row 'policyType'); Category = [string](& $at $row 'metadata.category'); Version = [string]$(if (& $at $row 'version') { & $at $row 'version' } else { & $at $row 'metadata.version' }) Policies = $members.Count; Groups = $groups.Count; Assignments = $(if ($memberCount.Contains($key)) { $memberCount[$key] } else { 0 }); Assigned = $(if ($assigned) { 'Yes' } else { 'No' }) Deprecated = $(if (& $deprecated $row) { 'Yes' } else { 'No' }); Preview = $(if (& $preview $row) { 'Yes' } else { 'No' }); DefinedAt = & $definitionScope $id Description = [string](& $at $row 'description'); HiddenMetadata = & $hidden (& $at $row 'metadata'); ResourceId = $id }))) if ($custom -and $platform) { if (-not $assigned) { & $finding 'Low' 'Initiative' 'Unassigned custom initiative' $display 'Not assigned anywhere in scope: perhaps a test, or no longer needed.' 'Assign it, or delete it if it isn''t needed.' "$docs/concepts/initiative-definition-structure" (& $definitionScope $id) $id } if ($unused.Count) { & $finding 'Low' 'Initiative' 'Unused policy definition groups' $display "Groups no policy uses: $($unused -join ', ')." 'Remove them from the initiative, or map its policies to them.' "$docs/concepts/initiative-definition-structure#policy-definition-groups" (& $definitionScope $id) $id } if (-not (& $at $row 'metadata.category')) { & $finding 'Info' 'Initiative' 'No category in the metadata' $display 'Without metadata.category, compliance can''t be read by category.' 'Set metadata.category (Azure''s common metadata properties).' "$docs/concepts/definition-structure-basics#common-metadata-properties" (& $definitionScope $id) $id } foreach ($group in $groups) { $metadataId = & $lower (& $at $group 'additionalMetadataId') if (-not $metadataId) { continue } if (-not $groupMetadata.Contains($metadataId)) { $groupMetadata[$metadataId] = [System.Collections.Generic.List[object]]::new() } $groupMetadata[$metadataId].Add(@{ Name = [string](& $at $group 'name'); Initiative = $display; Id = $id }) } } } else { $effect = & $effectOf $row $null $definitionRows.Add((& $object 'AAC.PolicyDefinitionReport' ([ordered]@{ Definition = $display; PolicyType = [string](& $at $row 'policyType'); Mode = [string](& $at $row 'mode'); Category = [string](& $at $row 'metadata.category') Version = [string]$(if (& $at $row 'version') { & $at $row 'version' } else { & $at $row 'metadata.version' }); Effect = $effect AllowedEffects = (@(& $list (& $at $row 'parameters.effect.allowedValues')) -join ', '); RolesNeeded = (@(& $rolesOf $row | ForEach-Object { if ($roleNames.Contains($_)) { $roleNames[$_] } else { $_ } }) -join ', ') Initiatives = $(if ($usedByInitiative.Contains($key)) { $usedByInitiative[$key] } else { 0 }); DirectAssignments = $(if ($memberCount.Contains($key)) { $memberCount[$key] } else { 0 }); Assigned = $(if ($assigned) { 'Yes' } else { 'No' }) Deprecated = $(if (& $deprecated $row) { 'Yes' } else { 'No' }); Preview = $(if (& $preview $row) { 'Yes' } else { 'No' }); DefinedAt = & $definitionScope $id Description = [string](& $at $row 'description'); HiddenMetadata = & $hidden (& $at $row 'metadata'); ResourceId = $id }))) if ($custom -and $platform) { if (-not $assigned -and -not $usedByInitiative.Contains($key)) { & $finding 'Low' 'Definition' 'Unassigned custom policy definition' $display 'Neither assigned nor in an initiative: perhaps a test, or no longer needed.' 'Assign it (in an initiative), or delete it if it isn''t needed.' "$docs/concepts/definition-structure-basics" (& $definitionScope $id) $id } if (-not (& $at $row 'metadata.category')) { & $finding 'Info' 'Definition' 'No category in the metadata' $display 'Without metadata.category, compliance can''t be read by category.' 'Set metadata.category (Azure''s common metadata properties).' "$docs/concepts/definition-structure-basics#common-metadata-properties" (& $definitionScope $id) $id } } } } foreach ($metadataId in $groupMetadata.Keys) { $names = @($groupMetadata[$metadataId] | ForEach-Object Name | Sort-Object -Unique) if ($names.Count -gt 1) { & $finding 'Low' 'Initiative' 'Same control, different group names' (& $leaf $metadataId) "Policy definition groups for $(& $leaf $metadataId) are named $($names -join ', ') in $(@($groupMetadata[$metadataId] | ForEach-Object Initiative | Sort-Object -Unique) -join ', ')." 'Name the groups for a control the same in every initiative.' "$docs/concepts/initiative-definition-structure#policy-definition-groups" '' $metadataId } } # --- Management groups (the hierarchy) and the tenant tree ----------------------------------------------------------------- $subscriptionRate = @{} foreach ($row in $subscriptionRows) { $subscriptionRate[$row.SubscriptionId] = $row } $groupRows = @(foreach ($key in $inScopeGroups) { $under = @($inScopeSubscriptions | Where-Object { $subscriptionChain[$_] -contains $key }) $n = [long](($under | ForEach-Object { $subscriptionRate[$_].NonCompliant } | Measure-Object -Sum).Sum); $c = [long](($under | ForEach-Object { $subscriptionRate[$_].Compliant } | Measure-Object -Sum).Sum) $e = [long](($under | ForEach-Object { $subscriptionRate[$_].Exempt } | Measure-Object -Sum).Sum); $x = [long](($under | ForEach-Object { $subscriptionRate[$_].Conflict } | Measure-Object -Sum).Sum) $pct = & $percent $c $e ($n + $c + $e + $x) & $object 'AAC.PolicyManagementGroup' ([ordered]@{ ManagementGroup = $groupNames[$key]; Name = $key; Parent = $(if ($groupNames.Contains($groupParent[$key])) { $groupNames[$groupParent[$key]] } else { '' }); Depth = @(& $ancestorsOfGroup $key).Count Assignments = @($inScope | Where-Object { $_.Where.Group -eq $key }).Count; Subscriptions = $under.Count NonCompliant = $n; Compliant = $c; Conflict = $x; Exempt = $e; CompliancePercent = $pct; Rating = & $rate $pct ResourceId = "/providers/Microsoft.Management/managementGroups/$key" }) }) | Sort-Object Depth, ManagementGroup $node = { param([string] $Group, [int] $Depth) $row = (@($groupRows | Where-Object Name -EQ $Group) | Select-Object -First 1) @{ l = 'm'; n = $groupNames[$Group]; d = $Group; p = $(if ($null -ne $row.CompliancePercent) { [int]$row.CompliancePercent }); c = @(, @($row.Assignments, 'assignments')) f = @{ table = 'policy-assignments'; filters = @{ Scope = $groupNames[$Group] } } k = @( if ($Depth -lt 12) { foreach ($child in @($groupRows | Where-Object { $groupParent[$_.Name] -eq $Group } | Sort-Object ManagementGroup)) { & $node $child.Name ($Depth + 1) } } foreach ($sub in @($subscriptionRows | Where-Object { (@($subscriptionChain[$_.SubscriptionId]) | Select-Object -First 1) -eq $Group } | Sort-Object Subscription)) { @{ l = 's'; n = $sub.Subscription; d = $sub.SubscriptionId; p = $(if ($null -ne $sub.CompliancePercent) { [int]$sub.CompliancePercent }); c = @(@($sub.Assignments, 'assignments'), @($sub.NonCompliant, 'non-compliant')); f = @{ table = 'policy-subscriptions'; filters = @{ Subscription = $sub.Subscription } } } } ) } } $roots = @($groupRows | Where-Object { -not $inScopeGroups.Contains($groupParent[$_.Name]) }) $overallN = [long](($subscriptionRows | Measure-Object NonCompliant -Sum).Sum); $overallC = [long](($subscriptionRows | Measure-Object Compliant -Sum).Sum) $overallE = [long](($subscriptionRows | Measure-Object Exempt -Sum).Sum); $overallX = [long](($subscriptionRows | Measure-Object Conflict -Sum).Sum) $overall = & $percent $overallC $overallE ($overallN + $overallC + $overallE + $overallX) $tree = @{ l = 't'; n = 'Azure Policy'; p = $(if ($null -ne $overall) { [int]$overall }); c = @(@($assignmentRows.Count, 'assignments'), @($subscriptionRows.Count, 'subscriptions')); k = @( foreach ($root in $roots) { & $node $root.Name 0 } # Subscriptions whose management group isn't in sight. foreach ($sub in @($subscriptionRows | Where-Object { -not $inScopeGroups.Contains([string](@($subscriptionChain[$_.SubscriptionId]) | Select-Object -First 1)) } | Sort-Object Subscription)) { @{ l = 's'; n = $sub.Subscription; d = $sub.SubscriptionId; p = $(if ($null -ne $sub.CompliancePercent) { [int]$sub.CompliancePercent }); c = @(@($sub.Assignments, 'assignments'), @($sub.NonCompliant, 'non-compliant')); f = @{ table = 'policy-subscriptions'; filters = @{ Subscription = $sub.Subscription } } } } ) } $rank = @{ High = 0; Medium = 1; Low = 2; Info = 3 } $sorted = @($findings | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, Area, Finding, Item) @{ Assignments = @($assignmentRows | Sort-Object -Property @{ Expression = { if ($null -eq $_.CompliancePercent) { 101 } else { $_.CompliancePercent } } }, Assignment) AssignmentCompliance = @($assignmentCompliance) Policies = @($policyRows) Categories = @($categoryRows) Subscriptions = @($subscriptionRows) ManagementGroups = @($groupRows) Initiatives = @($initiativeRows | Sort-Object Assigned, Initiative -Descending) Definitions = @($definitionRows | Sort-Object Assigned, Definition -Descending) Exemptions = @($exemptionRows) Roles = $roleRows.ToArray() Findings = $sorted Tree = $tree Audience = $Audience Stats = [ordered]@{ Assignments = $assignmentRows.Count Initiatives = @($initiativeRows | Where-Object Assigned -EQ 'Yes').Count Definitions = @($definitionRows | Where-Object Assigned -EQ 'Yes').Count Subscriptions = @($subscriptionRows).Count ManagementGroups = @($groupRows).Count Exemptions = @($exemptionRows).Count ExpiringExemptions = @($exemptionRows | Where-Object { $_.Status -in 'Expired', 'Expiring' }).Count CompliancePercent = $overall Rating = & $rate $overall NonCompliant = $overallN Resources = $overallN + $overallC + $overallE + $overallX NotEnforced = @($assignmentRows | Where-Object Enforcement -EQ 'DoNotEnforce').Count High = @($sorted | Where-Object Severity -EQ 'High').Count Medium = @($sorted | Where-Object Severity -EQ 'Medium').Count Low = @($sorted | Where-Object Severity -EQ 'Low').Count Info = @($sorted | Where-Object Severity -EQ 'Info').Count } } } |