ADAppFinder.psm1

#Region '.\Private\Get-ADHostManageability.ps1' 0
function Get-ADHostManageability {
    [OutputType([System.Collections.Hashtable])]
    # $this[0] : Name = Computername; Value = [array]RemotableHostNames
    # $this[1] : Name = Computername; Value = [array]Non-RemotableHostNames
    param (
        [int]$DaysInactive = 90,
        [switch]$Servers,
        [switch]$Workstations,
        [String]$OperatingSystemSearchString,
        [string]$SearchBase = $null
    )
    if ($SearchBase) {
        $Time = (Get-Date).Adddays( - ($DaysInactive))
        $AllComputers = Get-ADComputer -SearchBase $SearchBase -Filter { (LastLogonTimeStamp -gt $time) } -Properties Ipv4address
        $Comps = $allComputers.name
        $Params = @{}
        $Params.ComputerName = @()
        $NoRemoteAccess = @{}
        $NoRemoteAccess.NoRemoteAccess = @()
        foreach ($comp in $comps) {
            $testRemoting = Test-WSMan -ComputerName $comp -ErrorAction SilentlyContinue
            if ($null -ne $testRemoting ) {
                $params.ComputerName += $comp
            }
            else {
                $NoRemoteAccess.NoRemoteAccess += $comp
            }
        }
    }
    else {
        if ($Servers) {
            $Search = "*server*"
        }
        elseif ($Workstations) {
            $Search = "*windows 1*"
        }
        elseif ($OperatingSystemSearchString) {
            $Search = "*" + $OperatingSystemSearchString + "*"
        }
        $Time = (Get-Date).Adddays( - ($DaysInactive))
        $AllComputers = Get-ADComputer -Filter { (LastLogonTimeStamp -gt $time) -and (OperatingSystem -like $search) } -Properties Ipv4address
        $Comps = $allComputers.name
        $Params = @{}
        $Params.ComputerName = @()
        $NoRemoteAccess = @{}
        $NoRemoteAccess.NoRemoteAccess = @()
        foreach ($comp in $comps) {
            $testRemoting = Test-WSMan -ComputerName $comp -ErrorAction SilentlyContinue
            if ($null -ne $testRemoting ) {
                $params.ComputerName += $comp
            }
            else {
                $NoRemoteAccess.NoRemoteAccess += $comp
            }
        }
    }
    return $Params, $NoRemoteAccess
}
#EndRegion '.\Private\Get-ADHostManageability.ps1' 59
#Region '.\Private\SearchForRegUninstallKey.ps1' 0
function SearchForRegUninstallKey {
    [OutputType([System.Management.Automation.PSCustomObject])]
    param(
        [Parameter(
            Mandatory = $true,
            Position = 0
        )]
        [string[]]$SearchFor,
        [Parameter(
            Position = 1
        )]
        [switch]$Wow6432Node
    )
    $output = @()
    foreach ($item in $SearchFor) {
        $matched = @()
        $results = @()
        Get-ChildItem HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall | `
            ForEach-Object {
            $obj = New-Object psobject
            Add-Member -InputObject $obj -MemberType NoteProperty -Name GUID -Value $_.pschildname
            Add-Member -InputObject $obj -MemberType NoteProperty -Name DisplayName -Value $_.GetValue("DisplayName")
            Add-Member -InputObject $obj -MemberType NoteProperty -Name DisplayVersion -Value $_.GetValue("DisplayVersion")
            if ($Wow6432Node) {
                Add-Member -InputObject $obj -MemberType NoteProperty -Name Wow6432Node? -Value "No"
            }
            $results += $obj
            #$output += $results
        }# End ForEach-Object
        if ($Wow6432Node) {
            Get-ChildItem HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall | `
                ForEach-Object {
                $obj = New-Object psobject
                Add-Member -InputObject $obj -MemberType NoteProperty -Name GUID -Value $_.pschildname
                Add-Member -InputObject $obj -MemberType NoteProperty -Name DisplayName -Value $_.GetValue("DisplayName")
                Add-Member -InputObject $obj -MemberType NoteProperty -Name DisplayVersion -Value $_.GetValue("DisplayVersion")
                Add-Member -InputObject $obj -MemberType NoteProperty -Name Wow6432Node? -Value "Yes"
                $results += $obj
            }
        }
        $matched = $results | Sort-Object DisplayName | Where-Object { $_.DisplayName -match $item }
        if ($matched) {
            $output += $matched
        }
        else {
            $obj = New-Object psobject
            Add-Member -InputObject $obj -MemberType NoteProperty -Name GUID -Value "Missing: $item"
            Add-Member -InputObject $obj -MemberType NoteProperty -Name DisplayName -Value "Missing: $item"
            Add-Member -InputObject $obj -MemberType NoteProperty -Name DisplayVersion -Value "Missing: $item"
            Add-Member -InputObject $obj -MemberType NoteProperty -Name Wow6432Node? -Value "Missing: $item"
            $output += $obj
        }
    } # For Each
    return $output
} # End Function
#EndRegion '.\Private\SearchForRegUninstallKey.ps1' 56
#Region '.\Private\Test-IsAdmin.ps1' 0
function Test-IsAdmin {
    <#
    .SYNOPSIS
    Checks if the current user is an administrator on the machine.
    .DESCRIPTION
    This private function returns a Boolean value indicating whether
    the current user has administrator privileges on the machine.
    It does this by creating a new WindowsPrincipal object, passing
    in a WindowsIdentity object representing the current user, and
    then checking if that principal is in the Administrator role.
    .INPUTS
    None.
    .OUTPUTS
    Boolean. Returns True if the current user is an administrator, and False otherwise.
    .EXAMPLE
    PS C:\> Test-IsAdmin
    True
    #>


    # Create a new WindowsPrincipal object for the current user and check if it is in the Administrator role
    (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}
#EndRegion '.\Private\Test-IsAdmin.ps1' 23
#Region '.\Private\Write-AuditLog.ps1' 0
function Write-AuditLog {
    <#
    .SYNOPSIS
        Writes log messages to the console and updates the script-wide log variable.
    .DESCRIPTION
        The Write-AuditLog function writes log messages to the console based on the severity (Verbose, Warning, or Error) and updates
        the script-wide log variable ($script:LogString) with the log entry. You can use the Start, End, and EndFunction switches to
        manage the lifecycle of the logging.
    .INPUTS
        System.String
        You can pipe a string to the Write-AuditLog function as the Message parameter.
        You can also pipe an object with a Severity property as the Severity parameter.
    .OUTPUTS
        None
        The Write-AuditLog function doesn't output any objects to the pipeline. It writes messages to the console and updates the
        script-wide log variable ($script:LogString).
    .PARAMETER BeginFunction
        Sets the message to "Begin [FunctionName] function log.", where FunctionName is the name of the calling function, and adds it to the log variable.
    .PARAMETER Message
        The message string to log.
    .PARAMETER Severity
        The severity of the log message. Accepted values are 'Information', 'Warning', and 'Error'. Defaults to 'Information'.
    .PARAMETER Start
        Initializes the script-wide log variable and sets the message to "Begin [FunctionName] Log.", where FunctionName is the name of the calling function.
    .PARAMETER End
        Sets the message to "End Log" and exports the log to a CSV file if the OutputPath parameter is provided.
    .PARAMETER EndFunction
        Sets the message to "End [FunctionName] log.", where FunctionName is the name of the calling function, and adds it to the log variable.
    .PARAMETER OutputPath
        The file path for exporting the log to a CSV file when using the End switch.
    .EXAMPLE
        Write-AuditLog -Message "This is a test message."
 
        Writes a test message with the default severity (Information) to the console and adds it to the log variable.
    .EXAMPLE
        Write-AuditLog -Message "This is a warning message." -Severity "Warning"
 
        Writes a warning message to the console and adds it to the log variable.
    .EXAMPLE
        Write-AuditLog -Start
 
        Initializes the log variable and sets the message to "Begin [FunctionName] Log.", where FunctionName is the name of the calling function.
    .EXAMPLE
        Write-AuditLog -BeginFunction
 
        Sets the message to "Begin [FunctionName] function log.", where FunctionName is the name of the calling function, and adds it to the log variable.
    .EXAMPLE
        Write-AuditLog -EndFunction
 
        Sets the message to "End [FunctionName] log.", where FunctionName is the name of the calling function, and adds it to the log variable.
    .EXAMPLE
        Write-AuditLog -End -OutputPath "C:\Logs\auditlog.csv"
 
        Sets the message to "End Log", adds it to the log variable, and exports the log to a CSV file.
    .NOTES
    Author: DrIOSx
#>

    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        ###
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Input a Message string.',
            Position = 0,
            ParameterSetName = 'Default',
            ValueFromPipeline = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Message,
        ###
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Information, Warning or Error.',
            Position = 1,
            ParameterSetName = 'Default',
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [ValidateSet('Information', 'Warning', 'Error')]
        [string]$Severity = 'Information',
        ###
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'End'
        )]
        [switch]$End,
        ###
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'BeginFunction'
        )]
        [switch]$BeginFunction,
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'EndFunction'
        )]
        [switch]$EndFunction,
        ###
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'Start'
        )]
        [switch]$Start,
        ###
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'End'
        )]
        [string]$OutputPath
    )
    begin {
        $ErrorActionPreference = "SilentlyContinue"
        # Define variables to hold information about the command that was invoked.
        $ModuleName = $Script:MyInvocation.MyCommand.Name -replace '\..*'
        $FuncName = (Get-PSCallStack)[1].Command
        $ModuleVer = $MyInvocation.MyCommand.Version.ToString()
        # Set the error action preference to continue.
        $ErrorActionPreference = "Continue"
    }
    process {
        try {
            $Function = $($FuncName + '.v' + $ModuleVer)
            if ($Start) {
                $script:LogString = @()
                $Message = '+++ Begin Log | ' + $Function + ' |'
            }
            elseif ($BeginFunction) {
                $Message = '>>> Begin Function Log | ' + $Function + ' |'
            }
            $logEntry = [pscustomobject]@{
                Time      = ((Get-Date).ToString('yyyy-MM-dd hh:mmTss'))
                Module    = $ModuleName
                PSVersion = ($PSVersionTable.PSVersion).ToString()
                PSEdition = ($PSVersionTable.PSEdition).ToString()
                IsAdmin   = $(Test-IsAdmin)
                User      = "$Env:USERDOMAIN\$Env:USERNAME"
                HostName  = $Env:COMPUTERNAME
                InvokedBy = $Function
                Severity  = $Severity
                Message   = $Message
                RunID     = -1
            }
            if ($BeginFunction) {
                $maxRunID = ($script:LogString | Where-Object { $_.InvokedBy -eq $Function } | Measure-Object -Property RunID -Maximum).Maximum
                if ($null -eq $maxRunID) { $maxRunID = -1 }
                $logEntry.RunID = $maxRunID + 1
            }
            else {
                $lastRunID = ($script:LogString | Where-Object { $_.InvokedBy -eq $Function } | Select-Object -Last 1).RunID
                if ($null -eq $lastRunID) { $lastRunID = 0 }
                $logEntry.RunID = $lastRunID
            }
            if ($EndFunction) {
                $FunctionStart = "$((($script:LogString | Where-Object {$_.InvokedBy -eq $Function -and $_.RunId -eq $lastRunID } | Sort-Object Time)[0]).Time)"
                $startTime = ([DateTime]::ParseExact("$FunctionStart", 'yyyy-MM-dd hh:mmTss', $null))
                $endTime = Get-Date
                $timeTaken = $endTime - $startTime
                $Message = '<<< End Function Log | ' + $Function + ' | Runtime: ' + "$($timeTaken.Minutes) min $($timeTaken.Seconds) sec"
                $logEntry.Message = $Message
            }
            elseif ($End) {
                $startTime = ([DateTime]::ParseExact($($script:LogString[0].Time), 'yyyy-MM-dd hh:mmTss', $null))
                $endTime = Get-Date
                $timeTaken = $endTime - $startTime
                $Message = '--- End Log | ' + $Function + ' | Runtime: ' + "$($timeTaken.Minutes) min $($timeTaken.Seconds) sec"
                $logEntry.Message = $Message
            }
            $script:LogString += $logEntry
            switch ($Severity) {
                'Warning' {
                    Write-Warning ('[WARNING] ! ' + $Message)
                    $UserInput = Read-Host "Warning encountered! Do you want to continue? (Y/N)"
                    if ($UserInput -eq 'N') {
                        Write-Output "Script execution stopped by user!"
                        exit
                    }
                }
                'Error'       { Write-Error ('[ERROR] X - ' + $FuncName + ' ' + $Message) -ErrorAction Continue }
                'Verbose'     { Write-Verbose ('[VERBOSE] ~ ' + $Message) }
                Default { Write-Information ('[INFORMATION] * ' + $Message)  -InformationAction Continue}
            }
        }
        catch {
            throw "Write-AuditLog encountered an error (process block): $($_.Exception.Message)"
        }

    }
    end {
        try {
            if ($End) {
                if (-not [string]::IsNullOrEmpty($OutputPath)) {
                    $script:LogString | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8
                    Write-Verbose "LogPath: $(Split-Path -Path $OutputPath -Parent)"
                }
                else {
                    throw "OutputPath is not specified for End action."
                }
            }
        }
        catch {
            throw "Error in Write-AuditLog (end block): $($_.Exception.Message)"
        }
    }
}
#EndRegion '.\Private\Write-AuditLog.ps1' 205
#Region '.\Public\Find-ADHostApp.ps1' 0
function Find-ADHostApp {
<#
    .SYNOPSIS
        Searches AD Computers uninstall registry nodes for input Strings for installed app finding.
    .DESCRIPTION
        Searches computers using Invoke-Command passing functions to remote hosts. It will
        search the registry for strings matching a provided array of app names. By default,
        the local computer on which the script is running will not be scanned unless the
        'Local' switch is used. If the 'Local' switch is used, ONLY the local computer will be scanned.
    .PARAMETER AppNames
        Array of one or more strings to search for apps: "Spiceworks","Microsoft","Adobe".
    .PARAMETER DaystoConsiderAHostInactive
        How many days back to consider an AD Computer last sign in as active.
    .PARAMETER SearchServers
        Enter one or more filenames.
    .PARAMETER SearchWorkstations
        Search Windows 10 and 11 workstations.
    .PARAMETER SearchOSString
        Search using custom OS Search String.
    .PARAMETER ComputerNames
        Search using specific hosts assumed to be online.
    .PARAMETER SearchBase
        Search a specific Organizational Unit: "OU=Infrastructure,OU=CorpComputers,DC=ad,DC=fabuloso,DC=com".
    .PARAMETER Filter
        Use a standard Filter: "Name -like "*PDC*"". Defaults to Wildcard.
    .PARAMETER Report
        Enable this switch to output a CSV Report.
    .PARAMETER DirPath
        Enter the working directory you wish the report to save to. Default creates C:\temp.
    .PARAMETER IncludeWow6432Node
        Also Search Wow6432Node.
    .PARAMETER Local
        Include the local machine in the scan. If set, ONLY the local computer will be scanned.
    .EXAMPLE
        Find-ADHostApp -AppNames "Adobe","Microsoft","Carbon" -ComputerNames "pdc-00","pdc-ha-00" -IncludeWow6432Node
            Output:
                GUID : Missing: Adobe
                DisplayName : Missing: Adobe
                DisplayVersion : Missing: Adobe
                Wow6432Node? : Missing: Adobe
                PSComputerName : pdc-00
                RunspaceId : 47d370fb-f095-4bcf-a036-40997cb5af12
 
                GUID : {F1BECD79-0887-4630-957B-108C894264AD}
                DisplayName : Microsoft Azure AD Connect Health agent for AD DS
                DisplayVersion : 3.1.77.0
                Wow6432Node? : No
                PSComputerName : pdc-00
                RunspaceId : 47d370fb-f095-4bcf-a036-40997cb5af12
    .NOTES
        The function defaults to Searching all remotable host in a domain found to have a login
        within the last 90 days. It does not take into account the state of the product if
        a service is involved. This would require a manual check on the specific service.
    .LINK
        https://criticalsolutionsnetwork.github.io/ADAppFinder
#>


    [CmdletBinding(DefaultParameterSetName = 'Default')]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param (
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'Array of one or more strings to search for apps: "Spiceworks","Microsoft","Adobe"',
            Position = 0
        )]
        [string[]]$AppNames,

        [Parameter(
            HelpMessage = 'How many days back to consider an AD Computer last sign in as active',
            Position = 1
        )]
        [int]$DaystoConsiderAHostInactive = 90,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Default',
            HelpMessage = 'Search Servers',
            Position = 2
        )]
        [switch]$SearchServers,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'SearchBase',
            HelpMessage = 'Search a specific Organizational Unit',
            Position = 2
        )]
        [string]$SearchBase,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'SearchBase',
            HelpMessage = 'Use a standard Filter: "Name -like "*PDC*"". Defaults to Wildcard',
            Position = 3
        )]
        [string]$Filter = "*",

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'SearchOSString',
            HelpMessage = 'Search using custom OS Search String',
            Position = 2
        )]
        [string]$SearchOSString,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'SearchWorkstations',
            HelpMessage = 'Search Windows 10 and 11 workstations',
            Position = 2
        )]
        [switch]$SearchWorkstations,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'SearchComputers',
            HelpMessage = 'Search using specific hosts assumed to be online.',
            Position = 2
        )]
        [string[]]$ComputerNames,

        [Parameter(
            Mandatory = $true,
            HelpMessage = 'Include the local machine in the scan. If set, ONLY the local computer will be scanned.',
            ParameterSetName = 'Local',
            Position = 2
        )]
        [switch]$Local,

        [Parameter(
            HelpMessage = 'Enable this switch to output a CSV Report.'
        )]
        [switch]$Report,

        [Parameter(
            HelpMessage = 'Enter the working directory you wish the report to save to. Default creates C:\temp'
        )]
        [string]$DirPath = 'C:\Temp\',

        [Parameter(
            HelpMessage = 'Also Search Wow6432Node.'
        )]
        [switch]$IncludeWow6432Node


    )

    begin {
        if (!($script:LogString)) {
            Write-AuditLog -Start
        }
        else {
            Write-AuditLog -BeginFunction
        }
        Write-AuditLog -Message "Starting Find-ADHostApp"
        if ($Report) {
            # Create temp directory if
            [bool]$DirPathCheck = Test-Path -Path $DirPath
            If (!($DirPathCheck)) {
                Try {
                    #If not present then create the dir
                    New-Item -ItemType Directory $DirPath -Force
                }
                Catch {
                    Write-Output "The Directory $DirPath was not created and does not exist. Evalate your permissions or modify the `$DirPath variable to suit."
                    break
                }
            }
        }
        # Logging message about local machine scanning
        if ($Local) {
            if (!(Test-IsAdmin)) {
                Write-AuditLog -Message "The user must be running as administrator to scan the local machine. Exiting." -Severity Error
                break
            }
            Write-AuditLog -Message "Local switch was used. Only the local computer will be scanned."
        }
        else {
            Write-AuditLog -Message "Unless using the 'local' switch, the local machine the script is running on will not be scanned."
        }
        if ($Local) {
            $computers = $env:COMPUTERNAME
        }
        elseif ($ComputerNames) {
            $computers = $ComputerNames | Where-Object { $_ -ne $env:COMPUTERNAME }
        }
        elseif (($SearchServers) -or ($SearchWorkstations) -or ($SearchOSString)) {
            $Params, $NoRemoteAccess = Get-ADHostManageability -DaysInactive $DaystoConsiderAHostInactive -Servers:$SearchServers -Workstations:$SearchWorkstations -OperatingSystemSearchString $SearchOSString
            if ($params.ComputerName) {
                $computers = $Params.ComputerName | Where-Object { $_ -ne $env:COMPUTERNAME }
                $NonReachable = $NoRemoteAccess.NoRemoteAcces
            }
            else {
                Write-AuditLog "No computers were found to be online. Exiting."
                break
            }
        }
        elseif ($SearchBase) {
            $Params, $NoRemoteAccess = Get-ADHostManageability -DaysInactive $DaystoConsiderAHostInactive -SearchBase $SearchBase
            if ($params.ComputerName) {
                $computers = $Params.ComputerName | Where-Object { $_ -ne $env:COMPUTERNAME }
                $NonReachable = $NoRemoteAccess.NoRemoteAcces
            }
            else {
                Write-AuditLog "No computers were found to be online. Exiting."
                break
            }
        }
    } # End Begin Block
    process {
        try {
            Write-AuditLog -Message "Invoking command on remote computers." -Severity "Information"
            Write-AuditLog -Message "The computers are: `n$(($Computers -join ", "))" -Severity "Information"
            $results = Invoke-Command -ComputerName $computers -ScriptBlock ${Function:SearchForRegUninstallKey} -ArgumentList $AppNames, $IncludeWow6432Node
        }
        catch {
            Write-AuditLog -Message "An error occurred while invoking the command on remote computers: $_" -Severity "Error"
        }
    } # End Process Block
    end {
        try {
            if ($Report) {
                if ($ComputerNames) {
                    $results | Export-Csv "$DirPath\$((Get-Date).ToString('yyyy-MM-dd_hh.mm.ss'))_$($env:USERDNSDOMAIN)\ADAppInstallStatus.csv" -NoTypeInformation
                } # End If $ComputerNames
                else {
                    $computers | Out-File "$DirPath\$((Get-Date).ToString('yyyy-MM-dd_hh.mm.ss'))_$($env:USERDNSDOMAIN)\ReachableHosts.txt" -NoTypeInformation
                    $NonReachable | Out-File "$DirPath\$((Get-Date).ToString('yyyy-MM_dd_hh.mm.ss'))_$($env:USERDNSDOMAIN)\NonReachableHosts.txt" -NoTypeInformation
                    $results | Export-Csv "$DirPath\$((Get-Date).ToString('yyyy-MM-dd_hh.mm.ss'))_$($env:USERDNSDOMAIN)\ADAppInstallStatus.csv" -NoTypeInformation
                }
            } # End If $Report
        }
        catch {
            Write-AuditLog -Message "An error occurred during the end block of Find-ADHostApp: $_" -Severity "Error"
        }
        Write-AuditLog -EndFunction
        return $results
    } #End Block
}
#EndRegion '.\Public\Find-ADHostApp.ps1' 240