src/pipeline/Get-ScoutResourceCompleteness.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Functions defined by a dot-sourced script do not retain a lexical $PSScriptRoot when they are # later invoked from an interactive/module scope. Capture it while this file is loading so the # optional standalone path can always find its sibling collector catalog loader. $script:ScoutResourceCompletenessScriptRoot = $PSScriptRoot <# .SYNOPSIS Build the universal discovery index and relationship evidence for a Scout run. .DESCRIPTION Specialized collectors deliberately flatten resource-provider payloads into useful report columns. That makes the report readable, but it cannot be the completeness boundary: a newly introduced or unsupported ARM type would otherwise exist only in raw-inventory.json. This function gives every discovered row a stable, machine-readable record. It retains the Resource Graph control-plane payload, names the specialized collectors that understand the type, correlates collection-health failures, classifies public/private exposure from direct evidence, and extracts ARM resource-id references into a generic relationship graph. It never calls Azure. The result is deterministic for a fixed resource set, collector catalog, and collection-health ledger. .NOTES AB#7366 and AB#7367. #> function Get-ScoutResourceCompletenessValue { [CmdletBinding()] param( [AllowNull()]$InputObject, [Parameter(Mandatory)][string]$Path ) $current = $InputObject foreach ($segment in ($Path -split '\.')) { if ($null -eq $current) { return $null } if ($current -is [System.Collections.IDictionary]) { $matchedKey = @($current.Keys | Where-Object { [string]$_ -ieq $segment } | Select-Object -First 1) if ($matchedKey.Count -eq 0) { return $null } $current = $current[$matchedKey[0]] continue } $property = @($current.PSObject.Properties | Where-Object Name -IEQ $segment | Select-Object -First 1) if ($property.Count -eq 0) { return $null } $current = $property[0].Value } return $current } function Test-ScoutDiscoverySensitiveName { [CmdletBinding()] [OutputType([bool])] param([AllowNull()][string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { return $false } $normalized = ($Name -replace '[^A-Za-z0-9]', '').ToLowerInvariant() return $normalized -match '(password|passwd|pwd|secrets?|tokens?|sharedkey|privatekey|connectionstrings?|accountkey|accesskey|apikey|primarykey|secondarykey|masterkey|sastoken|saskey)$' -or $normalized -eq 'credential' } function Protect-ScoutDiscoveryValue { [CmdletBinding()] param( [AllowNull()]$InputObject, [AllowNull()][string]$PropertyName = '', [ValidateRange(0, 100)][int]$Depth = 0, [ValidateRange(1, 100)][int]$MaximumDepth = 60 ) if ($null -eq $InputObject) { return $null } if (Test-ScoutDiscoverySensitiveName -Name $PropertyName) { return '[REDACTED]' } if ($InputObject -is [string] -or $InputObject.GetType().IsPrimitive -or $InputObject -is [decimal] -or $InputObject -is [datetime] -or $InputObject -is [datetimeoffset] -or $InputObject -is [guid]) { return $InputObject } if ($Depth -ge $MaximumDepth) { return '[REDACTED:MAXIMUM-DEPTH]' } $contextName = [string](Get-ScoutResourceCompletenessValue -InputObject $InputObject -Path 'name') $contextType = [string](Get-ScoutResourceCompletenessValue -InputObject $InputObject -Path 'type') $protectContextValue = (Test-ScoutDiscoverySensitiveName -Name $contextName) -or $contextType -match '(?i)^secure(string|object)$' if ($InputObject -is [System.Collections.IDictionary]) { $copy = [ordered]@{} foreach ($key in $InputObject.Keys) { $keyName = [string]$key $normalizedKey = ($keyName -replace '[^A-Za-z0-9]', '').ToLowerInvariant() if ((Test-ScoutDiscoverySensitiveName -Name $keyName) -or ($protectContextValue -and $normalizedKey -in @('value', 'defaultvalue'))) { $copy[$keyName] = '[REDACTED]' } else { $copy[$keyName] = Protect-ScoutDiscoveryValue -InputObject $InputObject[$key] ` -PropertyName $keyName -Depth ($Depth + 1) -MaximumDepth $MaximumDepth } } return [pscustomobject]$copy } if ($InputObject -is [System.Collections.IEnumerable]) { $items = [System.Collections.Generic.List[object]]::new() foreach ($item in $InputObject) { $items.Add((Protect-ScoutDiscoveryValue -InputObject $item -PropertyName $PropertyName ` -Depth ($Depth + 1) -MaximumDepth $MaximumDepth)) } return , $items.ToArray() } $properties = @($InputObject.PSObject.Properties | Where-Object { $_.MemberType -in @('NoteProperty', 'Property', 'AliasProperty', 'ScriptProperty') }) if ($properties.Count -eq 0) { return $InputObject } $copy = [ordered]@{} foreach ($property in $properties) { $propertyName = [string]$property.Name $normalizedName = ($propertyName -replace '[^A-Za-z0-9]', '').ToLowerInvariant() if ((Test-ScoutDiscoverySensitiveName -Name $propertyName) -or ($protectContextValue -and $normalizedName -in @('value', 'defaultvalue'))) { $copy[$propertyName] = '[REDACTED]' } else { $copy[$propertyName] = Protect-ScoutDiscoveryValue -InputObject $property.Value ` -PropertyName $propertyName -Depth ($Depth + 1) -MaximumDepth $MaximumDepth } } return [pscustomobject]$copy } function Get-ScoutArmReferenceRelationshipType { [CmdletBinding()] param([Parameter(Mandatory)][string]$Path) switch -Regex ($Path) { '(?i)remoteVirtualNetwork\.id$' { return 'VNetPeering' } '(?i)networkSecurityGroup\.id$' { return 'NetworkSecurityGroup' } '(?i)routeTable\.id$' { return 'RouteTable' } '(?i)natGateway\.id$' { return 'NatGateway' } '(?i)subnet\.id$|subnets\[\d+\]\.id$' { return 'Subnet' } '(?i)publicIpAddress(?:es)?(?:\[\d+\])?\.id$' { return 'PublicIPAddress' } '(?i)privateEndpoint(?:s)?(?:\[\d+\])?\.id$' { return 'PrivateEndpoint' } '(?i)networkInterface(?:s)?(?:\[\d+\])?\.id$' { return 'NetworkInterface' } '(?i)privateLinkServiceId$' { return 'PrivateLinkTarget' } '(?i)privateDnsZone(?:Id)?$' { return 'PrivateDnsZone' } '(?i)managedBy$' { return 'ManagedBy' } default { return 'ArmReference' } } } function Get-ScoutArmReference { [CmdletBinding()] param( [AllowNull()]$InputObject, [string]$Path = 'properties', [ValidateRange(1, 100)][int]$Depth = 1, [ValidateRange(1, 100)][int]$MaximumDepth = 40 ) if ($null -eq $InputObject -or $Depth -gt $MaximumDepth) { return } if ($InputObject -is [string]) { if ($InputObject -match '(?i)^/(subscriptions|providers)/[^\s]+$') { [pscustomobject]@{ TargetId = $InputObject PropertyPath = $Path RelationshipType = Get-ScoutArmReferenceRelationshipType -Path $Path } } return } if ($InputObject -is [System.Collections.IDictionary]) { foreach ($key in @($InputObject.Keys | Sort-Object { [string]$_ })) { Get-ScoutArmReference -InputObject $InputObject[$key] -Path "$Path.$key" -Depth ($Depth + 1) -MaximumDepth $MaximumDepth } return } if ($InputObject -is [System.Collections.IEnumerable]) { $index = 0 foreach ($item in $InputObject) { Get-ScoutArmReference -InputObject $item -Path "$Path[$index]" -Depth ($Depth + 1) -MaximumDepth $MaximumDepth $index++ } return } foreach ($property in @($InputObject.PSObject.Properties | Sort-Object Name)) { if ($property.MemberType -notin @('NoteProperty', 'Property', 'AliasProperty', 'ScriptProperty')) { continue } Get-ScoutArmReference -InputObject $property.Value -Path "$Path.$($property.Name)" -Depth ($Depth + 1) -MaximumDepth $MaximumDepth } } function Get-ScoutExposureEvidence { [CmdletBinding()] param( [Parameter(Mandatory)]$Resource, [AllowNull()]$Relationships, [AllowNull()]$ProviderProperties ) $resourceProperties = Get-ScoutResourceCompletenessValue -InputObject $Resource -Path 'properties' $properties = if ($null -ne $ProviderProperties) { $ProviderProperties } else { $resourceProperties } $type = [string](Get-ScoutResourceCompletenessValue -InputObject $Resource -Path 'type') $evidence = [System.Collections.Generic.List[string]]::new() $public = $false $private = $false $applicable = $false $publicNetworkAccess = Get-ScoutResourceCompletenessValue -InputObject $properties -Path 'publicNetworkAccess' if ($null -ne $publicNetworkAccess -and -not [string]::IsNullOrWhiteSpace([string]$publicNetworkAccess)) { $applicable = $true [void]$evidence.Add("publicNetworkAccess=$publicNetworkAccess") if ([string]$publicNetworkAccess -match '(?i)^(enabled|true|allow|all)$') { $public = $true } elseif ([string]$publicNetworkAccess -match '(?i)^(disabled|false|deny|none)$') { $private = $true } } foreach ($path in @('publicNetworkAccessForIngestion', 'publicNetworkAccessForQuery')) { $value = Get-ScoutResourceCompletenessValue -InputObject $properties -Path $path if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { continue } $applicable = $true [void]$evidence.Add("$path=$value") if ([string]$value -match '(?i)^(enabled|true|allow|all)$') { $public = $true } elseif ([string]$value -match '(?i)^(disabled|false|deny|none)$') { $private = $true } } $networkDefaultAction = Get-ScoutResourceCompletenessValue -InputObject $properties -Path 'networkAcls.defaultAction' if ($null -ne $networkDefaultAction -and -not [string]::IsNullOrWhiteSpace([string]$networkDefaultAction)) { $applicable = $true [void]$evidence.Add("networkAcls.defaultAction=$networkDefaultAction") if ([string]$networkDefaultAction -ieq 'Allow') { $public = $true } elseif ([string]$networkDefaultAction -ieq 'Deny') { $ipRules = @(Get-ScoutResourceCompletenessValue -InputObject $properties -Path 'networkAcls.ipRules') if (@($ipRules | Where-Object { $null -ne $_ }).Count -gt 0) { $public = $true [void]$evidence.Add("networkAcls.ipRules=$(@($ipRules | Where-Object { $null -ne $_ }).Count)") } else { $private = $true } } } $privateEndpointCount = @($Relationships | Where-Object RelationshipType -eq 'PrivateEndpoint').Count if ($privateEndpointCount -eq 0) { $privateEndpointCount = @(Get-ScoutResourceCompletenessValue -InputObject $properties -Path 'privateEndpointConnections' | Where-Object { $null -ne $_ }).Count } if ($privateEndpointCount -gt 0) { $applicable = $true $private = $true [void]$evidence.Add("privateEndpoints=$privateEndpointCount") } $publicIpReferences = @($Relationships | Where-Object RelationshipType -eq 'PublicIPAddress').Count if ($publicIpReferences -gt 0) { $applicable = $true $public = $true [void]$evidence.Add("publicIpReferences=$publicIpReferences") } if ($type -ieq 'microsoft.network/publicipaddresses') { $applicable = $true $ipAddress = Get-ScoutResourceCompletenessValue -InputObject $properties -Path 'ipAddress' if ($ipAddress) { $public = $true [void]$evidence.Add("publicIpAddress=$ipAddress") } else { [void]$evidence.Add('publicIpAddress=Unallocated') } } $classification = if (-not $applicable) { 'Unknown' } elseif ($public -and $private) { 'Mixed' } elseif ($public) { 'Public' } elseif ($private) { 'Private' } else { 'None' } [pscustomobject]@{ Classification = $classification Evidence = @($evidence | Sort-Object -Unique) Confidence = if ($applicable) { 'DirectControlPlaneEvidence' } else { 'Unknown' } } } function Get-ScoutResourceCompleteness { [CmdletBinding()] [OutputType([pscustomobject])] param( [AllowNull()][object[]]$Resources, [AllowNull()][object[]]$CollectionHealth = @(), [AllowNull()][object[]]$Collectors, [AllowNull()][string]$DefinitionRoot ) if (-not $PSBoundParameters.ContainsKey('Collectors') -or $null -eq $Collectors) { $collectorArguments = @{ Category = @('All') } if ($DefinitionRoot) { $collectorArguments['DefinitionRoot'] = $DefinitionRoot } if (Get-Command Get-ScoutCollector -ErrorAction SilentlyContinue) { $Collectors = @(Get-ScoutCollector @collectorArguments) } else { $collectorFile = Join-Path $script:ScoutResourceCompletenessScriptRoot 'Get-ScoutCollector.ps1' if (Test-Path -LiteralPath $collectorFile -PathType Leaf) { # A function dot-sourced from inside this function exists only in this local # scope. Load and invoke it in one child scope, returning only its descriptors. $Collectors = @(& { param($LoaderPath, $Arguments) . $LoaderPath Get-ScoutCollector @Arguments } $collectorFile $collectorArguments) } else { $Collectors = @() } } } Write-Verbose ("Get-ScoutResourceCompleteness: collector descriptors={0}" -f @($Collectors).Count) $collectorTypeMap = @{} $collectorPatterns = [System.Collections.Generic.List[object]]::new() foreach ($collectorDescriptor in @($Collectors)) { if ($null -eq $collectorDescriptor) { continue } $collectorCategory = [string](Get-ScoutResourceCompletenessValue -InputObject $collectorDescriptor -Path 'FolderCategory') $collectorBaseName = [string](Get-ScoutResourceCompletenessValue -InputObject $collectorDescriptor -Path 'Name') $collectorName = '{0}/{1}' -f $collectorCategory, $collectorBaseName $resourceTypes = @((Get-ScoutResourceCompletenessValue -InputObject $collectorDescriptor -Path 'ResourceTypes') | Where-Object { $_ }) $definitionPath = '' if ($resourceTypes.Count -eq 0) { $definitionPath = [string](Get-ScoutResourceCompletenessValue -InputObject $collectorDescriptor -Path 'DefinitionPath') if (-not [string]::IsNullOrWhiteSpace($definitionPath) -and (Test-Path -LiteralPath $definitionPath -PathType Leaf)) { $definition = Import-PowerShellDataFile -LiteralPath $definitionPath $resourceTypes = @((Get-ScoutResourceCompletenessValue -InputObject $definition -Path 'ResourceTypes') | Where-Object { $_ }) } } foreach ($resourceType in $resourceTypes) { if ([string]::IsNullOrWhiteSpace([string]$resourceType)) { continue } $typeKey = ([string]$resourceType).ToLowerInvariant() if ($typeKey.Contains('*') -or $typeKey.Contains('?')) { $collectorPatterns.Add([pscustomobject]@{ Pattern = $typeKey; Collector = $collectorName }) continue } if (-not $collectorTypeMap.ContainsKey($typeKey)) { $collectorTypeMap[$typeKey] = [System.Collections.Generic.List[string]]::new() } if (-not $collectorTypeMap[$typeKey].Contains($collectorName)) { $collectorTypeMap[$typeKey].Add($collectorName) } } } Write-Verbose ("Get-ScoutResourceCompleteness: exact resource-type mappings={0}; wildcard mappings={1}" -f $collectorTypeMap.Count, $collectorPatterns.Count) $healthRows = @($CollectionHealth | Where-Object { $null -ne $_ }) $enrichmentById = @{} foreach ($candidate in @($Resources)) { if ($null -eq $candidate) { continue } $candidateType = [string](Get-ScoutResourceCompletenessValue -InputObject $candidate -Path 'type') $candidateId = [string](Get-ScoutResourceCompletenessValue -InputObject $candidate -Path 'id') if ($candidateType -notlike 'AZSC/*' -or [string]::IsNullOrWhiteSpace($candidateId)) { continue } $key = $candidateId.ToLowerInvariant() if (-not $enrichmentById.ContainsKey($key)) { $enrichmentById[$key] = [System.Collections.Generic.List[object]]::new() } $enrichmentById[$key].Add([pscustomobject]@{ Type = $candidateType Properties = Get-ScoutResourceCompletenessValue -InputObject $candidate -Path 'properties' }) } $seenIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $resourceRows = [System.Collections.Generic.List[object]]::new() $relationshipRows = [System.Collections.Generic.List[object]]::new() foreach ($resource in @($Resources)) { if ($null -eq $resource) { continue } $id = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'id') $type = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'type') if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($type)) { continue } if ($type -like 'AZSC/*' -and $enrichmentById.ContainsKey($id.ToLowerInvariant())) { continue } if (-not $seenIds.Add($id)) { continue } $typeKey = $type.ToLowerInvariant() $matchedCollectors = [System.Collections.Generic.List[string]]::new() if ($collectorTypeMap.ContainsKey($typeKey)) { foreach ($name in $collectorTypeMap[$typeKey]) { if (-not $matchedCollectors.Contains($name)) { $matchedCollectors.Add($name) } } } foreach ($pattern in $collectorPatterns) { if ($typeKey -like $pattern.Pattern -and -not $matchedCollectors.Contains($pattern.Collector)) { $matchedCollectors.Add($pattern.Collector) } } $resourceHealth = @( foreach ($health in $healthRows) { $healthCollectors = @((Get-ScoutResourceCompletenessValue -InputObject $health -Path 'Collectors') | Where-Object { $_ }) $healthTypes = @((Get-ScoutResourceCompletenessValue -InputObject $health -Path 'ResourceTypes') | Where-Object { $_ }) $healthResourceIds = @((Get-ScoutResourceCompletenessValue -InputObject $health -Path 'ResourceIds') | Where-Object { $_ }) if ($healthResourceIds.Count -gt 0 -and @($healthResourceIds | Where-Object { [string]$_ -ieq $id }).Count -eq 0) { continue } $collectorMatch = @($healthCollectors | Where-Object { $matchedCollectors -contains [string]$_ }).Count -gt 0 $typeMatch = @($healthTypes | Where-Object { $candidate = ([string]$_).ToLowerInvariant() $typeKey -eq $candidate -or $typeKey -like $candidate }).Count -gt 0 if ($collectorMatch -or $typeMatch) { $health } } ) $properties = Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'properties' $enrichment = @() if ($enrichmentById.ContainsKey($id.ToLowerInvariant())) { $enrichment = @($enrichmentById[$id.ToLowerInvariant()]) } $propertyCount = if ($null -eq $properties) { 0 } else { @($properties.PSObject.Properties).Count } $status = if ($resourceHealth.Count -gt 0 -and $propertyCount -eq 0) { 'Unavailable' } elseif ($resourceHealth.Count -gt 0) { 'Partial' } elseif ($matchedCollectors.Count -gt 0) { 'Detailed' } else { 'GenericOnly' } $statusReasons = [System.Collections.Generic.List[string]]::new() if ($matchedCollectors.Count -eq 0) { [void]$statusReasons.Add('No specialized collector declares this resource type; the complete Resource Graph row is retained generically.') } foreach ($health in $resourceHealth) { $reason = [string](Get-ScoutResourceCompletenessValue -InputObject $health -Path 'Reason') if (-not [string]::IsNullOrWhiteSpace($reason) -and -not $statusReasons.Contains($reason)) { $statusReasons.Add($reason) } } if ($propertyCount -eq 0) { [void]$statusReasons.Add('Resource Graph returned no properties for this row.') } $references = @( @(Get-ScoutArmReference -InputObject $properties -Path 'properties') for ($enrichmentIndex = 0; $enrichmentIndex -lt $enrichment.Count; $enrichmentIndex++) { $providerPayloadProperties = Get-ScoutResourceCompletenessValue -InputObject $enrichment[$enrichmentIndex] -Path 'Properties.Payload.properties' if ($null -ne $providerPayloadProperties) { Get-ScoutArmReference -InputObject $providerPayloadProperties -Path "enrichment[$enrichmentIndex].properties" } } $managedBy = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'managedBy') if ($managedBy -match '(?i)^/(subscriptions|providers)/') { [pscustomobject]@{ TargetId = $managedBy; PropertyPath = 'managedBy'; RelationshipType = 'ManagedBy' } } ) $referenceKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $uniqueReferences = [System.Collections.Generic.List[object]]::new() foreach ($reference in $references) { if ($null -eq $reference -or [string]$reference.TargetId -ieq $id) { continue } $key = '{0}|{1}|{2}' -f $reference.TargetId, $reference.PropertyPath, $reference.RelationshipType if (-not $referenceKeys.Add($key)) { continue } $edge = [pscustomobject]@{ SourceId = $id TargetId = [string]$reference.TargetId RelationshipType = [string]$reference.RelationshipType PropertyPath = [string]$reference.PropertyPath } $uniqueReferences.Add($edge) $relationshipRows.Add($edge) } $providerProperties = @($enrichment | ForEach-Object { Get-ScoutResourceCompletenessValue -InputObject $_ -Path 'Properties.Payload.properties' } | Where-Object { $null -ne $_ } | Select-Object -First 1) $providerExposureProperties = $null if ($providerProperties.Count -gt 0) { $providerExposureProperties = $providerProperties[0] } $exposure = Get-ScoutExposureEvidence -Resource $resource -Relationships @($uniqueReferences) ` -ProviderProperties $providerExposureProperties $tags = Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'tags' $protectedProperties = Protect-ScoutDiscoveryValue -InputObject $properties -PropertyName 'properties' $protectedTags = Protect-ScoutDiscoveryValue -InputObject $tags -PropertyName 'tags' $protectedEnrichment = @( foreach ($enrichmentItem in $enrichment) { Protect-ScoutDiscoveryValue -InputObject $enrichmentItem -PropertyName 'enrichment' } ) $sourceKind = if ($type -like 'Entra/*') { 'Entra' } elseif ($type -like 'AZSC/*') { 'Enrichment' } elseif ($id -match '(?i)^/(subscriptions|providers)/') { 'ARM' } else { 'Other' } $resourceRows.Add([pscustomobject]@{ Id = $id Name = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'name') Type = $type SourceKind = $sourceKind TenantId = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'tenantId') SubscriptionId = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'subscriptionId') ResourceGroup = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'resourceGroup') Location = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'location') Kind = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'kind') ManagedBy = [string](Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'managedBy') Sku = Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'sku' Plan = Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'plan' Identity = Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'identity' Zones = @(Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'zones') ExtendedLocation = Get-ScoutResourceCompletenessValue -InputObject $resource -Path 'extendedLocation' Tags = $protectedTags TagCount = if ($null -eq $tags) { 0 } else { @($tags.PSObject.Properties).Count } Properties = $protectedProperties Enrichment = $protectedEnrichment PropertyCount = $propertyCount DetailStatus = $status DetailReasons = @($statusReasons) SpecializedCollectors = @($matchedCollectors | Sort-Object) CollectionHealth = @($resourceHealth) Exposure = $exposure.Classification ExposureConfidence = $exposure.Confidence ExposureEvidence = @($exposure.Evidence) RelationshipCount = $uniqueReferences.Count }) } $orderedResources = @($resourceRows | Sort-Object Type, SubscriptionId, ResourceGroup, Name, Id) $orderedRelationships = @($relationshipRows | Sort-Object SourceId, RelationshipType, TargetId, PropertyPath -Unique) [pscustomobject]@{ Schema = 'azure-scout/discovery-completeness/v1' # Run timing lives in the report/run metadata. Keeping this index content-derived makes # Discovery.json byte-identical for the same resource set, like every other cache file. GeneratedAt = $null Summary = [pscustomobject]@{ Resources = $orderedResources.Count Detailed = @($orderedResources | Where-Object DetailStatus -eq 'Detailed').Count GenericOnly = @($orderedResources | Where-Object DetailStatus -eq 'GenericOnly').Count Partial = @($orderedResources | Where-Object DetailStatus -eq 'Partial').Count Unavailable = @($orderedResources | Where-Object DetailStatus -eq 'Unavailable').Count Public = @($orderedResources | Where-Object Exposure -eq 'Public').Count Private = @($orderedResources | Where-Object Exposure -eq 'Private').Count Mixed = @($orderedResources | Where-Object Exposure -eq 'Mixed').Count ExposureNone = @($orderedResources | Where-Object Exposure -eq 'None').Count ExposureUnknown = @($orderedResources | Where-Object Exposure -eq 'Unknown').Count Relationships = $orderedRelationships.Count } Resources = $orderedResources Relationships = $orderedRelationships CollectionHealth = $healthRows } } |