Modules/Private/Main/Get-AZSCSafeProperty.ps1
|
<#
.Synopsis Reads a (possibly nested) property off a single object, safely under StrictMode. .DESCRIPTION PowerShell's member access ($object.Name) is unsafe under Set-StrictMode -Version Latest in a way that is easy to miss: a $null VALUE is fine (the NoteProperty exists, it just holds nothing), but an ABSENT property throws The property 'Name' cannot be found on this object. Verify that the property exists. Real Azure API responses (Resource Graph's `properties` blob in particular) only include a field when the resource actually has something to say about it -- an optional block like `licenseType`, `availabilitySet` or `diagnosticsProfile` is simply missing from the JSON, not present-and-null, on the (very common) resources that do not use it. The 176 InventoryModules collectors were written and tested without StrictMode, so they read deep chains like `$data.osProfile.windowsConfiguration.enableAutomaticUpdates` unconditionally; under StrictMode the very first VM whose OS profile does not carry that block throws (AB#5667), and because which field is missing depends on which resources a tenant actually has, the failure is data-dependent and different on every estate -- the same shape of defect AB#5633 already fixed for member ENUMERATION over a collection. This is its single-object counterpart. This function walks a dotted path one segment at a time and returns $null the moment any segment is missing or its value is $null, rather than letting the chain throw. It is a read-only, side-effect-free helper -- nothing about a collector's output changes when every field in the path is actually present. .PARAMETER InputObject The object to read from. $null is accepted and returns $null. .PARAMETER Path A dot-separated property path, e.g. 'osProfile.windowsConfiguration.enableAutomaticUpdates'. Each segment is looked up case-insensitively, matching PowerShell's own member resolution. .PARAMETER Enumerate Reproduce PowerShell's MEMBER ENUMERATION for any intermediate segment whose value is a collection, instead of treating the collection itself as the object to read from. Without this switch, a path that crosses an array returns $null at that point: an array has no NoteProperty called `properties`, so 'networkProfile.networkInterfaceConfigurations.properties.x' stops dead. Unguarded PowerShell does NOT stop there -- it enumerates the array, reads the segment off every element, and flattens the results (returning a bare scalar when exactly one element yields a value). Several collectors depend on precisely that, so making their chains StrictMode-safe without this switch would silently change the values they emit. With the switch, each element is read explicitly and elements lacking the segment contribute nothing, so an EMPTY collection -- or a collection where no element carries the key -- yields $null rather than the The property 'x' cannot be found on this object. that member enumeration throws under StrictMode when it yields nothing at all (AB#5633). Off by default: single-object paths must not start silently enumerating. .OUTPUTS The value at the end of the path, or $null if $InputObject is $null, or any segment along the way is absent, or any intermediate value is $null. .EXAMPLE Get-AZSCSafeProperty -InputObject $data -Path 'osProfile.windowsConfiguration.enableAutomaticUpdates' .EXAMPLE $data.PROPERTIES | Get-AZSCSafeProperty -Path 'storageProfile.osDisk.vhd.uri' .EXAMPLE # networkInterfaceConfigurations is an array -- without -Enumerate this returns $null even when # every NIC config carries the field. Get-AZSCSafeProperty -InputObject $data -Enumerate ` -Path 'virtualMachineProfile.networkProfile.networkInterfaceConfigurations.properties.networkSecurityGroup.id' .COMPONENT This PowerShell Module is part of Azure Scout (AZSC). #> function Get-AZSCSafeProperty { [CmdletBinding()] Param( [Parameter(Position = 0, ValueFromPipeline)] [AllowNull()] $InputObject, [Parameter(Mandatory, Position = 1)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter()] [switch]$Enumerate ) begin { # Local, not a second module-surface function: one segment read off one element for the # -Enumerate walk. It reports PRESENCE separately from VALUE, because member enumeration # distinguishes them -- an element carrying the key with a $null value contributes a $null # to the result, while an element missing the key contributes nothing at all. $ReadSegment = { param($Object, [string] $Name) if ($null -eq $Object) { return @{ Found = $false; Value = $null } } if ($Object -is [System.Collections.IDictionary]) { # .Contains rather than .ContainsKey: IDictionary guarantees the former. if ($Object.Contains($Name)) { return @{ Found = $true; Value = $Object[$Name] } } return @{ Found = $false; Value = $null } } # PSObject.Properties lookup never throws for an absent member, unlike $Object.$Name. $Property = $Object.PSObject.Properties[$Name] if ($Property) { return @{ Found = $true; Value = $Property.Value } } return @{ Found = $false; Value = $null } } } process { $Current = $InputObject foreach ($Segment in ($Path -split '\.')) { if ($null -eq $Current) { return $null } # A string is IEnumerable (over its characters) and a dictionary is IEnumerable (over # its entries); neither is what member enumeration means, so both are excluded. $IsCollection = $Current -is [System.Collections.IEnumerable] -and $Current -isnot [string] -and $Current -isnot [System.Collections.IDictionary] # PowerShell resolves a member on a collection against the COLLECTION'S OWN members # first and only falls back to member enumeration when there is no such member -- which # is why `$array.Count` is the length of the array and not a per-element read. Mirror # that precedence, or a path ending in .Count/.Length would silently change meaning. $OwnMember = $null if ($Enumerate -and $IsCollection) { $OwnMember = $Current.PSObject.Properties[$Segment] } if ($Enumerate -and $IsCollection -and -not $OwnMember) { # Member enumeration, done explicitly: read the segment off every element and # flatten. Elements that lack it contribute nothing (that is the part unguarded # PowerShell turns into a throw when NOTHING at all is contributed). $Collected = @( foreach ($Element in $Current) { $Read = & $ReadSegment $Element $Segment # Emitted bare, not comma-wrapped: member enumeration FLATTENS, so an # element whose value is itself an array contributes its elements. if ($Read.Found) { $Read.Value } } ) # PowerShell unwraps a single-element enumeration result to a scalar; match that # exactly, or `.split('/')` on the result would start seeing an array. $Current = switch ($Collected.Count) { 0 { $null } 1 { $Collected[0] } default { $Collected } } continue } if ($Current -is [System.Collections.IDictionary]) { if ($Current.Contains($Segment)) { $Current = $Current[$Segment] } else { return $null } continue } $Property = $Current.PSObject.Properties[$Segment] if ($Property) { $Current = $Property.Value } else { return $null } } return $Current } } |