powershellobjectpropertiespowershell-v5.1

How to search an object for a value?


Let's say you have a giant object - one which may or may not have nested arrays / objects,

# Assuming 'user1' exists in the current domain    
$obj = Get-ADUser 'user1' -Properties *

and I want to search that object for the string SMTP case-insensitively...

What I tried

$obj | Select-String "SMTP"

But it does not work because the match is inside a nested Collection... to be concise, it sits inside the property $obj.proxyAddresses.

If I run $obj.proxyAddress.GetType() it returns:

IsPublic IsSerial Name                      BaseType
-------- -------- ----                      --------
True     False    ADPropertyValueCollection System.Collections.CollectionBase

What's the best way to go about this? I know you could loop through the properties and look for it manually using wildcard matching or .Contains(), but I'd prefer a built in solution.

Thus, it would be a grep for objects and not only strings.


Solution

  • Here's one solution. It can be very slow depending on what depth you search to; but a depth of 1 or 2 works well for your scenario:

    function Find-ValueMatchingCondition {
        Param (
            [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
            [PSObject]$InputObject
            ,
            [Parameter(Mandatory = $true)]
            [ScriptBlock]$Condition
            ,
            [Parameter()]
            [Int]$Depth = 10
            ,
            [Parameter()]
            [string]$Name = 'InputObject'
            ,
            [Parameter()]
            [System.Management.Automation.PSMemberTypes]$PropertyTypesToSearch = ([System.Management.Automation.PSMemberTypes]::Properties)
    
        )
        Process {
            if ($InputObject -ne $null) {
                if ($InputObject | Where-Object -FilterScript $Condition) {
                    New-Object -TypeName 'PSObject' -Property @{Name=$Name;Value=$InputObject}
                }
                #also test children (regardless of whether we've found a match
                if (($Depth -gt 0)  -and -not ($InputObject.GetType().IsPrimitive -or ($InputObject -is 'System.String'))) {
                    [string[]]$members = Get-Member -InputObject $InputObject -MemberType $PropertyTypesToSearch | Select-Object -ExpandProperty Name
                    ForEach ($member in $members) {
                        $InputObject."$member" | Where-Object {$_ -ne $null} | Find-ValueMatchingCondition -Condition $Condition -Depth ($Depth - 1) -Name $member | ForEach-Object {$_.Name = ('{0}.{1}' -f $Name, $_.Name);$_}
                    }
                }
            }
        }
    }
    Get-AdUser $env:username -Properties * `
        | Find-ValueMatchingCondition -Condition {$_ -like '*SMTP*'} -Depth 2
    

    Example Results:

    Value                                           Name                                  
    -----                                           ----                                  
    smtp:SomeOne@myCompany.com                      InputObject.msExchShadowProxyAddresses
    SMTP:some.one@myCompany.co.uk                   InputObject.msExchShadowProxyAddresses
    smtp:username@myCompany.com                     InputObject.msExchShadowProxyAddresses
    smtp:some.one@myCompany.mail.onmicrosoft.com    InputObject.msExchShadowProxyAddresses    
    smtp:SomeOne@myCompany.com                      InputObject.proxyAddresses  
    SMTP:some.one@myCompany.co.uk                   InputObject.proxyAddresses  
    smtp:username@myCompany.com                     InputObject.proxyAddresses  
    smtp:some.one@myCompany.mail.onmicrosoft.com    InputObject.proxyAddresses     
    SMTP:some.one@myCompany.mail.onmicrosoft.com    InputObject.targetAddress  
    

    Explanation

    Find-ValueMatchingCondition is a function which takes a given object (InputObject) and tests each of its properties against a given condition, recursively.

    The function is divided into two parts. The first part is the testing of the input object itself against the condition:

    if ($InputObject | Where-Object -FilterScript $Condition) {
        New-Object -TypeName 'PSObject' -Property @{Name=$Name;Value=$InputObject}
    }
    

    This says, where the value of $InputObject matches the given $Condition then return a new custom object with two properties; Name and Value. Name is the name of the input object (passed via the function's Name parameter), and Value is, as you'd expect, the object's value. If $InputObject is an array, each of the values in the array is assessed individually. The name of the root object passed in is defaulted as "InputObject"; but you can override this value to whatever you like when calling the function.

    The second part of the function is where we handle recursion:

    if (($Depth -gt 0)  -and -not ($InputObject.GetType().IsPrimitive -or ($InputObject -is 'System.String'))) {
        [string[]]$members = Get-Member -InputObject $InputObject -MemberType $PropertyTypesToSearch | Select-Object -ExpandProperty Name
        ForEach ($member in $members) {
            $InputObject."$member" | Where-Object {$_ -ne $null} | Find-ValueMatchingCondition -Condition $Condition -Depth ($Depth - 1) -Name $member | ForEach-Object {$_.Name = ('{0}.{1}' -f $Name, $_.Name);$_}
        }
    }
    

    The If statement checks how deep we've gone into the original object (i.e. since each of an objects properties may have properties of their own, to a potentially infinite level (since properties may point back to the parent), it's best to limit how deep we can go. This is essentially the same purpose as the ConvertTo-Json's Depth parameter.

    The If statement also checks the object's type. i.e. for most primitive types, that type holds the value, and we're not interested in their properties/methods (primitive types don't have any properties, but do have various methods, which may be scanned depending on $PropertyTypeToSearch). Likewise if we're looking for -Condition {$_ -eq 6} we wouldn't want all strings of length 6; so we don't want to drill down into the string's properties. This filter could likely be improved further to help ignore other types / we could alter the function to provide another optional script block parameter (e.g. $TypeCondition) to allow the caller to refine this to their needs at runtime.

    After we've tested whether we want to drill down into this type's members, we then fetch a list of members. Here we can use the $PropertyTypesToSearch parameter to change what we search on. By default we're interested in members of type Property; but we may want to only scan those of type NoteProperty; especially if dealing with custom objects. See https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.psmembertypes?view=powershellsdk-1.1.0 for more info on the various options this provides.

    Once we've selected what members/properties of the input object we wish to inspect, we fetch each in turn, ensure they're not null, then recurse (i.e. call Find-ValueMatchingCondition). In this recursion, we decrement $Depth by one (i.e. since we've already gone down 1 level & we stop at level 0), and pass the name of this member to the function's Name parameter.

    Finally, for any returned values (i.e. the custom objects created by part 1 of the function, as outlined above), we prepend the $Name of our current InputObject to the name of the returned value, then return this amended object. This ensures that each object returned has a Name representing the full path from the root InputObject down to the member matching the condition, and gives the value which matched.