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 } } |