helpers/config-drift/ConfigDriftHelpers.ps1
|
#requires -Version 5.1 <# .SYNOPSIS Configuration drift auditing with a self-contained baseline store. .DESCRIPTION Provides golden-configuration tracking, line-level diffing, policy rule evaluation and cross-device consistency checks without depending on Git or any other external tool. Baselines are kept in a local folder with a JSON index, hashed with SHA-256, and versioned by timestamp. This complements WhatsUp Gold Config Management rather than replacing it: it adds custom policy checks, peer-group comparison and dashboard output. Every function except Get-ConfigViaSsh is pure, so the whole engine can be tested offline. .NOTES Author : jason@wug.ninja Requires: PowerShell 5.1+, helpers/ssh/WhatsUpGoldPS.Ssh for SSH collection. .LINK https://github.com/jayyx2/WhatsUpGoldPS #> # ============================================================================ # region Normalization # ============================================================================ function Get-ConfigVendorProfile { <# .SYNOPSIS Returns collection commands and volatile-line ignore patterns per platform. .PARAMETER Name Profile name: cisco-ios, cisco-nxos, cisco-asa, linux, generic. #> [CmdletBinding()] param( [ValidateSet('cisco-ios', 'cisco-nxos', 'cisco-asa', 'linux', 'generic')] [string]$Name = 'generic' ) $profiles = @{ 'cisco-ios' = @{ Command = @('show running-config') SetupCommand = @('terminal length 0') PromptPattern = '(?m)^[^\r\n]*[>#]\s*$' CommentPrefix = '!' IgnorePattern = @( '^Building configuration', '^Current configuration\s*:', '^!\s*Last configuration change', '^!\s*NVRAM config last updated', '^ntp clock-period', '^!\s*Time:' ) IgnoreBlock = @( @{ Start = '^crypto pki certificate chain'; End = '^\s*quit\s*$' } ) } 'cisco-nxos' = @{ Command = @('show running-config') SetupCommand = @('terminal length 0') PromptPattern = '(?m)^[^\r\n]*[>#]\s*$' CommentPrefix = '!' IgnorePattern = @( '^!Time:', '^!Command:', '^!Running configuration last done' ) IgnoreBlock = @( @{ Start = '^crypto ca certificate'; End = '^\s*quit\s*$' } ) } 'cisco-asa' = @{ Command = @('show running-config') SetupCommand = @('terminal pager 0') PromptPattern = '(?m)^[^\r\n]*[>#]\s*$' CommentPrefix = '!' IgnorePattern = @( '^:\s*Saved', '^:\s*Written by', '^:\s*Serial Number', '^:\s*Hardware' ) IgnoreBlock = @( @{ Start = '^crypto ca certificate chain'; End = '^\s*quit\s*$' } ) } 'linux' = @{ Command = @() SetupCommand = @() PromptPattern = '(?m)^[^\r\n]*[\$#]\s*$' CommentPrefix = '#' IgnorePattern = @( '^\s*#\s*Generated on', '^\s*#\s*Last modified' ) IgnoreBlock = @( @{ Start = '^-+BEGIN [A-Z ]*(CERTIFICATE|PRIVATE KEY)-+'; End = '^-+END [A-Z ]*(CERTIFICATE|PRIVATE KEY)-+' } ) } 'generic' = @{ Command = @() SetupCommand = @() PromptPattern = '(?m)^[^\r\n]*[>#\$]\s*$' CommentPrefix = '' IgnorePattern = @() IgnoreBlock = @() } } $selected = $profiles[$Name] return [pscustomobject]@{ Name = $Name Command = @($selected.Command) SetupCommand = @($selected.SetupCommand) PromptPattern = $selected.PromptPattern CommentPrefix = $selected.CommentPrefix IgnorePattern = @($selected.IgnorePattern) IgnoreBlock = @($selected.IgnoreBlock) } } function Get-ConfigSection { <# .SYNOPSIS Splits a configuration into indentation-based sections. .DESCRIPTION A section starts at a non-indented line and includes the indented lines beneath it. A comment line such as "!" terminates the current section. Global one-line commands come back as single-line sections, so every line belongs to exactly one section. .OUTPUTS Objects with Header, StartIndex, EndIndex and Lines. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][AllowEmptyString()][AllowNull()]$Config, [string]$CommentPrefix = '' ) $lines = @() if ($Config -is [string]) { $lines = @($Config -split "`r?`n") } elseif ($null -ne $Config) { $lines = @($Config) } $sections = [System.Collections.Generic.List[object]]::new() $current = $null for ($i = 0; $i -lt $lines.Count; $i++) { $text = ([string]$lines[$i]).TrimEnd() if ([string]::IsNullOrWhiteSpace($text)) { continue } if ($CommentPrefix -and $text.Trim().StartsWith($CommentPrefix)) { $current = $null continue } if ($text -match '^[ \t]') { if ($current) { $current.EndIndex = $i; $current.Lines.Add($text) } continue } $current = [pscustomobject]@{ Header = $text StartIndex = $i EndIndex = $i Lines = [System.Collections.Generic.List[string]]::new() } $current.Lines.Add($text) $sections.Add($current) } return @($sections) } function ConvertTo-NormalizedConfig { <# .SYNOPSIS Normalizes a configuration into comparable lines. .DESCRIPTION Drops volatile lines matching IgnorePattern, optionally removes comments and blank lines, and trims trailing whitespace so cosmetic changes do not register as drift. .PARAMETER IgnorePattern Regular expressions for lines that must never count as drift. .PARAMETER IgnoreBlock Hashtables with Start and End regular expressions. The anchor line is kept so a deleted block is still detected, but the volatile body is dropped. Use this for certificate chains and key material that are regenerated without any real configuration change. .PARAMETER IgnoreSection Wildcard patterns matched against section headers. The header is kept and the indented body is dropped. Easier than IgnoreBlock because there is no terminator to get right. .PARAMETER IncludePattern When supplied, only lines matching one of these expressions are audited. .PARAMETER IncludeBlock When supplied, only lines inside these Start/End regions are audited. The Start line is kept and the End line is treated as a terminator. .PARAMETER IncludeSection Wildcard patterns matched against section headers, for example 'interface *'. Include filters combine, so a line survives when it matches any of IncludePattern, IncludeBlock or IncludeSection. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][AllowEmptyString()][AllowNull()]$Config, [string[]]$IgnorePattern = @(), [hashtable[]]$IgnoreBlock = @(), [string[]]$IgnoreSection = @(), [string[]]$IncludePattern = @(), [hashtable[]]$IncludeBlock = @(), [string[]]$IncludeSection = @(), [string]$CommentPrefix = '', [switch]$KeepComments, [switch]$KeepBlankLines ) $lines = @() if ($Config -is [string]) { $lines = @($Config -split "`r?`n") } elseif ($null -ne $Config) { $lines = @($Config) } $activeIncludeBlocks = @(@($IncludeBlock) | Where-Object { $null -ne $_ -and $_.Start }) $activeIncludePatterns = @(@($IncludePattern) | Where-Object { $_ }) $activeIncludeSections = @(@($IncludeSection) | Where-Object { $_ }) $activeIgnoreSections = @(@($IgnoreSection) | Where-Object { $_ }) $inIncludedSection = New-Object 'bool[]' ($lines.Count) $inIgnoredSectionBody = New-Object 'bool[]' ($lines.Count) if ($activeIncludeSections.Count -gt 0 -or $activeIgnoreSections.Count -gt 0) { foreach ($section in @(Get-ConfigSection -Config $lines -CommentPrefix $CommentPrefix)) { foreach ($pattern in $activeIncludeSections) { if ($section.Header -like $pattern) { for ($i = $section.StartIndex; $i -le $section.EndIndex; $i++) { $inIncludedSection[$i] = $true } break } } foreach ($pattern in $activeIgnoreSections) { if ($section.Header -like $pattern) { for ($i = $section.StartIndex + 1; $i -le $section.EndIndex; $i++) { $inIgnoredSectionBody[$i] = $true } break } } } } $useInclude = ($activeIncludeBlocks.Count -gt 0 -or $activeIncludePatterns.Count -gt 0 -or $activeIncludeSections.Count -gt 0) $kept = [System.Collections.Generic.List[string]]::new() $includeEndPattern = $null $blockEndPattern = $null for ($idx = 0; $idx -lt $lines.Count; $idx++) { $text = ([string]$lines[$idx]).TrimEnd() if ($useInclude) { $selected = $false if ($includeEndPattern) { if ($text -match $includeEndPattern) { $includeEndPattern = $null } else { $selected = $true } } else { foreach ($block in $activeIncludeBlocks) { if ($text -match $block.Start) { $selected = $true if ($block.End) { $includeEndPattern = $block.End } break } } if (-not $selected -and $inIncludedSection[$idx]) { $selected = $true } if (-not $selected) { foreach ($pattern in $activeIncludePatterns) { if ($text -match $pattern) { $selected = $true; break } } } } if (-not $selected) { continue } } if ($blockEndPattern) { if ($text -match $blockEndPattern) { $blockEndPattern = $null } continue } if ($inIgnoredSectionBody[$idx]) { continue } if (-not $KeepBlankLines -and [string]::IsNullOrWhiteSpace($text)) { continue } if (-not $KeepComments -and $CommentPrefix -and $text.Trim().StartsWith($CommentPrefix)) { continue } $skip = $false foreach ($pattern in @($IgnorePattern)) { if (-not $pattern) { continue } if ($text -match $pattern) { $skip = $true; break } } if ($skip) { continue } $startedBlock = $false foreach ($block in @($IgnoreBlock)) { if ($null -eq $block -or -not $block.Start) { continue } if ($text -match $block.Start) { $kept.Add($text) $blockEndPattern = $block.End $startedBlock = $true break } } if ($startedBlock) { continue } $kept.Add($text) } return @($kept) } function Get-ConfigHash { <# .SYNOPSIS Returns the SHA-256 hash of normalized configuration lines. #> [CmdletBinding()] param([Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Lines) $sha = [System.Security.Cryptography.SHA256]::Create() try { $bytes = [System.Text.Encoding]::UTF8.GetBytes(($Lines -join "`n")) $hash = $sha.ComputeHash($bytes) return (($hash | ForEach-Object { $_.ToString('x2') }) -join '') } finally { $sha.Dispose() } } # endregion # ============================================================================ # region Diff engine # ============================================================================ function Get-ConfigDiff { <# .SYNOPSIS Produces a line-level diff between a baseline and a current configuration. .DESCRIPTION Trims the common prefix and suffix, then runs a longest-common-subsequence comparison on the remaining block. Added means present now but not in the baseline; Removed means present in the baseline but missing now. When the differing block is too large for the LCS matrix, the function falls back to an unordered set comparison so very large configurations still return a usable result. .PARAMETER MaxMatrixCells Upper bound on LCS matrix size before falling back to set comparison. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Reference, [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Difference, [int]$MaxMatrixCells = 1000000, [switch]$IncludeUnchanged ) $refLines = @($Reference) $difLines = @($Difference) $results = [System.Collections.Generic.List[object]]::new() function Add-DiffRow { param($List, [string]$Operation, $ReferenceLine, $DifferenceLine, [string]$Text) $List.Add([pscustomobject]@{ Operation = $Operation ReferenceLine = $ReferenceLine DifferenceLine = $DifferenceLine Text = $Text }) } $start = 0 while ($start -lt $refLines.Count -and $start -lt $difLines.Count -and $refLines[$start] -ceq $difLines[$start]) { if ($IncludeUnchanged) { Add-DiffRow $results 'Unchanged' ($start + 1) ($start + 1) $refLines[$start] } $start++ } $endRef = $refLines.Count - 1 $endDif = $difLines.Count - 1 while ($endRef -ge $start -and $endDif -ge $start -and $refLines[$endRef] -ceq $difLines[$endDif]) { $endRef-- $endDif-- } $midRef = @() if ($endRef -ge $start) { $midRef = @($refLines[$start..$endRef]) } $midDif = @() if ($endDif -ge $start) { $midDif = @($difLines[$start..$endDif]) } $m = $midRef.Count $n = $midDif.Count if ($m -eq 0 -and $n -eq 0) { # Only the trimmed suffix remains. } elseif ($m -eq 0) { for ($j = 0; $j -lt $n; $j++) { Add-DiffRow $results 'Added' $null ($start + $j + 1) $midDif[$j] } } elseif ($n -eq 0) { for ($i = 0; $i -lt $m; $i++) { Add-DiffRow $results 'Removed' ($start + $i + 1) $null $midRef[$i] } } elseif (([double]$m + 1) * ([double]$n + 1) -gt $MaxMatrixCells) { $refCounts = @{} foreach ($line in $midRef) { if ($refCounts.ContainsKey($line)) { $refCounts[$line] = $refCounts[$line] + 1 } else { $refCounts[$line] = 1 } } foreach ($line in $midDif) { if ($refCounts.ContainsKey($line) -and $refCounts[$line] -gt 0) { $refCounts[$line] = $refCounts[$line] - 1 } else { Add-DiffRow $results 'Added' $null $null $line } } foreach ($key in @($refCounts.Keys)) { for ($c = 0; $c -lt $refCounts[$key]; $c++) { Add-DiffRow $results 'Removed' $null $null $key } } } else { # Flat array: PowerShell 5.1 reads $a[$i,$j] on an int[,] as an index list, not a 2D index. $width = $n + 1 $lcs = New-Object 'int[]' (($m + 1) * $width) for ($i = $m - 1; $i -ge 0; $i--) { $rowBase = $i * $width $nextBase = ($i + 1) * $width for ($j = $n - 1; $j -ge 0; $j--) { if ($midRef[$i] -ceq $midDif[$j]) { $lcs[$rowBase + $j] = $lcs[$nextBase + $j + 1] + 1 } elseif ($lcs[$nextBase + $j] -ge $lcs[$rowBase + $j + 1]) { $lcs[$rowBase + $j] = $lcs[$nextBase + $j] } else { $lcs[$rowBase + $j] = $lcs[$rowBase + $j + 1] } } } $i = 0 $j = 0 while ($i -lt $m -and $j -lt $n) { if ($midRef[$i] -ceq $midDif[$j]) { if ($IncludeUnchanged) { Add-DiffRow $results 'Unchanged' ($start + $i + 1) ($start + $j + 1) $midRef[$i] } $i++ $j++ } elseif ($lcs[(($i + 1) * $width) + $j] -ge $lcs[($i * $width) + $j + 1]) { Add-DiffRow $results 'Removed' ($start + $i + 1) $null $midRef[$i] $i++ } else { Add-DiffRow $results 'Added' $null ($start + $j + 1) $midDif[$j] $j++ } } while ($i -lt $m) { Add-DiffRow $results 'Removed' ($start + $i + 1) $null $midRef[$i]; $i++ } while ($j -lt $n) { Add-DiffRow $results 'Added' $null ($start + $j + 1) $midDif[$j]; $j++ } } if ($IncludeUnchanged) { $tailRef = $endRef + 1 $tailDif = $endDif + 1 while ($tailRef -lt $refLines.Count -and $tailDif -lt $difLines.Count) { Add-DiffRow $results 'Unchanged' ($tailRef + 1) ($tailDif + 1) $refLines[$tailRef] $tailRef++ $tailDif++ } } return @($results) } function Format-ConfigDiff { <# .SYNOPSIS Renders diff rows as unified-style text. #> [CmdletBinding()] param([Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Diff) $lines = [System.Collections.Generic.List[string]]::new() foreach ($row in $Diff) { switch ($row.Operation) { 'Added' { $lines.Add('+ ' + $row.Text) } 'Removed' { $lines.Add('- ' + $row.Text) } default { $lines.Add(' ' + $row.Text) } } } return ($lines -join "`n") } # endregion # ============================================================================ # region Baseline store # ============================================================================ function ConvertTo-SafeDeviceKey { [CmdletBinding()] param([Parameter(Mandatory = $true)][string]$DeviceKey) $safe = [regex]::Replace($DeviceKey, '[^A-Za-z0-9._-]', '_') $safe = [regex]::Replace($safe, '\.{2,}', '_') $safe = $safe.Trim('.') if (-not $safe) { $safe = 'device' } return $safe } function Initialize-ConfigBaselineStore { <# .SYNOPSIS Creates the baseline store folder and index if they do not exist. #> [CmdletBinding()] param([Parameter(Mandatory = $true)][string]$StorePath) if (-not (Test-Path -LiteralPath $StorePath)) { New-Item -ItemType Directory -Path $StorePath -Force | Out-Null } $configDir = Join-Path $StorePath 'configs' if (-not (Test-Path -LiteralPath $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } $indexPath = Join-Path $StorePath 'index.json' if (-not (Test-Path -LiteralPath $indexPath)) { @{ version = 1; revisions = @() } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $indexPath -Encoding UTF8 } return $StorePath } function Get-ConfigBaselineIndex { <# .SYNOPSIS Returns all baseline revisions recorded in the store. #> [CmdletBinding()] param([Parameter(Mandatory = $true)][string]$StorePath) $indexPath = Join-Path $StorePath 'index.json' if (-not (Test-Path -LiteralPath $indexPath)) { return @() } $raw = Get-Content -LiteralPath $indexPath -Raw if ([string]::IsNullOrWhiteSpace($raw)) { return @() } $parsed = $raw | ConvertFrom-Json if ($null -eq $parsed -or $null -eq $parsed.revisions) { return @() } return @($parsed.revisions) } function Save-ConfigBaselineIndex { [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$StorePath, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Revisions ) $indexPath = Join-Path $StorePath 'index.json' @{ version = 1; revisions = @($Revisions) } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $indexPath -Encoding UTF8 } function Save-ConfigBaseline { <# .SYNOPSIS Stores a configuration revision, skipping writes when nothing changed. .PARAMETER Approve Marks the revision as the golden configuration for the device. .PARAMETER Force Writes a revision even when the hash matches the latest stored one. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)][string]$StorePath, [Parameter(Mandatory = $true)][string]$DeviceKey, [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Config, [string]$Note = '', [switch]$Approve, [switch]$Force ) Initialize-ConfigBaselineStore -StorePath $StorePath | Out-Null $safeKey = ConvertTo-SafeDeviceKey -DeviceKey $DeviceKey $hash = Get-ConfigHash -Lines $Config $revisions = @(Get-ConfigBaselineIndex -StorePath $StorePath) $existing = @($revisions | Where-Object { $_.deviceKey -eq $DeviceKey }) $latest = $existing | Select-Object -Last 1 if ($latest -and $latest.hash -eq $hash -and -not $Force -and -not $Approve) { return [pscustomobject]@{ DeviceKey = $DeviceKey Hash = $hash Timestamp = $latest.timestamp Approved = [bool]$latest.approved Created = $false Path = (Join-Path $StorePath $latest.file) } } if (-not $PSCmdlet.ShouldProcess($DeviceKey, 'Save configuration baseline')) { return $null } $timestamp = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss') $fileStamp = (Get-Date).ToString('yyyyMMdd-HHmmss') $relativePath = Join-Path (Join-Path 'configs' $safeKey) "$fileStamp-$($hash.Substring(0, 8)).txt" $fullPath = Join-Path $StorePath $relativePath $parent = Split-Path $fullPath -Parent if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } ($Config -join "`n") | Set-Content -LiteralPath $fullPath -Encoding UTF8 $revision = [pscustomobject]@{ deviceKey = $DeviceKey timestamp = $timestamp hash = $hash file = $relativePath note = $Note approved = [bool]$Approve lineCount = @($Config).Count } Save-ConfigBaselineIndex -StorePath $StorePath -Revisions (@($revisions) + @($revision)) return [pscustomobject]@{ DeviceKey = $DeviceKey Hash = $hash Timestamp = $timestamp Approved = [bool]$Approve Created = $true Path = $fullPath } } function Get-ConfigBaselineHistory { <# .SYNOPSIS Returns stored revisions for a device, oldest first. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$StorePath, [Parameter(Mandatory = $true)][string]$DeviceKey ) return @(Get-ConfigBaselineIndex -StorePath $StorePath | Where-Object { $_.deviceKey -eq $DeviceKey }) } function Get-ConfigBaseline { <# .SYNOPSIS Loads a stored baseline configuration for a device. .PARAMETER Golden Return the most recent approved revision instead of the most recent revision. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$StorePath, [Parameter(Mandatory = $true)][string]$DeviceKey, [switch]$Golden ) $history = @(Get-ConfigBaselineHistory -StorePath $StorePath -DeviceKey $DeviceKey) if ($history.Count -eq 0) { return $null } if ($Golden) { $approved = @($history | Where-Object { $_.approved }) if ($approved.Count -eq 0) { return $null } $revision = $approved[-1] } else { $revision = $history[-1] } $fullPath = Join-Path $StorePath $revision.file if (-not (Test-Path -LiteralPath $fullPath)) { return $null } $content = Get-Content -LiteralPath $fullPath -Raw $lines = @() if ($null -ne $content) { $lines = @($content -split "`r?`n") } if ($lines.Count -gt 0 -and $lines[-1] -eq '') { $lines = @($lines[0..($lines.Count - 2)]) } return [pscustomobject]@{ DeviceKey = $DeviceKey Timestamp = $revision.timestamp Hash = $revision.hash Approved = [bool]$revision.approved Note = $revision.note Path = $fullPath Lines = $lines } } # endregion # ============================================================================ # region Policy and peer comparison # ============================================================================ function Get-ConfigPolicyPack { <# .SYNOPSIS Returns a ready-made set of policy rules. .DESCRIPTION Saves writing common hardening rules by hand. The returned hashtables can be passed straight to Test-ConfigPolicy or Invoke-ConfigDriftAudit, and can be extended with your own rules. .PARAMETER Name cisco-hardening, cisco-snmp, or linux-ssh. .EXAMPLE Invoke-ConfigDriftAudit -Target r1 -Config $cfg -StorePath .\base -Rule (Get-ConfigPolicyPack cisco-hardening) #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateSet('cisco-hardening', 'cisco-snmp', 'linux-ssh')] [string]$Name ) switch ($Name) { 'cisco-hardening' { return @( @{ Id = 'Telnet disabled'; MustNotMatch = 'transport input .*telnet'; Severity = 'Fail' }, @{ Id = 'AAA enabled'; MustMatch = '^aaa new-model'; Severity = 'Fail' }, @{ Id = 'HTTP server disabled'; MustMatch = '^no ip http server'; Severity = 'Warn' }, @{ Id = 'Password encryption enabled'; MustMatch = '^service password-encryption'; Severity = 'Warn' }, @{ Id = 'No cleartext enable password'; MustNotMatch = '^enable password '; Severity = 'Fail' }, @{ Id = 'SSH version 2'; MustMatch = '^ip ssh version 2'; Severity = 'Warn' }, @{ Id = 'Logging configured'; MustMatch = '^logging (host|server) '; Severity = 'Warn' }, @{ Id = 'Exec timeout set'; MustMatch = 'exec-timeout'; Severity = 'Warn' } ) } 'cisco-snmp' { return @( @{ Id = 'No default SNMP community'; MustNotMatch = '^snmp-server community (public|private)\b'; Severity = 'Fail' }, @{ Id = 'No read-write SNMP'; MustNotMatch = '^snmp-server community \S+ RW'; Severity = 'Fail' }, @{ Id = 'SNMP configured'; MustMatch = '^snmp-server '; Severity = 'Warn' } ) } 'linux-ssh' { return @( @{ Id = 'SSH root login disabled'; MustNotMatch = '(?i)^\s*permitrootlogin\s+yes'; Severity = 'Fail' }, @{ Id = 'SSH empty passwords disabled'; MustNotMatch = '(?i)^\s*permitemptypasswords\s+yes'; Severity = 'Fail' }, @{ Id = 'SSH password auth disabled'; MustNotMatch = '(?i)^\s*passwordauthentication\s+yes'; Severity = 'Warn' }, @{ Id = 'SSH X11 forwarding disabled'; MustNotMatch = '(?i)^\s*x11forwarding\s+yes'; Severity = 'Warn' } ) } } } function Test-ConfigPolicy { <# .SYNOPSIS Evaluates configuration lines against policy rules. .PARAMETER Rule Hashtables with Id, optional Description, Severity (Fail or Warn), and either MustMatch or MustNotMatch as a regular expression. .EXAMPLE Test-ConfigPolicy -Config $lines -Target r1 -Rule @{Id='NO-TELNET'; MustNotMatch='transport input .*telnet'} #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$Config, [Parameter(Mandatory = $true)][AllowEmptyCollection()][hashtable[]]$Rule, [string]$Target = '' ) $checks = [System.Collections.Generic.List[object]]::new() foreach ($item in @($Rule)) { if (-not $item.ContainsKey('Id')) { continue } $severity = 'Fail' if ($item.ContainsKey('Severity') -and $item['Severity']) { $severity = [string]$item['Severity'] } $description = [string]$item['Id'] if ($item.ContainsKey('Description') -and $item['Description']) { $description = [string]$item['Description'] } $status = 'Unknown' $detail = '' $value = '' if ($item.ContainsKey('MustMatch') -and $item['MustMatch']) { $pattern = [string]$item['MustMatch'] $hits = @($Config | Where-Object { $_ -match $pattern }) if ($hits.Count -gt 0) { $status = 'Pass' $value = "$($hits.Count) match(es)" $detail = $hits[0] } else { $status = $severity $value = 'missing' $detail = "Required pattern not found: $pattern" } } elseif ($item.ContainsKey('MustNotMatch') -and $item['MustNotMatch']) { $pattern = [string]$item['MustNotMatch'] $hits = @($Config | Where-Object { $_ -match $pattern }) if ($hits.Count -eq 0) { $status = 'Pass' $value = 'absent' } else { $status = $severity $value = "$($hits.Count) match(es)" $detail = ($hits | Select-Object -First 3) -join ' | ' } } else { $detail = 'Rule defines neither MustMatch nor MustNotMatch.' } $checks.Add([pscustomobject]@{ Target = $Target Category = 'Policy' Check = $description Status = $status Value = $value Detail = $detail }) } return @($checks) } function Compare-ConfigPeerGroup { <# .SYNOPSIS Finds configuration lines that most peers share but some devices are missing. .PARAMETER ConfigMap Hashtable of device name to normalized configuration lines. .PARAMETER ConsensusPercent Percentage of devices that must share a line before a missing line counts as drift. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][hashtable]$ConfigMap, [ValidateRange(1, 100)][int]$ConsensusPercent = 80, [string[]]$IgnorePattern = @() ) $devices = @($ConfigMap.Keys) $checks = [System.Collections.Generic.List[object]]::new() if ($devices.Count -lt 2) { return @($checks) } $lineOwners = @{} foreach ($device in $devices) { $seen = @{} foreach ($line in @($ConfigMap[$device])) { $text = ([string]$line).Trim() if (-not $text) { continue } $skip = $false foreach ($pattern in @($IgnorePattern)) { if ($pattern -and $text -match $pattern) { $skip = $true; break } } if ($skip -or $seen.ContainsKey($text)) { continue } $seen[$text] = $true if (-not $lineOwners.ContainsKey($text)) { $lineOwners[$text] = [System.Collections.Generic.List[string]]::new() } $lineOwners[$text].Add($device) } } $threshold = [Math]::Ceiling($devices.Count * ($ConsensusPercent / 100.0)) $missingByDevice = @{} foreach ($device in $devices) { $missingByDevice[$device] = [System.Collections.Generic.List[string]]::new() } foreach ($line in @($lineOwners.Keys)) { $owners = @($lineOwners[$line]) if ($owners.Count -ge $threshold -and $owners.Count -lt $devices.Count) { foreach ($device in $devices) { if ($owners -notcontains $device) { $missingByDevice[$device].Add($line) } } } } foreach ($device in $devices) { $missing = @($missingByDevice[$device]) if ($missing.Count -eq 0) { $checks.Add([pscustomobject]@{ Target = $device Category = 'PeerGroup' Check = 'Peer configuration consistency' Status = 'Pass' Value = '0 missing' Detail = "Consistent with $ConsensusPercent% peer consensus." }) } else { $checks.Add([pscustomobject]@{ Target = $device Category = 'PeerGroup' Check = 'Peer configuration consistency' Status = 'Warn' Value = "$($missing.Count) missing" Detail = (($missing | Select-Object -First 5) -join ' | ') }) } } return @($checks) } # endregion # ============================================================================ # region Collection and orchestration # ============================================================================ function Compare-ConfigRevision { <# .SYNOPSIS Diffs two stored revisions of the same device. .DESCRIPTION Answers "what changed between these two dates" without leaving PowerShell. From and To accept a 1-based revision number, a negative offset from the newest revision, 'latest', or 'golden'. .EXAMPLE Compare-ConfigRevision -StorePath .\baselines -DeviceKey SW1 -From golden -To latest .EXAMPLE Compare-ConfigRevision -StorePath .\baselines -DeviceKey SW1 -From -1 -To latest #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$StorePath, [Parameter(Mandatory = $true)][string]$DeviceKey, [Parameter(Mandatory = $true)]$From, $To = 'latest' ) $history = @(Get-ConfigBaselineHistory -StorePath $StorePath -DeviceKey $DeviceKey) if ($history.Count -eq 0) { throw "No revisions stored for '$DeviceKey'." } function Resolve-Revision { param($Selector, $History) if ($Selector -is [string]) { if ($Selector -eq 'latest') { return $History[-1] } if ($Selector -eq 'golden') { $approved = @($History | Where-Object { $_.approved }) if ($approved.Count -eq 0) { throw "No approved revision stored." } return $approved[-1] } } $number = 0 if (-not [int]::TryParse([string]$Selector, [ref]$number)) { throw "Invalid revision selector: $Selector" } if ($number -lt 0) { $index = $History.Count + $number - 1 if ($index -lt 0) { throw "Offset $number goes past the oldest revision." } return $History[$index] } if ($number -lt 1 -or $number -gt $History.Count) { throw "Revision $number is out of range (1..$($History.Count))." } return $History[$number - 1] } $fromRevision = Resolve-Revision -Selector $From -History $history $toRevision = Resolve-Revision -Selector $To -History $history function Read-RevisionLines { param($Revision, [string]$StorePath) $path = Join-Path $StorePath $Revision.file if (-not (Test-Path -LiteralPath $path)) { throw "Revision file missing: $path" } $content = Get-Content -LiteralPath $path -Raw $lines = @() if ($null -ne $content) { $lines = @($content -split "`r?`n") } if ($lines.Count -gt 0 -and $lines[-1] -eq '') { $lines = @($lines[0..($lines.Count - 2)]) } return $lines } $fromLines = Read-RevisionLines -Revision $fromRevision -StorePath $StorePath $toLines = Read-RevisionLines -Revision $toRevision -StorePath $StorePath $diff = @(Get-ConfigDiff -Reference $fromLines -Difference $toLines) return [pscustomobject]@{ DeviceKey = $DeviceKey FromTimestamp = $fromRevision.timestamp ToTimestamp = $toRevision.timestamp FromHash = $fromRevision.hash ToHash = $toRevision.hash AddedLines = @($diff | Where-Object { $_.Operation -eq 'Added' }).Count RemovedLines = @($diff | Where-Object { $_.Operation -eq 'Removed' }).Count Diff = $diff } } function Get-ConfigViaSsh { <# .SYNOPSIS Retrieves a device configuration over SSH. .DESCRIPTION Runs one or more commands and returns their combined output. By default the SSH exec channel is used. Devices that need a real terminal (enable mode, menus, control characters, pagers) should use -Shell. .PARAMETER Command One or more commands whose output forms the configuration. Defaults to the vendor profile command. .PARAMETER SetupCommand Commands run before collection whose output is discarded, such as disabling the pager. Defaults to the vendor profile setup command. .PARAMETER Shell Use an interactive shell instead of the exec channel. .PARAMETER PreStep Raw shell steps sent before the setup commands. Use for control characters such as Ctrl+Z or for navigating a menu. Implies -Shell. .PARAMETER EnablePassword Privileged-mode password. Implies -Shell. .EXAMPLE Get-ConfigViaSsh -Target sw1 -Username admin -SecurePassword $pw -Profile cisco-ios .EXAMPLE # Several commands make up the full configuration Get-ConfigViaSsh -Target sw1 -Username admin -SecurePassword $pw ` -Command 'show running-config','show vlan brief' .EXAMPLE # Escape a vendor menu with Ctrl+Z before collecting Get-ConfigViaSsh -Target box1 -Username admin -SecurePassword $pw -Shell ` -PreStep @(@{ Send = [char]26; NoNewline = $true; Collect = $false }) ` -Command 'show running-config' #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Target, [Parameter(Mandatory = $true)][string]$Username, [string]$Password, [System.Security.SecureString]$SecurePassword, [string]$KeyFile, [string]$KeyPassphrase, [int]$Port = 22, [int]$TimeoutSeconds = 60, [string[]]$Command, [string[]]$SetupCommand, [switch]$Shell, [object[]]$PreStep, [System.Security.SecureString]$EnablePassword, [string]$EnableCommand = 'enable', [string]$PromptPattern, [ValidateSet('cisco-ios', 'cisco-nxos', 'cisco-asa', 'linux', 'generic')] [string]$Profile = 'generic', [string]$SshModulePath ) $vendor = Get-ConfigVendorProfile -Name $Profile if (-not $Command) { $Command = @($vendor.Command) } if (-not $PSBoundParameters.ContainsKey('SetupCommand')) { $SetupCommand = @($vendor.SetupCommand) } if (-not $PromptPattern) { $PromptPattern = $vendor.PromptPattern } $Command = @($Command | Where-Object { $_ }) if ($Command.Count -eq 0) { throw "No command specified and profile '$Profile' has no default command." } if (-not $SshModulePath) { $SshModulePath = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'helpers\ssh\WhatsUpGoldPS.Ssh\WhatsUpGoldPS.Ssh.psm1' if (-not (Test-Path -LiteralPath $SshModulePath)) { $SshModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'ssh\WhatsUpGoldPS.Ssh\WhatsUpGoldPS.Ssh.psm1' } } if (-not (Test-Path -LiteralPath $SshModulePath)) { throw "SSH module not found: $SshModulePath" } Import-Module -Name $SshModulePath -Force -ErrorAction Stop $splat = @{ HostName = $Target; Port = $Port; Username = $Username; TimeoutSeconds = $TimeoutSeconds } if ($Password) { $splat['Password'] = $Password } if ($SecurePassword) { $splat['SecurePassword'] = $SecurePassword } if ($KeyFile) { $resolvedKeyFile = $null try { $resolvedKeyFile = (Resolve-Path -Path $KeyFile -ErrorAction Stop).ProviderPath } catch { throw "Key file not found: $KeyFile" } $splat['KeyFile'] = $resolvedKeyFile } if ($KeyPassphrase) { $splat['KeyPassphrase'] = $KeyPassphrase } $useShell = ($Shell -or $PreStep -or $EnablePassword) $session = New-SshSession @splat try { if ($useShell) { $steps = [System.Collections.Generic.List[object]]::new() foreach ($pre in @($PreStep)) { if ($null -ne $pre) { $steps.Add($pre) } } foreach ($setup in @($SetupCommand)) { if ($setup) { $steps.Add(@{ Send = $setup; Collect = $false }) } } foreach ($cmd in $Command) { $steps.Add($cmd) } $shellSplat = @{ Session = $session Step = $steps.ToArray() TimeoutSeconds = $TimeoutSeconds } if ($PromptPattern) { $shellSplat['PromptPattern'] = $PromptPattern } if ($EnablePassword) { $shellSplat['EnablePassword'] = $EnablePassword $shellSplat['EnableCommand'] = $EnableCommand } $shellResult = Invoke-SshShellCommand @shellSplat $output = $shellResult.Output $errorText = '' } else { $full = (@($SetupCommand | Where-Object { $_ }) + $Command) -join ' ; ' $result = Invoke-SshCommand -Session $session -Command ($full -replace "`r", '') -TimeoutSeconds $TimeoutSeconds $output = $result.Output $errorText = $result.Error } } finally { Close-SshSession -Session $session } if (-not $output) { throw "No configuration returned from ${Target}. stderr: $errorText" } return $output } function Get-ConfigDriftTarget { <# .SYNOPSIS Resolves audit targets from a WhatsUp Gold device group. .DESCRIPTION Returns one object per device with the address to connect to and the WUG device id, so results can be published back later. Requires an active session created by Connect-WUGServer. .PARAMETER GroupName Device group name. Omit to search all devices. .PARAMETER SearchValue Optional device search filter. .EXAMPLE Get-ConfigDriftTarget -GroupName 'Routers' #> [CmdletBinding()] param( [string]$GroupName, [string]$SearchValue, [int]$Limit = 250 ) if (-not (Get-Command Get-WUGDevice -ErrorAction SilentlyContinue)) { throw 'The WhatsUpGoldPS module is not loaded. Import it and run Connect-WUGServer first.' } $groupId = '-1' if ($GroupName) { $groups = @(Get-WUGDeviceGroup -SearchValue $GroupName) $match = $groups | Where-Object { $_.name -eq $GroupName } | Select-Object -First 1 if (-not $match) { $match = $groups | Select-Object -First 1 } if (-not $match) { throw "Device group not found: $GroupName" } $groupId = [string]$match.id } $deviceSplat = @{ DeviceGroupID = $groupId; View = 'card'; Limit = $Limit } if ($SearchValue) { $deviceSplat['SearchValue'] = $SearchValue } $devices = @(Get-WUGDevice @deviceSplat) $targets = [System.Collections.Generic.List[object]]::new() foreach ($device in $devices) { $address = $null foreach ($name in @('networkAddress', 'address', 'hostName', 'displayName')) { if ($device.PSObject.Properties[$name] -and $device.PSObject.Properties[$name].Value) { $address = [string]$device.PSObject.Properties[$name].Value break } } if (-not $address) { continue } $displayName = $address if ($device.PSObject.Properties['displayName'] -and $device.displayName) { $displayName = [string]$device.displayName } $targets.Add([pscustomobject]@{ Target = $address Name = $displayName DeviceId = $device.id }) } return @($targets) } function Publish-ConfigDriftToWUG { <# .SYNOPSIS Writes drift audit results back to WhatsUp Gold as device attributes. .DESCRIPTION Sets ConfigDrift.* attributes on the matching device so drift becomes visible and alertable inside WhatsUp Gold. Requires an active session created by Connect-WUGServer. .PARAMETER Audit Result objects from Invoke-ConfigDriftAudit. .PARAMETER DeviceId Device id to update. When omitted the device is looked up by target name. .PARAMETER AttributePrefix Attribute name prefix. Default ConfigDrift. .EXAMPLE $result | Publish-ConfigDriftToWUG -WhatIf #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)]$Audit, [int]$DeviceId, [string]$AttributePrefix = 'ConfigDrift' ) begin { if (-not (Get-Command Set-WUGDeviceAttribute -ErrorAction SilentlyContinue)) { throw 'The WhatsUpGoldPS module is not loaded. Import it and run Connect-WUGServer first.' } $published = [System.Collections.Generic.List[object]]::new() } process { foreach ($item in @($Audit)) { $resolvedId = $DeviceId if (-not $resolvedId -and $item.PSObject.Properties['DeviceId'] -and $item.DeviceId) { $resolvedId = [int]$item.DeviceId } if (-not $resolvedId) { $found = @(Get-WUGDevice -SearchValue $item.Target -View id) | Select-Object -First 1 if ($found) { $resolvedId = [int]$found.id } } if (-not $resolvedId) { Write-Warning "No WUG device matched '$($item.Target)'; skipping." continue } $status = 'Clean' if ($item.DriftDetected) { $status = 'Drift' } $attributes = [ordered]@{ "$AttributePrefix.Status" = $status "$AttributePrefix.LastChecked" = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') "$AttributePrefix.Added" = [string]$item.AddedLines "$AttributePrefix.Removed" = [string]$item.RemovedLines "$AttributePrefix.Hash" = [string]$item.Hash } if (-not $PSCmdlet.ShouldProcess("Device $resolvedId ($($item.Target))", "Set $AttributePrefix attributes")) { continue } foreach ($name in $attributes.Keys) { try { Set-WUGDeviceAttribute -DeviceId $resolvedId -Name $name -Value $attributes[$name] -ErrorAction Stop | Out-Null } catch { Write-Warning "Failed to set '$name' on device ${resolvedId}: $($_.Exception.Message)" } } $published.Add([pscustomobject]@{ Target = $item.Target DeviceId = $resolvedId Status = $status }) } } end { return @($published) } } function Get-ConfigDriftVaultCredential { <# .SYNOPSIS Loads SSH credentials for drift audits from the DPAPI discovery vault. .DESCRIPTION Reuses the credential vault in helpers/discovery so scheduled runs never prompt. The stored credential may be a single secret (treated as the password) or a bundle with Username, Password, KeyFile or KeyPassphrase fields. .PARAMETER Name Vault credential name saved with Save-DiscoveryCredential. .EXAMPLE Get-ConfigDriftVaultCredential -Name 'NetworkAdmin' #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Name, [string]$DiscoveryHelpersPath ) if (-not (Get-Command Get-DiscoveryCredential -ErrorAction SilentlyContinue)) { if (-not $DiscoveryHelpersPath) { $DiscoveryHelpersPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'discovery\DiscoveryHelpers.ps1' } if (-not (Test-Path -LiteralPath $DiscoveryHelpersPath)) { throw "DiscoveryHelpers.ps1 not found: $DiscoveryHelpersPath" } . $DiscoveryHelpersPath } $stored = Get-DiscoveryCredential -Name $Name if (-not $stored) { throw "Vault credential '$Name' not found. Save it with Save-DiscoveryCredential first." } $result = [ordered]@{ Username = $null SecurePassword = $null KeyFile = $null KeyPassphrase = $null } if ($stored -is [System.Collections.IDictionary]) { foreach ($key in @($stored.Keys)) { $value = [string]$stored[$key] switch -Regex ($key) { '^(?i)user(name)?$' { $result['Username'] = $value } '^(?i)pass(word)?$' { $result['SecurePassword'] = (ConvertTo-SecureString $value -AsPlainText -Force) } '^(?i)key(file|path)$' { $result['KeyFile'] = $value } '^(?i)key(pass|passphrase)$' { $result['KeyPassphrase'] = $value } } } } else { $result['SecurePassword'] = ConvertTo-SecureString ([string]$stored) -AsPlainText -Force } return [pscustomobject]$result } function Invoke-ConfigDriftAudit { <# .SYNOPSIS Compares a configuration against its stored baseline and policy rules. .DESCRIPTION Normalizes the supplied configuration, diffs it against the stored baseline (golden revision when -UseGolden is set), evaluates policy rules, and returns both dashboard-ready checks and the raw diff. .PARAMETER Config Raw configuration text or lines. Supply this to audit without SSH. .PARAMETER UpdateBaseline Store the current configuration as a new revision after auditing. .EXAMPLE Invoke-ConfigDriftAudit -Target r1 -Config $text -StorePath .\baselines -Profile cisco-ios #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Target, [Parameter(Mandatory = $true)][AllowEmptyString()][AllowNull()]$Config, [Parameter(Mandatory = $true)][string]$StorePath, [ValidateSet('cisco-ios', 'cisco-nxos', 'cisco-asa', 'linux', 'generic')] [string]$Profile = 'generic', [string[]]$IgnorePattern = @(), [hashtable[]]$IgnoreBlock = @(), [string[]]$IgnoreSection = @(), [string[]]$IncludePattern = @(), [hashtable[]]$IncludeBlock = @(), [string[]]$IncludeSection = @(), [hashtable[]]$Rule, [switch]$UseGolden, [switch]$UpdateBaseline, [switch]$ApproveBaseline ) $vendor = Get-ConfigVendorProfile -Name $Profile $allIgnore = @($vendor.IgnorePattern) + @($IgnorePattern) $allBlocks = @($vendor.IgnoreBlock) + @($IgnoreBlock) $current = @(ConvertTo-NormalizedConfig -Config $Config -IgnorePattern $allIgnore -IgnoreBlock $allBlocks ` -IgnoreSection $IgnoreSection -IncludePattern $IncludePattern -IncludeBlock $IncludeBlock ` -IncludeSection $IncludeSection -CommentPrefix $vendor.CommentPrefix) $currentHash = Get-ConfigHash -Lines $current Initialize-ConfigBaselineStore -StorePath $StorePath | Out-Null $baseline = Get-ConfigBaseline -StorePath $StorePath -DeviceKey $Target -Golden:$UseGolden $checks = [System.Collections.Generic.List[object]]::new() $diff = @() $added = 0 $removed = 0 $driftDetected = $false if ($null -eq $baseline) { $checks.Add([pscustomobject]@{ Target = $Target Category = 'Drift' Check = 'Configuration drift' Status = 'Unknown' Value = 'no baseline' Detail = 'No stored baseline yet; this run establishes one.' }) } else { $diff = @(Get-ConfigDiff -Reference $baseline.Lines -Difference $current) $added = @($diff | Where-Object { $_.Operation -eq 'Added' }).Count $removed = @($diff | Where-Object { $_.Operation -eq 'Removed' }).Count $driftDetected = ($added + $removed) -gt 0 $status = 'Pass' if ($driftDetected) { $status = 'Fail' } $checks.Add([pscustomobject]@{ Target = $Target Category = 'Drift' Check = 'Configuration drift' Status = $status Value = "+$added/-$removed" Detail = "Compared against baseline $($baseline.Timestamp) ($($baseline.Hash.Substring(0,8)))." }) } if ($Rule) { foreach ($policyCheck in @(Test-ConfigPolicy -Config $current -Rule $Rule -Target $Target)) { $checks.Add($policyCheck) } } $savedRevision = $null if ($UpdateBaseline -or $ApproveBaseline) { $savedRevision = Save-ConfigBaseline -StorePath $StorePath -DeviceKey $Target -Config $current ` -Note "drift audit +$added/-$removed" -Approve:$ApproveBaseline } return [pscustomobject]@{ Target = $Target CollectedAt = (Get-Date) Hash = $currentHash BaselineHash = $(if ($baseline) { $baseline.Hash } else { $null }) DriftDetected = $driftDetected AddedLines = $added RemovedLines = $removed LineCount = $current.Count Checks = @($checks) Diff = $diff Lines = $current Revision = $savedRevision } } # endregion # SIG # Begin signature block # MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBlrsdstckAThBJ # 3PXbzaz3OsaBqYj/1aY27N90lWTpWKCCJQ0wggVvMIIEV6ADAgECAhBI/JO0YFWU # jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI # DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM # EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy # dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG # EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv # IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA # A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s # hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD # J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7 # P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme # me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz # T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q # RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz # mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc # QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T # OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/ # AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID # AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD # VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV # HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE # VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v # ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE # KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI # hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF # OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC # J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ # pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl # d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH # +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO # +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD # VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi # BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw # MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp # Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp # Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC # AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb # hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT # cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb # NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP # XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr # VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP # ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv # kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM # cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls # 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR # a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6 # MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY # rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E # BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k # aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig # NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v # dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg # v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT # SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh # 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw # uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO # QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD # TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G # CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp # bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290 # IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT # AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g # UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB # jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe # AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV # oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk # 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh # P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41 # aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+ # ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/ # 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g # Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA # QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B # Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD # AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG # Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n # Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v # Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu # cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG # 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW # 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh # rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp # mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc # ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE # Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz # NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw # 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6 # E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6 # xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt # g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+ # MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ # BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl # Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw # WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj # dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB # bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW # kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw # zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1 # DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb # w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1 # Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k # dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J # BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d # n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs # CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU # q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE # LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW # gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G # aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI # KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC # ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw # PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT # aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0 # cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz # Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG # SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3 # wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9 # vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd # vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6 # LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO # WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7 # nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6 # g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7 # z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G # CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy # NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT # CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh # of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA # xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv # MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps # 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF # 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi # UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM # CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP # pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU # G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+ # sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s # BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI # GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+ # IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M # yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2 # th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj # V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2 # Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze # GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG # 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N # Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT # etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW # kPZPubdcMIIG7TCCBNWgAwIBAgIQCE/cM09+RU7bww+P+ZIYNTANBgkqhkiG9w0B # AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/ # BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg # U0hBMjU2IDIwMjUgQ0ExMB4XDTI2MDgwNTAwMDAwMFoXDTM3MTEwNDIzNTk1OVow # YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD # EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw # MjYgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALZ7pvLJ/s1K+NSb # TGWz/TjGMPh8CQ6RucZCLv5anHzWJjF/NWJrFIhy24fcpKXlgRiky4WAawDfU3YP # 0BMxt9l3Dm5oCG5Z69AqEN1kgHg2epx+l+lZBcmJCcN0ASURML5uFIS80sZsDwO3 # BSkUxDjLJhBI+qiZP3aixAC/qEGLjsBNlLol9VZ7pfGEXiMlneJIC5/YKuizVzNF # KZZEeoy/0B8Zm+nzKBgSWG52lCO1w+nCg6XpCtklTJXeIg283hw7TmmsZXR+SMbj # brEOvZ3fP2VxIgeR28Y90ZStd3F9VuA5RVynb/whITPAo9b75Zr4Ta6Mj3URm26Q # ZYMn/FnbuTegcoRcFEZ9FOqM5T6MTdtr/n74lIT/ug0eeOzmZ6QTFg33otX+bFRs # IolvykE1jive4PuESaT8zzVeFWDAMDtozNgLctkGD1ZjkEyZtJrLl5ya0m5doH/S # cpaZCZVl6pNUOCybMc/kxC6EAmSJY24L0yYKD1Nkddsnb/ItVKi/2nXpQNMu1PT5 # prW83vV8d67WowuUs0HdY4H8AMLGvdL/WHEj3ZnqMqAQQP9u3Ai9t+5eQ02GDwy0 # ODjdzi0xlp70W+ow63/0++YDEX1M0iwgUHwbrJvfpklkZQvw3+kv3vUPItdwrocz # k9icflf55W1zOEKAcJVAIXpcMCU9AgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA # MB0GA1UdDgQWBBQUyWOKMC7USvtulPPm40B+9ezN4jAfBgNVHSMEGDAWgBTvb1NK # 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI # KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v # b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT # SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k # aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2 # U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9 # bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQCNxTphHp1SCt+ZrAmAfn0oQLFr0mLywSLa # DXQIENoyKqxrFbJblzCVP/pkXmwXOdrOpWygLzlT12os5ipDCy35RBCg2UMeApEt # rfGhz45F4Wt4WGdNdIbRWt3YTYJmpR+b7lr4d7Uwn+H600u4D7RnOGf8Wj4UNgAd # ZkfHhHv1mx9EVh71SJelcEN/oORSjXzdjfw1iZH9d8Nh/thn6hH23d+VsPAr6GAY # yzSA02nXD1nYLI7Ijmiv+xLCiYC41DSFYL3GhTiy0PxpawPtGRyaBVGzq+UiTfM8 # pD7KVyF5aQyWP4KhVGUUTnmm/RlYJoW3TiXA/+t0YcT2oRVBm3JETjajHug2AL+v # 5jhtKVnd3D0rbHXEu27o+Q8p4sEWPMqKDB+qbceb6T/6WcwTwXmQ9lOCLLYcsQeS # WmvKqzpAec9etE14jOQAzLKWdE3w/TCaKtLRaRT7LCkRYVnhA2D73FLje1O5b3HR # 5eHs0NzU/+xX7NbEdcofy0W3Wdwd1XOqtlpg/JgwtKfZM5dqO94lbUveOiJBI+xZ # EbGRsMNbXmMREUTgu+Oca7Y73MPWcslIx2VhkSKSXjDbD6rgg39H5Mh7QfieAIjW # agkJNt68Yfim6cjEzVSiLSeZfdkr5dtFPTW6jATlWJdYeeDRGCyatf8R1hSjzSvd # N8yWQPT9gzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl # Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu # aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY # BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3 # AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi # BCAi1QK8hHCAM+MhgYT3s52HL7oY2BaT+Xbp6RZPvqVv+jANBgkqhkiG9w0BAQEF # AASCAgAG9JM+6qFuYpKhReXdgf+joD/2DS47tVN5PnvXfHPxqaoMipkNCFvwRKo2 # MQhmoibjESZvE1KE4ZQAgq/ihpF3tcQHH8iJM/X/WUUmF8OSK6u/lggnRQVQJim2 # 3f5rfDsFcKaO9K9BaqLwJsxA+ylyY1e8m7gti4e/7K+6V02WbtQ63qxPFlebWG8V # OfRkBJlTAkjiyEdPz2rcMpgjSZ1rcCEoBgRtqmbrudc1wp4gWPHDYLdd6oJ+ZsI8 # v1wObsJVcYxM8m6H1G+vbIRGIiBVsoFPRdMnxt5vsG4w2dkEoMRBsGstXQtjS3/l # Tw4GHYEQKQV2a694RtX1cydZPxqToLIvw9m1qX2N2M1/AnoNGH1PIy8dqQqbneSD # ogawb1wh+7nNRXubPGBHdKPUfGmJkGAlRQ+NiyLjT6BqPXKSquBBUeJ1fJzjiFiW # BTFXf2wtojL+bnp2xouaHvj/g1lKib0ZMwltI/fZZS/sMHXazvQTyM7Wc1X9OMlU # 2nWOcha8JOGKrSsuQt8Q/Nuskq3yr0kUOuN9LQKw3Hne5iuTQvbMRyE9HQDIaevU # 5eVkLdcSTb4e6XJb73O9B2XqltcJeBwzE38OFYbL2WJO8TU9JpANJD/tZDEyBT/e # NNBLeb/aeY0dFIHyHcTleEUoW8RS/Udrq1Kw4gkw6kHXlaj2baGCAyYwggMiBgkq # hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E # aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l # U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAhP3DNPfkVO28MPj/mS # GDUwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG # CSqGSIb3DQEJBTEPFw0yNjA5MjkxOTM3MzVaMC8GCSqGSIb3DQEJBDEiBCBrcC7S # Pyj13d7vSRk2nLe+pPVrk30iUdspBZt8SBeqAjANBgkqhkiG9w0BAQEFAASCAgCB # JVMeJWXILoIYfTZnx4KbstEbxhNSyMBZQpGQKA0p+5KAIyAxTvfsgf97qV88uAta # ymSdE+BGB9dOiDpnlFgwhvMBN1CVztlZ8ralYYBqq52JPnSUDZeeJUjwrr/8QEAX # nv/RTB9eszR2CoETPW56ZSpzkgf/EhSJ/jTSBQxgdyBuGrNe4QLupRzhTSuVn2h+ # iXBmrr1OY/xcb3Bgr148NlvN0ZXSM/fHQfu0G2zQaDZwK+gwvnxj6EM6okdA9cEi # /yPKcLKnNKV/kYvZNDsqQA5yRgTJllZ746Di6Sti6+qb+6qiuq2TsHrz8oF7EopP # rAaRywzW9HknODTyw4yw3mLjGf0p6gU7q3gBsI8xT47cOroGKGfdaoUa1bvk4KFx # YFQSFFqP7PXDheB9slVCHvd+rnsI5kEWMjQbjQV6/rUPj09ZABWdH4Z9ktv6gazC # 3xLPuKru9Hubi6PvTWYTj2w8DxiT/NlM/20gPNGqtbvWLpNaiaxI6TsfaiX3c8w6 # 6n3w8jtIvzeXVMjlvY0GsXLVoQg5vyPJWo0JR/GYPXxRUZ9CyyDIXKFW+qsXVohF # ifP06Cjlxi4zBnb7xWlaQ0Am+1wksR+0tRQU+qpaBR6DEDIybXcgbsoR9ndYykvx # WApWcjV9zwXBVJzAgCE5dj4U8GZ7azncOQBmdW2hQQ== # SIG # End signature block |