src/analyze/Get-ScoutIacGap.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Compare discovered Azure resources (from collect.json) against a folder of Bicep/ARM-JSON templates and report resources that exist in Azure but are NOT represented in the templates (drift-from-IaC / unmanaged resources). Never calls Azure. .DESCRIPTION Two independent, best-effort steps: 1. Discovery flattening — `-CollectData` accepts either the canonical collect.json object (src/collect/Invoke-Collect.ps1's shape: networking/compute/management/governance/domains.* sections) or an already-flattened array of `{ Type, Name, ResourceGroup }`-shaped objects. A static section->ARM-type map (copied from Invoke-Collect's own KQL `type =~ "..."` filters, so it matches the real ARM types the collector queries) is used to flatten the nested shape; deployment records, subscription-level settings and rule-level sub-collections (subnets, nsgPublicInbound) are deliberately excluded — see LIMITATIONS. 2. Template parsing — every `.bicep`/`.json` file under `-TemplatePath` (recursively) is parsed BEST-EFFORT as text/JSON (no `bicep build`, no external dependency): - Bicep: `resource <symbolicName> '<type>@<apiVersion>' = ...` declarations are found by regex; the resource's literal `name:` value (if it is a plain quoted string, not an expression/ parameter/interpolation) is read from the text immediately following the declaration. - ARM JSON: `resources[].type`/`.name` are read directly; a `name` value that is an ARM template expression (`"[...]"`) is treated as unresolved, same as an unresolvable Bicep name. Discovered resources are matched to template resources on (normalized-lowercase Type, normalized-lowercase Name). A discovered resource with no matching template entry is reported as `Unmanaged` — annotated with whether the TYPE at least appears somewhere in the template set (a parameter-driven name can make a genuinely-managed resource statically unmatchable; that case gets `TypePresentInTemplates = $true` and `Severity = 'Medium'` instead of `'High'`, so results are not over-confident). Matched resources are reported separately. `-IncludeTemplatedButMissing` additionally reports template resources with a resolvable name that have no matching discovered resource (declared in IaC but not found in Azure — may be undeployed). .PARAMETER CollectData The parsed collect.json object, OR a pre-flattened array of `{ Type, Name, ResourceGroup }` objects. $null/empty degrades to zero discovered resources (not an error) — the template-side comparison still runs and simply reports nothing on the discovery side. .PARAMETER TemplatePath Folder to search (recursively) for `.bicep`/`.json` template files. Absent, blank, non-existent, or a folder with no matching files degrades gracefully: `HasTemplates = $false` with a clear `Message`, never a throw. .PARAMETER IncludeTemplatedButMissing Also report template resources with a resolvable literal name that have no matching discovered resource. Off by default (the primary contract is "what's unmanaged", per AB#325). .OUTPUTS [pscustomobject] — HasTemplates/Message/GeneratedOn/DiscoveredCount/ TemplateResourceCount/TemplateFileCount/Unmanaged[]/Matched[]/ TemplatedButMissing[]. See tests/Analyze.IacGap.Tests.ps1 for the exact shape exercised. .NOTES Tracks ADO Story AB#325. LIMITATIONS (honest, by design): - collect.json items carry `name`/`resourceGroup`/`subscriptionId` but NOT a full ARM resourceId or `type` string (confirmed against src/collect/Invoke-Collect.ps1 and tests/datadump/sample-collect.json) — the section->type map here is this function's own best-effort reconstruction of "what ARM type does this collect.json array hold", not data the collector stamps onto each item. - Excluded from discovery on purpose: `management.deployments` (a deployment EXECUTION record, not a `resource` block a template declares — not comparable), `security.defenderPlans` (a subscription-wide setting, not a discretely-named resource), `networking.subnets` / `networking.nsgPublicInbound` (child-resource / rule-level rows, not a full independent resource inventory for that type). - Bicep name resolution is regex/text-based, not a real Bicep parse: a `name:` value that is anything other than a single-quoted literal (a parameter reference, `${interpolation}`, a `for`-loop expression, `concat(...)`, etc.) is correctly reported as unresolved rather than guessed at — such resources fall back to the type-level `TypePresentInTemplates` signal instead of a confident name match. - Matching is exact on (Type, Name) only; it does not account for a resource being declared in one subscription's template set but deployed to a different resource group/subscription than discovered, nor for child resources whose collect.json `name` omits the parent (e.g. `domains.databases.sqlDatabases`). #> function Get-ScoutIacGap { [CmdletBinding()] param( $CollectData, [string] $TemplatePath, [switch] $IncludeTemplatedButMissing ) function Get-IacGapProp { param($Obj, [string] $Name) if ($null -eq $Obj) { return $null } $p = $Obj.PSObject.Properties[$Name] if ($p) { return $p.Value } else { return $null } } function Get-IacGapPathValue { param($Obj, [string] $Path) $cur = $Obj foreach ($seg in ($Path -split '\.')) { $cur = Get-IacGapProp $cur $seg if ($null -eq $cur) { return $null } } return $cur } # ---- section (collect.json path) -> ARM resource type, sourced from the # real `type =~ "..."` filters in src/collect/Invoke-Collect.ps1 ---- $sectionMap = @( @{ Path = 'networking.virtualNetworks'; Type = 'Microsoft.Network/virtualNetworks' } @{ Path = 'networking.azureFirewalls'; Type = 'Microsoft.Network/azureFirewalls' } @{ Path = 'networking.vpnGateways'; Type = 'Microsoft.Network/virtualNetworkGateways' } @{ Path = 'networking.privateEndpoints'; Type = 'Microsoft.Network/privateEndpoints' } @{ Path = 'networking.privateDnsZones'; Type = 'Microsoft.Network/privateDnsZones' } @{ Path = 'compute.virtualMachines'; Type = 'Microsoft.Compute/virtualMachines' } @{ Path = 'management.recoveryVaults'; Type = 'Microsoft.RecoveryServices/vaults' } @{ Path = 'governance.managementGroups'; Type = 'Microsoft.Management/managementGroups' } @{ Path = 'governance.policyAssignments'; Type = 'Microsoft.Authorization/policyAssignments' } @{ Path = 'governance.resourceLocks'; Type = 'Microsoft.Authorization/locks' } @{ Path = 'governance.budgets'; Type = 'Microsoft.Consumption/budgets' } @{ Path = 'domains.storage.storageAccounts'; Type = 'Microsoft.Storage/storageAccounts' } @{ Path = 'domains.databases.sqlServers'; Type = 'Microsoft.Sql/servers' } @{ Path = 'domains.databases.sqlDatabases'; Type = 'Microsoft.Sql/servers/databases' } @{ Path = 'domains.web.webApps'; Type = 'Microsoft.Web/sites' } @{ Path = 'domains.containers.aksClusters'; Type = 'Microsoft.ContainerService/managedClusters' } @{ Path = 'domains.containers.containerRegistries'; Type = 'Microsoft.ContainerRegistry/registries' } @{ Path = 'domains.security.keyVaults'; Type = 'Microsoft.KeyVault/vaults' } @{ Path = 'domains.ai.cognitiveAccounts'; Type = 'Microsoft.CognitiveServices/accounts' } @{ Path = 'domains.hybrid.arcServers'; Type = 'Microsoft.HybridCompute/machines' } @{ Path = 'domains.hybrid.azureLocalClusters'; Type = 'Microsoft.AzureStackHCI/clusters' } @{ Path = 'domains.integration.eventHubNamespaces'; Type = 'Microsoft.EventHub/namespaces' } @{ Path = 'domains.integration.apiManagement'; Type = 'Microsoft.ApiManagement/service' } @{ Path = 'domains.integration.serviceBusNamespaces'; Type = 'Microsoft.ServiceBus/namespaces' } @{ Path = 'domains.iot.iotHubs'; Type = 'Microsoft.Devices/IotHubs' } @{ Path = 'domains.iot.dpsInstances'; Type = 'Microsoft.Devices/provisioningServices' } @{ Path = 'domains.iot.digitalTwinsInstances'; Type = 'Microsoft.DigitalTwins/digitalTwinsInstances' } @{ Path = 'domains.analytics.synapseWorkspaces'; Type = 'Microsoft.Synapse/workspaces' } @{ Path = 'domains.analytics.purviewAccounts'; Type = 'Microsoft.Purview/accounts' } ) # ---- flatten discovery input ---- $discovered = [System.Collections.Generic.List[object]]::new() foreach ($m in $sectionMap) { $arr = @(Get-IacGapPathValue -Obj $CollectData -Path $m.Path) foreach ($item in $arr) { if ($null -eq $item) { continue } $name = Get-IacGapProp $item 'name' if ([string]::IsNullOrWhiteSpace($name)) { continue } $discovered.Add([pscustomobject]@{ Name = $name Type = $m.Type ResourceGroup = Get-IacGapProp $item 'resourceGroup' SubscriptionId = Get-IacGapProp $item 'subscriptionId' SourcePath = $m.Path }) } } # ---- fall back to treating -CollectData directly as a pre-flattened array ---- if ($discovered.Count -eq 0) { foreach ($item in @($CollectData | Where-Object { $null -ne $_ })) { $t = Get-IacGapProp $item 'Type' if (-not $t) { $t = Get-IacGapProp $item 'type' } $n = Get-IacGapProp $item 'Name' if (-not $n) { $n = Get-IacGapProp $item 'name' } if (-not $t -or -not $n) { continue } $rg = Get-IacGapProp $item 'ResourceGroup' if (-not $rg) { $rg = Get-IacGapProp $item 'resourceGroup' } $subId = Get-IacGapProp $item 'SubscriptionId' if (-not $subId) { $subId = Get-IacGapProp $item 'subscriptionId' } $discovered.Add([pscustomobject]@{ Name = $n; Type = $t; ResourceGroup = $rg; SubscriptionId = $subId; SourcePath = '(direct)' }) } } # ---- degrade gracefully: no template path supplied / doesn't exist ---- if ([string]::IsNullOrWhiteSpace($TemplatePath) -or -not (Test-Path -LiteralPath $TemplatePath)) { return [pscustomobject]@{ HasTemplates = $false Message = 'Get-ScoutIacGap: -TemplatePath was not supplied or does not exist — no IaC templates to compare against, so gap detection was skipped. Pass -TemplatePath at a folder of .bicep/.json templates to enable this check.' GeneratedOn = (Get-Date).ToString('o') DiscoveredCount = $discovered.Count TemplateResourceCount = 0 TemplateFileCount = 0 Unmanaged = @() Matched = @() TemplatedButMissing = @() } } $templateFiles = @(Get-ChildItem -LiteralPath $TemplatePath -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.bicep', '.json' }) if ($templateFiles.Count -eq 0) { return [pscustomobject]@{ HasTemplates = $false Message = "Get-ScoutIacGap: -TemplatePath '$TemplatePath' contains no .bicep or .json template files — nothing to compare against." GeneratedOn = (Get-Date).ToString('o') DiscoveredCount = $discovered.Count TemplateResourceCount = 0 TemplateFileCount = 0 Unmanaged = @() Matched = @() TemplatedButMissing = @() } } # ---- parse template files (best-effort) ---- function Get-IacGapTemplateResource { param([string] $FilePath, [string] $Extension) $found = [System.Collections.Generic.List[object]]::new() $content = Get-Content -LiteralPath $FilePath -Raw -ErrorAction SilentlyContinue if ([string]::IsNullOrWhiteSpace($content)) { return $found } if ($Extension -eq '.bicep') { $pattern = "resource\s+[A-Za-z0-9_]+\s+'([^'@]+)@([^']+)'\s*=" foreach ($m in [regex]::Matches($content, $pattern)) { $type = $m.Groups[1].Value $apiVersion = $m.Groups[2].Value $startIdx = $m.Index + $m.Length $windowLen = [Math]::Min(600, $content.Length - $startIdx) $name = $null if ($windowLen -gt 0) { $window = $content.Substring($startIdx, $windowLen) $nameMatch = [regex]::Match($window, "name\s*:\s*'([^'\$]*)'") if ($nameMatch.Success) { $name = $nameMatch.Groups[1].Value } } $found.Add([pscustomobject]@{ Type = $type; ApiVersion = $apiVersion; Name = $name NameResolved = [bool]$name; SourceFile = $FilePath }) } } elseif ($Extension -eq '.json') { try { $json = $content | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { return $found } $resources = Get-IacGapProp $json 'resources' foreach ($res in @($resources)) { if ($null -eq $res) { continue } $type = Get-IacGapProp $res 'type' if (-not $type) { continue } $rawName = Get-IacGapProp $res 'name' $isExpr = ($rawName -is [string]) -and $rawName.StartsWith('[') -and $rawName.EndsWith(']') $name = if ($isExpr -or -not $rawName) { $null } else { [string]$rawName } $found.Add([pscustomobject]@{ Type = $type; ApiVersion = (Get-IacGapProp $res 'apiVersion'); Name = $name NameResolved = [bool]$name; SourceFile = $FilePath }) } } return $found } $templateResources = [System.Collections.Generic.List[object]]::new() foreach ($f in $templateFiles) { foreach ($tr in (Get-IacGapTemplateResource -FilePath $f.FullName -Extension $f.Extension)) { $templateResources.Add($tr) } } $templateByKey = @{} $templateTypesPresent = @{} foreach ($tr in $templateResources) { $ntype = ([string]$tr.Type).ToLowerInvariant() $templateTypesPresent[$ntype] = $true if ($tr.NameResolved) { $key = "$ntype|$(([string]$tr.Name).ToLowerInvariant())" if (-not $templateByKey.ContainsKey($key)) { $templateByKey[$key] = [System.Collections.Generic.List[object]]::new() } $templateByKey[$key].Add($tr) } } # ---- compare ---- $unmanaged = [System.Collections.Generic.List[object]]::new() $matched = [System.Collections.Generic.List[object]]::new() foreach ($d in $discovered) { $ntype = ([string]$d.Type).ToLowerInvariant() $nname = ([string]$d.Name).ToLowerInvariant() $key = "$ntype|$nname" if ($templateByKey.ContainsKey($key)) { $matched.Add([pscustomobject]@{ Name = $d.Name; Type = $d.Type; ResourceGroup = $d.ResourceGroup MatchedTemplateFile = $templateByKey[$key][0].SourceFile }) } else { $typePresent = [bool]$templateTypesPresent[$ntype] $message = if ($typePresent) { "No template resource named '$($d.Name)' of type $($d.Type) was found, though this type IS declared elsewhere in the template set — the name may be parameter-driven and not statically resolvable; verify manually." } else { "Resource '$($d.Name)' ($($d.Type)) exists in the discovered inventory but no template of this type was found under -TemplatePath at all — likely unmanaged / created outside IaC." } $unmanaged.Add([pscustomobject]@{ Name = $d.Name; Type = $d.Type; ResourceGroup = $d.ResourceGroup; SubscriptionId = $d.SubscriptionId TypePresentInTemplates = $typePresent Severity = if ($typePresent) { 'Medium' } else { 'High' } Message = $message }) } } $templatedButMissing = [System.Collections.Generic.List[object]]::new() if ($IncludeTemplatedButMissing.IsPresent) { $discoveredKeys = [System.Collections.Generic.HashSet[string]]::new() foreach ($d in $discovered) { [void]$discoveredKeys.Add("$(([string]$d.Type).ToLowerInvariant())|$(([string]$d.Name).ToLowerInvariant())") } foreach ($kv in $templateByKey.GetEnumerator()) { if (-not $discoveredKeys.Contains($kv.Key)) { $first = $kv.Value[0] $templatedButMissing.Add([pscustomobject]@{ Name = $first.Name; Type = $first.Type; SourceFile = $first.SourceFile Message = "Template declares '$($first.Name)' ($($first.Type)) in $($first.SourceFile) but no matching resource was found in the discovered inventory — may be undeployed, deployed under a different name, or removed outside IaC." }) } } } $summary = "Compared $($discovered.Count) discovered resource(s) against $($templateResources.Count) template resource declaration(s) across $($templateFiles.Count) file(s): $($unmanaged.Count) unmanaged, $($matched.Count) matched" if ($IncludeTemplatedButMissing.IsPresent) { $summary += ", $($templatedButMissing.Count) templated-but-missing" } $summary += '.' return [pscustomobject]@{ HasTemplates = $true Message = $summary GeneratedOn = (Get-Date).ToString('o') DiscoveredCount = $discovered.Count TemplateResourceCount = $templateResources.Count TemplateFileCount = $templateFiles.Count Unmanaged = @($unmanaged | Sort-Object Type, Name) Matched = @($matched | Sort-Object Type, Name) TemplatedButMissing = @($templatedButMissing | Sort-Object Type, Name) } } |