TrackGpo.psm1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
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 2>$null } 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">.*</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) {
        [string[]]$Out = @()
        # 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
    @(
        @("&nbsp;", " "),
        @("&amp;bull;", " * "),
        @("&amp;lsaquo;", "<"),
        @("&amp;rsaquo;", ">"),
        @("&amp;(rsquo|lsquo|#39|#039);", "'"),
        @("&#0?39;", "'"),
        @("&amp;(quot|ldquo|rdquo);", '"'),
        @("&amp;trade;", "(tm)"),
        @("&amp;frasl;", "/"),
        @("&amp;(quot|#34|#034|#x22);", '"'),
        @('&amp;(amp|#38|#038|#x26);', "&amp;"),
        @("&amp;(lt|#60|#060|#x3c);", "<"),
        @("&amp;(gt|#62|#062|#x3e);", ">"),
        @('&amp;(copy|#169);', "(c)"),
        @("&amp;(reg|#174);", "(r)"),
        @("&amp;nbsp;", " "),
        @("&amp;(.{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
}