src/collect/Get-ScoutDefenderPlanSweep.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Collects Microsoft Defender for Cloud plan assignments (Microsoft.Security/pricings)
    for every subscription, one ARM GET per subscription.
 
.DESCRIPTION
    AB#6903. `security.defenderPlans` shipped as a hard-coded empty placeholder from the
    day the collect contract was written, while `caf.security.yaml` and `waf.security.yaml`
    carry live rules querying `$.security.defenderPlans[?(@.properties.pricingTier ==
    'Standard')]` -- so those rules failed on every tenant regardless of the estate's real
    Defender posture (proven live: 18 plans, 4 non-Free, on a subscription the banked
    corpus showed as zero rows).
 
    Plain ARM REST, no Az.Security dependency. A subscription where the Microsoft.Security
    provider is not registered answers 404 or the "register to Microsoft.Security" error
    (AB#6900) -- both are the quiet no-Defender state, never a warning. Any other failure
    warns once for that subscription and collection continues with its neighbours.
 
.PARAMETER Subscriptions
    Subscription objects with an `id` property and an optional `name` property.
 
.OUTPUTS
    One flat [pscustomobject] per Defender plan across every subscription:
    id / name / subscriptionId / subscriptionName / properties.pricingTier /
    properties.subPlan. Always an array; empty when no subscription carries a plan.
#>

function Get-ScoutDefenderPlanSweep {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Subscriptions
    )

    $plans = @(
        foreach ($subscription in $Subscriptions) {
            $idProperty = if ($subscription) { $subscription.PSObject.Properties['id'] } else { $null }
            if ($null -eq $idProperty -or [string]::IsNullOrWhiteSpace([string] $idProperty.Value)) { continue }
            $subscriptionId = [string] $idProperty.Value
            $nameProperty = $subscription.PSObject.Properties['name']
            $subscriptionName = if ($null -ne $nameProperty -and -not [string]::IsNullOrWhiteSpace([string] $nameProperty.Value)) {
                [string] $nameProperty.Value
            }
            else { $subscriptionId }

            try {
                $response = Invoke-AzRestMethod -Path "/subscriptions/$subscriptionId/providers/Microsoft.Security/pricings?api-version=2024-01-01" -Method GET -ErrorAction Stop
                if ($null -eq $response) { throw 'ARM returned no response.' }
                if ([int]$response.StatusCode -eq 404) { continue }
                if ([int]$response.StatusCode -lt 200 -or [int]$response.StatusCode -ge 300) {
                    $content = [string]$response.Content
                    if ($content -match '(?i)MissingSubscriptionRegistration|SubscriptionNotRegistered|not registered.+Microsoft\.Security|Microsoft\.Security.+not registered|register to Microsoft\.Security') {
                        continue
                    }
                    throw "ARM returned status $($response.StatusCode): $content"
                }

                $body = $response.Content | ConvertFrom-Json
                $rows = if ($body -and $body.PSObject.Properties['value']) { @($body.value) } else { @() }
                foreach ($plan in $rows) {
                    if (-not $plan) { continue }
                    $props = if ($plan.PSObject.Properties['properties'] -and $plan.properties) { $plan.properties } else { $null }
                    [pscustomobject]@{
                        id               = if ($plan.PSObject.Properties['id']) { [string]$plan.id } else { $null }
                        name             = if ($plan.PSObject.Properties['name']) { [string]$plan.name } else { $null }
                        subscriptionId   = $subscriptionId
                        subscriptionName = $subscriptionName
                        properties       = [pscustomobject]@{
                            pricingTier = if ($props -and $props.PSObject.Properties['pricingTier']) { [string]$props.pricingTier } else { $null }
                            subPlan     = if ($props -and $props.PSObject.Properties['subPlan']) { [string]$props.subPlan } else { $null }
                        }
                    }
                }
            }
            catch {
                Write-Warning "Get-ScoutDefenderPlanSweep: Defender plan collection failed for subscription '$subscriptionName' -- security.defenderPlans will omit it: $($_.Exception.Message)"
            }
        }
    )
    return $plans
}