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
                # 'Please register to Microsoft.Security in order to view your security status' is
                # the phrasing Azure actually uses for an unregistered provider on this endpoint
                # (observed live, AB#6900) -- it says "register to", never "not registered", so the
                # original patterns missed it and the quiet Unavailable path fell through to a
                # raw Write-Warning on every collect against a subscription without Defender.
                $providerUnavailable =
                    $ProviderRegistrationIsUnavailable -and
                    $message -match '(?i)MissingSubscriptionRegistration|SubscriptionNotRegistered|not registered.+Microsoft\.Security|Microsoft\.Security.+not registered|register to Microsoft\.Security'

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

                $transientFailure = $message -match '(?i)\bHTTP\s*(429|500|502|503|504)\b|InternalServerError|BadGateway|ServiceUnavailable|GatewayTimeout|TooManyRequests|HttpClient\.Timeout|timed out|TaskCanceled|operation (?:was|has been) canceled'
                if ($transientFailure -and $attempt -lt $MaxAttempts) {
                    Start-Sleep -Milliseconds (200 * $attempt)
                    continue
                }

                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
                    }
                }
            }
        }
    }

    function Invoke-ScoutDefenderPricingDataset {
        param([Parameter(Mandatory)][string] $SubscriptionName)

        # Az.Security emits an error when Microsoft.Security is unregistered. Run this known
        # optional read with a quiet error variable so the expected provider state does not leak
        # into scout-console.log as a TerminatingError before Scout can classify it.
        $pricingErrors = @()
        $pricingData = @()
        try {
            $pricingData = @(Get-AzSecurityPricing -ErrorAction SilentlyContinue -ErrorVariable +pricingErrors)
        }
        catch {
            $pricingErrors += $_
        }

        if (@($pricingErrors).Count -eq 0) {
            return [pscustomobject]@{ Data = $pricingData; Status = 'Success'; Error = $null }
        }

        $message = (@($pricingErrors | ForEach-Object { $_.Exception.Message }) -join '; ')
        if ($message -match '(?i)MissingSubscriptionRegistration|SubscriptionNotRegistered|not registered.+Microsoft\.Security|Microsoft\.Security.+not registered|register to Microsoft\.Security') {
            return [pscustomobject]@{ Data = @(); Status = 'Unavailable'; Error = $null }
        }

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

    function ConvertFrom-ScoutDefenderAlertRestRow {
        param([Parameter(Mandatory)]$Alert)

        function Get-AlertValue {
            param($Object, [Parameter(Mandatory)][string]$Name)
            if ($null -eq $Object) { return $null }
            $property = $Object.PSObject.Properties[$Name]
            if ($null -eq $property) { return $null }
            return $property.Value
        }

        $properties = Get-AlertValue -Object $Alert -Name 'properties'
        if ($null -eq $properties) { $properties = $Alert }
        $timeGeneratedValue = Get-AlertValue -Object $properties -Name 'timeGeneratedUtc'
        $timeGenerated = if ($timeGeneratedValue) { [datetime]$timeGeneratedValue } else { $null }

        # Preserve the established Get-AzSecurityAlert projection consumed by the declarative
        # DefenderAlerts collector. The REST API nests these values under `properties`.
        [pscustomobject]@{
            Id                  = Get-AlertValue -Object $Alert -Name 'id'
            Name                = Get-AlertValue -Object $Alert -Name 'name'
            AlertDisplayName    = Get-AlertValue -Object $properties -Name 'alertDisplayName'
            AlertType           = Get-AlertValue -Object $properties -Name 'alertType'
            Severity            = Get-AlertValue -Object $properties -Name 'severity'
            Status              = Get-AlertValue -Object $properties -Name 'status'
            TimeGeneratedUtc    = $timeGenerated
            Description         = Get-AlertValue -Object $properties -Name 'description'
            RemediationSteps    = @(Get-AlertValue -Object $properties -Name 'remediationSteps')
            Intent              = Get-AlertValue -Object $properties -Name 'intent'
            Entities            = @(Get-AlertValue -Object $properties -Name 'entities')
            ResourceIdentifiers = @(Get-AlertValue -Object $properties -Name 'resourceIdentifiers')
            ExtendedProperties  = Get-AlertValue -Object $properties -Name 'extendedProperties'
            ConfidenceLevel     = Get-AlertValue -Object $properties -Name 'confidenceLevel'
        }
    }

    function Invoke-ScoutDefenderAlertRestFallback {
        param([Parameter(Mandatory)][string]$SubscriptionId)

        $alerts = [System.Collections.Generic.List[object]]::new()
        $path = "/subscriptions/$SubscriptionId/providers/Microsoft.Security/alerts?api-version=2022-01-01"
        do {
            # Do not pass -SkipHttpErrorCheck: older supported Az.Accounts releases do not expose
            # it on Invoke-AzRestMethod. Non-success responses throw and are classified from their
            # actual service error by the surrounding dataset boundary.
            $response = Invoke-AzRestMethod -Path $path -Method GET -ErrorAction Stop
            if ($null -eq $response) { throw 'Defender alerts REST fallback returned no response.' }

            $statusProperty = $response.PSObject.Properties['StatusCode']
            if ($null -ne $statusProperty) {
                $statusCode = [int]$statusProperty.Value
                if ($statusCode -lt 200 -or $statusCode -ge 300) {
                    $responseContent = $response.PSObject.Properties['Content']
                    $responseText = if ($null -ne $responseContent) { [string]$responseContent.Value } else { '' }
                    throw "Defender alerts REST fallback returned HTTP $statusCode. $responseText"
                }
            }

            $contentProperty = $response.PSObject.Properties['Content']
            $content = if ($null -ne $contentProperty) { $contentProperty.Value } else { $response }
            if ($content -is [string]) {
                if ([string]::IsNullOrWhiteSpace($content)) { $content = [pscustomobject]@{ value = @() } }
                else { $content = $content | ConvertFrom-Json }
            }
            if ($null -eq $content) { $content = [pscustomobject]@{ value = @() } }

            $valueProperty = $content.PSObject.Properties['value']
            $pageRows = if ($null -ne $valueProperty) { @($valueProperty.Value) } else { @() }
            foreach ($alert in $pageRows) {
                if ($null -ne $alert) { $alerts.Add((ConvertFrom-ScoutDefenderAlertRestRow -Alert $alert)) }
            }
            $nextLinkProperty = $content.PSObject.Properties['nextLink']
            $path = if ($null -ne $nextLinkProperty) { [string]$nextLinkProperty.Value } else { $null }
        } while (-not [string]::IsNullOrWhiteSpace($path))

        return $alerts.ToArray()
    }

    function Get-ScoutDefenderAlerts {
        param([Parameter(Mandatory)][string]$SubscriptionId)

        try {
            return @(Get-AzSecurityAlert -ErrorAction Stop)
        }
        catch {
            if ($_.Exception -isnot [System.NullReferenceException] -and
                $_.Exception.Message -notmatch '(?i)Object reference not set to an instance of an object') {
                throw
            }
            Write-Verbose 'Get-AzSecurityAlert failed inside Az.Security; using the documented Defender for Cloud alerts REST endpoint.'
            return @(Invoke-ScoutDefenderAlertRestFallback -SubscriptionId $SubscriptionId)
        }
    }

    function ConvertFrom-ScoutDefenderAssessmentRestRow {
        param([Parameter(Mandatory)]$Assessment)

        function Get-AssessmentValue {
            param($Object, [Parameter(Mandatory)][string]$Name)
            if ($null -eq $Object) { return $null }
            $property = $Object.PSObject.Properties[$Name]
            if ($null -eq $property) { return $null }
            return $property.Value
        }

        $properties = Get-AssessmentValue -Object $Assessment -Name 'properties'
        if ($null -eq $properties) { $properties = $Assessment }

        # Preserve the Get-AzSecurityAssessment projection consumed by the shipped collector.
        # The documented REST response nests these values beneath `properties`.
        [pscustomobject]@{
            Id              = Get-AssessmentValue -Object $Assessment -Name 'id'
            Name            = Get-AssessmentValue -Object $Assessment -Name 'name'
            DisplayName     = Get-AssessmentValue -Object $properties -Name 'displayName'
            ResourceDetails = Get-AssessmentValue -Object $properties -Name 'resourceDetails'
            Status          = Get-AssessmentValue -Object $properties -Name 'status'
            Metadata        = Get-AssessmentValue -Object $properties -Name 'metadata'
            AdditionalData  = Get-AssessmentValue -Object $properties -Name 'additionalData'
            Links           = Get-AssessmentValue -Object $properties -Name 'links'
            PartnersData    = Get-AssessmentValue -Object $properties -Name 'partnersData'
        }
    }

    function Invoke-ScoutDefenderAssessmentRestFallback {
        param([Parameter(Mandatory)][string]$SubscriptionId)

        $assessments = [System.Collections.Generic.List[object]]::new()
        $path = "/subscriptions/$SubscriptionId/providers/Microsoft.Security/assessments?api-version=2021-06-01"
        do {
            $response = Invoke-AzRestMethod -Path $path -Method GET -ErrorAction Stop
            if ($null -eq $response) { throw 'Defender assessments REST fallback returned no response.' }

            $statusProperty = $response.PSObject.Properties['StatusCode']
            if ($null -ne $statusProperty) {
                $statusCode = [int]$statusProperty.Value
                if ($statusCode -lt 200 -or $statusCode -ge 300) {
                    $responseContent = $response.PSObject.Properties['Content']
                    $responseText = if ($null -ne $responseContent) { [string]$responseContent.Value } else { '' }
                    throw "Defender assessments REST fallback returned HTTP $statusCode. $responseText"
                }
            }

            $contentProperty = $response.PSObject.Properties['Content']
            $content = if ($null -ne $contentProperty) { $contentProperty.Value } else { $response }
            if ($content -is [string]) {
                if ([string]::IsNullOrWhiteSpace($content)) { $content = [pscustomobject]@{ value = @() } }
                else { $content = $content | ConvertFrom-Json }
            }
            if ($null -eq $content) { $content = [pscustomobject]@{ value = @() } }

            $valueProperty = $content.PSObject.Properties['value']
            foreach ($assessment in @(if ($null -ne $valueProperty) { $valueProperty.Value } else { @() })) {
                if ($null -ne $assessment) {
                    $assessments.Add((ConvertFrom-ScoutDefenderAssessmentRestRow -Assessment $assessment))
                }
            }
            $nextLinkProperty = $content.PSObject.Properties['nextLink']
            $path = if ($null -ne $nextLinkProperty) { [string]$nextLinkProperty.Value } else { $null }
        } while (-not [string]::IsNullOrWhiteSpace($path))

        return $assessments.ToArray()
    }

    function Get-ScoutDefenderAssessments {
        param(
            [Parameter(Mandatory)][string]$SubscriptionId
        )

        # Az.Security can surface an HttpClient.Timeout after 100 seconds even though the
        # documented list endpoint remains healthy. Suppress the raw cmdlet error record, retain
        # its message for classification, and fall back only for client-side timeout/null failures.
        $assessmentErrors = @()
        $assessmentData = @()
        try {
            $assessmentData = @(Get-AzSecurityAssessment -ErrorAction SilentlyContinue -ErrorVariable +assessmentErrors)
        }
        catch {
            $assessmentErrors += $_
        }
        if (@($assessmentErrors).Count -eq 0) { return $assessmentData }

        $message = (@($assessmentErrors | ForEach-Object { $_.Exception.Message }) -join '; ')
        $fallbackRequired = $message -match '(?i)HttpClient\.Timeout|timed out|TaskCanceled|operation (?:was|has been) canceled|Object reference not set'
        if (-not $fallbackRequired) { throw $message }

        Write-Verbose 'Get-AzSecurityAssessment failed inside Az.Security; using the documented Defender for Cloud assessments REST endpoint.'
        return @(Invoke-ScoutDefenderAssessmentRestFallback -SubscriptionId $SubscriptionId)
    }

    $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 `
                -MaxAttempts 3 `
                -ProviderRegistrationIsUnavailable `
                -Operation { Get-ScoutDefenderAlerts -SubscriptionId $subscriptionId }

            $queries.DefenderAssessments = Invoke-ScoutSweepDataset `
                -Dataset 'DefenderAssessments' `
                -SubscriptionName $subscriptionName `
                -MaxAttempts 3 `
                -ProviderRegistrationIsUnavailable `
                -Operation { Get-ScoutDefenderAssessments -SubscriptionId $subscriptionId }

            $queries.DefenderPricing = Invoke-ScoutDefenderPricingDataset -SubscriptionName $subscriptionName

            $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)
}