Private/Kinds/NetworkDependencySource.ps1
|
# The sources of the NetworkDependency Gatherer: a part of that Kind, and no Kind. # # A source reads one place a dependency leaves a trace - the running processes and their # modules, services, scheduled tasks, startup entries, shortcuts, registered application # paths, shell folders, Offline Files, printers, ODBC data sources, configuration files - # and returns traces made with New-NetworkDependencyTrace. It knows nothing about drive # letters or Servers: Get-NetworkDependencyData hands what it returns to # ConvertTo-NetworkDependency. The list of sources, and what each one's rows carry, opens # NetworkDependency.ps1. function Get-NetworkDependencyProcessTrace { <# .SYNOPSIS Source: the program path of every process in the logged-on user's session. .DESCRIPTION The session rather than the user name, because asking who owns a process needs admin rights and the session does not: it is the desktop the Technician is looking at, whoever's credentials the Run was started with. A process this Run may not open gives no path. It is recorded by name, so the Report can say what was not looked at instead of implying it runs locally. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined, # What the Elevated Part read of the processes, or nothing where it did not. [AllowNull()]$Elevated ) $sessionId = [Diagnostics.Process]::GetCurrentProcess().SessionId $seen = @{} $unread = @{} foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) { if ($process.SessionId -ne $sessionId) { continue } $name = "$($process.ProcessName)" $path = $null try { $path = $process.Path } catch { } if (-not $path) { # Read with admin rights where this session could not: every path that # reading has for a process of this name in this session. $filled = @(Select-NetworkDependencyElevatedPath -Elevated $Elevated -Name $name -SessionId $sessionId) if ($filled.Count) { foreach ($one in $filled) { $key = ('{0}|{1}' -f $name, $one).ToUpperInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true New-NetworkDependencyTrace -Application $name -Path $one -Kind 'program' -Source 'process' } continue } if (-not $unread.ContainsKey($name)) { $unread[$name] = $true $Unexamined.Add((New-NetworkDependencyUnexamined -What $name -Reason 'PathNotReadable')) } continue } $key = ('{0}|{1}' -f $name, $path).ToUpperInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true New-NetworkDependencyTrace -Application $name -Path $path -Kind 'program' -Source 'process' } } # The Win32 error codes the loaded modules source tells apart. Every other code is a # module list that could not be read. $script:NetworkDependencyErrorAccessDenied = 5 $script:NetworkDependencyErrorNoSuchProcess = 87 function Initialize-NetworkDependencyNativeMethod { <# .SYNOPSIS Defines the Win32 calls that list a process's modules, once per session. .DESCRIPTION Process.Modules is not used: under Windows PowerShell 5.1 it returns seven system DLLs for a 32-bit process, without an error, and misses a DLL loaded from a share. EnumProcessModulesEx with LIST_MODULES_ALL returns the whole list on both editions (docs/research/process-modules-on-windows-powershell.md). A type of its own, because Gutcheck.Native in App.ps1 is defined once per session and cannot gain members. Compiled on first use and not at import, for the reason Initialize-AppNativeMethod gives. The loop is in the type and not in the Gatherer for two reasons: the last Win32 error has to be read before anything else runs on the thread, and a machine has some twelve thousand modules to name. A failure comes back as its error code; nothing here throws. #> [CmdletBinding()] param() if ('Gutcheck.ModuleNative' -as [type]) { return } Add-Type -Namespace Gutcheck -Name ModuleNative -MemberDefinition @' [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenProcess(uint access, [MarshalAs(UnmanagedType.Bool)] bool inherit, int processId); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(IntPtr handle); [DllImport("psapi.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool EnumProcessModulesEx(IntPtr process, [Out] IntPtr[] modules, int cb, out int needed, uint filter); [DllImport("psapi.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern uint GetModuleFileNameEx(IntPtr process, IntPtr module, System.Text.StringBuilder name, int size); // PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, and LIST_MODULES_ALL. private const uint Access = 0x1010; private const uint AllModules = 3; // The paths of the modules a process has loaded. On failure the list is empty and error // holds the Win32 error code; on success error is 0. public static string[] GetModulePath(int processId, out int error) { error = 0; System.Collections.Generic.List<string> paths = new System.Collections.Generic.List<string>(); IntPtr process = OpenProcess(Access, false, processId); if (process == IntPtr.Zero) { error = Marshal.GetLastWin32Error(); if (error == 0) { error = -1; } return paths.ToArray(); } try { IntPtr[] modules = new IntPtr[1024]; int count = 0; // The list can grow between two calls, so the buffer is grown more than once. for (int attempt = 0; attempt < 4; attempt++) { int cb = modules.Length * IntPtr.Size; int needed; if (!EnumProcessModulesEx(process, modules, cb, out needed, AllModules)) { error = Marshal.GetLastWin32Error(); if (error == 0) { error = -1; } return paths.ToArray(); } count = Math.Min(needed, cb) / IntPtr.Size; if (needed <= cb) { break; } modules = new IntPtr[needed / IntPtr.Size + 64]; } System.Text.StringBuilder name = new System.Text.StringBuilder(1024); for (int i = 0; i < count; i++) { name.Length = 0; uint length = GetModuleFileNameEx(process, modules[i], name, name.Capacity); if (length >= (uint)(name.Capacity - 1) && name.Capacity < 32768) { // Cut short: a long path. Asked again with room for the longest there is. name = new System.Text.StringBuilder(32768); length = GetModuleFileNameEx(process, modules[i], name, name.Capacity); } // A module unloaded since the list was read has no name any more. if (length > 0) { paths.Add(name.ToString()); } } } finally { CloseHandle(process); } return paths.ToArray(); } '@ } function Get-NetworkDependencyProcessModule { <# .SYNOPSIS The modules loaded by every process in the logged-on user's session and by every process that hosts a service. One row per process; interprets none of it. .DESCRIPTION Which processes host a service is an argument. Returns { ProcessName, ProcessId, IsService, Modules, Error }. Modules is every path, local ones included. Error is the Win32 error code that stopped the reading, or $null. IsService says the process was selected because it hosts a service and is not in the session: without admin rights none of those can be opened, which the caller reports as a number and not as a hundred names. A process that ended between being listed and being opened is not returned: there is nothing left that was not examined. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()][int[]]$ServiceProcessId) Initialize-NetworkDependencyNativeMethod $hosts = @{} foreach ($id in @($ServiceProcessId)) { $hosts[[int]$id] = $true } $sessionId = [Diagnostics.Process]::GetCurrentProcess().SessionId $selected = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { # 0 and 4 are the idle process and the kernel: no module list to read. $_.Id -ne 0 -and $_.Id -ne 4 -and ($_.SessionId -eq $sessionId -or $hosts.ContainsKey([int]$_.Id)) }) $status = Get-Text 'Console.Progress.Modules' $index = 0 foreach ($process in $selected) { # In twenty steps and not one per process: a bar drawn three hundred times costs # Windows PowerShell 5.1 more than a second of the four this takes. Write-CheckProgress -Status $status -Step ([math]::Floor($index * 20 / $selected.Count)) -Of 20 $index++ $code = 0 $modules = @([Gutcheck.ModuleNative]::GetModulePath($process.Id, [ref]$code)) if ($code -eq $script:NetworkDependencyErrorNoSuchProcess) { continue } [pscustomobject]@{ ProcessName = "$($process.ProcessName)" ProcessId = [int]$process.Id IsService = $process.SessionId -ne $sessionId Modules = [string[]]$modules Error = $(if ($code) { $code } else { $null }) } } } function Select-NetworkDependencyElevatedPath { <# .SYNOPSIS Where the Elevated Part found the program of a process this session could not open: the paths of the processes of that name in that session. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Elevated, [Parameter(Mandatory)][string]$Name, [int]$SessionId) (Get-DataCollection $Elevated 'Processes') | Where-Object { "$(Get-DataProperty $_ 'Name')" -eq $Name -and (ConvertTo-Number (Get-DataProperty $_ 'SessionId')) -eq $SessionId -and "$(Get-DataProperty $_ 'Path')" } | ForEach-Object { "$(Get-DataProperty $_ 'Path')" } | Select-Object -Unique } function Join-NetworkDependencyElevatedModule { <# .SYNOPSIS The modules this session read, with those the Elevated Part read beside them, and what is then still unread. Pure. .DESCRIPTION Where the Elevated Part read, a process this session was refused is no longer unread: the service processes as a number, and a process of the session by name. What even an administrator could not open - a protected process of Windows - is said instead, from the Elevated Part's own list. A module both found is one trace. Where the Elevated Part read nothing, everything is as this session read it. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$Read, [AllowNull()]$Elevated) $traces = @((Get-DataCollection $Read 'Traces') | ForEach-Object { $_ }) $unexamined = @((Get-DataCollection $Read 'Unexamined') | ForEach-Object { $_ }) # Whether the Elevated Part read the modules at all, and not whether it found any on # a share: a reading that found none is a reading, and most machines have none. $wasRead = $false if ($Elevated -is [System.Collections.IDictionary]) { $wasRead = $Elevated.Contains('ModuleTraces') } elseif ($null -ne $Elevated) { $wasRead = $null -ne $Elevated.PSObject.Properties['ModuleTraces'] } if (-not $wasRead) { return [pscustomobject]@{ Traces = $traces; Unexamined = $unexamined } } $seen = @{} foreach ($trace in $traces) { $seen[('{0}|{1}' -f (Get-DataProperty $trace 'Application'), (Get-DataProperty $trace 'Path')).ToUpperInvariant()] = $true } foreach ($trace in (Get-DataCollection $Elevated 'ModuleTraces')) { $key = ('{0}|{1}' -f (Get-DataProperty $trace 'Application'), (Get-DataProperty $trace 'Path')).ToUpperInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true $traces += New-NetworkDependencyTrace -Application "$(Get-DataProperty $trace 'Application')" -Path "$(Get-DataProperty $trace 'Path')" -Kind 'program' -Source 'module' } $refused = 'ServiceAccessDenied', 'ModulesAccessDenied' $left = @($unexamined | Where-Object { $refused -notcontains "$(Get-DataProperty $_ 'Reason')" }) # What the Elevated Part was refused as well is no question of rights any more: # Windows protects those processes from an administrator too, and it is said so. $left += @((Get-DataCollection $Elevated 'ModuleUnexamined') | ForEach-Object { $reason = "$(Get-DataProperty $_ 'Reason')" if ($reason -eq 'ServiceAccessDenied') { $reason = 'ServiceProtected' } if ($reason -eq 'ModulesAccessDenied') { $reason = 'ModulesProtected' } $count = ConvertTo-Number (Get-DataProperty $_ 'Count') if ($null -ne $count) { New-NetworkDependencyUnexamined -What "$(Get-DataProperty $_ 'What')" -Reason $reason -Count ([int]$count) } else { New-NetworkDependencyUnexamined -What "$(Get-DataProperty $_ 'What')" -Reason $reason } }) [pscustomobject]@{ Traces = $traces; Unexamined = $left } } function ConvertTo-NetworkDependencyModuleTrace { <# .SYNOPSIS Turns what Get-NetworkDependencyProcessModule read into traces and into rows for what it could not read. Pure. .DESCRIPTION Returns Traces and Unexamined. Only a module that is not on one of the machine's own drives becomes a trace: a UNC path, or a drive letter the mapping has to place. ConvertTo-NetworkDependency would drop the local ones as well, but a machine has some twelve thousand of them and that function is not built for the number. A process of the session that could not be read is named once, whatever number of it runs. The service processes that may not be opened are one row with their number: without admin rights that is every one of them. .PARAMETER LocalDrive The letters of the machine's own drives. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$ProcessModule, [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive ) $local = ConvertTo-NetworkDependencyDriveSet -LocalDrive $LocalDrive $traces = [System.Collections.Generic.List[psobject]]::new() $unexamined = [System.Collections.Generic.List[psobject]]::new() $seen = @{} $unread = @{} $services = 0 foreach ($row in @($ProcessModule)) { if ($null -eq $row) { continue } $name = "$(Get-DataProperty $row 'ProcessName')" $code = Get-DataProperty $row 'Error' if ($null -ne $code -and "$code" -ne '') { $denied = "$code" -eq "$script:NetworkDependencyErrorAccessDenied" if ($denied -and (Get-DataProperty $row 'IsService')) { $services++; continue } $reason = 'ModulesNotReadable' if ($denied) { $reason = 'ModulesAccessDenied' } $key = ('{0}|{1}' -f $reason, $name).ToUpperInvariant() if (-not $unread.ContainsKey($key)) { $unread[$key] = $true $unexamined.Add((New-NetworkDependencyUnexamined -What $name -Reason $reason)) } continue } foreach ($module in @(Get-DataProperty $row 'Modules')) { $path = "$module" if (-not $path) { continue } # The long form of a drive path (\\?\C:\...) is the same drive. $start = 0 if ($path.StartsWith('\\?\') -and -not $path.StartsWith('\\?\UNC\', [StringComparison]::OrdinalIgnoreCase)) { $start = 4 } if ($path.Length -ge $start + 2 -and $path[$start + 1] -eq ':' -and $local.ContainsKey("$($path[$start])".ToUpperInvariant())) { continue } $key = ('{0}|{1}' -f $name, $path).ToUpperInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true $traces.Add((New-NetworkDependencyTrace -Application $name -Path $path -Kind 'program' -Source 'module')) } } if ($services) { $unexamined.Add((New-NetworkDependencyUnexamined -What 'services' -Reason 'ServiceAccessDenied' -Count $services)) } [pscustomobject]@{ Traces = @($traces) Unexamined = @($unexamined) } } function Get-NetworkDependencyModuleTrace { <# .SYNOPSIS Source: the modules loaded by the processes in the logged-on user's session and by the service processes, where they are not on one of the machine's own drives. .DESCRIPTION Finds the program that is installed locally and loads an add-in or a database DLL from a share. A module is code Windows pages in from its Server like the program itself, so its kind is program. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined, [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive, # What the Elevated Part read of the modules, or nothing where it did not. [AllowNull()]$Elevated ) # Which processes host a service. Asked not to stop: a machine whose WMI does not # answer still has a session to read, and the Report says the services were left out. $problems = @() $services = @(Get-CimInstance -ClassName Win32_Service -Filter 'ProcessId != 0' -Property ProcessId ` -ErrorAction SilentlyContinue -ErrorVariable problems) if (@($problems).Count) { $reason = 'NotReadable' if (Test-AccessDenied -ErrorRecord @($problems)[0]) { $reason = 'AccessDenied' } $Unexamined.Add((New-NetworkDependencyUnexamined -What 'Win32_Service' -Reason $reason)) } $ids = @($services | ForEach-Object { [int]$_.ProcessId } | Sort-Object -Unique) $read = ConvertTo-NetworkDependencyModuleTrace -LocalDrive $LocalDrive ` -ProcessModule @(Get-NetworkDependencyProcessModule -ServiceProcessId $ids) $read = Join-NetworkDependencyElevatedModule -Read $read -Elevated $Elevated foreach ($row in $read.Unexamined) { $Unexamined.Add($row) } $read.Traces } function Merge-NetworkDependencyModule { <# .SYNOPSIS Drops the modules a program on a share loads from that same share. Pure. .DESCRIPTION A program that runs from \\server\share loads its own DLLs from there, and the row for the program already says that it depends on that share: thirty more rows would bury it. A module on another share, or on another Server, is a second dependency and stays, and so does every module of a program that is installed locally. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Dependency) $rows = @($Dependency | Where-Object { $null -ne $_ }) $programs = @{} foreach ($row in $rows) { if ("$(Get-DataProperty $row 'Source')" -ne 'process') { continue } $parts = Split-NetworkDependencyUncPath -UncPath "$(Get-DataProperty $row 'UncPath')" if (-not $parts) { continue } $programs[('{0}|{1}' -f (Get-DataProperty $row 'Application'), $parts.Share).ToUpperInvariant()] = $true } foreach ($row in $rows) { if ("$(Get-DataProperty $row 'Source')" -eq 'module') { $parts = Split-NetworkDependencyUncPath -UncPath "$(Get-DataProperty $row 'UncPath')" if ($parts -and $programs.ContainsKey(('{0}|{1}' -f (Get-DataProperty $row 'Application'), $parts.Share).ToUpperInvariant())) { continue } } $row } } # What makes a path a program and not a document: Windows runs it, a script host does, or # a program loads it as code - a library, a control, a control panel item, an installer # package. The one list of them; everything that tells a program from data by its name # reads it through the two below. $script:NetworkDependencyProgramExtensions = 'exe|com|bat|cmd|ps1|psm1|vbs|vbe|js|jse|wsf|wsh|msi|msp|msc|dll|ocx|cpl|scr|pif|jar|hta|lnk|py|xll' # A path that names code, and what may stand behind it: rundll32 and control.exe are handed # "library,entry point", and the entry point is no part of the file's name. $script:NetworkDependencyProgramPathPattern = '^(.+\.(?:{0}))(?:,[^\\/]*)?$' -f $script:NetworkDependencyProgramExtensions # A command line that begins with a path and is not quoted: the program ends where the # first of these extensions does, which is how Windows reads C:\Program Files\App\app.exe. $script:NetworkDependencyLauncherPattern = '^(.+?\.(?:{0}))(?=\s|$)(.*)$' -f $script:NetworkDependencyProgramExtensions # A UNC path or a path on a drive letter among the arguments of a command line: quoted - # in double quotes or in single ones - or up to the next blank. Not after a letter, a # slash, a backslash or a quote, so that neither a switch (/c:fast), nor a URL, nor the # inside of another path is taken for one. A program handed 'path','NAME=value' had its # path read on to the next blank, and the Report named a share "oic','NLS_LANG=...". # Read what it matched through Get-NetworkDependencyArgumentPath. $script:NetworkDependencyArgumentPathPattern = '"(?<dq>(?:\\\\[^\\"\s]|[A-Za-z]:\\)[^"]*)"|''(?<sq>(?:\\\\[^\\''\s]|[A-Za-z]:\\)[^'']*)''|(?<![\w\\/"''])(?<bare>(?:\\\\[^\\"\s]|[A-Za-z]:\\)[^\s"]*)' function Get-NetworkDependencyArgumentPath { <# .SYNOPSIS The path one match of the pattern above found, whichever way it was written. Pure. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Match) foreach ($name in 'dq', 'sq', 'bare') { if ($Match.Groups[$name].Success) { return $Match.Groups[$name].Value.Trim() } } '' } function Get-NetworkDependencyProgramPath { <# .SYNOPSIS The file a path names when that file is code, or nothing when it is a folder or a document. Pure. .DESCRIPTION Code that is loaded and run from a share is a program on a share, whatever started it: "rundll32 \\server\share\x.dll,Entry" pages x.dll in from its Server exactly as x.exe would be. The entry point or the resource number behind the comma is cut off, so the path is that of the file. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Path) if ("$Path".Trim() -match $script:NetworkDependencyProgramPathPattern) { $Matches[1] } } function Split-NetworkDependencyCommand { <# .SYNOPSIS A command line as the program it starts and the rest. Pure. .DESCRIPTION A service's PathName, a startup entry and an App Paths value are one string, quoted or not. Not quoted and beginning with a path, the program may contain blanks. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Command) $text = "$Command".Trim() if (-not $text) { return } $executable = $text $arguments = '' if ($text.StartsWith('"')) { $end = $text.IndexOf('"', 1) if ($end -gt 0) { $executable = $text.Substring(1, $end - 1) $arguments = $text.Substring($end + 1) } else { $executable = $text.Substring(1) } } elseif ($text -match '^(?:\\\\|[A-Za-z]:\\)' -and $text -match $script:NetworkDependencyLauncherPattern) { $executable = $Matches[1] $arguments = $Matches[2] } elseif ($text -match '^(\S+)\s+(.*)$') { $executable = $Matches[1] $arguments = $Matches[2] } [pscustomobject]@{ Executable = $executable.Trim() Arguments = $arguments.Trim() } } function ConvertTo-NetworkDependencyCommandTrace { <# .SYNOPSIS Turns things that start a program - services, scheduled tasks, startup entries, shortcuts, App Paths entries - into traces. Pure. .DESCRIPTION An entry is { Name, Command }, or { Name, Executable, Arguments } where Windows keeps the two apart. The program is a trace, and so is every path among the arguments: the script a local powershell.exe is handed lives on the share as much as a program would. What is on one of the machine's own drives is dropped here, as for the loaded modules: a machine has several hundred of these and nearly all are local. The kind follows from what the path is. A program, a script or a library is a program dependency, with or without an entry point behind it (Get-NetworkDependencyProgramPath); a folder or a document among the arguments is data. .PARAMETER BareTargetKind The kind of a program path without one of the extensions Windows runs. A service or a task starts a program whatever it is called; a shortcut may as well point at a folder or a document, and says 'data'. .PARAMETER LocalDrive The letters of the machine's own drives. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyCollection()]$Entry, [Parameter(Mandatory)][string]$Source, [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive, [string]$BareTargetKind = 'program' ) $local = ConvertTo-NetworkDependencyDriveSet -LocalDrive $LocalDrive $seen = @{} foreach ($item in @($Entry)) { if ($null -eq $item) { continue } $name = "$(Get-DataProperty $item 'Name')" $executable = "$(Get-DataProperty $item 'Executable')" $arguments = "$(Get-DataProperty $item 'Arguments')" $command = Get-DataProperty $item 'Command' if ($null -ne $command) { $split = Split-NetworkDependencyCommand -Command "$command" if (-not $split) { continue } $executable = $split.Executable $arguments = $split.Arguments } $found = [System.Collections.Generic.List[psobject]]::new() $target = $executable.Trim().Trim('"').Trim() if ($target -match '^(?:\\\\[^\\\s]|[A-Za-z]:\\)') { $program = Get-NetworkDependencyProgramPath -Path $target if ($program) { $found.Add([pscustomobject]@{ Path = $program; Kind = 'program' }) } else { $found.Add([pscustomobject]@{ Path = $target; Kind = $BareTargetKind }) } } foreach ($match in [regex]::Matches($arguments, $script:NetworkDependencyArgumentPathPattern)) { $path = Get-NetworkDependencyArgumentPath -Match $match if (-not $path) { continue } $program = Get-NetworkDependencyProgramPath -Path $path if ($program) { $found.Add([pscustomobject]@{ Path = $program; Kind = 'program' }) } else { $found.Add([pscustomobject]@{ Path = $path; Kind = 'data' }) } } foreach ($row in $found) { $path = $row.Path if ($path.Length -ge 2 -and $path[1] -eq ':' -and $local.ContainsKey("$($path[0])".ToUpperInvariant())) { continue } $key = ('{0}|{1}' -f $name, $path).ToUpperInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true New-NetworkDependencyTrace -Application $name -Path $path -Kind $row.Kind -Source $Source } } } function Add-NetworkDependencyUnread { <# .SYNOPSIS Records a source that did not answer, by the name a Technician can type. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined, [Parameter(Mandatory)][string]$What, [AllowNull()][AllowEmptyCollection()]$Problem ) $reason = 'NotReadable' if (@($Problem).Count -and (Test-AccessDenied -ErrorRecord @($Problem)[0])) { $reason = 'AccessDenied' } $Unexamined.Add((New-NetworkDependencyUnexamined -What $What -Reason $reason)) } function Get-NetworkDependencyServiceTrace { <# .SYNOPSIS Source: the services whose executable is on a share. .DESCRIPTION Every installed service, running or not: one that is stopped today starts from the same share tomorrow. Named as the Technician sees it in the services console. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined, [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive ) $problems = @() $services = @(Get-CimInstance -ClassName Win32_Service -Property Name, DisplayName, PathName ` -ErrorAction SilentlyContinue -ErrorVariable problems) if (@($problems).Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_Service' -Problem $problems } ConvertTo-NetworkDependencyCommandTrace -Source 'service' -LocalDrive $LocalDrive -Entry @($services | ForEach-Object { $name = "$($_.DisplayName)" if (-not $name) { $name = "$($_.Name)" } [pscustomobject]@{ Name = $name; Command = "$($_.PathName)" } }) } function Get-NetworkDependencyTaskTrace { <# .SYNOPSIS Source: the scheduled tasks whose action starts something from a share. .DESCRIPTION Only the program and the arguments of each action are read. An action that is a COM handler names no program and is not one. Without admin rights Windows hands out the tasks this user may see and says nothing about the rest: no error and no number (observed on Windows 11, 199 tasks, no error). Where it does name tasks it would not read, they are one row with their number, and not a hundred names. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined, [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive ) # Asked first: Windows Server Core and stripped-down images have no such cmdlet, and # calling a command that is not there is an error no -ErrorAction quietens. if (-not (Get-Command -Name Get-ScheduledTask -ErrorAction SilentlyContinue)) { $Unexamined.Add((New-NetworkDependencyUnexamined -What 'Get-ScheduledTask' -Reason 'NotReadable')) return } $problems = @() $tasks = @(Get-ScheduledTask -ErrorAction SilentlyContinue -ErrorVariable problems) if (@($problems).Count) { if ($tasks.Count) { $Unexamined.Add((New-NetworkDependencyUnexamined -What 'tasks' -Reason 'TaskNotReadable' -Count @($problems).Count)) } else { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Get-ScheduledTask' -Problem $problems } } $entries = foreach ($task in $tasks) { foreach ($action in @($task.Actions)) { $execute = "$(Get-DataProperty $action 'Execute')" if (-not $execute) { continue } [pscustomobject]@{ Name = "$($task.TaskName)" Executable = [Environment]::ExpandEnvironmentVariables($execute) Arguments = [Environment]::ExpandEnvironmentVariables("$(Get-DataProperty $action 'Arguments')") } } } ConvertTo-NetworkDependencyCommandTrace -Source 'task' -LocalDrive $LocalDrive -Entry @($entries) } function Get-NetworkDependencyStartupTrace { <# .SYNOPSIS Source: the startup entries that start something from a share. .PARAMETER Startup What the Startup Check gathered, when it ran before this one. Its entries are then used and nothing is read a second time. Absent, or without entries because Windows would not enumerate them, they are read here. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined, [AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive, [AllowNull()]$Startup ) if ($null -ne $Startup -and (Get-DataProperty $Startup 'Available')) { $entries = (Get-DataCollection $Startup 'Entries') } else { $problems = @() $entries = @(Get-CimInstance -ClassName Win32_StartupCommand -ErrorAction SilentlyContinue -ErrorVariable problems) if (@($problems).Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_StartupCommand' -Problem $problems } } ConvertTo-NetworkDependencyCommandTrace -Source 'startup' -LocalDrive $LocalDrive -Entry @($entries | ForEach-Object { [pscustomobject]@{ Name = "$(Get-DataProperty $_ 'Name')" Command = [Environment]::ExpandEnvironmentVariables("$(Get-DataProperty $_ 'Command')") } }) } function Get-NetworkDependencyShortcutTrace { <# .SYNOPSIS Source: the Desktop and Start menu shortcuts that point at a share. .DESCRIPTION The program that is not running right now. Read by the App Kind's reader, which reads them once per Run whoever asks. A shortcut to a folder or a document on a share is a data dependency: nothing is paged in from there. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive) ConvertTo-NetworkDependencyCommandTrace -Source 'shortcut' -LocalDrive $LocalDrive -BareTargetKind 'data' -Entry @( Get-StartMenuShortcut | ForEach-Object { [pscustomobject]@{ Name = "$($_.Name)"; Executable = "$($_.Target)"; Arguments = "$($_.Arguments)" } }) } # Where Windows looks a program up that is started by its name alone. $script:NetworkDependencyAppPathKeys = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths' 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths' 'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths' ) function Get-NetworkDependencyAppPathTrace { <# .SYNOPSIS Source: the registered application paths that point at a share. .DESCRIPTION A key that is not there is the usual case for the user's own hive and is no gap. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()][string[]]$LocalDrive) $entries = foreach ($root in $script:NetworkDependencyAppPathKeys) { foreach ($key in @(Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { $value = "$($key.GetValue(''))" if (-not $value) { continue } [pscustomobject]@{ Name = ("$($key.PSChildName)" -replace '\.exe$', ''); Command = $value } } } ConvertTo-NetworkDependencyCommandTrace -Source 'apppath' -LocalDrive $LocalDrive -Entry @($entries) } # Where the user's folders are, as Explorer resolves them. $script:NetworkDependencyShellFolderKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders' # The folders Windows keeps a second time under a GUID, by the name they have without # one, so that a redirected folder is one row. Downloads has no other name. $script:NetworkDependencyShellFolderGuids = @{ '{F42EE2D3-909F-4907-8871-4C22FC0BF756}' = 'Personal' '{754AC886-DF64-4CBA-86B5-F7FBF4FBCEF5}' = 'Desktop' '{0DDD015D-B06C-45D5-8C4C-F59713854639}' = 'My Pictures' '{A0C69A99-21C8-4671-8703-7934162FCF1D}' = 'My Music' '{35286A68-3C57-41A1-BBB1-0EAE73D76C95}' = 'My Video' '{374DE290-123F-4565-9164-39C4925E467B}' = 'Downloads' '{7D83EE9B-2244-4E70-B1F5-5393042AF1E4}' = 'Downloads' } function ConvertTo-NetworkDependencyShellFolderTrace { <# .SYNOPSIS Turns the user's shell folders, as { Name, Path }, into traces. Pure. .DESCRIPTION Every folder is a trace; the ones on the machine's own drives are dropped where every local path is. The application is the folder's name in the registry, which the Report translates. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Folder) foreach ($item in @($Folder)) { if ($null -eq $item) { continue } $name = "$(Get-DataProperty $item 'Name')".Trim() $path = "$(Get-DataProperty $item 'Path')".Trim() if (-not $name -or -not $path) { continue } if ($script:NetworkDependencyShellFolderGuids.ContainsKey($name)) { $name = $script:NetworkDependencyShellFolderGuids[$name] } New-NetworkDependencyTrace -Application $name -Path $path -Kind 'profile' -Source 'folder-redirection' } } function Get-NetworkDependencyShellFolderTrace { <# .SYNOPSIS Source: folder redirection - Documents, Desktop, AppData and the like on a Server. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined) $problems = @() $properties = Get-ItemProperty -Path $script:NetworkDependencyShellFolderKey -ErrorAction SilentlyContinue -ErrorVariable problems if (-not $properties) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'User Shell Folders' -Problem $problems return } ConvertTo-NetworkDependencyShellFolderTrace -Folder @($properties.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object { [pscustomobject]@{ Name = "$($_.Name)"; Path = "$($_.Value)" } }) } function ConvertTo-NetworkDependencyOfflineFilesTrace { <# .SYNOPSIS Turns the shares Offline Files keeps a copy of, as { ItemPath }, into traces. Pure. .DESCRIPTION A share that is kept offline goes on working when its Server is gone, from the copy, and the user notices nothing until the two disagree. That is a dependency of the profile kind which hides its own failures, and the Report says so. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Item) foreach ($row in @($Item)) { if ($null -eq $row) { continue } $path = "$(Get-DataProperty $row 'ItemPath')".Trim() if (-not $path) { continue } New-NetworkDependencyTrace -Application 'offline-files' -Path $path -Kind 'profile' -Source 'offline-files' } } function Get-NetworkDependencyOfflineFilesTrace { <# .SYNOPSIS Source: Offline Files (CSC), when it is switched on - the shares it keeps a copy of. .DESCRIPTION Asked of WMI, which answers without admin rights (observed on Windows 11: Win32_OfflineFilesCache with Enabled and Active false). A Windows without the class has no Offline Files, which is an answer and not a gap. The shares are only asked for when the cache is active: with it off the provider refuses the query. Not verified on a machine with Offline Files switched on; the query for the shares is bounded so that a large cache cannot hold up the Run. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined) $problems = @() $cache = Get-CimInstance -ClassName Win32_OfflineFilesCache -ErrorAction SilentlyContinue -ErrorVariable problems | Select-Object -First 1 if (-not $cache) { $missing = @($problems | Where-Object { "$($_.Exception.NativeErrorCode)" -match 'InvalidClass|InvalidNamespace' }) if (@($problems).Count -and -not $missing.Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_OfflineFilesCache' -Problem $problems } return } if (-not ($cache.Enabled -and $cache.Active)) { return } # ItemType 2 is a share, as Win32_OfflineFilesItem numbers them. $problems = @() $shares = @(Get-CimInstance -Query 'SELECT ItemPath FROM Win32_OfflineFilesItem WHERE ItemType = 2' ` -OperationTimeoutSec 20 -ErrorAction SilentlyContinue -ErrorVariable problems) if (@($problems).Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_OfflineFilesItem' -Problem $problems } ConvertTo-NetworkDependencyOfflineFilesTrace -Item @($shares | ForEach-Object { [pscustomobject]@{ ItemPath = "$($_.ItemPath)" } }) } function ConvertTo-NetworkDependencyPrinterTrace { <# .SYNOPSIS Turns printers, as { Name, ServerName, ShareName }, into traces. Pure. .DESCRIPTION Only a printer that is connected through a print server: its queue, its driver and its settings come from there, and every print dialog waits for it. A printer with its own port depends on no Server. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Printer) foreach ($item in @($Printer)) { if ($null -eq $item) { continue } $server = "$(Get-DataProperty $item 'ServerName')".Trim().TrimStart('\').Trim() # A printer reached over IPP names a URL here, which is no Server to measure. if ($server -notmatch '^[\w.\-]+$') { continue } $name = "$(Get-DataProperty $item 'Name')".Trim() $share = "$(Get-DataProperty $item 'ShareName')".Trim() $application = $name $path = '' if ($name -match '^\\\\[^\\]+\\(.+)$') { $application = $Matches[1] $path = $name } elseif ($share) { $path = '\\{0}\{1}' -f $server, $share } New-NetworkDependencyTrace -Application $application -Path $path -Kind 'service' -Source 'printer' ` -Server $server -Port $script:NetworkDependencySmbPort } } function Get-NetworkDependencyPrinterTrace { <# .SYNOPSIS Source: the printers that are connected through a print server. .PARAMETER Printing What the Printing Check gathered, when it ran before this one. Its printers are then used and nothing is read a second time. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined, [AllowNull()]$Printing ) if (Test-NetworkDependencyProperty -InputObject $Printing -Name 'Printers') { $printers = (Get-DataCollection $Printing 'Printers') } else { $problems = @() $printers = @(Get-CimInstance -ClassName Win32_Printer -Property Name, ServerName, ShareName ` -ErrorAction SilentlyContinue -ErrorVariable problems) if (@($problems).Count) { Add-NetworkDependencyUnread -Unexamined $Unexamined -What 'Win32_Printer' -Problem $problems } } ConvertTo-NetworkDependencyPrinterTrace -Printer @($printers) } # The drivers that speak to a SQL Server, by the file or the name a data source records. # Only for these is the default port known when the data source names none. $script:NetworkDependencySqlServerDriverPattern = 'sqlsrv32|sqlncli|msodbcsql|SQL Server|SQL Native Client' function ConvertTo-NetworkDependencyOdbcTrace { <# .SYNOPSIS Turns ODBC data sources, as { Dsn, Server, Port, Driver }, into traces. Pure. .DESCRIPTION A data source names a Server and no path. The Server value is read by the Odbc Kind's parser, which knows "tcp:host,1433" and "host\INSTANCE" and drops a data source that points at this machine. The port is the one the Server value names, else the one the data source names, else 1433 for a SQL Server driver and a default instance. A named instance negotiates its port, and a driver nobody knows has none to guess: the trace then carries no port, and the Server is resolved and pinged but not connected to. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$DataSource) foreach ($item in @($DataSource)) { if ($null -eq $item) { continue } $target = ConvertTo-OdbcServerTarget -Server "$(Get-DataProperty $item 'Server')" if (-not $target) { continue } $port = $null $stated = ConvertTo-Number (Get-DataProperty $item 'Port') if ($target.Port) { $port = [int]$target.Port } elseif ($null -ne $stated -and $stated -ge 1) { $port = [int]$stated } elseif (-not $target.DynamicPort -and "$(Get-DataProperty $item 'Driver')" -match $script:NetworkDependencySqlServerDriverPattern) { $port = $script:OdbcDefaultSqlPort } # "LxPostgreSQL" names no program a Technician knows. Whose driver it is does, # where the driver came with a program and not with Windows. $name = "$(Get-DataProperty $item 'Dsn')" $owner = "$(Get-DataProperty $item 'Owner')" if ($owner) { $name = (Get-Text 'Value.NetworkDependency.OdbcOwned') -f $owner, $name } New-NetworkDependencyTrace -Application $name -Kind 'database' -Source 'odbc' ` -Server $target.HostName -Port $port } } function ConvertTo-NetworkDependencyOdbcOwner { <# .SYNOPSIS Whose ODBC driver a file is, from where it is installed and who made it. Pure. .DESCRIPTION A driver a program brought along is installed in that program's folder, and the folder under Program Files names the product or its maker. A driver in the Windows folder came with Windows or was installed for everybody: any program may use it, and it says nothing about which one does. Then there is no owner. #> [CmdletBinding()] [OutputType([string])] param( [AllowNull()][AllowEmptyString()][string]$Path, [AllowNull()][AllowEmptyString()][string]$Company ) if (-not $Path -or $Path -match '(?i)^[a-z]:\\Windows\\') { return '' } $folder = '' if ($Path -match '(?i)\\Program Files(?: \(x86\))?\\(?:Common Files\\)?(?<folder>[^\\]+)\\') { $folder = $Matches['folder'].Trim() } $maker = "$Company".Trim() # A driver Microsoft installs for everybody, outside the Windows folder. if ($maker -match '(?i)^Microsoft' -and (-not $folder -or $folder -match '(?i)^Microsoft')) { return '' } if ($folder) { return $folder } $maker } function Get-NetworkDependencyOdbcOwner { <# .SYNOPSIS Finds a data source's driver file and says whose it is. Nothing where the file is not found. .PARAMETER Driver What the data source names as its driver: the file, or the driver's name. .PARAMETER Root The registry key the data source was read under. A driver named by its name is looked up beside it, on the same side of the 32-bit registry. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Driver, [AllowNull()][AllowEmptyString()][string]$Root) if (-not $Driver) { return '' } try { $file = [Environment]::ExpandEnvironmentVariables($Driver) if ($file -notmatch '\\') { $file = '' $installed = @(($Root -replace '(?i)ODBC\.INI$', 'ODBCINST.INI'), 'HKLM:\SOFTWARE\ODBC\ODBCINST.INI', 'HKLM:\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI') | Where-Object { $_ -and $_ -notmatch '^HKCU' } | Select-Object -Unique foreach ($key in $installed) { $entry = Get-ItemProperty -LiteralPath (Join-Path $key $Driver) -Name Driver -ErrorAction SilentlyContinue if ($entry -and $entry.Driver) { $file = [Environment]::ExpandEnvironmentVariables("$($entry.Driver)"); break } } } if (-not $file) { return '' } $company = '' if (Test-Path -LiteralPath $file) { $company = "$((Get-Item -LiteralPath $file -ErrorAction Stop).VersionInfo.CompanyName)" } ConvertTo-NetworkDependencyOdbcOwner -Path $file -Company $company } catch { '' } } function Get-NetworkDependencyOdbcTrace { <# .SYNOPSIS Source: the ODBC data sources that name a Server. .DESCRIPTION Read from the registry here and not taken from the Odbc Check: several Checks of that Kind may run, each for another application, and what an earlier Check gathered is keyed by Kind, so they overwrite each other. The keys are the Odbc Kind's. A machine without data sources has no such keys, which is no gap. psqlODBC writes Servername where the others write Server. #> [CmdletBinding()] [OutputType([psobject])] param() $sources = foreach ($root in $script:OdbcDataSourceKeys) { foreach ($key in @(Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { if ($key.PSChildName -eq 'ODBC Data Sources') { continue } $driver = "$($key.GetValue('Driver'))" $server = "$($key.GetValue('Server'))" if (-not $server -and $driver -match 'psql') { $server = "$($key.GetValue('Servername'))" } if (-not $server) { continue } [pscustomobject]@{ Dsn = "$($key.PSChildName)" Server = $server Port = $key.GetValue('Port') Driver = $driver Owner = Get-NetworkDependencyOdbcOwner -Driver $driver -Root $root } } } ConvertTo-NetworkDependencyOdbcTrace -DataSource @($sources) } # How much of the configuration files beside the executables is read: no file above this # size, no more .ini files per directory and no more files in a Run than these. Not Check # Definition parameters, for the reason Private/Sampling.ps1 gives: they bound what a Run # costs, and decide nothing about what it finds. $script:NetworkDependencyConfigMaxBytes = 1MB $script:NetworkDependencyConfigMaxPerDirectory = 20 $script:NetworkDependencyConfigMaxFiles = 100 # \\server\share in a file. The Server begins with a letter or a digit and the pattern # does not begin inside a run of backslashes, so that neither a device path (\\.\pipe) nor # a doubled backslash from an escaped string or a regular expression is taken for one. $script:NetworkDependencyConfigUncPattern = '(?<![\\\w])\\\\(?<host>[A-Za-z0-9][\w.\-]*(?:@[A-Za-z0-9]+)*)\\(?<share>[^\\/:*?"<>|\r\n\t;=,'']+)' # The Server of a connection string, and the Server= line of an .ini. The key stands at # the beginning of a line or after a separator: SmtpServer= and ProxyServer= are not it. $script:NetworkDependencyConfigServerPattern = '(?im)(?:^|[;"''\s])[ \t]*(?:Data Source|Server|Addr|Address|Network Address)[ \t]*=[ \t]*(?<value>[^;"''<>\r\n]*)' # Values of Server= that are a switch and no name, and the endings of a Data Source that # is a file. $script:NetworkDependencyConfigNoHostPattern = '^(?:true|false|yes|no|on|off|none|null|default|auto|local|ja|nein)$' $script:NetworkDependencyConfigFilePattern = '\.(?:db|db3|sqlite|sqlite3|mdb|accdb|mdf|sdf|fdb|gdb|dbf|xml|ini|txt|dat|udl|dsn|csv|xls|xlsx)$' function Test-NetworkDependencyLocalName { <# .SYNOPSIS Whether a Server name stands for this machine. Pure: the machine's name is an argument. #> [CmdletBinding()] [OutputType([bool])] param( [AllowNull()][AllowEmptyString()][string]$Server, [AllowNull()][AllowEmptyString()][string]$ComputerName ) $name = "$Server".Trim().TrimEnd('.') if (-not $name) { return $false } if ($name -in '.', 'localhost', '127.0.0.1', '::1', '(local)') { return $true } $own = "$ComputerName".Trim() if (-not $own) { return $false } ($name -eq $own) -or $name.StartsWith($own + '.', [StringComparison]::OrdinalIgnoreCase) } function Get-NetworkDependencyConfigReference { <# .SYNOPSIS The Servers and shares the text of a configuration file names. Pure. .DESCRIPTION Returns { Kind, Path, Server, Port } and nothing else of the text: a configuration file holds user names, passwords and licence keys, and none of that belongs in a Report. A share is cut down to \\server\share and is a data dependency. The Server of a connection string is a database dependency without a path; its port is the one the string names, else none. A name is only taken when it can be one: what points at this machine, a file, a switch (Server=true) or a URL is left out. A share name with a blank in it is only kept whole when the path goes on behind it; at the end of a value nobody can tell where the name ends, and the first word is taken. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()][AllowEmptyString()][string]$Text, [AllowNull()][AllowEmptyString()][string]$ComputerName ) if (-not $Text) { return } $seen = @{} foreach ($match in [regex]::Matches($Text, $script:NetworkDependencyConfigUncPattern)) { $hostPart = $match.Groups['host'].Value $share = $match.Groups['share'].Value $after = $match.Index + $match.Length if (-not ($after -lt $Text.Length -and $Text[$after] -eq '\')) { $share = @($share.Trim() -split '\s')[0] } $share = $share.Trim() $server = @($hostPart -split '@')[0] if (-not $share -or $server.Length -lt 2) { continue } if (Test-NetworkDependencyLocalName -Server $server -ComputerName $ComputerName) { continue } $path = '\\{0}\{1}' -f $hostPart, $share $key = ('data|{0}' -f $path).ToUpperInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true [pscustomobject]@{ Kind = 'data'; Path = $path; Server = $server; Port = $null } } foreach ($match in [regex]::Matches($Text, $script:NetworkDependencyConfigServerPattern)) { $target = ConvertTo-OdbcServerTarget -Server $match.Groups['value'].Value if (-not $target) { continue } $server = "$($target.HostName)" if ($server.Length -lt 2 -or $server -notmatch '^[A-Za-z0-9][A-Za-z0-9._\-]*$') { continue } if ($server -notmatch '[A-Za-z]' -and $server -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { continue } if ($server -match $script:NetworkDependencyConfigNoHostPattern -or $server -match $script:NetworkDependencyConfigFilePattern) { continue } if (Test-NetworkDependencyLocalName -Server $server -ComputerName $ComputerName) { continue } $port = $null if ($target.Port) { $port = [int]$target.Port } $key = ('database|{0}|{1}' -f $server, $port).ToUpperInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true [pscustomobject]@{ Kind = 'database'; Path = ''; Server = $server; Port = $port } } } function Select-NetworkDependencyExecutable { <# .SYNOPSIS The dependencies that are an executable on a share: where configuration files are searched. Pure. .DESCRIPTION A program, found as itself and not as a module some other program loaded or as a name out of a configuration file, whose path ends in .exe. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyCollection()]$Dependency) foreach ($row in @($Dependency)) { if ($null -eq $row) { continue } if ("$(Get-DataProperty $row 'Kind')" -ne 'program') { continue } if ("$(Get-DataProperty $row 'Source')" -in 'module', 'config') { continue } if ("$(Get-DataProperty $row 'UncPath')" -notmatch '[\\/][^\\/]+\.exe$') { continue } $row } } function Get-NetworkDependencyConfigTrace { <# .SYNOPSIS Source: the Servers and shares named in <exe>.config and in the .ini files beside an executable that is already in the inventory. .DESCRIPTION Only there, and only those files: a search for configuration files across a share is not something a Run may do on somebody else's file server. Bounded in size and number; what the bounds left out is counted. The share of a Server that did not answer is not touched - opening a path there waits for a timeout per file - and is named as not read. .PARAMETER Server The measurements of the Servers the executables are on. #> [CmdletBinding()] [OutputType([psobject])] param( [Parameter(Mandatory)][AllowEmptyCollection()]$Unexamined, [AllowNull()][AllowEmptyCollection()]$Dependency, [AllowNull()][AllowEmptyCollection()]$Server, [AllowNull()][AllowEmptyString()][string]$ComputerName ) $done = @{} $listed = @{} $unread = @{} $read = 0 $skipped = 0 foreach ($program in @(Select-NetworkDependencyExecutable -Dependency $Dependency)) { $unc = "$(Get-DataProperty $program 'UncPath')" if ($done.ContainsKey($unc.ToUpperInvariant())) { continue } $done[$unc.ToUpperInvariant()] = $true $directory = $unc.Substring(0, $unc.LastIndexOfAny([char[]]'\/')) $place = $directory.ToUpperInvariant() $row = Select-NetworkDependencyServerRow -Server $Server -Dependency $program if ($null -eq $row -or (Test-NetworkDependencyServerDown -Server $row)) { if (-not $unread.ContainsKey($place)) { $unread[$place] = $true $Unexamined.Add((New-NetworkDependencyUnexamined -What $directory -Reason 'ConfigNotReadable')) } continue } # Not List[object]: @($x) throws on one of those. See Public/Invoke-Gutcheck.ps1. $files = [System.Collections.Generic.List[psobject]]::new() # Most programs have none, which is no gap. $own = Get-Item -LiteralPath ($unc + '.config') -ErrorAction SilentlyContinue if ($own) { $files.Add($own) } if (-not $listed.ContainsKey($place)) { $listed[$place] = $true $problems = @() $ini = @(Get-ChildItem -LiteralPath $directory -Filter '*.ini' -File -ErrorAction SilentlyContinue -ErrorVariable problems | Where-Object { $_.Extension -eq '.ini' } | Sort-Object Name) if (@($problems).Count -and -not $unread.ContainsKey($place)) { $unread[$place] = $true $Unexamined.Add((New-NetworkDependencyUnexamined -What $directory -Reason 'ConfigNotReadable')) } if ($ini.Count -gt $script:NetworkDependencyConfigMaxPerDirectory) { $skipped += $ini.Count - $script:NetworkDependencyConfigMaxPerDirectory } foreach ($file in ($ini | Select-Object -First $script:NetworkDependencyConfigMaxPerDirectory)) { $files.Add($file) } } foreach ($file in $files) { if ($file.Length -gt $script:NetworkDependencyConfigMaxBytes -or $read -ge $script:NetworkDependencyConfigMaxFiles) { $skipped++ continue } $read++ $problems = @() $text = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue -ErrorVariable problems if (@($problems).Count) { if (-not $unread.ContainsKey($place)) { $unread[$place] = $true $Unexamined.Add((New-NetworkDependencyUnexamined -What $directory -Reason 'ConfigNotReadable')) } continue } # Only what the reference holds leaves this function: nothing of $text does. foreach ($reference in @(Get-NetworkDependencyConfigReference -Text "$text" -ComputerName $ComputerName)) { New-NetworkDependencyTrace -Application "$(Get-DataProperty $program 'Application')" -Path $reference.Path ` -Kind $reference.Kind -Source 'config' -Server $reference.Server -Port $reference.Port } } } if ($skipped) { $Unexamined.Add((New-NetworkDependencyUnexamined -What 'configuration files' -Reason 'ConfigSkipped' -Count $skipped)) } } |