src/collect/ConvertFrom-ScoutInventory.ps1

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

<#
.SYNOPSIS
    Shape already-collected inventory rows into the query result-set Invoke-Collect produces,
    so a combined run collects from Azure once instead of twice (AB#5543).

.DESCRIPTION
    Inventory and assessment historically each ran their own Azure Resource Graph pass over the
    same resource types. `Start-AZSCGraphExtraction` already projects the FULL `properties` bag
    from `resources`, `networkresources` and `resourcecontainers`, which is a superset of what
    the assessment's typed queries re-fetch. This function derives every assessment scalar from
    those in-memory rows instead of asking Azure again.

    It returns the same `$r`-shaped hashtable of query-name -> row-array that Invoke-Collect's
    KQL pack produces, so the downstream canonical-contract shaping is untouched.

    ONE query cannot be served from inventory and is deliberately NOT produced here:
    `sqlDefenderPricing` reads the `SecurityResources` table (Microsoft.Security/pricings).
    Inventory only touches `securityresources` when -SecurityCenter is supplied, and then filters
    to `microsoft.security/assessments` with an Unhealthy status, so the pricings rows are never
    present. Invoke-Collect still issues that one live query. Callers must not assume this
    function returns a key for it.

.NOTES
    Tracks ADO AB#5543 (Epic AB#5545).

    Every projection below mirrors the KQL in src/collect/Invoke-Collect.ps1 field for field.
    The KQL remains the reference implementation and is still exercised whenever a run is
    assessment-only; this path is used when an inventory pass has already fetched the data.
    tests/CollectorCollapse.Tests.ps1 asserts the two paths agree.

    Semantics deliberately preserved from the KQL:
      - `array_length(null)` in KQL yields null, NOT 0. A VNet with no peerings array therefore
        reports peeringCount $null, matching the ARG path, so a rule filtering `peeringCount > 0`
        behaves identically.
      - `tobool()` of a missing property yields $null, not $false. Returning $false here would
        silently flip rules that test for an explicit false.
      - Subnet capacity is `2^(32-prefix) - 5` (Azure reserves five addresses per subnet).
      - `resources` and `networkresources` overlap for some network types, so rows are
        de-duplicated by `id` before shaping — without this, a VNet present in both tables would
        be counted twice and inflate every existence-count rule.
#>


function Get-ScoutProp {
    <#
    .SYNOPSIS
        StrictMode-safe nested property read. Returns $null for any missing segment.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowNull()] $InputObject,
        [Parameter(Mandatory)] [string] $Path
    )
    $current = $InputObject
    foreach ($segment in $Path.Split('.')) {
        if ($null -eq $current) { return $null }
        if ($current -isnot [System.Management.Automation.PSObject] -and $current -isnot [pscustomobject]) {
            $wrapped = [System.Management.Automation.PSObject]::AsPSObject($current)
        }
        else { $wrapped = $current }
        $property = $wrapped.PSObject.Properties[$segment]
        if (-not $property) { return $null }
        $current = $property.Value
    }
    return $current
}

function ConvertTo-ScoutBool {
    <#
    .SYNOPSIS
        KQL tobool() semantics: $null stays $null so a missing property never reads as an
        explicit false.
    #>

    param([AllowNull()] $Value)
    if ($null -eq $Value) { return $null }
    if ($Value -is [bool]) { return $Value }
    $text = [string] $Value
    if ([string]::IsNullOrWhiteSpace($text)) { return $null }
    return [bool]::TryParse($text, [ref] $null) ? [bool]::Parse($text) : $null
}

function Measure-ScoutArray {
    <#
    .SYNOPSIS
        KQL array_length() semantics: null in, null out (NOT zero).
    #>

    param([AllowNull()] $Value)
    if ($null -eq $Value) { return $null }
    return @($Value).Count
}

