TrackGpo.psm1
function Invoke-GpoTracking { <# .SYNOPSIS This function will export all GPO info into restorable objects and place them into a folder for easy access .DESCRIPTION This function aims to make life easy for SysAdmins everywhere who work with Group Policy Objects (GPOs) and want to be able to audit/detect changes to them. Each folder backup is a GroupPolicy compatible backup that can be restored at will in order to revert changes or restore an accidently deleted GPO. They also contain a summary document that makes it easy to digest the state of each folder if you need to dig that deep. The default settings provide a resilient backup snapshot for all GPOs in your domain. Though you can override many settings as needed, you generally shouldn't need to. This includes disabling the git repo functionality, or deleting policies entirely from the backup folder when they get deleted from your domain. Defaults include the following settings: * Will update the Git repo ONLY if less than 10% of GPOs have changed since the last run * When a GPO is deleted from the domain: -all versions of the GPO get removed from the git repo -all versions of the GPO remain in the folder * Any change diff will include 3 lines of context above and below the change and also the common stuff about revision number and modified date This cmdlet depends on having Git installed and available for its diff capabilities. In order to make the MOST of this function, you need to create your own functions for two external events that can happen: * Normal GPO additions, changes, and deletions * Errors during processing Do so by creating a function or module named New-TrackGpoTicket_External and New-TrackGpoError_External respectively. The private functions in this repo will pass the same parameters to your external version of the function if you create one. You can also install the PSGallery module TrackGpo_Builtin for some samples to work off of. See: https://gitlab.com/devirich/trackgpo_builtin .PARAMETER ChangeRemovePercentMaxDelta Maximum percentage of change allowed in removals or additions before the script throws an error .PARAMETER GpoRepo The folder path to store the backed up GPOs to. .PARAMETER WorkingDir The folder path to temporarily store fresh backup for all GPOs for comparison. .PARAMETER Initialize By default, this function does NOT create folders or init a git repo. Enable this switch to turn on these features .PARAMETER RemoveOldPolicyVersions When a GPO is changed, this function will keep both folders. This switch makes it so that all old versions of the policy get removed. .PARAMETER RemoveDeletedPolicies Enable this switch if you want the backups folder to ONLY contain GPOs that are live on your domain. .PARAMETER DisableGitRepo Enable this switch if you hate git repos or have a legit worry about storing GPOs in a git repo history. .PARAMETER SkipCommonChanges Enable this switch if you don't want to see the modified date and revision number in the change documentation. See the wiki for an edge case to be aware of. .PARAMETER GpoChangeContext When a GPO is changed, the diff by default will show context around the actual changed lines. This parameter affects how many lines of context to show. .EXAMPLE PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working -ChangeRemovePercentMaxDelta 100 -Initialize -WhatIf What if: Performing the operation "Create Directory" on target "Destination: C:\Protected\GpoStore". What if: Performing the operation "Create Directory" on target "Destination: C:\Protected\GpoStore_working". What if: Performing the operation "Push-Location" on target "C:\Protected\GpoStore". What if: Performing the operation "Remove-Item" on target "C:\Protected\GpoStore_working\*". What if: Performing the operation "git `reset --hard`" on target "C:\Protected\GpoStore". What if: Performing the operation "Compare GroupPolicy to repo" on target "C:\Protected\GpoStore". What if: Back up all the GPOs in the domain.test domain to the following location: C:\Protected\GpoStore_working. (Backup-GPO) What if: Performing the operation "Set-Content" on target "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary (Foreach-Object)". What if: Performing the operation "Compare GPO GUIDs and reconcile changes" on target "`ls $GpoRepo\*\*.summary` and `ls $WorkingDir\*\*.summary`". What if: Performing the operation "Process any new GPO objects." on target "$WorkingDir\<FOLDER>". What if: Performing the operation "Process any updated GPO objects." on target "$WorkingDir\<FOLDER(S)>". What if: Performing the operation "Process any removed GPO objects." on target "$GpoRepo\<FOLDERS>". What if: Performing the operation "Remove-Item" on target "C:\Protected\GpoStore_working\*". What if: Performing the operation "git `reset --hard`" on target "C:\Protected\GpoStore". For a first run, consider enabling -Initialize to automatically create folders and set -Change to 100 so that the script expects the entire repo to get changed. Also use -WhatIf to get a view of what actions will be taken and where. Confirm that these actions and paths are what you expect. Remove -WhatIf and let the script run! .EXAMPLE PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working Use this for subsequent runs if you're ok with the defaults. .EXAMPLE PS> Invoke-GpoTracking -GpoRepo GpoStore -WorkingDir GpoStore_working -RemoveOldPolicyVersions -RemoveDeletedPolicies -DisableGitRepo -GpoChangeContext 0 -SkipCommonChanges This example will: Disable git functionality and make the repo folder a 1:1 match of live group policies in the domain. It also will set the context around each GPO diff to 0 lines above and below and skip showing the GPO revision number and modified date. .NOTES Original Publish date: 31Oct2018 Hope you like it! #> [cmdletbinding( SupportsShouldProcess, ConfirmImpact = "Medium" )] Param( [ValidateRange(0, 100)] [int]$ChangeRemovePercentMaxDelta = 10, [string]$GpoRepo, [string]$WorkingDir, [switch]$Initialize, [switch]$RemoveOldPolicyVersions, [switch]$RemoveDeletedPolicies, [switch]$DisableGitRepo, [switch]$SkipCommonChanges, [int]$GpoChangeContext = 3 ) $ErrorActionPreference = "Stop" # Need to ensure that Git is installed and accessible as expected try { git | Out-Null } catch { $Message = "Unable to run `git`. Exiting. Install Git or create an alias to run `git` if installed" New-TrackGpoError $Message throw $Message } $GpoRepo = Resolve-Path_Force $GpoRepo $WorkingDir = Resolve-Path_Force $WorkingDir if (Test-Path $GpoRepo) {} # Good to go. I just hate nested if statements elseif ($Initialize) { mkdir $GpoRepo } else { $Message = "$GpoRepo does not exist and -Initialize was not specified. Exiting." New-TrackGpoError $Message throw $Message } if (Test-Path $WorkingDir) {} # Good to go. I just hate nested if statements elseif ($Initialize) { mkdir $WorkingDir } else { $Message = "WorkingDir does not exist and -Initialize was not specified. Exiting." New-TrackGpoError $Message throw $Message } try { if ($pscmdlet.ShouldProcess($GpoRepo, 'Push-Location')) { Push-Location $GpoRepo } try { $Status = git status } catch {} if ($DisableGitRepo -or $Status) {} # Good to go. elseif ($Initialize) { git init } else { throw "GpoRepo is not a git repo and -Initialize was not specified. Exiting." } # Want working dir and git repo in a fresh state if ($pscmdlet.ShouldProcess("$WorkingDir\*", 'Remove-Item')) { Remove-Item $WorkingDir\* -Recurse -Force } if (-not $DisableGitRepo -and $pscmdlet.ShouldProcess($GpoRepo, 'git `reset --hard`')) { git reset --hard | Out-Null } if ($pscmdlet.ShouldProcess($GpoRepo, 'Compare GroupPolicy to repo')) { # Get the newest version of each GPO based on GUID [array]$GpoRepo_LatestGpos = Get-ChildItem $GpoRepo\*\*.summary | Sort-Object -Desc LastWriteTime | Group-Object Name | ForEach-Object { $_.Group[0] } $PercentChanged = Get-TrackGpoDeltaPercent -GpoRepo_LatestGpos $GpoRepo_LatestGpos if ($PercentChanged -gt $ChangeRemovePercentMaxDelta) { throw "Too many added or removed GPOs. $PercentChanged% of existing $($GpoRepo_LatestGpos.count) policies have been added or deleted. It should be at or under $ChangeRemovePercentMaxDelta% changed. Change -ChangeRemovePercentMaxDelta or determine why so many are listed as having added/removed. Initial run needs this param set to 100 in order to continue. Subsequent runs should NOT be set to 100 unless you like to live dangerously." } } try { #Region Export GPOs and Summary files to working dir $i = 0 Backup-GPO -All -Path $WorkingDir | ForEach-Object { $i++ Write-Progress -Activity "Backing up GPO reports" -Status "Processing policy number: $i" -CurrentOperation $_.DisplayName Get-GPOReport -ReportType Html -Guid $_.GpoId | Select-Object -OutVariable GpoReport_html | Out-Null Set-Content "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).htm" -Value $GpoReport_html # Data collected data will always be different. Need to remove it before storing summary for comparison: $GpoReport_html = $GpoReport_html -replace '<td id="dtstamp">Data collected on: .*</td>' # We want the comparison to be as neat as possible. Strip HTML data- $GpoReport = Remove-HtmlContent $GpoReport_html Set-Content "$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary" -Value $GpoReport } #These commands are needed because -WhatIf processing will NOT reach the inner loop of the above foreach. if ($pscmdlet.ShouldProcess('$($_.BackupDirectory)\{$($_.ID)}\$($_.GpoId).summary (Foreach-Object)', "Set-Content")) {} #EndRegion Export GPOs and Summary files to working dir } catch { throw "Backing up GPO's failed. Exiting immediately: $($_.Exception.Message)" } if ($pscmdlet.ShouldProcess('`ls $GpoRepo\*\*.summary` and `ls $WorkingDir\*\*.summary`', "Compare GPO GUIDs and reconcile changes")) { $WorkingFileIO = Get-ChildItem $WorkingDir\*\*.summary if ($GpoRepo_LatestGpos -and $WorkingFileIO) { $ComparisonCases = Compare-Object $GpoRepo_LatestGpos $WorkingFileIO -prop Name -IncludeEqual | Sort-Object SideIndicator # Previous test only checked for added/removed GPO's. The following test looks for too many changed GPOs in the domain: if (($ComparisonCases | Where-Object SideIndicator -NE "==").count / $GpoRepo_LatestGpos.count * 100 -gt $ChangeRemovePercentMaxDelta) { throw "Too many changed GPOs. $PercentChanged% of existing $($GpoRepo_LatestGpos.count) policies are changed. It should be at or under $ChangeRemovePercentMaxDelta% changed. Change -ChangeRemovePercentMaxDelta or determine why so many are listed as having added/removed. Initial run needs this param set to 100 in order to continue. Subsequent runs should NOT be set to 100 unless you like to live dangerously." } $i = 0 foreach ($Comparison in $ComparisonCases) { $i++ Write-Progress -Activity "Comparing policies" -CurrentOperation $Comparison.Name -PercentComplete ($i / $ComparisonCases.Count * 100) $Gpo = $Comparison switch ($Comparison.SideIndicator) { "<=" { #Previously existed. Not present anymore. Write-Information "Removing GPO! Sleep for 20s in case you want to cancel. $($Gpo.BaseName)" Start-Sleep 20 $VersionsOfGpo = Get-ChildItem $GpoRepo\*\$($Gpo.Name) $LatestVersionOfGpo = $VersionsOfGpo | Sort-Object -Desc LastWriteTime | Select-Object -First 1 $GpoInfo = Get-GpoInfo ($LatestVersionOfGpo.FullName -replace "summary", "htm") Write-Verbose "Removing GPO: $($GpoInfo.Title)" $CommitMessage = New-TrackGpoTicket -GpoInfo $GpoInfo -Type Remove $VersionsOfGpo | ForEach-Object { if ($RemoveDeletedPolicies) { Remove-Item -Recurse -Force $_.DirectoryName } if (-not $DisableGitRepo -and (git ls-files $_.DirectoryName)) { git rm --cached -r $_.DirectoryName } } if (-not $DisableGitRepo) { if ([string]::IsNullOrWhiteSpace($CommitMessage)) { $CommitMessage = "Remove: {0}" -f $GpoInfo.Title } git commit -m $CommitMessage } } "=>" { #Just created! $GpoReport = $WorkingFileIO | Where-Object Name -EQ $Gpo.Name $HeadGpoFolder = Move-Item $GpoReport.DirectoryName $GpoRepo -PassThru $HeadGpoReport = Get-ChildItem -Path $HeadGpoFolder\*.summary $GpoInfo = Get-GpoInfo ($HeadGpoReport.FullName -replace "summary", "htm") Write-Verbose "Adding GPO: $($GpoInfo.Title)" $CommitMessage = New-TrackGpoTicket -GpoInfo $GpoInfo -Type Add if (-not $DisableGitRepo) { if ([string]::IsNullOrWhiteSpace($CommitMessage)) { $CommitMessage = "Add: {0}" -f $GpoInfo.Title } git add $HeadGpoReport.DirectoryName git commit -m $CommitMessage } } "==" { # Exists previously and still exists. # Most of the time, this is what gets run. $ExistingGpoObject = $GpoRepo_LatestGpos | Where-Object Name -EQ $Gpo.Name $UpdatedGpoObject = $WorkingFileIO | Where-Object Name -EQ $Gpo.Name $Format = "U$GpoChangeContext" $DiffResults = git diff --shortstat --no-index -$Format -p --ignore-all-space $ExistingGpoObject.FullName $UpdatedGpoObject.FullName if ($DiffResults) { # need to pull out the stats on line 1 and discard line number 2 so that the results are ready for parsing $DiffStats, $null, $DiffResults = $DiffResults $Diff = ConvertFrom-Diff $DiffResults if ($SkipCommonChanges) { $DiffResults = $Diff.ToString("-", "User Revisions|Computer Revisions", 2) } else { $DiffResults = $Diff.ToString() } $GpoInfo = Get-GpoInfo ($UpdatedGpoObject.FullName -replace "summary", "htm") $Splat = @{ GpoInfo = $GpoInfo Type = "Change" Diff = $DiffResults Stats = $DiffStats } $CommitMessage = New-TrackGpoTicket @Splat Move-Item $UpdatedGpoObject.DirectoryName $GpoRepo if ($RemoveOldPolicyVersions) { Get-ChildItem $GpoRepo\*\$($Gpo.Name) | Sort-Object -Desc LastWriteTime | Select-Object -Skip 1 | ForEach-Object { Write-Verbose "Removing previous version of GPO backup: $($_.Directory)" $_.Directory | Remove-Item -Recurse -Force if (-not $DisableGitRepo) { git rm -r $_.DirectoryName } } } if (-not $DisableGitRepo) { if ([string]::IsNullOrWhiteSpace($CommitMessage)) { $CommitMessage = "Add: {0}" -f $GpoInfo.Title } Write-Verbose "Adding modified GPO to repo - $($Gpo.BaseName)" git add (Split-Path -Leaf $UpdatedGpoObject.DirectoryName) git commit -m $CommitMessage } } else { Write-Verbose "GPO has not changed. Removing from working." Remove-Item $UpdatedGpoObject.DirectoryName -Recurse -Force } } } } } elseif ($GpoRepo_LatestGpos) { throw "I'm scared: All GPOs removed from domain??! Or other error. Ya, you should look carefully at what is causing this." } elseif ($WorkingFileIO) { Write-Verbose "No GPOs currently exist in head! Adding all GPOs to git." foreach ($GpoReport in $WorkingFileIO) { $MovedItem = Move-Item $GpoReport.DirectoryName $GpoRepo -PassThru $HeadGpoReport = Get-ChildItem -Path $MovedItem\*.summary $GpoInfo = Get-GpoInfo ($HeadGpoReport.FullName -replace "summary", "htm") if (-not $DisableGitRepo) { Write-Verbose "Committing to head with comment: $($GpoInfo['Title'])" git add $HeadGpoReport.DirectoryName git commit -m "Init domain: $($GpoInfo.Title)" } } } else { throw "No files in head or working! What's going on here anyway!?!" } } if ($pscmdlet.ShouldProcess('$WorkingDir\<FOLDER>', "Process any new GPO objects.")) {} if ($pscmdlet.ShouldProcess('$WorkingDir\<FOLDER(S)>', "Process any updated GPO objects.")) {} if ($pscmdlet.ShouldProcess('$GpoRepo\<FOLDERS>', "Process any removed GPO objects.")) {} if ($pscmdlet.ShouldProcess("$WorkingDir\*", 'Remove-Item')) { Remove-Item $WorkingDir\* -Recurse -Force } if (-not $DisableGitRepo -and $pscmdlet.ShouldProcess($GpoRepo, 'git `reset --hard`')) { git reset --hard | Out-Null } Pop-Location } catch { Pop-Location $Message = $_.Exception.Message New-TrackGpoError $Message throw $Message } } function ConvertFrom-Diff { [CmdletBinding()] param ( [string[]]$In ) $String = $In -join "`n" $Sections = $String -replace "`r" -split "(?m)`n(?=^diff)" $Sections | ForEach-Object { [Diff]::new($_) } } class Diff { [string]$Header [string[]]$ExtendedHeaders [string]$From [string]$To [array[]]$Hunk Diff() {} Diff([string[]]$String) { # Need to ensure that whether you input an array of strings, or a string with multiple lines, or a combo, # that it ends up as an array of strings in a queue collection: $In = $String -join "`n" [System.Collections.Generic.Queue[string]]$Q = $In -split "`n" # Need to make sure that Q is properly populated. If so, convert to our object! if ($Q.Count -and $Q.Peek() -match "^diff") { $this.Header = $Q.Dequeue() $this.ExtendedHeaders = while ($Q.Peek() -notmatch "^---") { $Q.Dequeue() } $this.From = $Q.Dequeue() $this.To = $Q.Dequeue() $this.Hunk = $Q -join "`n" -split "(?m)`n(?=^@@)" } } [string[]] ToString() { return $this.ToString($null, $null, $null) } [string[]] ToString([string]$ExcludeHunkPattern) { return $this.ToString("-", $ExcludeHunkPattern, 2) } [string[]] ToString([string]$Modifier, [string]$HunkPattern, [int]$SearchScope) { $Out = $this.Header, $this.ExtendedHeaders, $this.From, $this.To # SearchScope is used with -SkipCommonChanges to search in the first couple lines for the Computer or User revisions fields. # This feels a bit like a hack. Cause it is. switch ($Modifier) { "+" { $out += $this.Hunk | Select-Object -First $SearchScope | Where-Object { $_ -match $HunkPattern } } "-" { $out += $this.Hunk | Select-Object -First $SearchScope | Where-Object { $_ -notmatch $HunkPattern } } default { $out += $this.Hunk | Select-Object -First $SearchScope } } $out += $this.Hunk | Select-Object -Skip $SearchScope return $out | ForEach-Object { $_ -split "`n" } } } function Get-GpoInfo ([string]$FilePath) { $GpoContents = Get-Content $FilePath $GpoInfo = [ordered]@{ Title = [regex]::Match($GpoContents, '(?<=<title>).*?(?=</title>)').Value Created = [regex]::Match($GpoContents, '(?<="row">Created</td><td>).*?(?=</td></tr>)').Value Modified = [regex]::Match($GpoContents, '(?<="row">Modified</td><td>).*?(?=</td></tr>)').Value GUID = [regex]::Match($GpoContents, '(?<="row">Unique ID</td><td>).*?(?=</td></tr>)').Value 'GPO Status' = [regex]::Match($GpoContents, '(?<="row">GPO Status</td><td>).*?(?=</td></tr>)').Value 'Enabled Links' = [regex]::Matches($GpoContents, '(?<=<td>Enabled</td><td>)feb.com/.*?(?=</td>)').Value -join "`n" } $GpoInfo } function Get-TrackGpoDeltaPercent { <# .SYNOPSIS Returns a percentage as 0-100 of how many GPOs are not common between the domain and a list of GPO ids .PARAMETER GpoRepo_LatestGpos Array of GPOs that was the last current snapshot of the domain #> [CmdletBinding()] [OutputType([int])] param ( [Parameter()] $GpoRepo_LatestGpos ) # Need a baseline of all previous GPOs in order to track possible failres if ($DomainGpos = Get-GPO -All) { # This check is placed inside the Get-GPO block to ensure that Get-GPO works even when there # are no existing GPO's getting tracked. if ($GpoRepo_LatestGpos) { (Compare-Object $DomainGpos.ID $GpoRepo_LatestGpos.Basename).count / $GpoRepo_LatestGpos.count * 100 } else { # When there are no existing GPOs getting tracked, everything is changed. Return 100% 100 } } else { throw "Could not get domain Group Policy Objects (GPOs). Please fix this issue. Permissions?" } } function New-TrackGpoError { param( [parameter(Mandatory)] [String]$Message ) $CommandName = $MyInvocation.InvocationName + "_External" if (Get-Command $CommandName -ea silent) { Get-Command $CommandName -All | Select-Object -Expand Source | ForEach-Object { if ($_) { $Command = "$_\$CommandName" } else { $Command = $CommandName } & $Command -Message $Message } } } function New-TrackGpoTicket { param( [ValidateSet("Add", "Remove", "Change")] [parameter(Mandatory)]$Type, [parameter(Mandatory)]$GpoInfo, $Diff, $Stats ) $Splat = @{ Type = $Type GpoInfo = $GpoInfo } if ($Diff) { $Splat.Add("Diff", $Diff) } if ($Stats) { $Splat.Add("Stats", $Stats) } $CommandName = $MyInvocation.InvocationName + "_External" if (Get-Command $CommandName -ea silent) { Get-Command $CommandName -All | Select-Object -Expand Source | ForEach-Object { if ($_) { $Command = "$_\$CommandName" } else { $Command = $CommandName } & $Command @Splat } } } function Remove-HtmlContent { param([System.String[]] $html) # Adapted from: http://winstonfassett.com/blog/2010/09/21/html-to-text-conversion-in-powershell/ # This function makes use of the single line (?s) regex modifier to make . apply to newlines # This function makes use of the multiline (?m) regex modifier to make ^|$ apply to newlines # Want to preserve line breaks for pretty formatting later, but need a single string with only newlines: $html = $html -replace "`r" -join "`n" # remove invisible content @('head', 'script', 'style', 'object', 'embed', 'applet', 'noframes', 'noscript', 'noembed') | ForEach-Object { $html = $html -replace "(?ms)<$_[^>]*?>.*?^</$_>", "" } # write-verbose "removed invisible blocks: `n`n$html`n" # Condense extra whitespace $html = $html -replace "( )+", " " # write-verbose "condensed whitespace: `n`n$html`n" # Remove the window styles $html = $html -replace '(?ms)<div id="explainText_windowStyles.*?</div>' # Add line breaks @('div', 'p', 'blockquote', 'h[1-9]', 'tr') | ForEach-Object { $html = $html -replace "(?ms)</?$_[^>]*?>.*?</$_>", ("`n" + '$0' ) } # Add line breaks for self-closing tags @('div', 'p', 'blockquote', 'h[1-9]', 'br') | ForEach-Object { $html = $html -replace "(?ms)<$_[^>]*?/>", ('$0' + "`n") } # write-verbose "added line breaks: `n`n$html`n" # table cells deserve a tab after them $html = $html -replace "</td>|</th>", " `t" #strip tags $html = $html -replace "<[^>]*?>", "" # write-verbose "removed tags: `n`n$html`n" # replace common entities @( @(" ", " "), @("&bull;", " * "), @("&lsaquo;", "<"), @("&rsaquo;", ">"), @("&(rsquo|lsquo|#39|#039);", "'"), @("�?39;", "'"), @("&(quot|ldquo|rdquo);", '"'), @("&trade;", "(tm)"), @("&frasl;", "/"), @("&(quot|#34|#034|#x22);", '"'), @('&(amp|#38|#038|#x26);', "&"), @("&(lt|#60|#060|#x3c);", "<"), @("&(gt|#62|#062|#x3e);", ">"), @('&(copy|#169);', "(c)"), @("&(reg|#174);", "(r)"), @("&nbsp;", " "), @("&(.{2,6});", "") ) | ForEach-Object { $html = $html -replace $_[0], $_[1] } # write-verbose "replaced entities: `n`n$html`n" # Extra lines should get condensed $html = $html -replace "`n+", "`n" $html -split "`n" } function Resolve-Path_Force { <# .SYNOPSIS Calls Resolve-Path but works for files that don't exist. .REMARKS From http://devhawk.net/blog/2010/1/22/fixing-powershells-busted-resolve-path-cmdlet #> param ( [string] $FileName ) $FileName = Resolve-Path $FileName -ErrorAction SilentlyContinue -ErrorVariable _frperror if (-not($FileName)) { $FileName = $_frperror[0].TargetObject } return $FileName } |