Public/Export-ServerFacts.ps1

function Export-ServerFacts {
    <#
    .SYNOPSIS
        Capture point-in-time configuration facts for one or more Windows Servers and write them to disk.

    .DESCRIPTION
        Export-ServerFacts is the entry point of the PSServerFacts module. From a jumphost,
        it opens a WinRM PSSession to each target in parallel, runs a configurable set of
        collectors (System, Hardware, Disks, Network, InstalledApplications, ChocolateyPackages,
        WindowsFeatures, Permissions, Services, Shares, ScheduledTasks, LocalUsers,
        FirewallRules, Iis, Certificates, PendingReboot, PowerPlan, PageFile, Tls, Smb, Chef,
        SqlServer, Defender, UserRightsAssignment) and emits a per-server JSON + Markdown plus
        a cross-server summary into a timestamped run folder under -OutputRoot.

        Each collector runs in its own try/catch so a single failure degrades the server's
        Status to PartialSuccess rather than aborting the run. The whole worker is wrapped
        likewise so one unreachable server doesn't fail the rest.

        Default-filter data files under PSServerFacts/data/*.psd1 trim Windows-shipped
        services, shares, scheduled tasks, firewall groups, cert issuers, and user-right
        principals from the output. Bypass per category with the matching -IncludeAll*
        switches.

        Optional advisory rules under Private/Advisors/ evaluate the collected data and
        emit Note / Warning / Critical entries. Advisories appear inline in the Markdown
        report by default; pass -AdvisoryJson to also embed them in the JSON, or
        -SkipAdvisories to bypass evaluation.

    .PARAMETER ComputerName
        One or more target server names (NetBIOS, FQDN, or IP). Each is scanned in parallel
        subject to -ThrottleLimit.

    .PARAMETER Credential
        PSCredential used for the WinRM PSSession. Omit to use the calling user's Kerberos
        context. The supplied identity must have local admin rights on every target.

    .PARAMETER SqlCredential
        PSCredential used by the SqlServer collector to authenticate to SQL Server instances.
        Omit for Windows authentication (default). Ignored if SqlServer is not collected.

    .PARAMETER OutputRoot
        Root folder under which a timestamped subfolder is created for this run. Defaults
        to ./output relative to the current location. The folder receives per-server
        <name>.json, <name>.md, _summary.md, _run.json, and _errors.log.

    .PARAMETER ThrottleLimit
        Maximum number of targets scanned concurrently via ForEach-Object -Parallel.
        Default: 8.

    .PARAMETER NtfsDepth
        How deep the Shares collector walks NTFS ACLs on each share path:
          Root - the share root only (fastest, default)
          Children - root + immediate children
          Recursive - the full subtree (slowest, largest output)

    .PARAMETER NtfsSecurityProvisioning
        How the worker obtains the NTFSSecurity module on the target:
          Auto - use if present, else stage to %TEMP% for the session, fall back to
                    Get-Acl on failure (default)
          Install - use if present, else Install-Module NTFSSecurity -Scope AllUsers -Force
                    on the target, fall back to Get-Acl on failure
          None - use if present, else Get-Acl directly (no install / stage)

    .PARAMETER IncludeAllServices
        Disable the default-services filter (data/DefaultServices.psd1). Without this, only
        services whose binary lives outside %SystemRoot% AND whose name is not in
        KnownDefaults are returned.

    .PARAMETER IncludeAllShares
        Disable the default-shares filter (data/DefaultShares.psd1). Without this, admin
        shares (ADMIN$, C$, etc.), IPC$, NETLOGON, SYSVOL, and print$ are hidden.

    .PARAMETER IncludeAllScheduledTasks
        Disable the default-task-path filter (data/DefaultTaskPaths.psd1). Without this,
        tasks under \Microsoft\* are hidden.

    .PARAMETER IncludeAllCertStores
        Include the full contents of LocalMachine\Root and LocalMachine\CA. Without this,
        public-CA and Microsoft-shipped issuers (data/DefaultCertIssuers.psd1) are filtered
        out of those two stores. My, WebHosting, and TrustedPublisher are always included
        in full.

    .PARAMETER IncludeAllFirewallRules
        Disable the default-firewall-group filter (data/DefaultFirewallGroupPrefixes.psd1).
        Without this, rules whose Group field begins with a Microsoft-shipped prefix
        (e.g. @FirewallAPI.dll, @%SystemRoot%\, AppX/UWP package groups) are hidden.

    .PARAMETER IncludeBackupDirectoryRoot
        For the SqlServer collector, include the root ACL of the SQL backup directory.
        Without this only non-inherited ACEs (where the directory deviates from its parent)
        are emitted.

    .PARAMETER ExpandGroupMembers
        For the Permissions collector, recursively expand AD groups inside Administrators,
        Remote Desktop Users, and Remote Management Users to their ultimate user leaves
        (resolved across forest trusts). Without this, AD group members are listed as
        groups, not users.

    .PARAMETER SkipDefaultGroupExpansion
        When -ExpandGroupMembers is set, do not recurse INTO well-known default AD groups
        (Domain Admins, Enterprise Admins, etc.). The group is still listed; its members
        are not enumerated. Useful when those groups are very large.

    .PARAMETER IncludeAllUserRights
        For the UserRightsAssignment collector, emit every privilege/principal pair.
        Without this, the collector hides pairs whose principal matches the Windows
        Server default for that privilege (data/DefaultUserRights.psd1).

    .PARAMETER ExpandUserRightsMembers
        For each user-right principal that is an AD or BUILTIN group, emit an
        EffectiveMembers field with the recursively-expanded user leaves. Off by default
        because effective-member sets can be large.

    .PARAMETER AdvisoryJson
        Embed the computed advisories in the per-server JSON output under an Advisories
        top-level field. Advisories are always included inline in the Markdown report
        unless -SkipAdvisories is set.

    .PARAMETER SkipAdvisories
        Do not evaluate advisory rules. The Markdown report will not include the advisory
        summary block or inline advisories; the JSON will not include them either,
        regardless of -AdvisoryJson.

    .PARAMETER IncludeSection
        Restrict collection to the listed sections (whitelist). When omitted, every
        section runs.

    .PARAMETER ExcludeSection
        Skip the listed sections (blacklist). Applied after -IncludeSection. If the
        combination produces an empty set, the function throws.

    .PARAMETER DomainCredential
        Optional hashtable mapping domain names to PSCredential objects, used when
        Resolve-AdPrincipal / Expand-AdGroupMember need to read AD for principals
        in domains where the current Kerberos context is insufficient — typically
        untrusted forests, or a different forest the caller has a separate account
        in.

        Keys may be FQDNs (recommended; e.g. 'partner.example.net') or NetBIOS
        names (e.g. 'PARTNER'). At startup each key is validated by a real
        Get-ADDomain call — a wrong password fails the whole run before any
        target is touched. FQDN keys work for both trusted and untrusted forests;
        NetBIOS keys only work where the partner DC is already locatable (trusted
        forest, but you want a different account).

        Domains not in the map use the caller's current Kerberos context as
        before. Has no effect on collectors that do not resolve AD principals.

    .EXAMPLE
        $partnerCred = Get-Credential -UserName 'PARTNER\readonly-svc' `
                                       -Message 'Partner forest read-only account'
        Export-ServerFacts -ComputerName srv01 -ExpandGroupMembers `
            -DomainCredential @{ 'partner.example.net' = $partnerCred }

        Scan srv01 and recursively expand the local Administrators group; when a
        member belongs to the untrusted PARTNER forest, read that forest with the
        supplied credential instead of failing with Type=Unresolved.

    .EXAMPLE
        Export-ServerFacts -ComputerName srv01

        Scan a single server with the current user's Kerberos context. Output lands in
        ./output/<timestamp>/ under the current working directory.

    .EXAMPLE
        $cred = Get-Credential
        Export-ServerFacts -ComputerName srv01,srv02,srv03 -Credential $cred `
            -OutputRoot D:\serverfacts

        Scan three servers in parallel under explicit credentials and write the run folder
        under D:\serverfacts\.

    .EXAMPLE
        Export-ServerFacts -ComputerName srv01 `
            -IncludeSection Services,Shares,ScheduledTasks,UserRightsAssignment

        Run only the four named collectors. Useful for fast, focused runs.

    .EXAMPLE
        Export-ServerFacts -ComputerName srv01 -IncludeAllServices -IncludeAllShares `
            -IncludeAllFirewallRules

        Emit every service, share, and firewall rule including Windows-shipped defaults.
        Useful when you need a full inventory and not a customizations-only view.

    .EXAMPLE
        $sqlCred = Get-Credential
        Export-ServerFacts -ComputerName sql01 -SqlCredential $sqlCred `
            -IncludeSection SqlServer -IncludeBackupDirectoryRoot

        Collect only SQL Server data using a SQL login, and include the backup directory's
        root ACL in addition to non-inherited ACEs.

    .EXAMPLE
        Export-ServerFacts -ComputerName srv01 -ExpandGroupMembers `
            -SkipDefaultGroupExpansion -IncludeAllUserRights -ExpandUserRightsMembers

        Get fully-expanded permissions and user-rights data without recursing into the
        very large default AD groups (Domain Admins, etc.). Useful for compliance
        evidence packages.

    .EXAMPLE
        Export-ServerFacts -ComputerName srv01,srv02 -SkipAdvisories -AdvisoryJson

        Note that -SkipAdvisories wins: advisories are not evaluated and -AdvisoryJson
        has no effect. To get advisories in JSON, drop -SkipAdvisories.

    .OUTPUTS
        None. The function writes files and progress to the host; it does not
        emit anything to the pipeline. The exit-code-style status is published
        to $LASTEXITCODE:
          0 - all servers Success
          1 - one or more PartialSuccess
          2 - one or more Failed

        File output (in <OutputRoot>/<runId>/):
          <server>.json - structured per-server inventory
          <server>.md - human-readable per-server report
          _summary.md - cross-server overview
          _run.json - parameters + per-server status
          _errors.log - orchestrator-level errors (rare)

    .NOTES
        Prerequisites on the jumphost:
          - PowerShell 7+
          - ActiveDirectory module (RSAT)
          - NTFSSecurity module (Install-Module NTFSSecurity)
          - WinRM reachability to all targets
        Prerequisites on each target:
          - WinRM enabled
          - Caller has local admin rights
          - PowerShell 5.1 or higher
        Optional on target:
          - chef-client.exe on PATH (used only to capture Chef Infra Client version)
          - dbatools module (full SQL Server live-query data)

        See docs/USAGE.md for the new-user getting-started guide.

    .LINK
        ConvertTo-ServerFactsMarkdown

    .LINK
        https://github.com/martin-joho/PSServerFacts
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][string[]]$ComputerName,
        [pscredential]$Credential,
        [pscredential]$SqlCredential,
        [string]$OutputRoot,
        [int]$ThrottleLimit = 8,
        [ValidateSet('Root','Children','Recursive')][string]$NtfsDepth = 'Root',
        [ValidateSet('Auto','Install','None')][string]$NtfsSecurityProvisioning = 'Auto',
        [switch]$IncludeAllServices,
        [switch]$IncludeAllShares,
        [switch]$IncludeAllScheduledTasks,
        [switch]$IncludeAllCertStores,
        [switch]$IncludeAllFirewallRules,
        [switch]$IncludeBackupDirectoryRoot,
        [switch]$ExpandGroupMembers,
        [switch]$SkipDefaultGroupExpansion,
        [switch]$IncludeAllUserRights,
        [switch]$ExpandUserRightsMembers,
        [switch]$AdvisoryJson,
        [switch]$SkipAdvisories,
        [hashtable]$DomainCredential,

        [ValidateSet('System','Hardware','Disks','Network','InstalledApplications',
                     'ChocolateyPackages','WindowsFeatures','Permissions','Services','Shares',
                     'ScheduledTasks','LocalUsers','FirewallRules','Iis','Certificates','PendingReboot','PowerPlan','PageFile','Tls','Smb','Chef','SqlServer','Defender','UserRightsAssignment')]
        [string[]]$IncludeSection,

        [ValidateSet('System','Hardware','Disks','Network','InstalledApplications',
                     'ChocolateyPackages','WindowsFeatures','Permissions','Services','Shares',
                     'ScheduledTasks','LocalUsers','FirewallRules','Iis','Certificates','PendingReboot','PowerPlan','PageFile','Tls','Smb','Chef','SqlServer','Defender','UserRightsAssignment')]
        [string[]]$ExcludeSection
    )

    if (-not $OutputRoot) { $OutputRoot = Join-Path (Get-Location).Path 'output' }
    $moduleRoot = Split-Path $PSScriptRoot -Parent

    Test-JumphostPrerequisite

    $allSections = @('System','Hardware','Disks','Network','InstalledApplications',
                     'ChocolateyPackages','WindowsFeatures','Permissions','Services','Shares',
                     'ScheduledTasks','LocalUsers','FirewallRules','Iis','Certificates','PendingReboot','PowerPlan','PageFile','Tls','Smb','Chef','SqlServer','Defender','UserRightsAssignment')

    $activeSections = if ($IncludeSection) { @($IncludeSection) } else { $allSections }
    if ($ExcludeSection) {
        $activeSections = @($activeSections | Where-Object { $_ -notin $ExcludeSection })
    }
    if ($activeSections.Count -eq 0) {
        throw 'No sections to collect — Include/Exclude combination produces empty set.'
    }

    $domainCache = [hashtable]::Synchronized(@{})
    try {
        $current = (Get-ADDomain).NetBIOSName
        $domainCache[$current.ToUpperInvariant()] = (Get-ADDomain).DNSRoot
        foreach ($trust in (Get-ADTrust -Filter * -ErrorAction SilentlyContinue)) {
            $domainCache[$trust.Name.ToUpperInvariant()] = $trust.Target
        }
    } catch { Write-Verbose "Domain cache pre-population failed: $($_.Exception.Message)" }

    # Normalize -DomainCredential into an FQDN(upper) → PSCredential map and
    # seed the netbios→fqdn cache so untrusted-forest principals are reachable.
    # Keys may be FQDN (recommended) or NetBIOS. Each is validated by a real
    # Get-ADDomain call up-front so a bad credential fails fast, before any
    # target scan starts. Failures here raise — they're a config problem.
    $domainCredentialMap = [hashtable]::Synchronized(@{})
    if ($DomainCredential -and $DomainCredential.Count -gt 0) {
        foreach ($k in @($DomainCredential.Keys)) {
            $cred = $DomainCredential[$k]
            if ($cred -isnot [pscredential]) {
                throw "DomainCredential['$k']: value must be a [pscredential]; got [$($cred.GetType().FullName)]."
            }
            try {
                $dom = if ($k -like '*.*') {
                    Get-ADDomain -Server $k -Credential $cred -ErrorAction Stop
                } else {
                    Get-ADDomain -Identity $k -Credential $cred -ErrorAction Stop
                }
            } catch {
                throw "DomainCredential['$k']: cannot reach AD with the supplied credential — $($_.Exception.Message)"
            }
            $domainCache[$dom.NetBIOSName.ToUpperInvariant()] = $dom.DNSRoot
            $domainCredentialMap[$dom.DNSRoot.ToUpperInvariant()] = $cred
        }
    }

    $dataFiles = @{
        DefaultServices              = Import-PowerShellDataFile -Path "$moduleRoot/data/DefaultServices.psd1"
        DefaultShares                = Import-PowerShellDataFile -Path "$moduleRoot/data/DefaultShares.psd1"
        DefaultTaskPaths             = Import-PowerShellDataFile -Path "$moduleRoot/data/DefaultTaskPaths.psd1"
        DefaultCertIssuers           = Import-PowerShellDataFile -Path "$moduleRoot/data/DefaultCertIssuers.psd1"
        DefaultFirewallGroupPrefixes = Import-PowerShellDataFile -Path "$moduleRoot/data/DefaultFirewallGroupPrefixes.psd1"
        DefaultUserRights            = Import-PowerShellDataFile -Path "$moduleRoot/data/DefaultUserRights.psd1"
    }

    $runFolder  = New-RunFolder -Root $OutputRoot
    $runId      = Split-Path $runFolder -Leaf
    $startedUtc = (Get-Date).ToUniversalTime().ToString('o')

    Write-Host "PSServerFacts run $runId — output: $runFolder"

    $paramSnapshot = [ordered]@{
        ComputerName               = $ComputerName
        Credential                 = if ($Credential)    { $Credential.UserName }    else { 'currentUser' }
        SqlCredential              = if ($SqlCredential) { $SqlCredential.UserName } else { '' }
        ThrottleLimit              = $ThrottleLimit
        NtfsDepth                  = $NtfsDepth
        NtfsSecurityProvisioning   = $NtfsSecurityProvisioning
        IncludeAllServices         = [bool]$IncludeAllServices
        IncludeAllShares           = [bool]$IncludeAllShares
        IncludeAllScheduledTasks   = [bool]$IncludeAllScheduledTasks
        IncludeAllCertStores       = [bool]$IncludeAllCertStores
        IncludeAllFirewallRules    = [bool]$IncludeAllFirewallRules
        IncludeBackupDirectoryRoot = [bool]$IncludeBackupDirectoryRoot
        ExpandGroupMembers         = [bool]$ExpandGroupMembers
        SkipDefaultGroupExpansion  = [bool]$SkipDefaultGroupExpansion
        IncludeAllUserRights       = [bool]$IncludeAllUserRights
        ExpandUserRightsMembers    = [bool]$ExpandUserRightsMembers
        AdvisoryJson               = [bool]$AdvisoryJson
        SkipAdvisories             = [bool]$SkipAdvisories
        DomainCredentialFqdns      = @($domainCredentialMap.Keys)
        Sections                   = $activeSections
    }

    $results = $ComputerName | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
        Import-Module (Join-Path $using:moduleRoot 'PSServerFacts.psd1') -Force -ErrorAction Stop
        Import-Module ActiveDirectory -ErrorAction SilentlyContinue
        Import-Module NTFSSecurity   -ErrorAction SilentlyContinue
        # dbatools is only used by the SqlServer collector and prints a noisy
        # encryption-defaults banner on import. Skip it entirely when SqlServer
        # is not in scope; swallow all output streams (`*>$null`) when it is.
        if ('SqlServer' -in $using:activeSections) {
            Import-Module dbatools -ErrorAction SilentlyContinue *>$null
        }

        $workerArgs = @{
            ComputerName               = $_
            DomainCache                = $using:domainCache
            DataFiles                  = $using:dataFiles
            NtfsDepth                  = $using:NtfsDepth
            NtfsSecurityProvisioning   = $using:NtfsSecurityProvisioning
            IncludeAllServices         = [bool]$using:IncludeAllServices
            IncludeAllShares           = [bool]$using:IncludeAllShares
            IncludeAllScheduledTasks   = [bool]$using:IncludeAllScheduledTasks
            IncludeAllCertStores       = [bool]$using:IncludeAllCertStores
            IncludeAllFirewallRules    = [bool]$using:IncludeAllFirewallRules
            IncludeBackupDirectoryRoot = [bool]$using:IncludeBackupDirectoryRoot
            ExpandGroupMembers         = [bool]$using:ExpandGroupMembers
            SkipDefaultGroupExpansion  = [bool]$using:SkipDefaultGroupExpansion
            IncludeAllUserRights       = [bool]$using:IncludeAllUserRights
            ExpandUserRightsMembers    = [bool]$using:ExpandUserRightsMembers
            AdvisoryJson               = [bool]$using:AdvisoryJson
            SkipAdvisories             = [bool]$using:SkipAdvisories
            DomainCredential           = $using:domainCredentialMap
            Section                    = $using:activeSections
        }
        if ($using:Credential)    { $workerArgs['Credential']    = $using:Credential }
        if ($using:SqlCredential) { $workerArgs['SqlCredential'] = $using:SqlCredential }

        Get-ServerFacts @workerArgs
    }

    foreach ($r in $results) {
        $safeName = ($r.Computer -replace '[^A-Za-z0-9._-]', '_')
        $jsonPath = Join-Path $runFolder "$safeName.json"
        $mdPath   = Join-Path $runFolder "$safeName.md"
        $r | ConvertTo-Json -Depth 12 | Set-Content -Path $jsonPath -Encoding utf8
        ConvertTo-ServerFactsMarkdown -Result $r | Set-Content -Path $mdPath -Encoding utf8
    }

    $finishedUtc = (Get-Date).ToUniversalTime().ToString('o')

    $run = [ordered]@{
        RunId       = $runId
        StartedUtc  = $startedUtc
        FinishedUtc = $finishedUtc
        Parameters  = $paramSnapshot
        Servers     = @($results | ForEach-Object { [ordered]@{ Name=$_.Computer; Status=$_.Status; DurationSeconds=$_.DurationSeconds } })
    }
    $run | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $runFolder '_run.json') -Encoding utf8

    try {
        Format-RunSummary -Results $results -StartedUtc $startedUtc -FinishedUtc $finishedUtc |
            Set-Content -Path (Join-Path $runFolder '_summary.md') -Encoding utf8
    } catch {
        "_summary.md generation failed: $($_.Exception.Message)" | Add-Content -Path (Join-Path $runFolder '_errors.log')
    }

    foreach ($r in $results) {
        Write-Host (" {0}: {1} {2} {3:F1}s" -f $r.Computer, $r.Status, [char]0x00B7, $r.DurationSeconds)
    }

    $failed  = @($results | Where-Object Status -eq 'Failed').Count
    $partial = @($results | Where-Object Status -eq 'PartialSuccess').Count
    # Don't emit the exit code as a pipeline value — it would print "0" at the
    # interactive prompt. Set $LASTEXITCODE so CI scripts can still inspect it.
    $global:LASTEXITCODE = if     ($failed  -gt 0) { 2 }
                           elseif ($partial -gt 0) { 1 }
                           else                    { 0 }
    return
}