function ConvertFrom-ScoutInventory {
    [CmdletBinding()]
    param(
        # The `Resources` array from Start-AZSCGraphExtraction (resources + networkresources +
        # the other typed tables it accumulates).
        [Parameter()] [AllowNull()] [object[]] $Resources,

        # The `ResourceContainers` array from Start-AZSCGraphExtraction (subscriptions + RGs).
        [Parameter()] [AllowNull()] [object[]] $ResourceContainers
    )

    # `resources` and `networkresources` overlap for several network types; shaping both without
    # de-duplication would double-count every affected resource.
    $seenIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $rows = [System.Collections.Generic.List[object]]::new()
    foreach ($resource in @($Resources)) {
        if ($null -eq $resource) { continue }
        $id = [string] (Get-ScoutProp $resource 'id')
        if ($id -and -not $seenIds.Add($id)) { continue }
        $rows.Add($resource)
    }

    $containers = @(@($ResourceContainers) | Where-Object { $null -ne $_ })

    function Select-ByType {
        param([string] $Type)
        return @($rows | Where-Object { [string] (Get-ScoutProp $_ 'type') -ieq $Type })
    }

    $result = @{}

    # ---- subscriptions (resourcecontainers) ----
    $result['subscriptions'] = @(
        $containers |
            Where-Object { [string] (Get-ScoutProp $_ 'type') -ieq 'microsoft.resources/subscriptions' } |
            ForEach-Object {
                [pscustomobject]@{
                    id    = [string] (Get-ScoutProp $_ 'subscriptionId')
                    name  = [string] (Get-ScoutProp $_ 'name')
                    state = [string] (Get-ScoutProp $_ 'properties.state')
                    tags  = Get-ScoutProp $_ 'tags'
                }
            }
    )

    # ---- deployments (resource groups) ----
    $result['deployments'] = @(
        $containers |
            Where-Object { [string] (Get-ScoutProp $_ 'type') -ieq 'microsoft.resources/subscriptions/resourcegroups' } |
            ForEach-Object {
                [pscustomobject]@{
                    name           = [string] (Get-ScoutProp $_ 'name')
                    subscriptionId = [string] (Get-ScoutProp $_ 'subscriptionId')
                }
            }
    )

    # ---- networking ----
    $virtualNetworks = Select-ByType 'microsoft.network/virtualnetworks'
    $result['virtualNetworks'] = @(
        $virtualNetworks | ForEach-Object {
            [pscustomobject]@{
                name           = [string] (Get-ScoutProp $_ 'name')
                resourceGroup  = [string] (Get-ScoutProp $_ 'resourceGroup')
                subscriptionId = [string] (Get-ScoutProp $_ 'subscriptionId')
                peeringCount   = Measure-ScoutArray (Get-ScoutProp $_ 'properties.virtualNetworkPeerings')
                ddosEnabled    = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.enableDdosProtection')
            }
        }
    )

    # mv-expand over properties.subnets. Azure reserves 5 addresses per subnet.
    $result['subnets'] = @(
        foreach ($vnet in $virtualNetworks) {
            foreach ($subnet in @(Get-ScoutProp $vnet 'properties.subnets')) {
                if ($null -eq $subnet) { continue }
                $prefix = [string] (Get-ScoutProp $subnet 'properties.addressPrefix')
                $total = 0
                $parts = $prefix -split '/'
                if ($parts.Count -eq 2) {
                    $maskBits = 0
                    if ([int]::TryParse($parts[1], [ref] $maskBits) -and $maskBits -ge 0 -and $maskBits -le 32) {
                        $total = [int] ([Math]::Pow(2, 32 - $maskBits)) - 5
                    }
                }
                $used = Measure-ScoutArray (Get-ScoutProp $subnet 'properties.ipConfigurations')
                $usedCount = if ($null -eq $used) { 0 } else { $used }
                [pscustomobject]@{
                    vnet             = [string] (Get-ScoutProp $vnet 'name')
                    subnet           = [string] (Get-ScoutProp $subnet 'name')
                    prefix           = $prefix
                    total            = $total
                    used             = $used
                    ipUtilizationPct = if ($total -gt 0) { [Math]::Round(([double] $usedCount / $total) * 100, 1) } else { [double] 0 }
                }
            }
        }
    )

    $result['azureFirewalls'] = @(
        Select-ByType 'microsoft.network/azurefirewalls' | ForEach-Object {
            [pscustomobject]@{
                name           = [string] (Get-ScoutProp $_ 'name')
                resourceGroup  = [string] (Get-ScoutProp $_ 'resourceGroup')
                subscriptionId = [string] (Get-ScoutProp $_ 'subscriptionId')
                sku            = [string] (Get-ScoutProp $_ 'properties.sku.name')
            }
        }
    )

    # policyName = split(id, "/")[8] in KQL — the parent firewall-policy segment.
    $result['firewallPolicyRuleGroups'] = @(
        Select-ByType 'microsoft.network/firewallpolicies/rulecollectiongroups' | ForEach-Object {
            $idSegments = ([string] (Get-ScoutProp $_ 'id')) -split '/'
            $priorityRaw = Get-ScoutProp $_ 'properties.priority'
            [pscustomobject]@{
                name            = [string] (Get-ScoutProp $_ 'name')
                resourceGroup   = [string] (Get-ScoutProp $_ 'resourceGroup')
                subscriptionId  = [string] (Get-ScoutProp $_ 'subscriptionId')
                policyName      = if ($idSegments.Count -gt 8) { $idSegments[8] } else { $null }
                priority        = if ($null -eq $priorityRaw) { $null } else { [int] $priorityRaw }
                ruleCollections = Get-ScoutProp $_ 'properties.ruleCollections'
            }
        }
    )

    $result['vpnGateways'] = @(
        Select-ByType 'microsoft.network/virtualnetworkgateways' | ForEach-Object {
            [pscustomobject]@{
                name          = [string] (Get-ScoutProp $_ 'name')
                resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
                gatewayType   = [string] (Get-ScoutProp $_ 'properties.gatewayType')
                sku           = [string] (Get-ScoutProp $_ 'properties.sku.name')
                activeActive  = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.activeActive')
                bgp           = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.enableBgp')
            }
        }
    )

    # coalesce(privateLinkServiceConnections[0], manualPrivateLinkServiceConnections[0]) then
    # split the target resource id: [6] = provider, [7] = type.
    $result['privateEndpoints'] = @(
        Select-ByType 'microsoft.network/privateendpoints' | ForEach-Object {
            $connection = @(Get-ScoutProp $_ 'properties.privateLinkServiceConnections')[0]
            if ($null -eq $connection) {
                $connection = @(Get-ScoutProp $_ 'properties.manualPrivateLinkServiceConnections')[0]
            }
            $targetResourceId = [string] (Get-ScoutProp $connection 'properties.privateLinkServiceId')
            $targetSegments = $targetResourceId -split '/'
            [pscustomobject]@{
                name             = [string] (Get-ScoutProp $_ 'name')
                resourceGroup    = [string] (Get-ScoutProp $_ 'resourceGroup')
                subscriptionId   = [string] (Get-ScoutProp $_ 'subscriptionId')
                targetResourceId = $targetResourceId
                targetProvider   = if ($targetSegments.Count -gt 6) { $targetSegments[6].ToLowerInvariant() } else { '' }
                targetType       = if ($targetSegments.Count -gt 7) { $targetSegments[7].ToLowerInvariant() } else { '' }
            }
        }
    )

    $result['privateDnsZones'] = @(
        Select-ByType 'microsoft.network/privatednszones' | ForEach-Object {
            [pscustomobject]@{
                name           = [string] (Get-ScoutProp $_ 'name')
                resourceGroup  = [string] (Get-ScoutProp $_ 'resourceGroup')
                subscriptionId = [string] (Get-ScoutProp $_ 'subscriptionId')
            }
        }
    )

    # mv-expand securityRules, keep Allow + Inbound from an internet-equivalent source.
    $openSources = @('*', '0.0.0.0/0', 'Internet')
    $result['nsgPublicInbound'] = @(
        foreach ($nsg in (Select-ByType 'microsoft.network/networksecuritygroups')) {
            foreach ($rule in @(Get-ScoutProp $nsg 'properties.securityRules')) {
                if ($null -eq $rule) { continue }
                $access = [string] (Get-ScoutProp $rule 'properties.access')
                $direction = [string] (Get-ScoutProp $rule 'properties.direction')
                $source = [string] (Get-ScoutProp $rule 'properties.sourceAddressPrefix')
                if ($access -ine 'Allow' -or $direction -ine 'Inbound') { continue }
                if ($openSources -notcontains $source) { continue }
                [pscustomobject]@{
                    nsg           = [string] (Get-ScoutProp $nsg 'name')
                    resourceGroup = [string] (Get-ScoutProp $nsg 'resourceGroup')
                    rule          = [string] (Get-ScoutProp $rule 'name')
                    port          = [string] (Get-ScoutProp $rule 'properties.destinationPortRange')
                }
            }
        }
    )

    # ---- compute ----
    # Region list copied verbatim from the KQL zoneEligible projection.
    $zoneEligibleRegions = @(
        'eastus', 'eastus2', 'westus2', 'westus3', 'centralus', 'southcentralus',
        'northeurope', 'westeurope', 'uksouth', 'francecentral', 'germanywestcentral',
        'switzerlandnorth', 'norwayeast', 'swedencentral', 'southeastasia', 'eastasia',
        'australiaeast', 'japaneast', 'koreacentral', 'centralindia', 'canadacentral',
        'brazilsouth', 'uaenorth', 'southafricanorth'
    )
    $result['virtualMachines'] = @(
        Select-ByType 'microsoft.compute/virtualmachines' | ForEach-Object {
            $zoneCount = Measure-ScoutArray (Get-ScoutProp $_ 'zones')
            [pscustomobject]@{
                name           = [string] (Get-ScoutProp $_ 'name')
                resourceGroup  = [string] (Get-ScoutProp $_ 'resourceGroup')
                subscriptionId = [string] (Get-ScoutProp $_ 'subscriptionId')
                zoneRedundant  = ($null -ne $zoneCount -and $zoneCount -gt 0)
                zoneEligible   = $zoneEligibleRegions -contains ([string] (Get-ScoutProp $_ 'location')).ToLowerInvariant()
                size           = [string] (Get-ScoutProp $_ 'properties.hardwareProfile.vmSize')
            }
        }
    )

    $result['orphanedDisks'] = @(
        Select-ByType 'microsoft.compute/disks' |
            Where-Object { ([string] (Get-ScoutProp $_ 'properties.diskState')) -ieq 'Unattached' } |
            ForEach-Object {
                $sizeRaw = Get-ScoutProp $_ 'properties.diskSizeGB'
                [pscustomobject]@{
                    name          = [string] (Get-ScoutProp $_ 'name')
                    resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
                    location      = [string] (Get-ScoutProp $_ 'location')
                    sku           = [string] (Get-ScoutProp $_ 'sku.name')
                    sizeGb        = if ($null -eq $sizeRaw) { $null } else { [int] $sizeRaw }
                }
            }
    )

    $result['orphanedPips'] = @(
        Select-ByType 'microsoft.network/publicipaddresses' |
            Where-Object { $null -eq (Get-ScoutProp $_ 'properties.ipConfiguration') } |
            ForEach-Object {
                [pscustomobject]@{
                    name          = [string] (Get-ScoutProp $_ 'name')
                    resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
                    location      = [string] (Get-ScoutProp $_ 'location')
                    sku           = [string] (Get-ScoutProp $_ 'sku.name')
                }
            }
    )

    # ---- ops posture: summarize total/withDiag by type ----
    $result['diagnosticCoverage'] = @(
        $rows | Group-Object { [string] (Get-ScoutProp $_ 'type') } | ForEach-Object {
            $total = $_.Count
            $withDiag = @($_.Group | Where-Object { $null -ne (Get-ScoutProp $_ 'properties.diagnosticSettings') }).Count
            [pscustomobject]@{
                type        = $_.Name
                total       = $total
                withDiag    = $withDiag
                coveragePct = if ($total -gt 0) { [Math]::Round(([double] $withDiag / $total) * 100, 1) } else { [double] 0 }
            }
        }
    )

    # ---- per-domain scalars ----
    $result['storageAccounts'] = @(
        Select-ByType 'microsoft.storage/storageaccounts' | ForEach-Object {
            [pscustomobject]@{
                name               = [string] (Get-ScoutProp $_ 'name')
                resourceGroup      = [string] (Get-ScoutProp $_ 'resourceGroup')
                sku                = [string] (Get-ScoutProp $_ 'sku.name')
                publicAccess       = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.allowBlobPublicAccess')
                httpsOnly          = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.supportsHttpsTrafficOnly')
                minTls             = [string] (Get-ScoutProp $_ 'properties.minimumTlsVersion')
                networkDefaultDeny = ([string] (Get-ScoutProp $_ 'properties.networkAcls.defaultAction')) -ieq 'Deny'
            }
        }
    )

    $result['sqlDatabases'] = @(
        Select-ByType 'microsoft.sql/servers/databases' | ForEach-Object {
            [pscustomobject]@{
                name          = [string] (Get-ScoutProp $_ 'name')
                resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
                zoneRedundant = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.zoneRedundant')
                tier          = [string] (Get-ScoutProp $_ 'sku.tier')
            }
        }
    )

    $result['sqlServers'] = @(
        Select-ByType 'microsoft.sql/servers' | ForEach-Object {
            [pscustomobject]@{
                name                = [string] (Get-ScoutProp $_ 'name')
                resourceGroup       = [string] (Get-ScoutProp $_ 'resourceGroup')
                publicNetworkAccess = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
            }
        }
    )

    $result['webApps'] = @(
        Select-ByType 'microsoft.web/sites' | ForEach-Object {
            $sslStates = Measure-ScoutArray (Get-ScoutProp $_ 'properties.hostNameSslStates')
            [pscustomobject]@{
                name              = [string] (Get-ScoutProp $_ 'name')
                resourceGroup     = [string] (Get-ScoutProp $_ 'resourceGroup')
                httpsOnly         = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.httpsOnly')
                minTls            = [string] (Get-ScoutProp $_ 'properties.siteConfig.minTlsVersion')
                vnetIntegrated    = -not [string]::IsNullOrEmpty([string] (Get-ScoutProp $_ 'properties.virtualNetworkSubnetId'))
                customDomainBound = ($null -ne $sslStates -and $sslStates -gt 1)
            }
        }
    )

    # agentPoolProfiles: mv-expand + summarize back to one row per cluster.
    $result['aksClusters'] = @(
        Select-ByType 'microsoft.containerservice/managedclusters' | ForEach-Object {
            $pools = @(Get-ScoutProp $_ 'properties.agentPoolProfiles')
            $totalPools = $pools.Count
            $zonedPools = @($pools | Where-Object {
                    $zones = Measure-ScoutArray (Get-ScoutProp $_ 'availabilityZones')
                    $null -ne $zones -and $zones -gt 0
                }).Count
            [pscustomobject]@{
                name                 = [string] (Get-ScoutProp $_ 'name')
                resourceGroup        = [string] (Get-ScoutProp $_ 'resourceGroup')
                k8sVersion           = [string] (Get-ScoutProp $_ 'properties.kubernetesVersion')
                privateCluster       = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.apiServerAccessProfile.enablePrivateCluster')
                rbacEnabled          = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.enableRBAC')
                networkPolicyEnabled = -not [string]::IsNullOrEmpty([string] (Get-ScoutProp $_ 'properties.networkProfile.networkPolicy'))
                aadIntegrated        = ($null -ne (Get-ScoutProp $_ 'properties.aadProfile'))
                allPoolsZoned        = ($zonedPools -eq $totalPools)
            }
        }
    )

    $result['containerRegistries'] = @(
        Select-ByType 'microsoft.containerregistry/registries' | ForEach-Object {
            [pscustomobject]@{
                name          = [string] (Get-ScoutProp $_ 'name')
                resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
                sku           = [string] (Get-ScoutProp $_ 'sku.name')
                adminEnabled  = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.adminUserEnabled')
                publicAccess  = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
            }
        }
    )

    $result['keyVaults'] = @(
        Select-ByType 'microsoft.keyvault/vaults' | ForEach-Object {
            [pscustomobject]@{
                name            = [string] (Get-ScoutProp $_ 'name')
                resourceGroup   = [string] (Get-ScoutProp $_ 'resourceGroup')
                softDelete      = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.enableSoftDelete')
                purgeProtection = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.enablePurgeProtection')
            }
        }
    )

    $result['cognitiveAccounts'] = @(
        Select-ByType 'microsoft.cognitiveservices/accounts' | ForEach-Object {
            [pscustomobject]@{
                name          = [string] (Get-ScoutProp $_ 'name')
                resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
                accountKind   = [string] (Get-ScoutProp $_ 'kind')
                publicAccess  = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
                identityType  = [string] (Get-ScoutProp $_ 'identity.type')
                cmkEnabled    = ([string] (Get-ScoutProp $_ 'properties.encryption.keySource')) -ieq 'Microsoft.KeyVault'
            }
        }
    )

    $result['arcServers'] = @(
        Select-ByType 'microsoft.hybridcompute/machines' | ForEach-Object {
            [pscustomobject]@{
                name          = [string] (Get-ScoutProp $_ 'name')
                resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
                status        = [string] (Get-ScoutProp $_ 'properties.status')
                agentVersion  = [string] (Get-ScoutProp $_ 'properties.agentVersion')
            }
        }
    )

    # machineId = split(id, "/extensions/")[0]
    $result['arcExtensions'] = @(
        Select-ByType 'microsoft.hybridcompute/machines/extensions' | ForEach-Object {
            $id = [string] (Get-ScoutProp $_ 'id')
            [pscustomobject]@{
                machineId     = ($id -split '/extensions/')[0]
                name          = [string] (Get-ScoutProp $_ 'name')
                extensionType = [string] (Get-ScoutProp $_ 'properties.type')
                resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
            }
        }
    )

    $result['azureLocalClusters'] = @(
        Select-ByType 'microsoft.azurestackhci/clusters' | ForEach-Object {
            [pscustomobject]@{
                name               = [string] (Get-ScoutProp $_ 'name')
                resourceGroup      = [string] (Get-ScoutProp $_ 'resourceGroup')
                connectivityStatus = [string] (Get-ScoutProp $_ 'properties.connectivityStatus')
            }
        }
    )

    $result['eventHubNamespaces'] = @(
        Select-ByType 'microsoft.eventhub/namespaces' | ForEach-Object {
            [pscustomobject]@{
                name               = [string] (Get-ScoutProp $_ 'name')
                resourceGroup      = [string] (Get-ScoutProp $_ 'resourceGroup')
                zoneRedundant      = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.zoneRedundant')
                publicAccess       = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
                autoInflateEnabled = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.isAutoInflateEnabled')
            }
        }
    )

    $result['apiManagement'] = @(
        Select-ByType 'microsoft.apimanagement/service' | ForEach-Object {
            [pscustomobject]@{
                name               = [string] (Get-ScoutProp $_ 'name')
                resourceGroup      = [string] (Get-ScoutProp $_ 'resourceGroup')
                sku                = [string] (Get-ScoutProp $_ 'sku.name')
                virtualNetworkType = [string] (Get-ScoutProp $_ 'properties.virtualNetworkType')
            }
        }
    )

    $result['serviceBusNamespaces'] = @(
        Select-ByType 'microsoft.servicebus/namespaces' | ForEach-Object {
            [pscustomobject]@{
                name          = [string] (Get-ScoutProp $_ 'name')
                resourceGroup = [string] (Get-ScoutProp $_ 'resourceGroup')
                publicAccess  = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
            }
        }
    )

    $result['iotHubs'] = @(
        Select-ByType 'microsoft.devices/iothubs' | ForEach-Object {
            [pscustomobject]@{
                name             = [string] (Get-ScoutProp $_ 'name')
                resourceGroup    = [string] (Get-ScoutProp $_ 'resourceGroup')
                sku              = [string] (Get-ScoutProp $_ 'sku.name')
                publicAccess     = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
                disableLocalAuth = ConvertTo-ScoutBool (Get-ScoutProp $_ 'properties.disableLocalAuth')
            }
        }
    )

    # linkedHubCount is a COUNT only — the array's connectionString entries are secrets and are
    # never read or projected here, matching the KQL.
    $result['dpsInstances'] = @(
        Select-ByType 'microsoft.devices/provisioningservices' | ForEach-Object {
            [pscustomobject]@{
                name             = [string] (Get-ScoutProp $_ 'name')
                resourceGroup    = [string] (Get-ScoutProp $_ 'resourceGroup')
                sku              = [string] (Get-ScoutProp $_ 'sku.name')
                publicAccess     = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
                allocationPolicy = [string] (Get-ScoutProp $_ 'properties.allocationPolicy')
                linkedHubCount   = Measure-ScoutArray (Get-ScoutProp $_ 'properties.iotHubs')
            }
        }
    )

    $result['digitalTwinsInstances'] = @(
        Select-ByType 'microsoft.digitaltwins/digitaltwinsinstances' | ForEach-Object {
            [pscustomobject]@{
                name                          = [string] (Get-ScoutProp $_ 'name')
                resourceGroup                 = [string] (Get-ScoutProp $_ 'resourceGroup')
                publicAccess                  = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
                privateEndpointConnectionCount = Measure-ScoutArray (Get-ScoutProp $_ 'properties.privateEndpointConnections')
                identityType                  = [string] (Get-ScoutProp $_ 'identity.type')
            }
        }
    )

    $result['synapseWorkspaces'] = @(
        Select-ByType 'microsoft.synapse/workspaces' | ForEach-Object {
            [pscustomobject]@{
                name              = [string] (Get-ScoutProp $_ 'name')
                resourceGroup     = [string] (Get-ScoutProp $_ 'resourceGroup')
                publicAccess      = [string] (Get-ScoutProp $_ 'properties.publicNetworkAccess')
                managedVnetEnabled = -not [string]::IsNullOrEmpty([string] (Get-ScoutProp $_ 'properties.managedVirtualNetwork'))
            }
        }
    )

    $result['purviewAccounts'] = @(
        Select-ByType 'microsoft.purview/accounts' | ForEach-Object {
            [pscustomobject]@{
                name           = [string] (Get-ScoutProp $_ 'name')
                resourceGroup  = [string] (Get-ScoutProp $_ 'resourceGroup')
                subscriptionId = [string] (Get-ScoutProp $_ 'subscriptionId')
            }
        }
    )

    $result['logAnalyticsWorkspaces'] = @(
        Select-ByType 'microsoft.operationalinsights/workspaces' | ForEach-Object {
            $retention = Get-ScoutProp $_ 'properties.retentionInDays'
            [pscustomobject]@{
                name            = [string] (Get-ScoutProp $_ 'name')
                resourceGroup   = [string] (Get-ScoutProp $_ 'resourceGroup')
                retentionInDays = if ($null -eq $retention) { $null } else { [int] $retention }
            }
        }
    )

    return $result
}