src/collect/Invoke-Collect.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Produce the normalized collect.json the assessment rules evaluate against. .DESCRIPTION Resolves the discovery -> assessment data-shape gap (AB#5081, AB#5082). The existing inventory modules emit flat, Excel-oriented rows; the rule engine needs a nested object with SCALAR fields (so rule JSONPath never has to use `.length` inside a filter, which Newtonsoft does not support — AB#5083). This adapter runs read-only Azure Resource Graph queries and shapes the result into the canonical contract below. Ingestors (AzGovViz, Advisor) enrich `governance` / `advisor` afterward. Canonical shape (top-level keys the rules query): subscriptions[], tags[] networking { virtualNetworks[{name,peeringCount,ddosEnabled}], subnets[{ipUtilizationPct}], azureFirewalls[], firewallPolicyRuleGroups[{policyName,priority,ruleCollectionCount,ruleCount,parseError}], nsgPublicInbound[], privateDnsZones[], vpnGateways[], privateEndpoints[{targetResourceId,targetProvider,targetType}] } compute { virtualMachines[{name,zoneRedundant,zoneEligible}] } management { recoveryVaults[{backupItems[]}], deployments[], logAnalyticsWorkspaces[{retentionInDays}] } security { defenderPlans[] } governance { managementGroups[], policyAssignments[], roleAssignments[], budgets[], resourceLocks[], pimEligibility[], classicAdministrators[] } (filled by the native Governance ingestor, Import-Governance) costCleanup { orphanedDisks[], orphanedPips[] } opsPosture { diagnosticCoverage[{type,coveragePct}] } domains { storage{storageAccounts[{networkDefaultDeny}]}, databases{sqlServers[],sqlDatabases[],sqlDefenderPricing[{pricingTier}]}, web{webApps[{vnetIntegrated,customDomainBound}]}, containers{aksClusters[{networkPolicyEnabled,aadIntegrated,allPoolsZoned}], containerRegistries[]}, security{keyVaults[]}, ai{cognitiveAccounts[{identityType,cmkEnabled}]}, hybrid{arcServers[], arcExtensions[{machineId,extensionType}], azureLocalClusters[{connectivityStatus}]}, integration{eventHubNamespaces[{autoInflateEnabled}], apiManagement[], serviceBusNamespaces[{publicAccess}]}, iot{iotHubs[{disableLocalAuth}], dpsInstances[{publicAccess,allocationPolicy,linkedHubCount}], digitalTwinsInstances[{publicAccess,privateEndpointConnectionCount,identityType}]}, analytics{synapseWorkspaces[{managedVnetEnabled}], purviewAccounts[]} } (per-category scalars, Epic AB#5056) advisor[] (filled by ingest) Read-only throughout. .NOTES Tracks ADO AB#5081, AB#5082 (Feature AB#5024). Every scalar the rules need (peeringCount, zoneRedundant, ipUtilizationPct, coveragePct) is computed here in KQL so no rule has to compute array lengths at evaluation time. AB#5057 follow-up: extended per-domain collectors so previously-manual CAF/WAF rules can assert against real ARG scalars instead of a human review. Every new field below was verified against a documented ARM property (or, for Microsoft.AzureStackHCI/clusters, the SDK-level enum) via Microsoft Learn before being added — sub-resources that Resource Graph does not index (SQL firewall rules/auditing/TDE, storage blob-service/management-policy settings, Synapse firewall rules, DPS enrollment groups) were deliberately left out; those rules stay manual. AB#5068/5071/5075 follow-up (Databases/Analytics/IoT rule-depth pass): added `sqlDefenderPricing` (Microsoft.Security/pricings, "SqlServers" plan — queried from the `SecurityResources` ARG table, not the default `Resources` table), `purviewAccounts` (Microsoft.Purview/accounts), and `iotHubs.disableLocalAuth` (documented IotHubProperties field). Each was confirmed present in the Azure Resource Graph supported-tables-and-resource-types reference before being added. Still deliberately NOT ARG-collectable (confirmed absent from that same reference) and left manual: SQL TDE/auditing/firewall-rules child resources, Data Factory/Synapse pipeline linked-service definitions, Synapse workspace firewall rules, DPS enrollment groups, per-device IoT credential type, and any metric- or Monitor-backed signal (IoT Hub throughput right-sizing). AB#330 follow-up (IoT deep coverage: Device Provisioning Service, Digital Twins, Edge): added `domains.iot.dpsInstances` (Microsoft.Devices/ provisioningServices — confirmed ARG-indexed, entry 473 in the ARG supported-tables-and-resource-types reference) projecting `publicAccess`, `allocationPolicy` (documented IotDpsPropertiesDescription fields) and `linkedHubCount` (`array_length(properties.iotHubs)` — a COUNT only; the array's `connectionString` entries are secrets and are never projected), and `domains.iot.digitalTwinsInstances` (Microsoft.DigitalTwins/ digitalTwinsInstances — confirmed ARG-indexed, entry 491 in that same reference) projecting `publicAccess`, `privateEndpointConnectionCount` (`array_length(properties.privateEndpointConnections)`, a top-level resource property, not a sub-resource join) and `identityType` (`identity` is a top-level Resource Graph column, same pattern as `cognitiveAccounts.identityType`). `networking.privateEndpoints` needed no change — its `targetProvider`/`targetType` projection is already type-agnostic, so PEs pointed at either new resource type are picked up by the existing query. Deliberately NOT collected here, confirmed absent/out of scope after checking the ARM template references before writing this note: - DPS enrollment groups (individualEnrollments/enrollmentGroups) — a data-plane child store, not a distinct ARM resource type Resource Graph indexes (unchanged from the AB#5057 finding; CAF-IOT-03 stays manual). - `Microsoft.DigitalTwins/digitalTwinsInstances/endpoints` (the EventGrid/EventHub/ServiceBus routing-endpoint child resource) — listed in the Microsoft.DigitalTwins resource-type/version reference as its own ARM resource type, but it does NOT appear in the Resource Graph supported-tables-and-resource-types reference (only the parent `digitalTwinsInstances` type does), so it is not ARG-queryable; stays manual. - An "IoT Edge-enabled hub" indicator — checked the full IotHubProperties template reference and found NO Edge-specific field on Microsoft.Devices/IotHubs itself. Edge is a per-device concept (`capabilities.iotEdge` on a device twin, in the Registry Manager / Device Twin data plane), not a hub-level ARM property, so there is genuinely nothing honest to add here — not even a coarse/existence signal. Left as a documented manual rule (CAF-IOT-12) rather than inventing a proxy field. Parameter wiring (this pass): - `-ManagementGroupId`, when supplied, is passed as `-ManagementGroup` to every `Search-AzGraph` call so Collect is actually scoped, not tenant-wide. Omitted entirely when not supplied (preserves current tenant-wide behavior). - `-Categories` now really filters which ARG queries run. Each query below is tagged in `$queryCategories` with the manifest `Collect` category name(s) whose rule files reference its output path (cross-checked against every `src/assess/rules/*.yaml` `query:` field) — including cross-domain references (e.g. `waf.security` needs `domains.databases.sqlServers`, so `sqlServers` is tagged both `Databases` and `Security`). `subscriptions` is always collected (base data every rule set / the canonical shape needs). A `'*'` entry, an empty list, or omitting `-Categories` runs every query (unchanged full-collect behavior). Collector/pipeline resilience (AB#397/398/399/400/401): - `subscriptions` is always collected FIRST (regardless of hashtable enumeration order) so its subscription-id list is available as a fallback scope for every other query. - AB#397: if a tenant/MG-wide batch query throws (DNS blip, expired token, any other transient ARG/ARM error), it is retried ONE SUBSCRIPTION AT A TIME using the subscription list above. A subscription that still fails on retry logs a warning naming that subscription and is skipped — the OTHER subscriptions' rows for that same query are still collected and returned. Only a query that fails with no subscription list available at all (i.e. the `subscriptions` query itself failed) degrades to an empty result for that query, same as before this change. - AB#398: an `AuthorizationFailed` error on a query scoped to `-ManagementGroupId` gets a specific, actionable warning naming the management-group Reader-role requirement, instead of a bare ARG exception. - AB#399: a small set of documented, known-false ARG/ARM errors (a resource provider reported as "not registered" on a subscription that Resource Graph can still query successfully) are logged at Verbose and treated as non-errors — no per-subscription retry, no warning noise. - AB#400: `firewallPolicyRuleGroups`' nested rule-collection/rule structure is walked in PowerShell (not KQL) with a try/catch PER GROUP — one malformed group is recorded with `parseError` set and the run continues; it never blanks out the other, well-formed rule-collection groups in the same run. - AB#401: if literally every collected query returns zero rows, a diagnostic warning is emitted suggesting the likely causes (missing Reader RBAC, wrong subscription/tenant context, an empty/inaccessible `-ManagementGroupId`, or a `-Categories` filter that matched nothing) instead of silently handing an empty dataset to the report layer. - AB#405: when `Write-ScoutProgress` (src/Write-ScoutProgress.ps1) is loaded in the calling session, each query reports phase progress through it. The call is guarded by `Get-Command` so Collect has zero hard dependency on that helper — a session that never loaded it behaves exactly as before. #> function Invoke-Collect { [CmdletBinding()] param( [string[]] $Categories = @('*'), [ValidateSet('All', 'ArmOnly', 'EntraOnly')] [string] $Scope = 'All', [string] $ManagementGroupId ) Import-Module Az.ResourceGraph -ErrorAction Stop # ---- read-only KQL producing SCALAR fields the rules filter on ---- $q = @{ subscriptions = @' resourcecontainers | where type =~ "microsoft.resources/subscriptions" | project id = subscriptionId, name, state = tostring(properties.state), tags '@ virtualNetworks = @' resources | where type =~ "microsoft.network/virtualnetworks" | extend peeringCount = array_length(properties.virtualNetworkPeerings) | extend ddosEnabled = tobool(properties.enableDdosProtection) | project name, resourceGroup, subscriptionId, peeringCount, ddosEnabled '@ subnets = @' 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 = iff(total > 0, round(todouble(used) / total * 100, 1), todouble(0)) '@ azureFirewalls = @' resources | where type =~ "microsoft.network/azurefirewalls" | project name, resourceGroup, subscriptionId, sku = tostring(properties.sku.name) '@ # AB#400: firewall policy rule-collection groups. The nested rule-collection/rule # array is intentionally left as a dynamic column here (`ruleCollections`) rather # than walked with KQL mv-expand — the PowerShell-side parse below handles a # malformed/unexpected group's shape per-group instead of per-run (see NOTES). firewallPolicyRuleGroups = @' resources | where type =~ "microsoft.network/firewallpolicies/rulecollectiongroups" | extend policyName = tostring(split(id, "/")[8]) | project name, resourceGroup, subscriptionId, policyName, priority = toint(properties.priority), ruleCollections = properties.ruleCollections '@ vpnGateways = @' resources | where type =~ "microsoft.network/virtualnetworkgateways" | project name, resourceGroup, gatewayType = tostring(properties.gatewayType), sku = tostring(properties.sku.name), activeActive = tobool(properties.activeActive), bgp = tobool(properties.enableBgp) '@ privateEndpoints = @' resources | where type =~ "microsoft.network/privateendpoints" // AB#5057: expose the linked-service target so per-domain rules (CAF-AI-04, // CAF-IOT-05, ...) can test "does this account/hub have a dedicated PE" without // a full cross-table join. ARG mv-expand only supports kind=bag|array (no outer), // so take the first connection instead — preserves endpoints with no connection // row, keeping the plain existence-count rules ($.networking.privateEndpoints[*]) // unaffected. PEs carry exactly one privateLinkServiceConnection in practice. | extend conn = coalesce(properties.privateLinkServiceConnections[0], properties.manualPrivateLinkServiceConnections[0]) | extend targetResourceId = tostring(conn.properties.privateLinkServiceId) | extend targetProvider = tolower(tostring(split(targetResourceId, "/")[6])) | extend targetType = tolower(tostring(split(targetResourceId, "/")[7])) | project name, resourceGroup, subscriptionId, targetResourceId, targetProvider, targetType '@ privateDnsZones = @' resources | where type =~ "microsoft.network/privatednszones" | project name, resourceGroup, subscriptionId '@ nsgPublicInbound = @' 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) '@ virtualMachines = @' resources | where type =~ "microsoft.compute/virtualmachines" | extend zoneCount = array_length(zones) | extend zoneRedundant = iff(zoneCount > 0, true, false) // Region-level AZ availability (documented, stable list) — a coarse but honest // zone-eligibility signal. SKU-level zone capability needs a join against the // resourceSkus table, deliberately deferred: it varies per region/SKU/quota // and would need periodic refresh to stay accurate (AB#5086). | extend zoneEligible = location in ( "eastus","eastus2","westus2","westus3","centralus","southcentralus", "northeurope","westeurope","uksouth","francecentral","germanywestcentral", "switzerlandnorth","norwayeast","swedencentral","southeastasia","eastasia", "australiaeast","japaneast","koreacentral","centralindia","canadacentral", "brazilsouth","uaenorth","southafricanorth" ) | project name, resourceGroup, subscriptionId, zoneRedundant, zoneEligible, size = tostring(properties.hardwareProfile.vmSize) '@ orphanedDisks = @' resources | where type =~ "microsoft.compute/disks" and properties.diskState =~ "Unattached" | project name, resourceGroup, location, sku = tostring(sku.name), sizeGb = toint(properties.diskSizeGB) '@ orphanedPips = @' resources | where type =~ "microsoft.network/publicipaddresses" and isnull(properties.ipConfiguration) | project name, resourceGroup, location, sku = tostring(sku.name) '@ diagnosticCoverage = @' resources | extend hasDiag = iff(isnotnull(properties.diagnosticSettings), true, false) | summarize total = count(), withDiag = countif(hasDiag) by type | extend coveragePct = iff(total > 0, round(todouble(withDiag)/total*100,1), todouble(0)) | project type, total, withDiag, coveragePct '@ deployments = @' resourcecontainers | where type =~ "microsoft.resources/subscriptions/resourcegroups" | project name, subscriptionId '@ storageAccounts = @' resources | where type =~ "microsoft.storage/storageaccounts" | extend publicAccess = tobool(properties.allowBlobPublicAccess) | extend httpsOnly = tobool(properties.supportsHttpsTrafficOnly) | extend minTls = tostring(properties.minimumTlsVersion) // properties.networkAcls is on the storage account resource itself (not a // sub-resource), so it's safe to project directly — CAF-STO-05 (AB#5057). | extend networkDefaultDeny = tostring(properties.networkAcls.defaultAction) =~ "Deny" | project name, resourceGroup, sku = tostring(sku.name), publicAccess, httpsOnly, minTls, networkDefaultDeny '@ sqlDatabases = @' resources | where type =~ "microsoft.sql/servers/databases" | extend zoneRedundant = tobool(properties.zoneRedundant) | project name, resourceGroup, zoneRedundant, tier = tostring(sku.tier) '@ sqlServers = @' resources | where type =~ "microsoft.sql/servers" | extend publicNetworkAccess = tostring(properties.publicNetworkAccess) | project name, resourceGroup, publicNetworkAccess '@ # AB#5068: Microsoft.Security/pricings ("SqlServers" plan) is the subscription-scoped # Defender for SQL / Advanced Threat Protection toggle and IS indexed by Resource # Graph — under the SecurityResources table, not the default Resources table # (confirmed via the ARG supported-tables-and-resource-types reference). This is a # coarse, subscription-wide signal (same tradeoff as security.defenderPlans / # CAF-SEC-02), not a per-server property — Microsoft.Sql/servers/auditingSettings and # .../extendedAuditingSettings (the "auditing" half of the old combined rule) are NOT # in that reference list, so they stay manual (CAF-DB-07). sqlDefenderPricing = @' SecurityResources | where type =~ "microsoft.security/pricings" and name =~ "sqlservers" | project subscriptionId, name, pricingTier = tostring(properties.pricingTier) '@ webApps = @' resources | where type =~ "microsoft.web/sites" | extend httpsOnly = tobool(properties.httpsOnly) | extend minTls = tostring(properties.siteConfig.minTlsVersion) // virtualNetworkSubnetId and hostNameSslStates are top-level site properties // (NOT part of the siteConfig summary object, which Resource Graph does not // fully expand) — CAF-WEB-04/05 (AB#5057). customDomainBound is a coarse // signal (more than the always-present *.azurewebsites.net entry), same // tradeoff as the VM zoneEligible heuristic below. | extend vnetIntegrated = isnotempty(tostring(properties.virtualNetworkSubnetId)) | extend customDomainBound = array_length(properties.hostNameSslStates) > 1 | project name, resourceGroup, httpsOnly, minTls, vnetIntegrated, customDomainBound '@ aksClusters = @' resources | where type =~ "microsoft.containerservice/managedclusters" | extend k8sVersion = tostring(properties.kubernetesVersion) | extend privateCluster = tobool(properties.apiServerAccessProfile.enablePrivateCluster) | extend rbacEnabled = tobool(properties.enableRBAC) | extend networkPolicyEnabled = isnotempty(tostring(properties.networkProfile.networkPolicy)) | extend aadIntegrated = isnotnull(properties.aadProfile) // agentPoolProfiles is an array, so per-pool zone spread needs mv-expand + // summarize back to one row per cluster — CAF-CON-05 (AB#5057). | mv-expand pool = properties.agentPoolProfiles | extend poolZoned = array_length(pool.availabilityZones) > 0 | summarize totalPools = count(), zonedPools = countif(poolZoned) by name, resourceGroup, k8sVersion, privateCluster, rbacEnabled, networkPolicyEnabled, aadIntegrated | extend allPoolsZoned = zonedPools == totalPools | project name, resourceGroup, k8sVersion, privateCluster, rbacEnabled, networkPolicyEnabled, aadIntegrated, allPoolsZoned '@ containerRegistries = @' resources | where type =~ "microsoft.containerregistry/registries" | extend adminEnabled = tobool(properties.adminUserEnabled) | extend publicAccess = tostring(properties.publicNetworkAccess) | project name, resourceGroup, sku = tostring(sku.name), adminEnabled, publicAccess '@ keyVaults = @' resources | where type =~ "microsoft.keyvault/vaults" | extend softDelete = tobool(properties.enableSoftDelete) | extend purgeProtection = tobool(properties.enablePurgeProtection) | project name, resourceGroup, softDelete, purgeProtection '@ cognitiveAccounts = @' resources | where type =~ "microsoft.cognitiveservices/accounts" | extend publicAccess = tostring(properties.publicNetworkAccess) // `kind` is a reserved Kusto keyword — it can be neither an extend target nor a // project alias; read the built-in column via ['kind'] and expose it as accountKind. | extend accountKind = tostring(['kind']) // `identity` is a top-level Resource Graph column (not under properties) — // CAF-AI-03 (AB#5057). properties.encryption.keySource is the standard // BYOK/CMK marker used across RPs (matches EventHub/Synapse) — CAF-AI-05. | extend identityType = tostring(identity.type) | extend cmkEnabled = tostring(properties.encryption.keySource) =~ "Microsoft.KeyVault" | project name, resourceGroup, accountKind, publicAccess, identityType, cmkEnabled '@ arcServers = @' resources | where type =~ "microsoft.hybridcompute/machines" | extend status = tostring(properties.status) | project name, resourceGroup, status, agentVersion = tostring(properties.agentVersion) '@ eventHubNamespaces = @' resources | where type =~ "microsoft.eventhub/namespaces" | extend zoneRedundant = tobool(properties.zoneRedundant) | extend publicAccess = tostring(properties.publicNetworkAccess) // The ARM property is isAutoInflateEnabled, not autoInflateEnabled — CAF-INT-05 (AB#5057). | extend autoInflateEnabled = tobool(properties.isAutoInflateEnabled) | project name, resourceGroup, zoneRedundant, publicAccess, autoInflateEnabled '@ apiManagement = @' resources | where type =~ "microsoft.apimanagement/service" | extend virtualNetworkType = tostring(properties.virtualNetworkType) | project name, resourceGroup, sku = tostring(sku.name), virtualNetworkType '@ serviceBusNamespaces = @' resources | where type =~ "microsoft.servicebus/namespaces" | extend publicAccess = tostring(properties.publicNetworkAccess) | project name, resourceGroup, publicAccess '@ iotHubs = @' resources | where type =~ "microsoft.devices/iothubs" | extend publicAccess = tostring(properties.publicNetworkAccess) // disableLocalAuth (documented IotHubProperties field) is true when SAS // tokens issued from the hub's own shared access policies are rejected, // forcing Microsoft Entra ID + RBAC for service-API access — CAF-IOT-06 // (AB#5075). This is a hub-level *service-API* auth control, distinct from // per-device X.509/TPM-vs-symmetric-key credential choice (CAF-IOT-02, still // manual — that lives in the device registry, a data-plane store Resource // Graph does not index). | extend disableLocalAuth = tobool(properties.disableLocalAuth) | project name, resourceGroup, sku = tostring(sku.name), publicAccess, disableLocalAuth '@ # AB#330: Microsoft.Devices/provisioningServices (Device Provisioning Service) IS # indexed by Resource Graph (confirmed via the ARG supported-tables-and-resource-types # reference, entry 473). allocationPolicy and publicNetworkAccess are documented # top-level IotDpsPropertiesDescription fields. linkedHubCount is a COUNT of the # linked-hub array only (array_length(properties.iotHubs)) -- each entry's # `connectionString` is a secret and is never read or projected here. DPS enrollment # groups (where the per-device cert-vs-symmetric-key auth type actually lives) remain # a data-plane child store Resource Graph does not index -- CAF-IOT-03 stays manual. dpsInstances = @' resources | where type =~ "microsoft.devices/provisioningservices" | extend publicAccess = tostring(properties.publicNetworkAccess) | extend allocationPolicy = tostring(properties.allocationPolicy) | extend linkedHubCount = array_length(properties.iotHubs) | project name, resourceGroup, sku = tostring(sku.name), publicAccess, allocationPolicy, linkedHubCount '@ # AB#330: Microsoft.DigitalTwins/digitalTwinsInstances IS indexed by Resource Graph # (confirmed via the ARG supported-tables-and-resource-types reference, entry 491). # publicNetworkAccess and privateEndpointConnections are documented top-level # properties on the digitalTwinsInstances resource itself (not a sub-resource join). # `identity` is a top-level Resource Graph column -- same pattern as # cognitiveAccounts.identityType (AB#5057) -- CAF-IOT-11. The message-routing # endpoints (Microsoft.DigitalTwins/digitalTwinsInstances/endpoints -- EventGrid/ # EventHub/ServiceBus routes) are a distinct ARM child resource type that does NOT # appear in that same ARG reference, so they are not ARG-queryable and stay manual. digitalTwinsInstances = @' resources | where type =~ "microsoft.digitaltwins/digitaltwinsinstances" | extend publicAccess = tostring(properties.publicNetworkAccess) | extend privateEndpointConnectionCount = array_length(properties.privateEndpointConnections) | extend identityType = tostring(identity.type) | project name, resourceGroup, publicAccess, privateEndpointConnectionCount, identityType '@ synapseWorkspaces = @' resources | where type =~ "microsoft.synapse/workspaces" | extend publicAccess = tostring(properties.publicNetworkAccess) // properties.managedVirtualNetwork is "default" when the workspace-managed // VNet is on, empty otherwise — a top-level workspace property, not a // sub-resource — CAF-ANL-03 (AB#5057). | extend managedVnetEnabled = isnotempty(tostring(properties.managedVirtualNetwork)) | project name, resourceGroup, publicAccess, managedVnetEnabled '@ # AB#5071: Microsoft.Purview/accounts IS indexed by Resource Graph (confirmed via the # ARG supported-tables-and-resource-types reference), unlike Data Factory/Synapse # pipeline linked services (CAF-ANL-04, still manual — linked services are a # data-plane sub-object of the factory/pipeline payload, not a distinct ARM child # resource) or Synapse workspace firewall rules (CAF-ANL-05, still manual — not in # that reference list either). Existence-only signal, same coarse tradeoff as the # VM zoneEligible / webApps customDomainBound heuristics above: a Purview account # existing is evidence a governance foundation is in place, not proof the whole # analytics estate is catalogued/classified — CAF-ANL-02. purviewAccounts = @' resources | where type =~ "microsoft.purview/accounts" | project name, resourceGroup, subscriptionId '@ arcExtensions = @' resources | where type =~ "microsoft.hybridcompute/machines/extensions" | extend extensionType = tostring(properties.type) | extend machineId = tostring(split(id, "/extensions/")[0]) | project machineId, name, extensionType, resourceGroup '@ azureLocalClusters = @' resources | where type =~ "microsoft.azurestackhci/clusters" // connectivityStatus is the documented cluster-resource health enum // (Connected/Disconnected/NotConnectedRecently/PartiallyConnected/ // NotYetRegistered/NotSpecified) — CAF-HYB-05 (AB#5057). | extend connectivityStatus = tostring(properties.connectivityStatus) | project name, resourceGroup, connectivityStatus '@ logAnalyticsWorkspaces = @' resources | where type =~ "microsoft.operationalinsights/workspaces" | extend retentionInDays = toint(properties.retentionInDays) | project name, resourceGroup, retentionInDays '@ } # ---- category tagging (AB#5057 follow-up) ---- # Which manifest `Collect` categories need each query's output, derived from # every rule file's `query:` JSONPath (see .NOTES). '*' means "always run" # regardless of the requested categories. $queryCategories = @{ subscriptions = @('*') virtualNetworks = @('Networking') subnets = @('Networking', 'Compute') azureFirewalls = @('Networking') firewallPolicyRuleGroups = @('Networking', 'Security') vpnGateways = @('Networking') # caf.security (CAF-SEC-03/06), caf.ai (CAF-AI-04), caf.iot (CAF-IOT-05) # all filter networking.privateEndpoints by target — those categories need # this query too, not just Networking. privateEndpoints = @('Networking', 'Security', 'AI', 'IoT') privateDnsZones = @('Networking', 'Security') nsgPublicInbound = @('Networking', 'Security') virtualMachines = @('Compute') # caf.billing (CAF-BIL-02/03) and waf.cost both need these under Management # and Compute/Cost respectively. orphanedDisks = @('Compute', 'Cost', 'Management') orphanedPips = @('Compute', 'Cost', 'Management') diagnosticCoverage = @('Monitor', 'Management') deployments = @('Monitor', 'Management') storageAccounts = @('Storage') sqlDatabases = @('Databases') # waf.security (WAF-SE-03) filters domains.databases.sqlServers too. sqlServers = @('Databases', 'Security') sqlDefenderPricing = @('Databases') webApps = @('Web') aksClusters = @('Containers') containerRegistries = @('Containers') keyVaults = @('Security') cognitiveAccounts = @('AI') arcServers = @('Hybrid') arcExtensions = @('Hybrid') azureLocalClusters = @('Hybrid') eventHubNamespaces = @('Integration') apiManagement = @('Integration') serviceBusNamespaces = @('Integration') iotHubs = @('IoT') dpsInstances = @('IoT') digitalTwinsInstances = @('IoT') synapseWorkspaces = @('Analytics') purviewAccounts = @('Analytics') logAnalyticsWorkspaces = @('Management', 'Monitor') } $runAllCategories = (-not $Categories) -or (@($Categories).Count -eq 0) -or ($Categories -contains '*') $selectedKeys = if ($runAllCategories) { $q.Keys } else { $q.Keys | Where-Object { $cats = $queryCategories[$_] $cats -and (($cats -contains '*') -or ($Categories | Where-Object { $cats -contains $_ })) } } function Invoke-Arg { param([string] $Query, [string] $SubscriptionId, [switch] $OmitManagementGroup) $rows = @(); $skip = 0 do { # Search-AzGraph rejects -Skip 0 (ValidateRange minimum is 1) — omit it on the first page. $params = @{ Query = $Query; First = 1000; ErrorAction = 'Stop' } if ($skip -gt 0) { $params.Skip = $skip } # -ManagementGroup and -Subscription are DIFFERENT, mutually exclusive # Search-AzGraph parameter sets (ManagementGroupScopedQuery vs. # SubscriptionScopedQuery) — passing both throws an ambiguous-parameter-set # error, so a per-subscription call (below, AB#397) always omits # -ManagementGroup regardless of whether one was supplied for this run. if ($ManagementGroupId -and -not $OmitManagementGroup) { $params.ManagementGroup = $ManagementGroupId } if ($SubscriptionId) { $params.Subscription = @($SubscriptionId) } $batch = @(Search-AzGraph @params) $rows += $batch; $skip += 1000 } while ($batch.Count -eq 1000) return , $rows } # A small set of ARG/ARM errors are documented, KNOWN FALSE POSITIVES — most # commonly a "resource provider not registered" condition reported for a # subscription that Resource Graph can (and does) still query successfully # (AB#399). These are noise, not a real collection gap: log at Verbose and move # on without a warning or a per-subscription retry. function Test-ScoutKnownNoiseError([string] $Message) { return [bool]($Message -match '(?i)resource provider.*not\s+registered' -or $Message -match '(?i)MissingSubscriptionRegistration' -or $Message -match '(?i)SubscriptionNotRegistered') } function Invoke-CollectQuery { param([string] $Key, [string] $Query, [string[]] $SubscriptionIds) try { return Invoke-Arg -Query $Query } catch { $errText = $_.Exception.Message # AB#399 — known noise: log at Verbose only, no retry, no warning. if (Test-ScoutKnownNoiseError $errText) { Write-Verbose "Invoke-Collect: query '$Key' hit the known false 'resource provider not registered' condition — ignoring (AB#399): $errText" # `return @()` (without the leading comma) gets pipeline-UNROLLED to # zero output objects -- the caller's `$r[$k] = Invoke-CollectQuery ...` # would capture $null instead of an empty array. The unary comma # prevents that (same idiom Invoke-Arg already uses for `$rows`). return , @() } # AB#398 — an AuthorizationFailed error on an MG-scoped query is almost # always a missing Reader assignment AT THE MANAGEMENT GROUP (a Reader # role at subscription scope alone does not satisfy an MG-scoped ARG # query), so surface that as an explicit, actionable hint. if ($ManagementGroupId -and $errText -match '(?i)AuthorizationFailed') { Write-Warning "Invoke-Collect: query '$Key' failed with AuthorizationFailed while scoped to management group '$ManagementGroupId'. Hint: assign the caller (user or service principal) the Reader role at the '$ManagementGroupId' management group scope — a Reader role assigned only at a subscription underneath it is not sufficient for a management-group-scoped Resource Graph query." } else { Write-Warning "Invoke-Collect: query '$Key' failed: $errText" } # AB#397 — fall back to one query per known subscription instead of losing # the whole dataset for this resource type: a single subscription with a # transient/DNS/token problem must not blank out every OTHER # subscription's data for the same query. if (-not $SubscriptionIds -or @($SubscriptionIds).Count -eq 0) { return , @() } $rows = @() foreach ($subId in $SubscriptionIds) { try { $rows += Invoke-Arg -Query $Query -SubscriptionId $subId -OmitManagementGroup } catch { $subErrText = $_.Exception.Message if (Test-ScoutKnownNoiseError $subErrText) { Write-Verbose "Invoke-Collect: query '$Key' hit the known false 'resource provider not registered' condition for subscription '$subId' — ignoring (AB#399): $subErrText" continue } Write-Warning "Invoke-Collect: query '$Key' failed for subscription '$subId' — skipping this subscription and continuing with the rest (AB#397): $subErrText" } } return , $rows } } $r = @{} # Always collect `subscriptions` first, regardless of hashtable enumeration # order, so its subscription-id list is available for the AB#397 per-subscription # fallback used by every OTHER query below. $subscriptionIds = @() if ($selectedKeys -contains 'subscriptions') { try { $r['subscriptions'] = Invoke-Arg -Query $q['subscriptions'] $subscriptionIds = @($r['subscriptions'] | ForEach-Object { if ($_ -and $_.PSObject.Properties['id']) { $_.id } } | Where-Object { $_ }) } catch { Write-Warning "Invoke-Collect: query 'subscriptions' failed: $_ — the per-subscription retry (AB#397) is unavailable for the rest of this run because the subscription list itself could not be collected." $r['subscriptions'] = @() } } $progressAvailable = [bool](Get-Command Write-ScoutProgress -ErrorAction SilentlyContinue) $remainingKeys = @($q.Keys | Where-Object { $_ -ne 'subscriptions' }) $queryIndex = 0 foreach ($k in $remainingKeys) { $queryIndex++ if ($selectedKeys -notcontains $k) { $r[$k] = @(); continue } if ($progressAvailable) { $pct = if (@($remainingKeys).Count -gt 0) { [Math]::Min(100, [Math]::Round(($queryIndex / @($remainingKeys).Count) * 100)) } else { -1 } try { Write-ScoutProgress -Activity 'Scout Collect' -Status "Querying: $k" -PercentComplete $pct -Id 1 } catch { Write-Verbose "Invoke-Collect: Write-ScoutProgress failed, continuing without progress UX: $_" } } $r[$k] = Invoke-CollectQuery -Key $k -Query $q[$k] -SubscriptionIds $subscriptionIds } if ($progressAvailable) { try { Write-ScoutProgress -Activity 'Scout Collect' -Id 1 -Completed } catch { Write-Verbose "Invoke-Collect: Write-ScoutProgress completion call failed: $_" } } # ---- firewall policy rule-collection group parsing (AB#400) ---- # properties.ruleCollections is a nested dynamic array (rule collections -> rules) # that Resource Graph hands back as-is; walking it needs real conditional logic, # not just KQL projection, so the walk happens here in PowerShell. A single # malformed/unexpected-shape rule-collection group must not blank out every OTHER # (perfectly fine) group from the same run, so each row gets its own try/catch — # a parse error is logged per-group, not per-run, and collection continues with a # placeholder entry that records what happened via `parseError`. $firewallPolicyRuleGroups = @() foreach ($group in $r['firewallPolicyRuleGroups']) { if (-not $group) { continue } try { $ruleCollections = @($group.ruleCollections) $ruleCount = 0 foreach ($rc in $ruleCollections) { $ruleCount += @($rc.rules).Count } $firewallPolicyRuleGroups += [pscustomobject]@{ name = $group.name resourceGroup = $group.resourceGroup policyName = $group.policyName priority = $group.priority ruleCollectionCount = $ruleCollections.Count ruleCount = $ruleCount parseError = $null } } catch { Write-Warning "Invoke-Collect: firewall policy rule-collection group '$($group.name)' (policy '$($group.policyName)') failed to parse — skipping this group and continuing (AB#400): $($_.Exception.Message)" $firewallPolicyRuleGroups += [pscustomobject]@{ name = $group.name resourceGroup = $group.resourceGroup policyName = $group.policyName priority = $group.priority ruleCollectionCount = 0 ruleCount = 0 parseError = $_.Exception.Message } } } # ---- empty-data guard (AB#401) ---- # A collect that returns literally nothing across every requested query is a # strong signal something is mis-configured (missing Reader RBAC, the wrong # tenant/subscription selected in the current Az context, an empty or # inaccessible -ManagementGroupId, or a -Categories filter that matched nothing) # rather than a genuinely empty estate — surface a diagnostic hint instead of # silently handing an empty dataset to the assess/report layers. $totalRows = 0 foreach ($k in $r.Keys) { $totalRows += @($r[$k]).Count } if ($totalRows -eq 0) { $scopeHint = if ($ManagementGroupId) { "management group '$ManagementGroupId'" } else { 'the current subscription/tenant context' } Write-Warning ("Invoke-Collect: no resources were returned for any collected query, scoped to {0}. This usually means (1) the signed-in identity is missing the Reader role at that scope, (2) the wrong tenant/subscription is selected in the current Az context, (3) -ManagementGroupId points at an empty or inaccessible management group, or (4) -Categories filtered out every query that would otherwise have run — verify RBAC and scope before treating this as a genuinely empty estate." -f $scopeHint) } # ---- unique tag-key/value aggregation across subscriptions (AB#367) ---- # $r.subscriptions[*].tags is a per-subscription dynamic bag (the KQL `tags` # column deserializes to a PSCustomObject keyed by tag name, or $null when a # subscription has no tags). The old code just concatenated those raw bags # (`ForEach-Object { $_.tags }`), which duplicated a value every time two # subscriptions shared it and never actually rolled values up per key. # Aggregate into one entry per distinct tag KEY with the deduplicated, sorted # set of values seen for that key across every subscription instead. $tagValuesByKey = [ordered]@{} foreach ($sub in $r.subscriptions) { $bag = $sub.tags if ($null -eq $bag) { continue } foreach ($prop in $bag.PSObject.Properties) { $key = $prop.Name $value = $prop.Value if ($null -eq $value) { continue } if (-not $tagValuesByKey.Contains($key)) { $tagValuesByKey[$key] = [System.Collections.Generic.HashSet[string]]::new() } [void] $tagValuesByKey[$key].Add([string] $value) } } $tags = @( foreach ($key in ($tagValuesByKey.Keys | Sort-Object)) { [pscustomobject]@{ key = $key values = @($tagValuesByKey[$key] | Sort-Object) } } ) # ---- shape into the canonical contract ---- $collect = [pscustomobject]@{ subscriptions = $r.subscriptions tags = $tags networking = [pscustomobject]@{ virtualNetworks = $r.virtualNetworks subnets = $r.subnets azureFirewalls = $r.azureFirewalls firewallPolicyRuleGroups = $firewallPolicyRuleGroups vpnGateways = $r.vpnGateways privateEndpoints = $r.privateEndpoints privateDnsZones = $r.privateDnsZones nsgPublicInbound = $r.nsgPublicInbound } compute = [pscustomobject]@{ virtualMachines = $r.virtualMachines } management = [pscustomobject]@{ recoveryVaults = @(); deployments = $r.deployments logAnalyticsWorkspaces = $r.logAnalyticsWorkspaces } security = [pscustomobject]@{ defenderPlans = @() } governance = [pscustomobject]@{ managementGroups = @(); policyAssignments = @(); roleAssignments = @() budgets = @(); resourceLocks = @(); pimEligibility = @(); classicAdministrators = @() } costCleanup = [pscustomobject]@{ orphanedDisks = $r.orphanedDisks; orphanedPips = $r.orphanedPips } opsPosture = [pscustomobject]@{ diagnosticCoverage = $r.diagnosticCoverage } # Per-domain resource data (scalar compliance fields) for the per-category # assessments in Epic AB#5056. domains = [pscustomobject]@{ storage = [pscustomobject]@{ storageAccounts = $r.storageAccounts } databases = [pscustomobject]@{ sqlDatabases = $r.sqlDatabases; sqlServers = $r.sqlServers sqlDefenderPricing = $r.sqlDefenderPricing } web = [pscustomobject]@{ webApps = $r.webApps } containers = [pscustomobject]@{ aksClusters = $r.aksClusters; containerRegistries = $r.containerRegistries } security = [pscustomobject]@{ keyVaults = $r.keyVaults } ai = [pscustomobject]@{ cognitiveAccounts = $r.cognitiveAccounts } hybrid = [pscustomobject]@{ arcServers = $r.arcServers; arcExtensions = $r.arcExtensions azureLocalClusters = $r.azureLocalClusters } integration = [pscustomobject]@{ eventHubNamespaces = $r.eventHubNamespaces; apiManagement = $r.apiManagement serviceBusNamespaces = $r.serviceBusNamespaces } iot = [pscustomobject]@{ iotHubs = $r.iotHubs; dpsInstances = $r.dpsInstances digitalTwinsInstances = $r.digitalTwinsInstances } analytics = [pscustomobject]@{ synapseWorkspaces = $r.synapseWorkspaces; purviewAccounts = $r.purviewAccounts } } advisor = @() _meta = [pscustomobject]@{ generatedOn = (Get-Date).ToString('o'); scope = $Scope categories = $Categories; managementGroupId = $ManagementGroupId } } return $collect } |