Public/Get-MsecAzureSecureScore.ps1
|
function Get-MsecAzureSecureScore { <# .SYNOPSIS Per-subscription Microsoft Defender for Cloud Secure Score, projected to flat rows: SubscriptionId / SubscriptionName / ScoreType / ScorePercent / Date. .DESCRIPTION Calls the Microsoft Defender for Cloud (formerly Azure Security Center) ARM API: GET https://management.azure.com/subscriptions/{id} /providers/Microsoft.Security/secureScores?api-version=2020-01-01 Returns one row per (subscription, ScoreType). By default, only the 'Overall' row per subscription is emitted. -IncludeControls also emits one row per Defender for Cloud control (e.g. 'Enable MFA', 'Encrypt data at rest', ...) - useful when an auditor asks "which categories are bringing the score down". Auth uses the CALLER's Az.Accounts identity, not the msec app - Defender for Cloud is ARM-rooted and access is granted via subscription RBAC, not Entra app permissions. You need at least Reader role on each subscription you want scored. Same auth boundary as Search-MsecAzureResourceGraph and Invoke-MsecAzureVMScript. No historical trend is available - the API only returns the current snapshot. Your bi-weekly archive script is the only source of history for this score. To match the row shape of Get-MsecSecureScore, a Date column is included with today's date. .PARAMETER SubscriptionId Restrict to specific subscriptions. Omit to score every accessible subscription in the current Az context's tenant. .PARAMETER IncludeControls Also emit one row per Defender for Cloud control (per subscription). Adds one extra ARM call per subscription, so noticeably slower on tenants with many subscriptions. Off by default. .EXAMPLE # Overall score per subscription (the headline number) Get-MsecAzureSecureScore | Format-Table SubscriptionName, ScorePercent .EXAMPLE # Control-level breakdown for a single sub Get-MsecAzureSecureScore -SubscriptionId '<guid>' -IncludeControls | Sort-Object ScorePercent | Select -First 10 ScoreType, ScorePercent .OUTPUTS PSCustomObject per (subscription, ScoreType) with SubscriptionId, SubscriptionName, ScoreType ('Overall' or a control name), ScorePercent (0-100, null if max=0), Date (today). #> [CmdletBinding()] param( [Parameter()] [string[]] $SubscriptionId, [Parameter()] [switch] $IncludeControls ) # Caller's Az.Accounts identity - NOT the msec app. Match the auth flow # used by Search-MsecAzureResourceGraph and Invoke-MsecAzureVMScript. if (-not (Get-AzContext -ErrorAction SilentlyContinue)) { throw 'No Azure context. Run Connect-AzAccount before Get-MsecAzureSecureScore.' } # Default to every accessible subscription in the current tenant (same convention as # Search-MsecAzureResourceGraph). Via Get-MsecSubscriptionList so the enumeration is pinned # to the active context's tenant rather than fanning out across every tenant the account can # see - which is what a bare Get-AzSubscription does, and what this comment always claimed # it did not. $subs = if ($SubscriptionId) { $SubscriptionId | ForEach-Object { Get-AzSubscription -SubscriptionId $_ -TenantId (Get-AzContext).Tenant.Id -ErrorAction Stop } } else { Get-MsecSubscriptionList } # ARM endpoint for the current cloud (management.chinacloudapi.cn in China). Derived # from the Az context so this works in every sovereign cloud, not just commercial. $arm = (Get-MsecEnvironment).ArmResource # ARM token. Get-AzAccessToken returns SecureString in Az.Accounts 5.x+ and # plain string in older versions - handle both transparently. $tokenResp = Get-AzAccessToken -ResourceUrl "$arm/" -ErrorAction Stop $token = if ($tokenResp.Token -is [System.Security.SecureString]) { [System.Net.NetworkCredential]::new('', $tokenResp.Token).Password } else { $tokenResp.Token } $headers = @{ Authorization = "Bearer $token" } $today = (Get-Date).Date foreach ($sub in $subs) { $subId = $sub.Id $subName = $sub.Name # Per-subscription Overall score lives in the 'ascScore' item of the # secureScores collection. There's only ever one item per sub - this # endpoint returns a collection for forward compatibility, not because # multiple scores exist today. $overallUri = "$arm/subscriptions/$subId/providers/Microsoft.Security/secureScores?api-version=2020-01-01" try { $resp = Invoke-RestMethod -Method GET -Uri $overallUri -Headers $headers -ErrorAction Stop } catch { # 403 = no Reader role; 404 = Defender for Cloud never enabled on the sub. # Either way, surface a warning and move on - don't fail the whole batch. Write-Warning "Could not read secure score for subscription '$subName' ($subId): $($_.Exception.Message)" continue } $overall = $resp.value | Where-Object name -eq 'ascScore' | Select-Object -First 1 if ($overall) { [PSCustomObject]@{ SubscriptionId = $subId SubscriptionName = $subName ScoreType = 'Overall' ScorePercent = & { param($s) if ($s.max -gt 0) { [math]::Round(($s.current / $s.max) * 100, 2) } else { $null } } $overall.properties.score Date = $today } } if ($IncludeControls) { $controlsUri = "$arm/subscriptions/$subId/providers/Microsoft.Security/secureScores/ascScore/secureScoreControls?api-version=2020-01-01" try { $cResp = Invoke-RestMethod -Method GET -Uri $controlsUri -Headers $headers -ErrorAction Stop } catch { Write-Warning "Could not read secure score controls for subscription '$subName' ($subId): $($_.Exception.Message)" continue } foreach ($c in $cResp.value) { [PSCustomObject]@{ SubscriptionId = $subId SubscriptionName = $subName ScoreType = $c.properties.displayName ScorePercent = & { param($s) if ($s.max -gt 0) { [math]::Round(($s.current / $s.max) * 100, 2) } else { $null } } $c.properties.score Date = $today } } } } } |