modules/UserProfiles/UserProfiles.psm1
|
<#
UserProfiles.psm1 - user-context dependencies the machine-context collectors miss. Produces: UserProfiles, MappedDrives, UserOdbcDsns, LogonScripts. Why this module exists: several existing collectors record a limitation that user-context data (mapped drives, per-user ODBC DSNs, HKCU installs) is invisible when discovery runs as SYSTEM - but nothing ever went and got it. The loaded and unloaded user hives under HKEY_USERS are readable, offline, without impersonating anyone, so the dependency can be recovered instead of just disclaimed. Profile SIZING is gated behind -IncludeUserProfiles because walking every profile on an RDS host is the same expensive I/O as the deep share crawl. Without the switch the module still enumerates profiles and reads their hives; it just does not measure them. Reads only. Never loads, unloads, modifies, or deletes a user hive that was not already loaded, and never touches profile contents. #> function Get-DiscoveryModuleMetadata { [pscustomobject]@{ ModuleName='UserProfiles'; DisplayName='User Profiles & User-Context Dependencies'; Category='System'; Version='1.0.0' DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true ProducesDatasets=@('UserProfiles','MappedDrives','UserOdbcDsns','LogonScripts') ProducesRisks=$true; ProducesFollowUpQuestions=$true; SupportsDeepMode=$true; SupportsComplianceLens=$false } } function Test-DiscoveryPrerequisites { param([object]$Context) $lim = @() if (-not $Context.IsAdmin) { $lim = @('Not elevated: profiles belonging to other users, and their registry hives, will not be readable.') } [pscustomobject]@{ ModuleName='UserProfiles'; CanRun=$true; Status='Ready'; Reason=''; Limitations=$lim } } function Get-UpSidIsRealUser { <# Filters out the built-in service SIDs so the output is actual people. #> param([string]$Sid) if ([string]::IsNullOrWhiteSpace($Sid)) { return $false } if ($Sid -in @('S-1-5-18','S-1-5-19','S-1-5-20')) { return $false } # SYSTEM / LOCAL SERVICE / NETWORK SERVICE if ($Sid -match '_Classes$') { return $false } return ($Sid -match '^S-1-5-21-') } function Get-UpLoadedUserHives { <# Returns the SIDs whose hives are currently loaded under HKEY_USERS. #> param() $sids = [System.Collections.Generic.List[string]]::new() try { if (-not (Test-Path -LiteralPath 'Registry::HKEY_USERS')) { return ,@($sids) } foreach ($k in (Get-ChildItem -LiteralPath 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { if (Get-UpSidIsRealUser -Sid $k.PSChildName) { [void]$sids.Add($k.PSChildName) } } } catch { } return ,@($sids) } function Invoke-DiscoveryCollection { param([object]$Context) $profiles = [System.Collections.Generic.List[object]]::new() $drives = [System.Collections.Generic.List[object]]::new() $dsns = [System.Collections.Generic.List[object]]::new() $scripts = [System.Collections.Generic.List[object]]::new() $measure = ([bool]$Context.Parameters['IncludeUserProfiles']) $loaded = @(Get-UpLoadedUserHives) # ---- Profiles ----------------------------------------------------------- try { foreach ($p in (Invoke-CimSafe -ClassName 'Win32_UserProfile')) { try { $sid = [string]$p.SID if (-not (Get-UpSidIsRealUser -Sid $sid)) { continue } $account = '' try { $account = (New-Object System.Security.Principal.SecurityIdentifier($sid)).Translate([System.Security.Principal.NTAccount]).Value } catch { $account = '<unresolved SID>' } $sizeGB = $null $fileCount = $null if ($measure -and $p.LocalPath -and (Test-Path -LiteralPath $p.LocalPath)) { try { $files = @(Get-ChildItem -LiteralPath $p.LocalPath -File -Recurse -ErrorAction SilentlyContinue) $fileCount = $files.Count $sizeGB = Convert-BytesToGB (($files | Measure-Object -Property Length -Sum).Sum) } catch { } } $profiles.Add([pscustomobject]@{ Account = $account Sid = $sid LocalPath = [string]$p.LocalPath IsRoaming = [bool]$p.RoamingConfigured RoamingPath = [string]$p.RoamingPath IsLoaded = [bool]($loaded -contains $sid) LastUseTime = (Normalize-DateTime $p.LastUseTime) IsSpecial = [bool]$p.Special Status = [string]$p.Status SizeGB = $sizeGB FileCount = $fileCount SizeMeasured = $measure }) if ($p.RoamingConfigured -and $p.RoamingPath) { Add-DependencyEdge -Context $Context -SourceType 'UserProfile' -SourceName $account ` -DependencyType 'RoamsTo' -Target ([string]$p.RoamingPath) -Evidence 'Roaming profile path' ` -Confidence 'Confirmed' -SourceDataset 'UserProfiles' -ProjectImpact 'Data Migration' ` -ValidationQuestion 'Does the roaming profile share survive the migration?' | Out-Null } } catch { } } } catch { Add-Limitation -Context $Context -Module 'UserProfiles' -Message 'User profile enumeration failed.' -Reason $_.Exception.Message | Out-Null } if (-not $measure -and $profiles.Count -gt 0) { Add-Limitation -Context $Context -Module 'UserProfiles' ` -Message 'Profile sizes were not measured (enable with -IncludeUserProfiles).' ` -Impact 'Profile data volume unknown' | Out-Null } # ---- Mapped drives + user ODBC DSNs + logon scripts, per loaded hive ---- # A drive letter mapped by a user is the single most common undocumented reason a # server rename breaks someone's morning, and it is invisible to every # machine-context collector in this toolkit. foreach ($sid in $loaded) { $account = '' try { $account = (New-Object System.Security.Principal.SecurityIdentifier($sid)).Translate([System.Security.Principal.NTAccount]).Value } catch { $account = $sid } # Mapped network drives try { $netPath = "Registry::HKEY_USERS\$sid\Network" if (Test-Path -LiteralPath $netPath) { foreach ($k in (Get-ChildItem -LiteralPath $netPath -ErrorAction SilentlyContinue)) { $props = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction SilentlyContinue if ($props -and $props.RemotePath) { $drives.Add([pscustomobject]@{ Account=$account; DriveLetter=$k.PSChildName; RemotePath=[string]$props.RemotePath ProviderName=[string]$props.ProviderName; UserName=[string]$props.UserName }) Add-DependencyEdge -Context $Context -SourceType 'MappedDrive' -SourceName ("{0}:{1}" -f $account, $k.PSChildName) ` -DependencyType 'ConnectsTo' -Target ([string]$props.RemotePath) -Evidence 'HKU Network mapping' ` -Confidence 'Confirmed' -SourceDataset 'MappedDrives' -ProjectImpact 'Cutover Complexity' ` -ValidationQuestion 'Is this mapped path referenced by server name, and does that name change?' | Out-Null } } } } catch { } # Per-user ODBC DSNs - the 32-bit ones in particular are easy to miss and are # exactly where a legacy line-of-business app hides its database target. foreach ($odbcRel in @('Software\ODBC\ODBC.INI', 'Software\WOW6432Node\ODBC\ODBC.INI')) { try { $odbcPath = "Registry::HKEY_USERS\$sid\$odbcRel" if (-not (Test-Path -LiteralPath $odbcPath)) { continue } $bitness = if ($odbcRel -match 'WOW6432Node') { '32' } else { '64' } foreach ($k in (Get-ChildItem -LiteralPath $odbcPath -ErrorAction SilentlyContinue)) { if ($k.PSChildName -eq 'ODBC Data Sources') { continue } $props = Get-ItemProperty -LiteralPath $k.PSPath -ErrorAction SilentlyContinue $dsns.Add([pscustomobject]@{ Account=$account; DsnName=$k.PSChildName; Driver=[string]$props.Driver Server=[string]$props.Server; Database=[string]$props.Database; Scope='User'; Bitness=$bitness }) if ($props.Server) { Add-DependencyEdge -Context $Context -SourceType 'ODBC DSN (user)' -SourceName $k.PSChildName ` -DependencyType 'ConnectsTo' -Target ("{0}/{1}" -f $props.Server, $props.Database) -Evidence 'Per-user ODBC DSN' ` -Confidence 'Confirmed' -SourceDataset 'UserOdbcDsns' -ProjectImpact 'Data Migration' ` -ValidationQuestion 'Which application uses this user DSN, and does its target change after migration?' | Out-Null } } } catch { } } } if ($loaded.Count -eq 0 -and $profiles.Count -gt 0) { Add-Limitation -Context $Context -Module 'UserProfiles' ` -Message 'No user registry hives were loaded at scan time, so mapped drives and per-user DSNs could not be read.' ` -Impact 'User-context dependencies under-reported' | Out-Null Add-Unknown -Context $Context -Unknown 'Mapped drives and per-user ODBC DSNs could not be enumerated (no user hives loaded).' ` -WhyItMatters 'Users and applications frequently reach this server through a mapped drive that nothing on the server records.' ` -Module 'UserProfiles' -RecommendedValidationQuestion 'Do users map drives to this server, and are any of those paths hardcoded in applications or shortcuts?' | Out-Null } # ---- Startup / logon script indicators --------------------------------- try { $startupPaths = @( (Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs\StartUp'), (Join-Path $env:SystemRoot 'System32\GroupPolicy\Machine\Scripts\Startup'), (Join-Path $env:SystemRoot 'System32\GroupPolicy\User\Scripts\Logon') ) + @(Get-ChildItem -Path (Join-Path $env:SystemRoot 'SYSVOL\sysvol\*\scripts') -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) # NETLOGON on a DC # Users whose AD scriptPath points at a logon script (DC only; ADSI, so no AD module needed). try { if ((Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).DomainRole -ge 4) { $ds = [adsisearcher]'(&(objectCategory=person)(objectClass=user)(scriptPath=*))' $ds.PropertiesToLoad.AddRange(@('samaccountname','scriptpath')); $ds.PageSize = 500 foreach ($u in $ds.FindAll()) { $scripts.Add([pscustomobject]@{ Scope='AD user scriptPath'; Name=[string]$u.Properties['scriptpath'][0]; Path=('AD user: ' + [string]$u.Properties['samaccountname'][0]); LastWriteTime=''; SizeKB='' }) } } } catch { } foreach ($sp in $startupPaths) { if (-not $sp -or -not (Test-Path -LiteralPath $sp)) { continue } foreach ($f in (Get-ChildItem -LiteralPath $sp -File -ErrorAction SilentlyContinue)) { $scripts.Add([pscustomobject]@{ Scope=(Split-Path $sp -Leaf); Name=$f.Name; Path=$f.FullName LastWriteTime=(Normalize-DateTime $f.LastWriteTime); SizeKB=([math]::Round($f.Length/1KB,1)) }) } } } catch { } return ,@{ UserProfiles=@($profiles); MappedDrives=@($drives); UserOdbcDsns=@($dsns); LogonScripts=@($scripts) } } function ConvertTo-DiscoveryDatasets { param([object]$Context, $RawData) if (-not $RawData) { $RawData = @{} } $get = { param($k) if ($RawData[$k]) { @($RawData[$k]) } else { @() } } Add-DataSet -Context $Context -Name 'UserProfiles' -Description 'Local/roaming user profiles on this server.' -Rows (& $get 'UserProfiles') -Visibility 'Internal' -SourceModule 'UserProfiles' | Out-Null Add-DataSet -Context $Context -Name 'MappedDrives' -Description 'Drive letters mapped by users (from loaded HKU hives).' -Rows (& $get 'MappedDrives') -Visibility 'Both' -SourceModule 'UserProfiles' | Out-Null Add-DataSet -Context $Context -Name 'UserOdbcDsns' -Description 'Per-user ODBC DSNs (32/64-bit) from loaded HKU hives.' -Rows (& $get 'UserOdbcDsns') -Visibility 'Internal' -SourceModule 'UserProfiles' | Out-Null Add-DataSet -Context $Context -Name 'LogonScripts' -Description 'Startup / logon script files present on this server.' -Rows (& $get 'LogonScripts') -Visibility 'Internal' -SourceModule 'UserProfiles' | Out-Null } function Get-DiscoveryFollowUpQuestions { param([object]$Context) try { if ($Context.DataSets.Contains('MappedDrives') -and @($Context.DataSets['MappedDrives'].Rows).Count -gt 0) { Add-FollowUpQuestion -Context $Context -Category 'Mapped drives / UNC' -Module 'UserProfiles' -Audience 'Both' ` -Question 'Mapped drives pointing at network paths were found in user profiles on this server. Who maintains those mappings, and are they created by logon script or Group Policy?' | Out-Null } if ($Context.DataSets.Contains('UserProfiles') -and @($Context.DataSets['UserProfiles'].Rows).Count -gt 3) { Add-FollowUpQuestion -Context $Context -Category 'Daily/periodic usage' -Module 'UserProfiles' -Audience 'ClientSafe' ` -Question 'Several people have profiles on this server, which suggests they log on to it directly. Who uses it interactively, and what do they do there?' | Out-Null } } catch { } } Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Get-DiscoveryFollowUpQuestions','Get-UpSidIsRealUser','Get-UpLoadedUserHives' |