src/collect/Get-ScoutSubscriptionSecurityPolicySweep.ps1

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

<#
.SYNOPSIS
    Collects the subscription-scoped security, diagnostic, and policy datasets used by the
    legacy inventory collectors.
 
.DESCRIPTION
    Performs one context-scoped sweep per subscription and returns one synthetic resource
    envelope for that subscription. Each Azure call is isolated so a denied or unavailable
    dataset becomes an empty collection without discarding its neighbours.
 
    Defender assessment queries retry transient HTTP 5xx failures up to three times. A
    Microsoft.Security provider-registration failure for Defender pricing is represented as
    an empty, Unavailable dataset rather than as a collection error.
 
.PARAMETER Subscriptions
    Subscription objects with an `id` property and an optional `name` property.
 
.OUTPUTS
    One `[pscustomobject]` per valid subscription with this integration contract:
 
        type = 'AZSC/Subscription/SecurityPolicySweep'
        subscriptionId
        subscriptionName
        properties.DefenderAlerts
        properties.DefenderAssessments
        properties.DefenderPricing
        properties.DefenderSecureScores
        properties.DefenderSecureScoreControls
        properties.SubscriptionDiagnosticSettings
        properties.PolicyComplianceStates
        properties.CollectionStatus
        properties.CollectionErrors
 
    Every dataset property is always an array, including on failure. CollectionStatus
    records Success, Unavailable, or Skipped for every dataset. CollectionErrors contains
    only actual failures and is also always an array.
 
.NOTES
    Collect-phase implementation for Epic AB#5638. The legacy collectors and the collect
    orchestrator intentionally remain unchanged until their declarative definitions are
    integrated.
