Public/Get-ServerConfigChanges.ps1

function Get-ServerConfigChanges {
    <#
    .SYNOPSIS
        Detects server configuration changes on remote servers.
 
    .DESCRIPTION
        Connects to remote servers via Invoke-Command and inspects event logs and
        CIM/WMI for service changes, scheduled task changes, local administrator
        group membership changes, firewall rule changes, and installed software changes.
 
    .PARAMETER ComputerName
        One or more server hostnames or IP addresses to check. Mandatory.
 
    .PARAMETER HoursBack
        Number of hours to look back for changes. Default is 24. Range: 1-720.
 
    .EXAMPLE
        Get-ServerConfigChanges -ComputerName 'WEB01','SQL02' -HoursBack 48
        Returns configuration changes on WEB01 and SQL02 from the last 48 hours.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$ComputerName,

        [Parameter()]
        [ValidateRange(1, 720)]
        [int]$HoursBack = 24
    )

    begin {
        $Changes = [System.Collections.Generic.List[PSCustomObject]]::new()
        $CutoffTime = (Get-Date).AddHours(-$HoursBack)
    }

    process {
        foreach ($Computer in $ComputerName) {
            Write-Verbose "Checking server configuration changes on $Computer..."

            # Test connectivity first
            if (-not (Test-Connection -ComputerName $Computer -Count 1 -Quiet -ErrorAction SilentlyContinue)) {
                Write-Warning "Server '$Computer' is unreachable. Skipping."
                $Changes.Add([PSCustomObject]@{
                    ChangeTime      = Get-Date
                    ChangeType      = 'Error'
                    Category        = 'ServerConfig'
                    ObjectName      = $Computer
                    ObjectType      = 'Server'
                    ChangedBy       = ''
                    OldValue        = ''
                    NewValue        = ''
                    Detail          = "Server '$Computer' is unreachable - unable to check for changes."
                    Source          = 'Connectivity Check'
                    Severity        = 'High'
                    ComputerName    = $Computer
                    ChangeCategory  = 'Connectivity'
                })
                continue
            }

            try {
                $RemoteResults = Invoke-Command -ComputerName $Computer -ArgumentList $CutoffTime, $HoursBack -ErrorAction Stop -ScriptBlock {
                    param($CutoffTime, $HoursBack)

                    $Results = [System.Collections.Generic.List[PSCustomObject]]::new()

                    # ============================================================
                    # 1. Service changes (Event IDs 7040 and 7045)
                    # ============================================================
                    try {
                        # Event 7040: Service startup type changed
                        $ServiceChangeEvents = Get-WinEvent -FilterHashtable @{
                            LogName   = 'System'
                            Id        = @(7040, 7045)
                            StartTime = $CutoffTime
                        } -ErrorAction SilentlyContinue

                        foreach ($Event in $ServiceChangeEvents) {
                            $EventXml = [xml]$Event.ToXml()
                            $DataItems = @{}
                            $EventXml.Event.EventData.Data | ForEach-Object {
                                if ($_.Name) { $DataItems[$_.Name] = $_.'#text' }
                                elseif ($_.'#text') { $DataItems["param$($DataItems.Count + 1)"] = $_.'#text' }
                            }

                            if ($Event.Id -eq 7040) {
                                # Service startup type changed
                                $ServiceName = $DataItems['param1']
                                if (-not $ServiceName) { $ServiceName = $DataItems['param4'] }
                                $OldStartType = $DataItems['param2']
                                $NewStartType = $DataItems['param3']
                                $ChangedByUser = $DataItems['param4']
                                if (-not $ChangedByUser) { $ChangedByUser = $DataItems['param1'] }

                                $Results.Add([PSCustomObject]@{
                                    ChangeTime     = $Event.TimeCreated
                                    ChangeType     = 'Modified'
                                    ChangeCategory = 'Service'
                                    ObjectName     = $ServiceName
                                    OldValue       = "Startup: $OldStartType"
                                    NewValue       = "Startup: $NewStartType"
                                    ChangedBy      = $ChangedByUser
                                    Detail         = "Service '$ServiceName' startup type changed from '$OldStartType' to '$NewStartType'"
                                    Source         = "System Event 7040"
                                    Severity       = 'Medium'
                                })
                            }
                            elseif ($Event.Id -eq 7045) {
                                # New service installed
                                $ServiceName = $DataItems['ServiceName']
                                if (-not $ServiceName) { $ServiceName = $DataItems['param1'] }
                                $ServiceFile = $DataItems['ImagePath']
                                if (-not $ServiceFile) { $ServiceFile = $DataItems['param2'] }
                                $ServiceType = $DataItems['ServiceType']
                                if (-not $ServiceType) { $ServiceType = $DataItems['param3'] }
                                $StartType = $DataItems['StartType']
                                if (-not $StartType) { $StartType = $DataItems['param4'] }
                                $AccountName = $DataItems['AccountName']
                                if (-not $AccountName) { $AccountName = $DataItems['param5'] }

                                $Results.Add([PSCustomObject]@{
                                    ChangeTime     = $Event.TimeCreated
                                    ChangeType     = 'Created'
                                    ChangeCategory = 'Service'
                                    ObjectName     = $ServiceName
                                    OldValue       = ''
                                    NewValue       = "Path: $ServiceFile, Account: $AccountName"
                                    ChangedBy      = $AccountName
                                    Detail         = "New service '$ServiceName' installed (Path: $ServiceFile, StartType: $StartType, Account: $AccountName)"
                                    Source         = "System Event 7045"
                                    Severity       = 'High'
                                })
                            }
                        }
                    }
                    catch {
                        if ($_.Exception.Message -notmatch 'No events were found') {
                            $Results.Add([PSCustomObject]@{
                                ChangeTime     = Get-Date
                                ChangeType     = 'Error'
                                ChangeCategory = 'Service'
                                ObjectName     = 'ServiceEventQuery'
                                OldValue       = ''
                                NewValue       = ''
                                ChangedBy      = ''
                                Detail         = "Failed to query service events: $_"
                                Source         = 'Error'
                                Severity       = 'Low'
                            })
                        }
                    }

                    # ============================================================
                    # 2. Scheduled task changes
                    # ============================================================
                    try {
                        # Event IDs for Task Scheduler:
                        # 106 = Task registered (created)
                        # 140 = Task updated
                        # 141 = Task deleted
                        # 142 = Task disabled
                        $TaskEvents = Get-WinEvent -FilterHashtable @{
                            LogName   = 'Microsoft-Windows-TaskScheduler/Operational'
                            Id        = @(106, 140, 141, 142)
                            StartTime = $CutoffTime
                        } -ErrorAction SilentlyContinue

                        foreach ($Event in $TaskEvents) {
                            $EventXml = [xml]$Event.ToXml()
                            $DataItems = @{}
                            $EventXml.Event.EventData.Data | ForEach-Object {
                                $DataItems[$_.Name] = $_.'#text'
                            }

                            $TaskName = $DataItems['TaskName']
                            if (-not $TaskName) { $TaskName = $DataItems['Name'] }
                            $UserName = $DataItems['UserContext']
                            if (-not $UserName) { $UserName = $DataItems['UserName'] }

                            $TaskChangeType = switch ($Event.Id) {
                                106 { 'Created' }
                                140 { 'Modified' }
                                141 { 'Deleted' }
                                142 { 'Disabled' }
                            }

                            $TaskSeverity = switch ($Event.Id) {
                                106 { 'Medium' }
                                140 { 'Low' }
                                141 { 'Medium' }
                                142 { 'Low' }
                            }

                            $Results.Add([PSCustomObject]@{
                                ChangeTime     = $Event.TimeCreated
                                ChangeType     = $TaskChangeType
                                ChangeCategory = 'ScheduledTask'
                                ObjectName     = $TaskName
                                OldValue       = ''
                                NewValue       = ''
                                ChangedBy      = $UserName
                                Detail         = "Scheduled task '$TaskName' $TaskChangeType by $UserName"
                                Source         = "TaskScheduler Event $($Event.Id)"
                                Severity       = $TaskSeverity
                            })
                        }
                    }
                    catch {
                        if ($_.Exception.Message -notmatch 'No events were found') {
                            Write-Verbose "Failed to query scheduled task events: $_"
                        }
                    }

                    # ============================================================
                    # 3. Local administrator group membership changes
                    # ============================================================
                    try {
                        # Event IDs 4732 (member added) and 4733 (member removed)
                        # filtered to the local Administrators group
                        $LocalAdminEvents = Get-WinEvent -FilterHashtable @{
                            LogName   = 'Security'
                            Id        = @(4732, 4733)
                            StartTime = $CutoffTime
                        } -ErrorAction SilentlyContinue

                        foreach ($Event in $LocalAdminEvents) {
                            $EventXml = [xml]$Event.ToXml()
                            $DataItems = @{}
                            $EventXml.Event.EventData.Data | ForEach-Object {
                                $DataItems[$_.Name] = $_.'#text'
                            }

                            $GroupName = $DataItems['TargetUserName']
                            # Only care about Administrators group (SID ends in -544)
                            $GroupSid = $DataItems['TargetSid']
                            if ($GroupSid -and $GroupSid -notmatch '-544$') { continue }
                            if (-not $GroupSid -and $GroupName -ne 'Administrators') { continue }

                            $MemberName = $DataItems['MemberName']
                            $MemberSid  = $DataItems['MemberSid']
                            $ChangedByUser = if ($DataItems['SubjectUserName']) {
                                "$($DataItems['SubjectDomainName'])\$($DataItems['SubjectUserName'])"
                            } else { 'Unknown' }

                            $AdminChangeType = if ($Event.Id -eq 4732) { 'MemberAdded' } else { 'MemberRemoved' }
                            $AdminAction = if ($Event.Id -eq 4732) { 'added to' } else { 'removed from' }

                            $Results.Add([PSCustomObject]@{
                                ChangeTime     = $Event.TimeCreated
                                ChangeType     = $AdminChangeType
                                ChangeCategory = 'LocalAdmin'
                                ObjectName     = 'Administrators'
                                OldValue       = ''
                                NewValue       = $MemberName
                                ChangedBy      = $ChangedByUser
                                Detail         = "Member '$MemberName' $AdminAction local Administrators group by $ChangedByUser"
                                Source         = "Security Event $($Event.Id)"
                                Severity       = 'Critical'
                            })
                        }
                    }
                    catch {
                        if ($_.Exception.Message -notmatch 'No events were found') {
                            Write-Verbose "Failed to query local admin events: $_"
                        }
                    }

                    # ============================================================
                    # 4. Firewall rule changes
                    # ============================================================
                    try {
                        # Event IDs in Microsoft-Windows-Windows Firewall With Advanced Security/Firewall:
                        # 2004 = Rule added
                        # 2005 = Rule modified
                        # 2006 = Rule deleted
                        # 2033 = Rule applied from GPO
                        $FirewallEvents = Get-WinEvent -FilterHashtable @{
                            LogName   = 'Microsoft-Windows-Windows Firewall With Advanced Security/Firewall'
                            Id        = @(2004, 2005, 2006)
                            StartTime = $CutoffTime
                        } -ErrorAction SilentlyContinue

                        foreach ($Event in $FirewallEvents) {
                            $EventXml = [xml]$Event.ToXml()
                            $DataItems = @{}
                            $EventXml.Event.EventData.Data | ForEach-Object {
                                $DataItems[$_.Name] = $_.'#text'
                            }

                            $RuleName = $DataItems['RuleName']
                            if (-not $RuleName) { $RuleName = $DataItems['RuleId'] }

                            $FwChangeType = switch ($Event.Id) {
                                2004 { 'Created' }
                                2005 { 'Modified' }
                                2006 { 'Deleted' }
                            }

                            $FwSeverity = switch ($Event.Id) {
                                2004 { 'Medium' }
                                2005 { 'Low' }
                                2006 { 'High' }
                            }

                            $Direction = $DataItems['Direction']
                            $Action    = $DataItems['Action']
                            $Profile   = $DataItems['Profiles']
                            $Protocol  = $DataItems['Protocol']
                            $LocalPort = $DataItems['LocalPort']
                            $AppPath   = $DataItems['ApplicationPath']

                            $FwDetail = "Firewall rule '$RuleName' $FwChangeType"
                            if ($Direction) { $FwDetail += " (Direction: $Direction" }
                            if ($Action)    { $FwDetail += ", Action: $Action" }
                            if ($LocalPort) { $FwDetail += ", Port: $LocalPort" }
                            if ($AppPath)   { $FwDetail += ", App: $AppPath" }
                            if ($Direction) { $FwDetail += ')' }

                            $ModifiedBy = $DataItems['ModifyingUser']
                            if (-not $ModifiedBy) { $ModifiedBy = $DataItems['ModifyingApplication'] }

                            $Results.Add([PSCustomObject]@{
                                ChangeTime     = $Event.TimeCreated
                                ChangeType     = $FwChangeType
                                ChangeCategory = 'Firewall'
                                ObjectName     = $RuleName
                                OldValue       = ''
                                NewValue       = "Direction: $Direction, Action: $Action, Port: $LocalPort"
                                ChangedBy      = $ModifiedBy
                                Detail         = $FwDetail
                                Source         = "Firewall Event $($Event.Id)"
                                Severity       = $FwSeverity
                            })
                        }
                    }
                    catch {
                        if ($_.Exception.Message -notmatch 'No events were found|does not exist') {
                            Write-Verbose "Failed to query firewall events: $_"
                        }
                    }

                    # ============================================================
                    # 5. Installed software changes (registry-based)
                    # ============================================================
                    try {
                        $UninstallPaths = @(
                            'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
                            'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
                        )

                        foreach ($RegPath in $UninstallPaths) {
                            $InstalledApps = Get-ItemProperty -Path $RegPath -ErrorAction SilentlyContinue |
                                Where-Object {
                                    $_.DisplayName -and $_.InstallDate -and
                                    $_.InstallDate -match '^\d{8}$'
                                }

                            foreach ($App in $InstalledApps) {
                                try {
                                    $InstallDate = [datetime]::ParseExact($App.InstallDate, 'yyyyMMdd', $null)
                                    if ($InstallDate -ge $CutoffTime.Date) {
                                        $Results.Add([PSCustomObject]@{
                                            ChangeTime     = $InstallDate
                                            ChangeType     = 'Installed'
                                            ChangeCategory = 'Software'
                                            ObjectName     = $App.DisplayName
                                            OldValue       = ''
                                            NewValue       = "Version: $($App.DisplayVersion), Publisher: $($App.Publisher)"
                                            ChangedBy      = ''
                                            Detail         = "Software '$($App.DisplayName)' v$($App.DisplayVersion) installed (Publisher: $($App.Publisher))"
                                            Source         = "Registry (Uninstall)"
                                            Severity       = 'Medium'
                                        })
                                    }
                                }
                                catch {
                                    # Skip apps with unparseable dates
                                }
                            }
                        }

                        # Also check Application event log for MsiInstaller events (1033, 1034, 11707, 11724)
                        $MsiEvents = Get-WinEvent -FilterHashtable @{
                            LogName      = 'Application'
                            ProviderName = 'MsiInstaller'
                            StartTime    = $CutoffTime
                        } -ErrorAction SilentlyContinue

                        foreach ($Event in $MsiEvents) {
                            if ($Event.Id -in @(1033, 11707)) {
                                # Software installed successfully
                                $Results.Add([PSCustomObject]@{
                                    ChangeTime     = $Event.TimeCreated
                                    ChangeType     = 'Installed'
                                    ChangeCategory = 'Software'
                                    ObjectName     = ($Event.Message -split "`n")[0]
                                    OldValue       = ''
                                    NewValue       = ''
                                    ChangedBy      = ''
                                    Detail         = "Software installed: $($Event.Message.Substring(0, [Math]::Min($Event.Message.Length, 200)))"
                                    Source         = "MsiInstaller Event $($Event.Id)"
                                    Severity       = 'Medium'
                                })
                            }
                            elseif ($Event.Id -in @(1034, 11724)) {
                                # Software removed
                                $Results.Add([PSCustomObject]@{
                                    ChangeTime     = $Event.TimeCreated
                                    ChangeType     = 'Removed'
                                    ChangeCategory = 'Software'
                                    ObjectName     = ($Event.Message -split "`n")[0]
                                    OldValue       = ''
                                    NewValue       = ''
                                    ChangedBy      = ''
                                    Detail         = "Software removed: $($Event.Message.Substring(0, [Math]::Min($Event.Message.Length, 200)))"
                                    Source         = "MsiInstaller Event $($Event.Id)"
                                    Severity       = 'Medium'
                                })
                            }
                        }
                    }
                    catch {
                        if ($_.Exception.Message -notmatch 'No events were found') {
                            Write-Verbose "Failed to query software changes: $_"
                        }
                    }

                    return $Results
                }

                # Process remote results and attach ComputerName
                foreach ($Result in $RemoteResults) {
                    $Changes.Add([PSCustomObject]@{
                        ChangeTime     = $Result.ChangeTime
                        ChangeType     = $Result.ChangeType
                        Category       = 'ServerConfig'
                        ObjectName     = $Result.ObjectName
                        ObjectType     = $Result.ChangeCategory
                        ChangedBy      = $Result.ChangedBy
                        OldValue       = $Result.OldValue
                        NewValue       = $Result.NewValue
                        Detail         = $Result.Detail
                        Source         = "$($Result.Source) on $Computer"
                        Severity       = $Result.Severity
                        ComputerName   = $Computer
                        ChangeCategory = $Result.ChangeCategory
                    })
                }
            }
            catch {
                Write-Warning "Failed to connect to '${Computer}': $_"
                $Changes.Add([PSCustomObject]@{
                    ChangeTime     = Get-Date
                    ChangeType     = 'Error'
                    Category       = 'ServerConfig'
                    ObjectName     = $Computer
                    ObjectType     = 'Server'
                    ChangedBy      = ''
                    OldValue       = ''
                    NewValue       = ''
                    Detail         = "Failed to query server '${Computer}': $_"
                    Source         = 'Remote Connection Error'
                    Severity       = 'High'
                    ComputerName   = $Computer
                    ChangeCategory = 'Connectivity'
                })
            }
        }
    }

    end {
        $SortedChanges = $Changes | Sort-Object ChangeTime -Descending
        Write-Verbose "Found $($SortedChanges.Count) server configuration changes across $($ComputerName.Count) server(s)."
        return $SortedChanges
    }
}