src/ingest/Invoke-ArgQueryPack.ps1

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

<#
.SYNOPSIS
    Run a read-only Azure Resource Graph query pack and normalize results into
    the collect shapes the rules expect.
 
.NOTES
    Tracks ADO Story AB#5039. `-ManagementGroupId`, when supplied, is passed as
    `-ManagementGroup` to every `Search-AzGraph` call so this ingest is scoped the
    same way `Invoke-Collect` is, instead of always running tenant-wide.
#>

function Invoke-ArgQueryPack {
    param($Collect, [string] $ManagementGroupId)
    Import-Module Az.ResourceGraph -ErrorAction Stop

    $queries = @{
        subnetIpUsage = @'
resources
| where type =~ "microsoft.network/virtualnetworks"
| mv-expand subnet = properties.subnets
| extend prefix = tostring(subnet.properties.addressPrefix)
| extend total = toint(exp2(32 - toint(split(prefix,"/")[1]))) - 5
| extend used = array_length(subnet.properties.ipConfigurations)
| project vnet = name, subnet = tostring(subnet.name), prefix, total, used,
          ipUtilizationPct = round(todouble(used) / total * 100, 1)
'@

        orphanedDisks = @'
resources
| where type =~ "microsoft.compute/disks" and properties.diskState =~ "Unattached"
| project name, resourceGroup, location, sku = sku.name, sizeGb = properties.diskSizeGB
'@

        orphanedPips = @'
resources
| where type =~ "microsoft.network/publicipaddresses" and isnull(properties.ipConfiguration)
| project name, resourceGroup, location, sku = sku.name
'@

        diagCoverage = @'
resources
| extend hasDiag = iff(isnotnull(properties.diagnosticSettings), true, false)
| summarize total = count(), withDiag = countif(hasDiag) by type
| extend coveragePct = round(todouble(withDiag)/total*100,1)
'@

        publicExposure = @'
resources
| where type =~ "microsoft.network/networksecuritygroups"
| mv-expand rule = properties.securityRules
| where rule.properties.access =~ "Allow" and rule.properties.direction =~ "Inbound"
      and (rule.properties.sourceAddressPrefix in ("*","0.0.0.0/0","Internet"))
| project nsg = name, resourceGroup, rule = tostring(rule.name),
          port = tostring(rule.properties.destinationPortRange)
'@

        nonZonalVms = @'
resources
| where type =~ "microsoft.compute/virtualmachines"
| extend zones = properties.zones
| where isnull(zones) or array_length(zones) == 0
| project name, resourceGroup, location, size = properties.hardwareProfile.vmSize
'@

    }

    $arg = [ordered]@{}
    foreach ($k in $queries.Keys) {
        $rows = @(); $skip = 0
        do {
            # Search-AzGraph rejects -Skip 0 (ValidateRange minimum is 1) — omit it on the first page.
            $params = @{ Query = $queries[$k]; First = 1000 }
            if ($skip -gt 0) { $params.Skip = $skip }
            if ($ManagementGroupId) { $params.ManagementGroup = $ManagementGroupId }
            $batch = @(Search-AzGraph @params)
            $rows += $batch; $skip += 1000
        } while ($batch.Count -eq 1000)
        $arg[$k] = $rows
    }

    # normalize into the shapes the rules expect.
    # Invoke-Collect already populated `networking` (virtualNetworks, azureFirewalls,
    # vpnGateways, privateEndpoints, privateDnsZones, ...). MERGE subnets +
    # nsgPublicInbound onto that existing object instead of replacing it — a `-Force`
    # replace here wiped the collector's networking data, so CAF-SEC-03/CAF-SEC-06/
    # CAF-NET-* rules saw empty inputs and false-failed.
    if (-not $Collect.PSObject.Properties['networking']) {
        $Collect | Add-Member -NotePropertyName networking -NotePropertyValue ([pscustomobject]@{}) -Force
    }
    $Collect.networking | Add-Member -NotePropertyName subnets          -NotePropertyValue $arg.subnetIpUsage  -Force
    $Collect.networking | Add-Member -NotePropertyName nsgPublicInbound -NotePropertyValue $arg.publicExposure -Force
    $Collect | Add-Member -NotePropertyName costCleanup -NotePropertyValue ([pscustomobject]@{
        orphanedDisks = $arg.orphanedDisks
        orphanedPips  = $arg.orphanedPips
    }) -Force
    $Collect | Add-Member -NotePropertyName opsPosture -NotePropertyValue ([pscustomobject]@{
        diagnosticCoverage = $arg.diagCoverage
    }) -Force
    return $Collect
}