#>

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

    if ($Subscriptions.Count -eq 0) {
        return @()
    }

    function Invoke-ScoutSweepDataset {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [string] $Dataset,

            [Parameter(Mandatory)]
            [string] $SubscriptionName,

            [Parameter(Mandatory)]
            [scriptblock] $Operation,

            [ValidateRange(1, 5)]
            [int] $MaxAttempts = 1,

            [switch] $ProviderRegistrationIsUnavailable
        )

        for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
            try {
                return [pscustomobject]@{
                    Data   = @(& $Operation)
                    Status = 'Success'
                    Error  = $null
                }
            }
            catch {
                $message = $_.Exception.Message
                $providerUnavailable =
                    $ProviderRegistrationIsUnavailable -and
                    $message -match '(?i)MissingSubscriptionRegistration|SubscriptionNotRegistered|not registered.+Microsoft\.Security|Microsoft\.Security.+not registered'

                if ($providerUnavailable) {
                    return [pscustomobject]@{
                        Data   = @()
                        Status = 'Unavailable'
                        Error  = $null
                    }
                }

                $transientFailure = $message -match '(?i)\bHTTP\s*(500|502|503|504)\b|InternalServerError|BadGateway|ServiceUnavailable|GatewayTimeout'
                if ($transientFailure -and $attempt -lt $MaxAttempts) {
                    Start-Sleep -Milliseconds (200 * $attempt)
                    continue
                }

                # A null-reference here is a known Az.Security client-side symptom of Defender for
                # Cloud not being fully provisioned/onboarded on this subscription -- not a Scout
                # defect, and not the transient-HTTP pattern retries above target. Surface a clearer
                # hint instead of the bare CLR exception text.
                if ($message -match '(?i)Object reference not set to an instance of an object') {
                    $message = "$message (commonly indicates Microsoft Defender for Cloud is not fully provisioned/onboarded on this subscription)"
                }

                Write-Warning "Get-ScoutSubscriptionSecurityPolicySweep: '$Dataset' failed for subscription '$SubscriptionName' after $attempt attempt(s): $message"
                return [pscustomobject]@{
                    Data   = @()
                    Status = 'Unavailable'
                    Error  = [pscustomobject]@{
                        Dataset = $Dataset
                        Message = $message
                    }
                }
            }
        }
    }

    $originalContext = try {
        Get-AzContext -ErrorAction SilentlyContinue
    }
    catch {
        $null
    }

    $results = try {
        foreach ($subscription in $Subscriptions) {
            $idProperty = $subscription.PSObject.Properties['id']
            if ($null -eq $idProperty -or [string]::IsNullOrWhiteSpace([string] $idProperty.Value)) {
                Write-Warning 'Get-ScoutSubscriptionSecurityPolicySweep: skipping a subscription object without an id.'
                continue
            }

            $subscriptionId = [string] $idProperty.Value
            $tenantIdProperty = $subscription.PSObject.Properties['tenantId']
            $subscriptionTenantId = if ($null -ne $tenantIdProperty -and -not [string]::IsNullOrWhiteSpace([string] $tenantIdProperty.Value)) {
                [string] $tenantIdProperty.Value
            }
            elseif ($null -ne $originalContext -and $null -ne $originalContext.PSObject.Properties['Tenant'] -and $null -ne $originalContext.Tenant -and $null -ne $originalContext.Tenant.PSObject.Properties['Id']) {
                [string] $originalContext.Tenant.Id
            }
            else {
                $null
            }
            $nameProperty = $subscription.PSObject.Properties['name']
            $subscriptionName = if (
                $null -ne $nameProperty -and
                -not [string]::IsNullOrWhiteSpace([string] $nameProperty.Value)
            ) {
                [string] $nameProperty.Value
            }
            else {
                $subscriptionId
            }

            $emptyData = [ordered]@{
                DefenderAlerts                  = @()
                DefenderAssessments             = @()
                DefenderPricing                 = @()
                DefenderSecureScores            = @()
                DefenderSecureScoreControls     = @()
                SubscriptionDiagnosticSettings  = @()
                PolicyComplianceStates          = @()
            }
            $statuses = [ordered]@{}
            $collectionErrors = [System.Collections.Generic.List[object]]::new()

            try {
                $contextParams = @{ Subscription = $subscriptionId; ErrorAction = 'Stop' }
                if ($subscriptionTenantId) { $contextParams['Tenant'] = $subscriptionTenantId }
                Set-AzContext @contextParams | Out-Null
            }
            catch {
                $message = $_.Exception.Message
                Write-Warning "Get-ScoutSubscriptionSecurityPolicySweep: could not enter subscription context '$subscriptionName': $message"
                foreach ($datasetName in $emptyData.Keys) {
                    $statuses[$datasetName] = 'Skipped'
                }
                $collectionErrors.Add([pscustomobject]@{
                        Dataset = 'Context'
                        Message = $message
                    })

                [pscustomobject]@{
                    id               = "/subscriptions/$subscriptionId/providers/AzureScout/securityPolicySweep/default"
                    name             = 'default'
                    type             = 'AZSC/Subscription/SecurityPolicySweep'
                    subscriptionId   = $subscriptionId
                    subscriptionName = $subscriptionName
                    properties       = [pscustomobject]@{
                        DefenderAlerts                 = @()
                        DefenderAssessments            = @()
                        DefenderPricing                = @()
                        DefenderSecureScores           = @()
                        DefenderSecureScoreControls    = @()
                        SubscriptionDiagnosticSettings = @()
                        PolicyComplianceStates         = @()
                        CollectionStatus               = [pscustomobject] $statuses
                        CollectionErrors               = @($collectionErrors)
                    }
                }
                continue
            }

            $queries = [ordered]@{}
            $queries.DefenderAlerts = Invoke-ScoutSweepDataset `
                -Dataset 'DefenderAlerts' `
                -SubscriptionName $subscriptionName `
                -Operation { Get-AzSecurityAlert -ErrorAction Stop }

            $queries.DefenderAssessments = Invoke-ScoutSweepDataset `
                -Dataset 'DefenderAssessments' `
                -SubscriptionName $subscriptionName `
                -MaxAttempts 3 `
                -Operation { Get-AzSecurityAssessment -ErrorAction Stop }

            $queries.DefenderPricing = Invoke-ScoutSweepDataset `
                -Dataset 'DefenderPricing' `
                -SubscriptionName $subscriptionName `
                -ProviderRegistrationIsUnavailable `
                -Operation { Get-AzSecurityPricing -ErrorAction Stop }

            $queries.DefenderSecureScores = Invoke-ScoutSweepDataset `
                -Dataset 'DefenderSecureScores' `
                -SubscriptionName $subscriptionName `
                -Operation { Get-AzSecuritySecureScore -ErrorAction Stop }

            if (@($queries.DefenderSecureScores.Data).Count -gt 0) {
                $queries.DefenderSecureScoreControls = Invoke-ScoutSweepDataset `
                    -Dataset 'DefenderSecureScoreControls' `
                    -SubscriptionName $subscriptionName `
                    -Operation { Get-AzSecuritySecureScoreControl -ErrorAction Stop }
            }
            else {
                $queries.DefenderSecureScoreControls = [pscustomobject]@{
                    Data   = @()
                    Status = 'Skipped'
                    Error  = $null
                }
            }

            $resourceId = "/subscriptions/$subscriptionId"
            $queries.SubscriptionDiagnosticSettings = Invoke-ScoutSweepDataset `
                -Dataset 'SubscriptionDiagnosticSettings' `
                -SubscriptionName $subscriptionName `
                -Operation { Get-AzDiagnosticSetting -ResourceId $resourceId -ErrorAction Stop }

            $queries.PolicyComplianceStates = Invoke-ScoutSweepDataset `
                -Dataset 'PolicyComplianceStates' `
                -SubscriptionName $subscriptionName `
                -Operation { Get-AzPolicyState -SubscriptionId $subscriptionId -ErrorAction Stop }

            foreach ($datasetName in @($emptyData.Keys)) {
                $query = $queries[$datasetName]
                $emptyData[$datasetName] = @($query.Data)
                $statuses[$datasetName] = $query.Status
                if ($null -ne $query.Error) {
                    $collectionErrors.Add($query.Error)
                }
            }

            [pscustomobject]@{
                id               = "/subscriptions/$subscriptionId/providers/AzureScout/securityPolicySweep/default"
                name             = 'default'
                type             = 'AZSC/Subscription/SecurityPolicySweep'
                subscriptionId   = $subscriptionId
                subscriptionName = $subscriptionName
                properties       = [pscustomobject]@{
                    DefenderAlerts                 = @($emptyData.DefenderAlerts)
                    DefenderAssessments            = @($emptyData.DefenderAssessments)
                    DefenderPricing                = @($emptyData.DefenderPricing)
                    DefenderSecureScores           = @($emptyData.DefenderSecureScores)
                    DefenderSecureScoreControls    = @($emptyData.DefenderSecureScoreControls)
                    SubscriptionDiagnosticSettings = @($emptyData.SubscriptionDiagnosticSettings)
                    PolicyComplianceStates         = @($emptyData.PolicyComplianceStates)
                    CollectionStatus               = [pscustomobject] $statuses
                    CollectionErrors               = @($collectionErrors)
                }
            }
        }
    }
    finally {
        $restoreId = $null
        if (
            $null -ne $originalContext -and
            $null -ne $originalContext.PSObject.Properties['Subscription'] -and
            $null -ne $originalContext.Subscription -and
            $null -ne $originalContext.Subscription.PSObject.Properties['Id']
        ) {
            $restoreId = [string] $originalContext.Subscription.Id
        }
        if (-not [string]::IsNullOrWhiteSpace($restoreId)) {
            try {
                $restoreParams = @{ Subscription = $restoreId; ErrorAction = 'Stop' }
                if ($null -ne $originalContext.PSObject.Properties['Tenant'] -and $null -ne $originalContext.Tenant -and $null -ne $originalContext.Tenant.PSObject.Properties['Id'] -and $originalContext.Tenant.Id) {
                    $restoreParams['Tenant'] = $originalContext.Tenant.Id
                }
                Set-AzContext @restoreParams | Out-Null
            }
            catch {
                Write-Warning "Get-ScoutSubscriptionSecurityPolicySweep: could not restore subscription context '$restoreId': $($_.Exception.Message)"
            }
        }
    }

    return @($results)
}