Private/Worker/Get-ServerFacts.ps1

function Get-ServerFacts {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$ComputerName,
        [pscredential]$Credential,
        [pscredential]$SqlCredential,
        [Parameter(Mandatory)][hashtable]$DomainCache,
        [Parameter(Mandatory)][hashtable]$DataFiles,
        [Parameter(Mandatory)][ValidateSet('Root','Children','Recursive')][string]$NtfsDepth,
        [Parameter(Mandatory)][ValidateSet('Auto','Install','None')][string]$NtfsSecurityProvisioning,
        [bool]$IncludeAllServices,
        [bool]$IncludeAllShares,
        [bool]$IncludeAllScheduledTasks,
        [bool]$IncludeAllCertStores,
        [bool]$IncludeAllFirewallRules,
        [bool]$IncludeBackupDirectoryRoot,
        [bool]$ExpandGroupMembers,
        [bool]$SkipDefaultGroupExpansion,
        [bool]$IncludeAllUserRights,
        [bool]$ExpandUserRightsMembers,
        [bool]$AdvisoryJson,
        [bool]$SkipAdvisories,
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Section,
        # FQDN(upper) → PSCredential map. Forwarded to Resolve-AdPrincipal,
        # Expand-AdGroupMember, and Get-RemoteUserRightsAssignment so cross-domain
        # principal resolution can use a different account than the worker's
        # Kerberos context. Pre-populated and validated in Export-ServerFacts.
        [hashtable]$DomainCredential = @{}
    )

    $start  = Get-Date
    $errors = [System.Collections.Generic.List[object]]::new()

    $scannedBy = "$env:USERDOMAIN\$env:USERNAME"

    try {
        $invokeArgs = @{ ComputerName = $ComputerName }
        if ($Credential) { $invokeArgs['Credential'] = $Credential }

        # Capture both data and section errors from inside the session block.
        # Dynamic scoping does not reliably reach back into Get-ServerFacts's
        # local scope from inside Invoke-WithSession's invocation chain, so we
        # bundle errors with the data and merge them after.
        $bundle = Invoke-WithSession @invokeArgs -ScriptBlock {
            param($session)

            $r           = [ordered]@{}
            $sectionErrs = [System.Collections.Generic.List[object]]::new()

            function Run-Section {
                param([string]$Name,[scriptblock]$Body)
                try { return & $Body } catch {
                    $sectionErrs.Add([pscustomobject]@{ Section = $Name; Message = $_.Exception.Message }) | Out-Null
                    return $null
                }
            }

            if ('System'                -in $Section) { $r.System        = Run-Section 'System'          { Get-RemoteSystem               -Session $session } }
            if ('Hardware'              -in $Section) { $r.Hardware      = Run-Section 'Hardware'        { Get-RemoteHardware             -Session $session } }
            if ('Disks'                 -in $Section) { $r.Disks         = Run-Section 'Disks'           { Get-RemoteDisks                -Session $session } }
            if ('Network'               -in $Section) { $r.Network       = Run-Section 'Network'         { Get-RemoteNetwork              -Session $session } }
            if ('InstalledApplications' -in $Section) { $r.InstalledApps = Run-Section 'InstalledApps'   { Get-RemoteInstalledApplication -Session $session } }
            if ('ChocolateyPackages'    -in $Section) { $r.Choco         = Run-Section 'Chocolatey'      { Get-RemoteChocolateyPackage    -Session $session } }
            if ('WindowsFeatures'       -in $Section) { $r.Features      = Run-Section 'WindowsFeatures' { Get-RemoteWindowsFeature       -Session $session } }
            if ('Permissions'           -in $Section) { $r.PermRaw       = Run-Section 'Permissions'     { Get-RemotePermission           -Session $session } }
            if ('Services'              -in $Section) { $r.ServicesRaw   = Run-Section 'Services'        { Get-RemoteService              -Session $session } }
            if ('Shares'                -in $Section) { $r.SharesRaw     = Run-Section 'Shares'          { Get-RemoteShare                -Session $session -NtfsDepth $NtfsDepth -NtfsSecurityProvisioning $NtfsSecurityProvisioning } }
            if ('ScheduledTasks'        -in $Section) { $r.TasksRaw      = Run-Section 'ScheduledTasks'  { Get-RemoteScheduledTask        -Session $session } }
            if ('LocalUsers'            -in $Section) { $r.LocalUsers    = Run-Section 'LocalUsers'      { Get-RemoteLocalUser            -Session $session } }
            if ('FirewallRules'         -in $Section) { $r.FirewallRaw   = Run-Section 'FirewallRules'   { Get-RemoteFirewallRule         -Session $session } }
            if ('Iis'                   -in $Section) { $r.Iis           = Run-Section 'Iis'             { Get-RemoteIis                  -Session $session } }
            if ('Certificates'          -in $Section) { $r.Certs         = Run-Section 'Certificates'    { Get-RemoteCertificate          -Session $session -IncludeAllStores $IncludeAllCertStores } }
            if ('Chef'                  -in $Section) { $r.Chef          = Run-Section 'Chef'             { Get-RemoteChef                 -Session $session } }
            if ('SqlServer'             -in $Section) {
                $r.SqlServer = Run-Section 'SqlServer' {
                    $sqlArgs = @{ Session = $session; ComputerName = $ComputerName }
                    if ($r.Disks)       { $sqlArgs['Disks']         = $r.Disks }
                    if ($SqlCredential) { $sqlArgs['SqlCredential'] = $SqlCredential }
                    if ($IncludeBackupDirectoryRoot) { $sqlArgs['IncludeBackupDirectoryRoot'] = $true }
                    Get-RemoteSqlServer @sqlArgs
                }
                # Per-instance ConnectionError / LiveQueryError live inside the
                # returned object — surface them as section-level errors so the
                # summary's Errors column reflects reality.
                # Also expand AD groups in BackupDirectory ACEs to recursive members.
                foreach ($inst in @($r.SqlServer)) {
                    if ($null -eq $inst) { continue }
                    if ($inst.ConnectionError) {
                        $sectionErrs.Add([pscustomobject]@{ Section = "SqlServer/$($inst.InstanceName)"; Message = "Connect: $($inst.ConnectionError)" }) | Out-Null
                    }
                    if ($inst.LiveQueryError) {
                        $sectionErrs.Add([pscustomobject]@{ Section = "SqlServer/$($inst.InstanceName)"; Message = "LiveQuery: $($inst.LiveQueryError)" }) | Out-Null
                    }
                    foreach ($acl in @($inst.BackupDirectoryAcls)) {
                        if ($null -eq $acl -or $null -eq $acl.Entries) { continue }
                        foreach ($entry in @($acl.Entries)) {
                            if ([string]::IsNullOrWhiteSpace($entry.Account)) { continue }
                            $sid = $null
                            try { $sid = (New-Object System.Security.Principal.NTAccount($entry.Account)).Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { }
                            if (-not $sid) { continue }
                            try {
                                $principal = Resolve-AdPrincipal -Sid $sid -Account $entry.Account -DomainCache $DomainCache -CredentialMap $DomainCredential
                                $entry | Add-Member -NotePropertyName Principal -NotePropertyValue $principal -Force
                                if ($principal.Type -eq 'Group' -and $ExpandGroupMembers) {
                                    $isDefault = Test-IsDefaultGroupSid -Sid $sid
                                    $members = if ($SkipDefaultGroupExpansion -and $isDefault) { @() }
                                               else { @(Expand-AdGroupMember -Sid $sid -Account $entry.Account -DomainCache $DomainCache -SkipDefaultGroupExpansion:$SkipDefaultGroupExpansion -CredentialMap $DomainCredential) }
                                    $entry | Add-Member -NotePropertyName Members -NotePropertyValue $members -Force
                                    $entry | Add-Member -NotePropertyName MembersSkipped -NotePropertyValue ($SkipDefaultGroupExpansion -and $isDefault) -Force
                                } else {
                                    $entry | Add-Member -NotePropertyName Members -NotePropertyValue @() -Force
                                }
                            } catch {
                                $sectionErrs.Add([pscustomobject]@{ Section = "SqlServer/$($inst.InstanceName)"; Message = "Backup ACL resolve failed for $($entry.Account): $($_.Exception.Message)" }) | Out-Null
                            }
                        }
                    }
                }
            }
            if ('PendingReboot'         -in $Section) { $r.PendingReboot          = Run-Section 'PendingReboot'          { Get-RemotePendingReboot            -Session $session } }
            if ('PowerPlan'             -in $Section) { $r.PowerPlan              = Run-Section 'PowerPlan'              { Get-RemotePowerPlan                -Session $session } }
            if ('PageFile'              -in $Section) { $r.PageFile               = Run-Section 'PageFile'               { Get-RemotePageFile                 -Session $session } }
            if ('Tls'                   -in $Section) { $r.Tls                    = Run-Section 'Tls'                    { Get-RemoteTls                      -Session $session } }
            if ('Smb'                   -in $Section) { $r.Smb                    = Run-Section 'Smb'                    { Get-RemoteSmb                      -Session $session } }
            if ('Defender'              -in $Section) { $r.Defender               = Run-Section 'Defender'               { Get-RemoteDefender                 -Session $session } }
            if ('UserRightsAssignment'  -in $Section) { $r.UserRightsAssignment   = Run-Section 'UserRightsAssignment'   { Get-RemoteUserRightsAssignment     -Session $session -DomainCache $DomainCache -ExpandMembers:$ExpandUserRightsMembers -DomainCredential $DomainCredential } }

            [pscustomobject]@{ Data = $r; SectionErrors = $sectionErrs }
        }

        $result = $bundle.Data
        foreach ($e in $bundle.SectionErrors) { $errors.Add($e) | Out-Null }

        $defServices = $DataFiles.DefaultServices
        $defShares   = $DataFiles.DefaultShares
        $defTasks    = $DataFiles.DefaultTaskPaths
        $defCert     = $DataFiles.DefaultCertIssuers
        $defFirewall = $DataFiles.DefaultFirewallGroupPrefixes

        # Helper: coerce possibly-null collector output into a real array,
        # stripping any embedded $null that might have come from a fault-tolerant section.
        $servicesRaw = @($result.ServicesRaw | Where-Object { $null -ne $_ })
        $sharesRawIn = @($result.SharesRaw   | Where-Object { $null -ne $_ })
        $tasksRawIn  = @($result.TasksRaw    | Where-Object { $null -ne $_ })
        $fwRawIn     = @($result.FirewallRaw | Where-Object { $null -ne $_ })
        $certsIn     = @($result.Certs       | Where-Object { $null -ne $_ })

        # Services filter
        $services = @(if ($IncludeAllServices) {
            $servicesRaw
        } else {
            $servicesRaw | Where-Object { -not (Test-IsDefaultService -Service $_ -KnownDefaults $defServices.KnownDefaults -KnownCustomFalsePositives $defServices.KnownCustomFalsePositives) }
        })

        # Shares filter + NTFS normalization
        $sharesRaw = @(if ($IncludeAllShares) {
            $sharesRawIn
        } else {
            $sharesRawIn | Where-Object { -not (Test-IsDefaultShare -Name $_.Name -DefaultNames $defShares.Names) }
        })
        $shares = @(foreach ($s in $sharesRaw) {
            $entries = @(foreach ($ace in $s.NtfsRawAces) {
                ConvertTo-NtfsPermissionEntry -Ace $ace -Path $ace._path -SourceKind $s.NtfsSourceKind
            })
            foreach ($e in $s.NtfsErrors) {
                $errors.Add([pscustomobject]@{ Section = 'Shares.NTFS'; Message = $e }) | Out-Null
            }
            [pscustomobject]@{
                Name             = $s.Name
                Path             = $s.Path
                Description      = $s.Description
                SharePermissions = @($s.SharePermissions)
                NtfsPermissions  = [pscustomobject]@{
                    Depth   = $NtfsDepth
                    Entries = $entries
                }
            }
        })

        # Scheduled tasks filter + trigger formatting
        $tasksFiltered = @(if ($IncludeAllScheduledTasks) {
            $tasksRawIn
        } else {
            $tasksRawIn | Where-Object { -not (Test-IsDefaultScheduledTask -TaskPath $_.TaskPath -Patterns $defTasks.Patterns) }
        })
        $tasks = @(foreach ($t in $tasksFiltered) {
            # Tasks with no triggers (on-demand only, or programmatically created
            # without one) come back with $t.Triggers = $null. ForEach-Object on
            # a null source still emits one $null, which Format-TriggerSummary
            # rejects via its [Parameter(Mandatory)] binder. Filter $null FIRST.
            # Each task is wrapped so one malformed trigger doesn't abort the
            # whole worker — degrade just this section instead.
            try {
                $triggers = @(@($t.Triggers) | Where-Object { $null -ne $_ } |
                              ForEach-Object { Format-TriggerSummary -Trigger $_ })
                [pscustomobject]@{
                    TaskPath = $t.TaskPath
                    TaskName = $t.TaskName
                    State    = $t.State
                    RunAs    = $t.RunAs
                    Triggers = $triggers
                    Actions  = @($t.Actions)
                }
            } catch {
                $errors.Add([pscustomobject]@{
                    Section = 'ScheduledTasks'
                    Message = "Normalize failed for $($t.TaskPath)$($t.TaskName): $($_.Exception.Message)"
                }) | Out-Null
            }
        })

        # Firewall filter
        $firewall = @(if ($IncludeAllFirewallRules) {
            $fwRawIn
        } else {
            $fwRawIn | Where-Object { -not (Test-IsDefaultFirewallRule -Group ([string]$_.Group) -GroupPrefixes $defFirewall.Prefixes) }
        })

        # Certificates filter — strip well-known issuers from the pre-seeded
        # Windows trust stores. LocalMachine\My (machine identity) is left
        # untouched: the certs there are real and worth seeing.
        $certs = @(if ($IncludeAllCertStores) {
            $certsIn
        } else {
            $certsIn | Where-Object {
                $store = $_.Store
                if ($store -in 'LocalMachine\Root','LocalMachine\CA','LocalMachine\TrustedPublisher') {
                    -not (Test-IsDefaultCertIssuer -Issuer $_.Issuer -DefaultIssuers $defCert.Issuers)
                } else { $true }
            }
        })

        # Permissions resolution. Top-level entries preserve the direct ACE/group
        # member shape (Type=Group stays Group, Type=User stays User). When
        # -ExpandGroupMembers is set, Group entries get an attached .Members
        # array with the recursive flat user list; -SkipDefaultGroupExpansion
        # stops recursion through Domain Admins / Domain Users / etc.
        # Each entry is wrapped so one bad principal can't abort the section.
        $resolved = [pscustomobject]@{
            Administrators        = @()
            RemoteDesktopUsers    = @()
            RemoteManagementUsers = @()
        }
        foreach ($groupName in 'Administrators','RemoteDesktopUsers','RemoteManagementUsers') {
            $raw = if ($result.PermRaw) { $result.PermRaw.$groupName } else { @() }
            $list = @(foreach ($entry in $raw) {
                if ([string]::IsNullOrWhiteSpace($entry.Sid)) {
                    $errors.Add([pscustomobject]@{ Section = "Permissions/$groupName"; Message = "No SID resolved for member: $($entry.Account)" }) | Out-Null
                    continue
                }
                try {
                    $p = Resolve-AdPrincipal -Sid $entry.Sid -Account $entry.Account -DomainCache $DomainCache -CredentialMap $DomainCredential
                    if ($p.Type -eq 'Group' -and $ExpandGroupMembers) {
                        $isDefault = Test-IsDefaultGroupSid -Sid $entry.Sid
                        if ($SkipDefaultGroupExpansion -and $isDefault) {
                            $p | Add-Member -NotePropertyName Members -NotePropertyValue @() -Force
                            $p | Add-Member -NotePropertyName MembersSkipped -NotePropertyValue $true -Force
                        } else {
                            $members = @(Expand-AdGroupMember -Sid $entry.Sid -Account $entry.Account -DomainCache $DomainCache -SkipDefaultGroupExpansion:$SkipDefaultGroupExpansion -CredentialMap $DomainCredential)
                            $p | Add-Member -NotePropertyName Members -NotePropertyValue $members -Force
                            $p | Add-Member -NotePropertyName MembersSkipped -NotePropertyValue $false -Force
                        }
                    }
                    $p
                } catch {
                    $errors.Add([pscustomobject]@{ Section = "Permissions/$groupName"; Message = "Resolve failed for $($entry.Account) ($($entry.Sid)): $($_.Exception.Message)" }) | Out-Null
                }
            })
            $resolved.$groupName = $list
        }

        # Service accounts cross-cut (auto-derives from Services + ScheduledTasks + Iis)
        $svcAccounts = ConvertTo-ServiceAccountList -Services $services -ScheduledTasks $tasks -Iis $result.Iis -Hostname $ComputerName

        $status = if ($errors.Count -gt 0) { 'PartialSuccess' } else { 'Success' }

        $out = [ordered]@{
            Computer          = $ComputerName
            ScannedAtUtc      = $start.ToUniversalTime().ToString('o')
            ScannedBy         = $scannedBy
            Status            = $status
            DurationSeconds   = [math]::Round(((Get-Date) - $start).TotalSeconds, 1)
            CollectedSections = @($Section)
        }

        if ('System'                -in $Section) { $out.System                = $result.System }
        if ('Hardware'              -in $Section) { $out.Hardware              = $result.Hardware }
        if ('Disks'                 -in $Section) { $out.Disks                 = @($result.Disks) }
        if ('Network'               -in $Section) { $out.Network               = $result.Network }
        if ('InstalledApplications' -in $Section) { $out.InstalledApplications = @($result.InstalledApps) }
        if ('ChocolateyPackages'    -in $Section) { $out.ChocolateyPackages    = @($result.Choco) }
        if ('WindowsFeatures'       -in $Section) { $out.WindowsFeatures       = @($result.Features) }
        if ('Permissions'           -in $Section) { $out.Permissions           = $resolved }
        if ('Services'              -in $Section) { $out.Services              = @($services) }
        if ('Shares'                -in $Section) { $out.Shares                = @($shares) }
        if ('ScheduledTasks'        -in $Section) { $out.ScheduledTasks        = @($tasks) }
        if ('LocalUsers'            -in $Section) { $out.LocalUsers            = @($result.LocalUsers | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Name) }) }
        if ('FirewallRules'         -in $Section) { $out.FirewallRules         = @($firewall) }
        if ('Iis'                   -in $Section) { $out.Iis                   = $result.Iis }
        if ('Certificates'          -in $Section) { $out.Certificates          = @($certs) }
        if ('Chef'                  -in $Section) { $out.Chef                  = $result.Chef }
        if ('SqlServer'             -in $Section) { $out.SqlServer             = $result.SqlServer }
        if ('PendingReboot'         -in $Section) { $out.PendingReboot         = $result.PendingReboot }
        if ('PowerPlan'             -in $Section) { $out.PowerPlan             = $result.PowerPlan }
        if ('PageFile'              -in $Section) { $out.PageFile              = $result.PageFile }
        if ('Tls'                   -in $Section) { $out.Tls                   = $result.Tls }
        if ('Smb'                   -in $Section) { $out.Smb                   = $result.Smb }
        if ('Defender'              -in $Section) { $out.Defender              = $result.Defender }
        if ('UserRightsAssignment'  -in $Section) {
            $defaults = $DataFiles.DefaultUserRights.Defaults
            $ura = if ($IncludeAllUserRights) {
                @($result.UserRightsAssignment)
            } else {
                @(foreach ($entry in @($result.UserRightsAssignment)) {
                    $filtered = @($entry.Principals | Where-Object {
                        -not (Test-IsDefaultUserRightPrincipal -Privilege $entry.Privilege -Account $_.Account -Defaults $defaults)
                    })
                    if ($filtered.Count -eq 0) { continue }
                    [pscustomobject]@{ Privilege = $entry.Privilege; Principals = $filtered }
                })
            }
            $out.UserRightsAssignment = $ura
            $out.UserRightsAssignmentIncludeAll = $IncludeAllUserRights
            $out.UserRightsExpandMembers = $ExpandUserRightsMembers

            if ($ExpandUserRightsMembers) {
                $visitedCache = @{}
                foreach ($entry in $ura) {
                    foreach ($p in $entry.Principals) {
                        if (-not ($p.PSObject.Properties['Members'])) { continue }
                        $currentMembers = @($p.Members)
                        if ($currentMembers.Count -eq 0) { continue }
                        $leaves = New-Object System.Collections.Generic.List[object]
                        foreach ($m in $currentMembers) {
                            if ($m.Type -eq 'Group' -and $m.Sid) {
                                try {
                                    # -Chain is $m.Path (chain up to BUT NOT including
                                    # this group). Expand-AdGroupMember appends $m.Account
                                    # itself once internally — don't pre-append here or we
                                    # get a duplicated tail element in the rendered path.
                                    $expanded = @(Expand-AdGroupMember -Sid $m.Sid -Account $m.Account `
                                        -DomainCache $DomainCache `
                                        -SkipDefaultGroupExpansion:$SkipDefaultGroupExpansion `
                                        -Visited $visitedCache `
                                        -Chain $m.Path `
                                        -CredentialMap $DomainCredential)
                                    foreach ($leaf in $expanded) {
                                        if ($leaf.Type -ne 'Group') { $leaves.Add($leaf) | Out-Null }
                                    }
                                } catch {
                                    $leaves.Add($m) | Out-Null
                                }
                            } elseif ($m.Type -ne 'Group') {
                                $leaves.Add($m) | Out-Null
                            }
                        }
                        # NOTE: $leaves is a List[object]. @($leaves) trips a
                        # PowerShell-internal "Argument types do not match" on
                        # lists containing pscustomobjects. .ToArray() works.
                        $p | Add-Member -NotePropertyName Members -NotePropertyValue $leaves.ToArray() -Force
                    }
                }
            }
        }

        # ServiceAccounts auto-derives if any of its source sections are present
        if ('Services' -in $Section -or 'ScheduledTasks' -in $Section -or 'Iis' -in $Section) {
            $out.ServiceAccounts = @($svcAccounts)
        }

        $out.Errors = @($errors)

        $resultObj = [pscustomobject]$out

        if (-not $SkipAdvisories) {
            $roles      = Get-ServerRoleFlag -Result $resultObj
            $advisories = @(
                Test-PendingRebootAdvisory -Result $resultObj -Roles $roles
                Test-PowerPlanAdvisory     -Result $resultObj -Roles $roles
                Test-PageFileAdvisory      -Result $resultObj -Roles $roles
                Test-TlsAdvisory           -Result $resultObj -Roles $roles
                Test-SmbAdvisory           -Result $resultObj -Roles $roles
                Test-DefenderAdvisory      -Result $resultObj -Roles $roles
                Test-UserRightsAdvisory    -Result $resultObj -Roles $roles
                Test-SystemAdvisory        -Result $resultObj -Roles $roles
            )
            $sorted = @($advisories | Sort-Object @{Expression={
                switch ($_.Severity) { 'Critical'{0} 'Warning'{1} 'Note'{2} default{3} }
            }}, 'Section', 'Rule')
            $resultObj | Add-Member -NotePropertyName AdvisoriesComputed -NotePropertyValue $sorted -Force
            if ($AdvisoryJson) {
                $resultObj | Add-Member -NotePropertyName Advisories -NotePropertyValue $sorted -Force
            }
        }

        $resultObj
    } catch {
        $errors.Add([pscustomobject]@{ Section = 'Connect'; Message = $_.Exception.Message }) | Out-Null
        [pscustomobject]@{
            Computer          = $ComputerName
            ScannedAtUtc      = $start.ToUniversalTime().ToString('o')
            ScannedBy         = $scannedBy
            Status            = 'Failed'
            DurationSeconds   = [math]::Round(((Get-Date) - $start).TotalSeconds, 1)
            CollectedSections = @()
            Errors            = @($errors)
        }
    }
}